1use std::collections::{HashMap, VecDeque};
4use std::time::{Duration, Instant};
5
6use parking_lot::Mutex;
7use prost::Message;
8
9use crate::conn::IncomingRequest;
10use futu_core::proto_id::*;
11
12const FREQ_WINDOW: Duration = Duration::from_secs(30);
14const CPP_ACC_DEFAULT_BUCKET: u64 = 0;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17enum FreqScope {
18 CppDefaultAccount,
26}
27
28impl FreqScope {
29 fn bucket_scope_id(self, _conn_id: u64) -> u64 {
30 match self {
31 Self::CppDefaultAccount => CPP_ACC_DEFAULT_BUCKET,
32 }
33 }
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37enum PageCursorKind {
38 None,
39 BytesNextReqKey,
40 StringNextKey,
41 StringPage,
42 Offset,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46struct ProtoFreqRule {
47 proto_id: u32,
48 limit: u32,
49 scope: FreqScope,
50 page_cursor: PageCursorKind,
51}
52
53impl ProtoFreqRule {
54 const fn cpp_default(proto_id: u32, limit: u32, page_cursor: PageCursorKind) -> Self {
55 Self {
56 proto_id,
57 limit,
58 scope: FreqScope::CppDefaultAccount,
59 page_cursor,
60 }
61 }
62
63 const fn non_pageable(proto_id: u32, limit: u32) -> Self {
64 Self::cpp_default(proto_id, limit, PageCursorKind::None)
65 }
66
67 fn is_pageable(self) -> bool {
68 self.page_cursor != PageCursorKind::None
69 }
70}
71
72const PROTO_FREQ_RULES: &[ProtoFreqRule] = &[
78 ProtoFreqRule::non_pageable(GET_USER_INFO, 10),
79 ProtoFreqRule::non_pageable(VERIFICATION, 10),
80 ProtoFreqRule::non_pageable(TRD_UNLOCK_TRADE, 10),
81 ProtoFreqRule::non_pageable(TRD_GET_MARGIN_RATIO, 10),
86 ProtoFreqRule::non_pageable(TRD_GET_COMBO_MAX_TRD_QTYS, 40),
87 ProtoFreqRule::non_pageable(QOT_GET_SECURITY_SNAPSHOT, 60),
88 ProtoFreqRule::non_pageable(QOT_GET_PLATE_SET, 10),
89 ProtoFreqRule::non_pageable(QOT_GET_PLATE_SECURITY, 10),
90 ProtoFreqRule::non_pageable(QOT_GET_OWNER_PLATE, 10),
91 ProtoFreqRule::non_pageable(QOT_GET_HOLDING_CHANGE_LIST, 10),
92 ProtoFreqRule::non_pageable(QOT_GET_OPTION_CHAIN, 10),
93 ProtoFreqRule::non_pageable(QOT_GET_OPTION_QUOTE, 60),
94 ProtoFreqRule::non_pageable(QOT_GET_OPTION_STRATEGY_ANALYSIS, 60),
95 ProtoFreqRule::non_pageable(QOT_GET_OPTION_STRATEGY, 60),
96 ProtoFreqRule::non_pageable(QOT_GET_OPTION_STRATEGY_SPREAD, 60),
97 ProtoFreqRule::non_pageable(QOT_GET_SEARCH_QUOTE, 10),
100 ProtoFreqRule::non_pageable(QOT_GET_SEARCH_NEWS, 10),
101 ProtoFreqRule::non_pageable(QOT_REQUEST_INDICATOR_CALC, 60),
104 ProtoFreqRule::cpp_default(
107 QOT_GET_OPTION_MARKET_STATISTIC,
108 60,
109 PageCursorKind::BytesNextReqKey,
110 ),
111 ProtoFreqRule::cpp_default(
112 QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC,
113 60,
114 PageCursorKind::BytesNextReqKey,
115 ),
116 ProtoFreqRule::non_pageable(QOT_GET_OPTION_UNDERLYING_OVERVIEW, 60),
117 ProtoFreqRule::cpp_default(
118 QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY,
119 60,
120 PageCursorKind::BytesNextReqKey,
121 ),
122 ProtoFreqRule::cpp_default(
125 QOT_GET_OPTION_UNDERLYING_RANK,
126 60,
127 PageCursorKind::StringPage,
128 ),
129 ProtoFreqRule::cpp_default(QOT_GET_OPTION_RANK, 60, PageCursorKind::StringPage),
130 ProtoFreqRule::cpp_default(QOT_GET_OPTION_EVENT, 60, PageCursorKind::StringPage),
131 ProtoFreqRule::cpp_default(QOT_GET_OPTION_EVENT_ALERT, 60, PageCursorKind::StringPage),
132 ProtoFreqRule::non_pageable(QOT_SET_OPTION_EVENT_ALERT, 60),
133 ProtoFreqRule::cpp_default(
137 QOT_GET_OPTION_ZERO_DTE_SCREENER,
138 60,
139 PageCursorKind::StringPage,
140 ),
141 ProtoFreqRule::non_pageable(QOT_GET_OPTION_ZERO_DTE_CONTRACT, 60),
142 ProtoFreqRule::cpp_default(
143 QOT_GET_OPTION_EARNINGS_SCREENER,
144 60,
145 PageCursorKind::StringPage,
146 ),
147 ProtoFreqRule::non_pageable(QOT_GET_OPTION_SELLER_SCREENER, 60),
148 ProtoFreqRule::cpp_default(QOT_REQUEST_HISTORY_KL, 60, PageCursorKind::BytesNextReqKey),
149 ProtoFreqRule::non_pageable(QOT_GET_WARRANT, 60),
150 ProtoFreqRule::non_pageable(QOT_REQUEST_REHAB, 60),
151 ProtoFreqRule::non_pageable(QOT_GET_CAPITAL_FLOW, 30),
152 ProtoFreqRule::non_pageable(QOT_GET_CAPITAL_DISTRIBUTION, 60),
153 ProtoFreqRule::non_pageable(QOT_GET_USER_SECURITY, 10),
154 ProtoFreqRule::non_pageable(QOT_MODIFY_USER_SECURITY, 10),
155 ProtoFreqRule::non_pageable(QOT_STOCK_FILTER, 10),
156 ProtoFreqRule::non_pageable(QOT_STOCK_SCREEN, 10),
157 ProtoFreqRule::non_pageable(QOT_OPTION_SCREEN, 10),
158 ProtoFreqRule::non_pageable(QOT_WARRANT_SCREEN, 60),
159 ProtoFreqRule::non_pageable(QOT_GET_IPO_LIST, 10),
160 ProtoFreqRule::non_pageable(QOT_GET_FUTURE_INFO, 30),
161 ProtoFreqRule::non_pageable(QOT_REQUEST_TRADE_DATE, 30),
162 ProtoFreqRule::non_pageable(QOT_SET_PRICE_REMINDER, 60),
163 ProtoFreqRule::non_pageable(QOT_GET_PRICE_REMINDER, 10),
164 ProtoFreqRule::non_pageable(QOT_GET_USER_SECURITY_GROUP, 10),
165 ProtoFreqRule::non_pageable(QOT_GET_MARKET_STATE, 10),
166 ProtoFreqRule::non_pageable(QOT_GET_OPTION_EXPIRATION_DATE, 60),
167 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_EARNINGS_PRICE_MOVE, 30),
168 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_EARNINGS_PRICE_HISTORY, 30),
169 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_STATEMENTS, 30),
170 ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_REVENUE_BREAKDOWN, 30),
171 ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_ANALYST_CONSENSUS, 30),
172 ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_RATING_SUMMARY, 30),
173 ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_MORNINGSTAR_REPORT, 30),
174 ProtoFreqRule::non_pageable(QOT_GET_VALUATION_DETAIL, 30),
175 ProtoFreqRule::non_pageable(QOT_GET_VALUATION_PLATE_STOCK_LIST, 30),
176 ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_DIVIDENDS, 30),
177 ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_BUYBACKS, 30),
178 ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_STOCK_SPLITS, 30),
179 ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_OVERVIEW, 30),
180 ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_HOLDING_CHANGES, 30),
181 ProtoFreqRule::cpp_default(
182 QOT_GET_SHAREHOLDERS_HOLDER_DETAIL,
183 30,
184 PageCursorKind::StringNextKey,
185 ),
186 ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_INSTITUTIONAL, 30),
187 ProtoFreqRule::cpp_default(
188 QOT_GET_INSIDER_HOLDER_LIST,
189 30,
190 PageCursorKind::StringNextKey,
191 ),
192 ProtoFreqRule::cpp_default(
193 QOT_GET_INSIDER_TRADE_LIST,
194 30,
195 PageCursorKind::StringNextKey,
196 ),
197 ProtoFreqRule::non_pageable(QOT_GET_COMPANY_PROFILE, 30),
198 ProtoFreqRule::non_pageable(QOT_GET_COMPANY_EXECUTIVES, 30),
199 ProtoFreqRule::non_pageable(QOT_GET_COMPANY_EXECUTIVE_BACKGROUND, 30),
200 ProtoFreqRule::cpp_default(
201 QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY,
202 30,
203 PageCursorKind::StringNextKey,
204 ),
205 ProtoFreqRule::non_pageable(QOT_GET_TOP_TEN_BUY_SELL_BROKERS, 30),
206 ProtoFreqRule::non_pageable(QOT_GET_DAILY_SHORT_VOLUME, 30),
207 ProtoFreqRule::non_pageable(QOT_GET_SHORT_INTEREST, 30),
208 ProtoFreqRule::non_pageable(QOT_GET_OPTION_VOLATILITY, 30),
209 ProtoFreqRule::non_pageable(QOT_GET_OPTION_EXERCISE_PROBABILITY, 30),
210 ProtoFreqRule::non_pageable(QOT_GET_EARNINGS_CALENDAR, 60),
212 ProtoFreqRule::non_pageable(QOT_GET_MACRO_INDICATOR_LIST, 60),
213 ProtoFreqRule::non_pageable(QOT_GET_MACRO_INDICATOR_HISTORY, 60),
214 ProtoFreqRule::non_pageable(QOT_GET_FED_WATCH_TARGET_RATE, 60),
215 ProtoFreqRule::non_pageable(QOT_GET_FED_WATCH_DOT_PLOT, 60),
216 ProtoFreqRule::non_pageable(QOT_GET_EARNINGS_BEAT_RANK, 60),
217 ProtoFreqRule::non_pageable(QOT_GET_DIVIDEND_RANK, 60),
218 ProtoFreqRule::non_pageable(QOT_GET_DIVIDEND_CALENDAR, 60),
219 ProtoFreqRule::non_pageable(QOT_GET_ECONOMIC_CALENDAR, 60),
220 ProtoFreqRule::cpp_default(QOT_GET_US_PRE_MARKET_RANK, 60, PageCursorKind::Offset),
221 ProtoFreqRule::cpp_default(QOT_GET_US_AFTER_HOURS_RANK, 60, PageCursorKind::Offset),
222 ProtoFreqRule::cpp_default(QOT_GET_US_OVERNIGHT_RANK, 60, PageCursorKind::Offset),
223 ProtoFreqRule::cpp_default(QOT_GET_TOP_MOVERS_RANK, 60, PageCursorKind::Offset),
224 ProtoFreqRule::cpp_default(QOT_GET_HOT_LIST, 60, PageCursorKind::Offset),
225 ProtoFreqRule::cpp_default(QOT_GET_SHORT_SELLING_RANK, 60, PageCursorKind::Offset),
226 ProtoFreqRule::cpp_default(QOT_GET_PERIOD_CHANGE_RANK, 60, PageCursorKind::Offset),
227 ProtoFreqRule::cpp_default(QOT_GET_HIGH_DIVIDEND_SOE_RANK, 60, PageCursorKind::Offset),
228 ProtoFreqRule::cpp_default(QOT_GET_INSTITUTION_LIST, 60, PageCursorKind::StringPage),
229 ProtoFreqRule::non_pageable(QOT_GET_INSTITUTION_PROFILE, 60),
230 ProtoFreqRule::non_pageable(QOT_GET_INSTITUTION_DISTRIBUTION, 60),
231 ProtoFreqRule::cpp_default(
232 QOT_GET_INSTITUTION_HOLDING_CHANGE,
233 60,
234 PageCursorKind::StringPage,
235 ),
236 ProtoFreqRule::cpp_default(
237 QOT_GET_INSTITUTION_HOLDING_LIST,
238 60,
239 PageCursorKind::StringPage,
240 ),
241 ProtoFreqRule::cpp_default(QOT_GET_ARK_FUND_HOLDING, 60, PageCursorKind::StringPage),
242 ProtoFreqRule::non_pageable(QOT_GET_ARK_STOCK_DYNAMIC, 60),
243 ProtoFreqRule::cpp_default(
244 QOT_GET_ARK_ACTIVE_TRANSACTION,
245 60,
246 PageCursorKind::StringPage,
247 ),
248 ProtoFreqRule::cpp_default(QOT_GET_RATING_CHANGE, 60, PageCursorKind::StringPage),
249 ProtoFreqRule::cpp_default(
250 QOT_GET_INDUSTRIAL_CHAIN_LIST,
251 60,
252 PageCursorKind::StringPage,
253 ),
254 ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_CHAIN_DETAIL, 60),
255 ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE, 60),
256 ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_PLATE_INFO, 60),
257 ProtoFreqRule::cpp_default(
258 QOT_GET_INDUSTRIAL_PLATE_STOCK,
259 60,
260 PageCursorKind::StringPage,
261 ),
262 ProtoFreqRule::cpp_default(QOT_GET_HEAT_MAP_DATA, 60, PageCursorKind::StringPage),
263 ProtoFreqRule::non_pageable(QOT_GET_RISE_FALL_DISTRIBUTION, 60),
264 ProtoFreqRule::non_pageable(QOT_GET_TECHNICAL_UNUSUAL, 30),
269 ProtoFreqRule::non_pageable(QOT_GET_FINANCIAL_UNUSUAL, 30),
270 ProtoFreqRule::non_pageable(QOT_GET_DERIVATIVE_UNUSUAL, 30),
271 ProtoFreqRule::non_pageable(QOT_GET_EVENT_CONTRACT_CATEGORY, 10),
277 ProtoFreqRule::non_pageable(QOT_FILTER_COMPETITION, 10),
278 ProtoFreqRule::non_pageable(QOT_GET_EVENT_CONTRACT_SERIES_LIST, 10),
279 ProtoFreqRule::cpp_default(
280 QOT_GET_EVENT_CONTRACT_EVENT_LIST,
281 10,
282 PageCursorKind::StringNextKey,
283 ),
284 ProtoFreqRule::cpp_default(QOT_GET_EVENT_CONTRACT, 10, PageCursorKind::StringNextKey),
285 ProtoFreqRule::cpp_default(
286 QOT_GET_EVENT_CONTRACT_MILESTONE_LIST,
287 10,
288 PageCursorKind::StringNextKey,
289 ),
290 ProtoFreqRule::cpp_default(
294 QOT_GET_EVENT_CONTRACT_COMBO_LIST,
295 15,
296 PageCursorKind::StringNextKey,
297 ),
298 ProtoFreqRule::non_pageable(QOT_GET_EVENT_CONTRACT_COMBO_RFQ, 15),
299];
300
301struct ConnFreqRecord {
303 last_serial: u32,
305}
306
307impl ConnFreqRecord {
308 fn new() -> Self {
309 Self { last_serial: 0 }
310 }
311}
312
313#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
314struct FreqBucketKey {
315 scope_id: u64,
316 proto_id: u32,
317}
318
319pub struct ProtectionManager {
321 records: Mutex<HashMap<u64, ConnFreqRecord>>,
322 freq_buckets: Mutex<HashMap<FreqBucketKey, VecDeque<Instant>>>,
323 proto_limits: HashMap<u32, ProtoFreqRule>,
324}
325
326impl ProtectionManager {
327 pub fn new() -> Self {
330 Self {
331 records: Mutex::new(HashMap::new()),
332 freq_buckets: Mutex::new(HashMap::new()),
333 proto_limits: PROTO_FREQ_RULES
334 .iter()
335 .map(|rule| (rule.proto_id, *rule))
336 .collect(),
337 }
338 }
339
340 pub fn check_freq_limit(&self, conn_id: u64, proto_id: u32) -> bool {
344 self.check_freq_limit_page(conn_id, proto_id, true)
345 }
346
347 pub fn check_request_freq_limit(&self, conn_id: u64, request: &IncomingRequest) -> bool {
352 let mapped_proto = map_proto_to_freq_limit_group(request.proto_id);
353 let is_first_page = self
354 .proto_limits
355 .get(&mapped_proto)
356 .filter(|rule| rule.is_pageable())
357 .and_then(|_| request_is_first_page_for_limit(mapped_proto, request.body.as_ref()))
358 .unwrap_or(true);
359 self.check_freq_limit_page(conn_id, request.proto_id, is_first_page)
360 }
361
362 pub fn check_freq_limit_page(&self, conn_id: u64, proto_id: u32, is_first_page: bool) -> bool {
367 let mapped_proto = map_proto_to_freq_limit_group(proto_id);
368 let item = match self.proto_limits.get(&mapped_proto) {
369 Some(item) => *item,
370 None => return false, };
372
373 if item.is_pageable() && !is_first_page {
374 return false;
375 }
376
377 let mut buckets = self.freq_buckets.lock();
378 let times = buckets
379 .entry(freq_bucket_key(conn_id, mapped_proto, item.scope))
380 .or_default();
381 let now = Instant::now();
382
383 while times
385 .front()
386 .is_some_and(|t| now.duration_since(*t) > FREQ_WINDOW)
387 {
388 times.pop_front();
389 }
390
391 if times.len() as u32 >= item.limit {
392 true } else {
394 times.push_back(now);
395 false
396 }
397 }
398
399 pub fn check_replay(&self, conn_id: u64, serial_no: u32) -> bool {
403 let mut records = self.records.lock();
404 let record = records.entry(conn_id).or_insert_with(ConnFreqRecord::new);
405
406 if serial_no <= record.last_serial {
407 true } else {
409 record.last_serial = serial_no;
410 false
411 }
412 }
413
414 pub fn on_disconnect(&self, conn_id: u64) {
416 self.records.lock().remove(&conn_id);
417 self.freq_buckets
418 .lock()
419 .retain(|key, _| key.scope_id != conn_id);
420 }
421}
422
423fn map_proto_to_freq_limit_group(proto_id: u32) -> u32 {
424 if proto_id == futu_core::proto_id::TRD_PLACE_COMBO_ORDER {
425 futu_core::proto_id::TRD_PLACE_ORDER
426 } else {
427 proto_id
428 }
429}
430
431fn freq_bucket_key(conn_id: u64, proto_id: u32, scope: FreqScope) -> FreqBucketKey {
432 FreqBucketKey {
433 scope_id: scope.bucket_scope_id(conn_id),
434 proto_id,
435 }
436}
437
438fn request_is_first_page_for_limit(proto_id: u32, body: &[u8]) -> Option<bool> {
439 match proto_id {
440 QOT_REQUEST_HISTORY_KL => {
441 decode_request::<futu_proto::qot_request_history_kl::Request, _>(body, |req| {
442 bytes_cursor_is_empty(&req.c2s.next_req_key)
443 })
444 }
445 QOT_GET_OPTION_MARKET_STATISTIC => {
446 decode_request::<futu_proto::qot_get_option_market_statistic::Request, _>(body, |req| {
447 bytes_cursor_is_empty(&req.c2s.next_page_key)
448 })
449 }
450 QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC => {
451 decode_request::<futu_proto::qot_get_option_underlying_his_statistic::Request, _>(
452 body,
453 |req| bytes_cursor_is_empty(&req.c2s.next_page_key),
454 )
455 }
456 QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY => {
457 decode_request::<futu_proto::qot_get_option_underlying_his_volatility::Request, _>(
458 body,
459 |req| bytes_cursor_is_empty(&req.c2s.next_page_key),
460 )
461 }
462 QOT_GET_OPTION_UNDERLYING_RANK => {
463 decode_request::<futu_proto::qot_get_option_underlying_rank::Request, _>(body, |req| {
464 string_offset_is_first_page(&req.c2s.page)
465 })
466 }
467 QOT_GET_OPTION_RANK => {
468 decode_request::<futu_proto::qot_get_option_rank::Request, _>(body, |req| {
469 string_offset_is_first_page(&req.c2s.page)
470 })
471 }
472 QOT_GET_OPTION_EVENT => {
473 decode_request::<futu_proto::qot_get_option_event::Request, _>(body, |req| {
474 string_cursor_is_empty(&req.c2s.page)
475 })
476 }
477 QOT_GET_OPTION_EVENT_ALERT => decode_request::<
478 futu_proto::qot_get_option_event_alert::Request,
479 _,
480 >(body, |req| string_cursor_is_empty(&req.c2s.page)),
481 QOT_GET_OPTION_ZERO_DTE_SCREENER => {
482 decode_request::<futu_proto::qot_get_option_zero_dte_screener::Request, _>(
483 body,
484 |req| string_cursor_is_empty(&req.c2s.page),
485 )
486 }
487 QOT_GET_OPTION_EARNINGS_SCREENER => {
488 decode_request::<futu_proto::qot_get_option_earnings_screener::Request, _>(
489 body,
490 |req| string_cursor_is_empty(&req.c2s.page),
491 )
492 }
493 QOT_GET_SHAREHOLDERS_HOLDER_DETAIL => {
494 decode_request::<futu_proto::qot_get_shareholders_holder_detail::Request, _>(
495 body,
496 |req| string_cursor_is_empty(&req.c2s.next_key),
497 )
498 }
499 QOT_GET_INSIDER_HOLDER_LIST => {
500 decode_request::<futu_proto::qot_get_insider_holder_list::Request, _>(body, |req| {
501 string_cursor_is_empty(&req.c2s.next_key)
502 })
503 }
504 QOT_GET_INSIDER_TRADE_LIST => {
505 decode_request::<futu_proto::qot_get_insider_trade_list::Request, _>(body, |req| {
506 string_cursor_is_empty(&req.c2s.next_key)
507 })
508 }
509 QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY => {
510 decode_request::<futu_proto::qot_get_company_operational_efficiency::Request, _>(
511 body,
512 |req| string_cursor_is_empty(&req.c2s.next_key),
513 )
514 }
515 QOT_GET_US_PRE_MARKET_RANK => decode_request::<
516 futu_proto::qot_get_us_pre_market_rank::Request,
517 _,
518 >(body, |req| offset_is_first_page(req.c2s.offset)),
519 QOT_GET_US_AFTER_HOURS_RANK => decode_request::<
520 futu_proto::qot_get_us_after_hours_rank::Request,
521 _,
522 >(body, |req| offset_is_first_page(req.c2s.offset)),
523 QOT_GET_US_OVERNIGHT_RANK => decode_request::<
524 futu_proto::qot_get_us_overnight_rank::Request,
525 _,
526 >(body, |req| offset_is_first_page(req.c2s.offset)),
527 QOT_GET_TOP_MOVERS_RANK => {
528 decode_request::<futu_proto::qot_get_top_movers_rank::Request, _>(body, |req| {
529 offset_is_first_page(req.c2s.offset)
530 })
531 }
532 QOT_GET_HOT_LIST => {
533 decode_request::<futu_proto::qot_get_hot_list::Request, _>(body, |req| {
534 offset_is_first_page(req.c2s.offset)
535 })
536 }
537 QOT_GET_SHORT_SELLING_RANK => decode_request::<
538 futu_proto::qot_get_short_selling_rank::Request,
539 _,
540 >(body, |req| offset_is_first_page(req.c2s.offset)),
541 QOT_GET_PERIOD_CHANGE_RANK => decode_request::<
542 futu_proto::qot_get_period_change_rank::Request,
543 _,
544 >(body, |req| offset_is_first_page(req.c2s.offset)),
545 QOT_GET_HIGH_DIVIDEND_SOE_RANK => {
546 decode_request::<futu_proto::qot_get_high_dividend_soe_rank::Request, _>(body, |req| {
547 offset_is_first_page(req.c2s.offset)
548 })
549 }
550 QOT_GET_INSTITUTION_LIST => decode_request::<
551 futu_proto::qot_get_institution_list::Request,
552 _,
553 >(body, |req| string_cursor_is_empty(&req.c2s.page)),
554 QOT_GET_INSTITUTION_HOLDING_CHANGE => {
555 decode_request::<futu_proto::qot_get_institution_holding_change::Request, _>(
556 body,
557 |req| string_cursor_is_empty(&req.c2s.page),
558 )
559 }
560 QOT_GET_INSTITUTION_HOLDING_LIST => {
561 decode_request::<futu_proto::qot_get_institution_holding_list::Request, _>(
562 body,
563 |req| string_cursor_is_empty(&req.c2s.page),
564 )
565 }
566 QOT_GET_ARK_FUND_HOLDING => decode_request::<
567 futu_proto::qot_get_ark_fund_holding::Request,
568 _,
569 >(body, |req| string_cursor_is_empty(&req.c2s.page)),
570 QOT_GET_ARK_ACTIVE_TRANSACTION => {
571 decode_request::<futu_proto::qot_get_ark_active_transaction::Request, _>(body, |req| {
572 string_cursor_is_empty(&req.c2s.page)
573 })
574 }
575 QOT_GET_RATING_CHANGE => {
576 decode_request::<futu_proto::qot_get_rating_change::Request, _>(body, |req| {
577 string_cursor_is_empty(&req.c2s.page)
578 })
579 }
580 QOT_GET_INDUSTRIAL_CHAIN_LIST => {
581 decode_request::<futu_proto::qot_get_industrial_chain_list::Request, _>(body, |req| {
582 string_cursor_is_empty(&req.c2s.page)
583 })
584 }
585 QOT_GET_INDUSTRIAL_PLATE_STOCK => {
586 decode_request::<futu_proto::qot_get_industrial_plate_stock::Request, _>(body, |req| {
587 string_cursor_is_empty(&req.c2s.page)
588 })
589 }
590 QOT_GET_HEAT_MAP_DATA => {
591 decode_request::<futu_proto::qot_get_heat_map_data::Request, _>(body, |req| {
592 string_cursor_is_empty(&req.c2s.page)
593 })
594 }
595 QOT_GET_EVENT_CONTRACT_EVENT_LIST => {
596 decode_request::<futu_proto::qot_get_event_contract_event_list::Request, _>(
597 body,
598 |req| string_cursor_is_empty(&req.c2s.next_page),
599 )
600 }
601 QOT_GET_EVENT_CONTRACT => {
602 decode_request::<futu_proto::qot_get_event_contract::Request, _>(body, |req| {
603 string_cursor_is_empty(&req.c2s.next_page)
604 })
605 }
606 QOT_GET_EVENT_CONTRACT_MILESTONE_LIST => {
607 decode_request::<futu_proto::qot_get_event_contract_milestone_list::Request, _>(
608 body,
609 |req| string_cursor_is_empty(&req.c2s.next_page),
610 )
611 }
612 QOT_GET_EVENT_CONTRACT_COMBO_LIST => {
613 decode_request::<futu_proto::qot_get_event_contract_combo_list::Request, _>(
614 body,
615 |req| string_cursor_is_empty(&req.c2s.next_page),
616 )
617 }
618 _ => None,
619 }
620}
621
622#[cfg(test)]
623fn has_first_page_decoder(proto_id: u32) -> bool {
624 matches!(
625 proto_id,
626 QOT_REQUEST_HISTORY_KL
627 | QOT_GET_OPTION_MARKET_STATISTIC
628 | QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC
629 | QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY
630 | QOT_GET_OPTION_UNDERLYING_RANK
631 | QOT_GET_OPTION_RANK
632 | QOT_GET_OPTION_EVENT
633 | QOT_GET_OPTION_EVENT_ALERT
634 | QOT_GET_OPTION_ZERO_DTE_SCREENER
635 | QOT_GET_OPTION_EARNINGS_SCREENER
636 | QOT_GET_SHAREHOLDERS_HOLDER_DETAIL
637 | QOT_GET_INSIDER_HOLDER_LIST
638 | QOT_GET_INSIDER_TRADE_LIST
639 | QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY
640 | QOT_GET_US_PRE_MARKET_RANK
641 | QOT_GET_US_AFTER_HOURS_RANK
642 | QOT_GET_US_OVERNIGHT_RANK
643 | QOT_GET_TOP_MOVERS_RANK
644 | QOT_GET_HOT_LIST
645 | QOT_GET_SHORT_SELLING_RANK
646 | QOT_GET_PERIOD_CHANGE_RANK
647 | QOT_GET_HIGH_DIVIDEND_SOE_RANK
648 | QOT_GET_INSTITUTION_LIST
649 | QOT_GET_INSTITUTION_HOLDING_CHANGE
650 | QOT_GET_INSTITUTION_HOLDING_LIST
651 | QOT_GET_ARK_FUND_HOLDING
652 | QOT_GET_ARK_ACTIVE_TRANSACTION
653 | QOT_GET_RATING_CHANGE
654 | QOT_GET_INDUSTRIAL_CHAIN_LIST
655 | QOT_GET_INDUSTRIAL_PLATE_STOCK
656 | QOT_GET_HEAT_MAP_DATA
657 | QOT_GET_EVENT_CONTRACT_EVENT_LIST
658 | QOT_GET_EVENT_CONTRACT
659 | QOT_GET_EVENT_CONTRACT_MILESTONE_LIST
660 | QOT_GET_EVENT_CONTRACT_COMBO_LIST
661 )
662}
663
664fn decode_request<M, F>(body: &[u8], is_first_page: F) -> Option<bool>
665where
666 M: Message + Default,
667 F: FnOnce(M) -> bool,
668{
669 M::decode(body).ok().map(is_first_page)
670}
671
672fn bytes_cursor_is_empty(cursor: &Option<Vec<u8>>) -> bool {
673 cursor.as_ref().is_none_or(Vec::is_empty)
674}
675
676fn string_cursor_is_empty(cursor: &Option<String>) -> bool {
677 cursor.as_deref().unwrap_or("").is_empty()
678}
679
680fn string_offset_is_first_page(cursor: &Option<String>) -> bool {
681 let Some(cursor) = cursor.as_deref().filter(|value| !value.is_empty()) else {
682 return true;
683 };
684 cursor.parse::<i32>().is_ok_and(|offset| offset == 0)
689}
690
691fn offset_is_first_page(offset: Option<i32>) -> bool {
692 offset.unwrap_or(0) == 0
694}
695
696impl Default for ProtectionManager {
697 fn default() -> Self {
698 Self::new()
699 }
700}
701
702#[cfg(test)]
703mod tests;