Skip to main content

futu_server/
protect.rs

1// 限频保护 + 防重放攻击
2
3use 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
12/// 限频窗口大小(30 秒)
13const 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    /// Ref: FutuOpenD/Src/APIServer/APIServerCS_Core.cpp:114-129 and
19    /// APIServerCS_Protect.hpp:173-180,228-243.
20    ///
21    /// The central API server gate calls `Protect_IsPassFreqLimitCheck` with
22    /// `ACCID_DEFAULT`; QOT/Other paths also use that default account bucket.
23    /// Trade protocol account-specific throttling happens in the trading path,
24    /// not in this central gate.
25    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
72/// Ref: FutuOpenD/Src/APIServer/APIServerCS_Protect.hpp:30-161.
73///
74/// C++ uses a 30s sliding window and marks pageable APIs so only first-page
75/// requests consume quota. Keep this as a declarative table so new upstream
76/// proto ids must make scope/page cursor decisions explicitly.
77const 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    // Ref: C++ APIServerCS_Core.cpp central gate deliberately skips
82    // cache-readable trade APIs plus Qot_GetReference. Cache hits are not
83    // counted there; server-forwarding trade paths are throttled inside the
84    // trading protocol with the real account id instead of this central gate.
85    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    // Ref: C++ `APIServerCS_Protect.hpp:71-72`: distinct non-pageable
98    // default-account buckets, each 10 requests per 30-second window.
99    ProtoFreqRule::non_pageable(QOT_GET_SEARCH_QUOTE, 10),
100    ProtoFreqRule::non_pageable(QOT_GET_SEARCH_NEWS, 10),
101    // Ref: owned C++ `APIServerCS_Protect.hpp:164-165`. 3260 owns a
102    // non-pageable default-account 60/30s bucket; 3259 is intentionally absent.
103    ProtoFreqRule::non_pageable(QOT_REQUEST_INDICATOR_CALC, 60),
104    // Ref: C++ `APIServerCS_Protect.hpp:113-116`: three bytes-pageable
105    // 60/30s default-account buckets and one non-pageable bucket.
106    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    // Ref: C++ APIServerCS_Protect.hpp:117-118. Each ranking owns a distinct
123    // 60/30s default-account bucket and only an empty string page is counted.
124    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    // Ref: C++ APIServer_Qot_OptionProductZone.cpp:239-250,425-433,
134    // 632-640 and APIServer_Qot_OptionEarnings.cpp:112-123. 3311/3313 count
135    // only empty string pages; 3312/3314 count every request.
136    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    // C++ latest 3401-3433: 30 秒 60 次,pageable=true 时仅首页计数。
211    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    // Ref: FutuOpenD/Src/APIServer/APIServerCS_Protect.hpp:159-162.
265    // SkillWrap unusual APIs each use 30 requests per 30 seconds and are
266    // non-pageable. The central C++ gate uses ACCID_DEFAULT, so all client
267    // connections intentionally share one bucket per proto id.
268    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    // Ref: frozen C++ aec0f6cda1
272    // Src/APIServer/APIServerCS_Protect.hpp:162-173.
273    // Event Contract APIs 3434-3439 use 10 requests / 30s. Only 3437-3439
274    // declare pageable=true, so their non-empty nextPage requests do not
275    // consume the first-page bucket.
276    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    // Combo 3453/3454 use 15 requests / 30s. ComboList is pageable and
291    // treats absent/present-empty nextPage as the first page; ComboRfq
292    // counts every request.
293    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
301/// 单连接的重放记录
302struct ConnFreqRecord {
303    /// 防重放:上次见到的 serial number
304    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
319/// 限频保护器
320pub 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    /// 创建新的 [`ProtectionManager`] 实例。内部自动加载 per-proto_id 默认限
328    /// 频表(对齐 C++ 默认配置),不需额外初始化。
329    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    /// 检查请求是否超频
341    ///
342    /// 返回 true 表示被限频(应拒绝),false 表示通过
343    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    /// 检查完整请求是否超频。
348    ///
349    /// 对 pageable API,需要解码请求体判断是否首页;解码失败时按首页处理,
350    /// 与 C++ 入口“无法确认续页就走普通频控”的保守行为一致。
351    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    /// 检查请求是否超频,并带分页首页标记。
363    ///
364    /// C++ 对 pageable API 的规则是:第 1 页计入 30 秒滑动窗口,后续页不计数。
365    /// 非 pageable API 忽略 `is_first_page`,每次请求都计数。
366    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, // 无限制的协议
371        };
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        // 清理过期记录
384        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 // 超频
393        } else {
394            times.push_back(now);
395            false
396        }
397    }
398
399    /// 检查防重放(serial number 必须递增)
400    ///
401    /// 返回 true 表示可能是重放攻击,false 表示正常
402    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 // 重放
408        } else {
409            record.last_serial = serial_no;
410            false
411        }
412    }
413
414    /// 连接断开时清理
415    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    // Ref: APIServer_Qot_OptionRank.cpp:206-219,444-457. C++ parses the
685    // decimal string into nFrom before deciding whether the request consumes
686    // the first-page bucket. Invalid/negative values are rejected by the
687    // handler before its rate check and therefore do not consume the bucket.
688    cursor.parse::<i32>().is_ok_and(|offset| offset == 0)
689}
690
691fn offset_is_first_page(offset: Option<i32>) -> bool {
692    // Ref: C++ APIServer_Qot_*Rank.cpp: `!c2s.has_offset() || c2s.offset() == 0`.
693    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;