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    ProtoFreqRule::cpp_default(QOT_REQUEST_HISTORY_KL, 60, PageCursorKind::BytesNextReqKey),
98    ProtoFreqRule::non_pageable(QOT_GET_WARRANT, 60),
99    ProtoFreqRule::non_pageable(QOT_REQUEST_REHAB, 60),
100    ProtoFreqRule::non_pageable(QOT_GET_CAPITAL_FLOW, 30),
101    ProtoFreqRule::non_pageable(QOT_GET_CAPITAL_DISTRIBUTION, 60),
102    ProtoFreqRule::non_pageable(QOT_GET_USER_SECURITY, 10),
103    ProtoFreqRule::non_pageable(QOT_MODIFY_USER_SECURITY, 10),
104    ProtoFreqRule::non_pageable(QOT_STOCK_FILTER, 10),
105    ProtoFreqRule::non_pageable(QOT_STOCK_SCREEN, 10),
106    ProtoFreqRule::non_pageable(QOT_OPTION_SCREEN, 10),
107    ProtoFreqRule::non_pageable(QOT_WARRANT_SCREEN, 60),
108    ProtoFreqRule::non_pageable(QOT_GET_IPO_LIST, 10),
109    ProtoFreqRule::non_pageable(QOT_GET_FUTURE_INFO, 30),
110    ProtoFreqRule::non_pageable(QOT_REQUEST_TRADE_DATE, 30),
111    ProtoFreqRule::non_pageable(QOT_SET_PRICE_REMINDER, 60),
112    ProtoFreqRule::non_pageable(QOT_GET_PRICE_REMINDER, 10),
113    ProtoFreqRule::non_pageable(QOT_GET_USER_SECURITY_GROUP, 10),
114    ProtoFreqRule::non_pageable(QOT_GET_MARKET_STATE, 10),
115    ProtoFreqRule::non_pageable(QOT_GET_OPTION_EXPIRATION_DATE, 60),
116    ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_EARNINGS_PRICE_MOVE, 30),
117    ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_EARNINGS_PRICE_HISTORY, 30),
118    ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_STATEMENTS, 30),
119    ProtoFreqRule::non_pageable(QOT_GET_FINANCIALS_REVENUE_BREAKDOWN, 30),
120    ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_ANALYST_CONSENSUS, 30),
121    ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_RATING_SUMMARY, 30),
122    ProtoFreqRule::non_pageable(QOT_GET_RESEARCH_MORNINGSTAR_REPORT, 30),
123    ProtoFreqRule::non_pageable(QOT_GET_VALUATION_DETAIL, 30),
124    ProtoFreqRule::non_pageable(QOT_GET_VALUATION_PLATE_STOCK_LIST, 30),
125    ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_DIVIDENDS, 30),
126    ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_BUYBACKS, 30),
127    ProtoFreqRule::non_pageable(QOT_GET_CORPORATE_ACTIONS_STOCK_SPLITS, 30),
128    ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_OVERVIEW, 30),
129    ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_HOLDING_CHANGES, 30),
130    ProtoFreqRule::cpp_default(
131        QOT_GET_SHAREHOLDERS_HOLDER_DETAIL,
132        30,
133        PageCursorKind::StringNextKey,
134    ),
135    ProtoFreqRule::non_pageable(QOT_GET_SHAREHOLDERS_INSTITUTIONAL, 30),
136    ProtoFreqRule::cpp_default(
137        QOT_GET_INSIDER_HOLDER_LIST,
138        30,
139        PageCursorKind::StringNextKey,
140    ),
141    ProtoFreqRule::cpp_default(
142        QOT_GET_INSIDER_TRADE_LIST,
143        30,
144        PageCursorKind::StringNextKey,
145    ),
146    ProtoFreqRule::non_pageable(QOT_GET_COMPANY_PROFILE, 30),
147    ProtoFreqRule::non_pageable(QOT_GET_COMPANY_EXECUTIVES, 30),
148    ProtoFreqRule::non_pageable(QOT_GET_COMPANY_EXECUTIVE_BACKGROUND, 30),
149    ProtoFreqRule::cpp_default(
150        QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY,
151        30,
152        PageCursorKind::StringNextKey,
153    ),
154    ProtoFreqRule::non_pageable(QOT_GET_TOP_TEN_BUY_SELL_BROKERS, 30),
155    ProtoFreqRule::non_pageable(QOT_GET_DAILY_SHORT_VOLUME, 30),
156    ProtoFreqRule::non_pageable(QOT_GET_SHORT_INTEREST, 30),
157    ProtoFreqRule::non_pageable(QOT_GET_OPTION_VOLATILITY, 30),
158    ProtoFreqRule::non_pageable(QOT_GET_OPTION_EXERCISE_PROBABILITY, 30),
159    // C++ latest 3401-3433: 30 秒 60 次,pageable=true 时仅首页计数。
160    ProtoFreqRule::non_pageable(QOT_GET_EARNINGS_CALENDAR, 60),
161    ProtoFreqRule::non_pageable(QOT_GET_MACRO_INDICATOR_LIST, 60),
162    ProtoFreqRule::non_pageable(QOT_GET_MACRO_INDICATOR_HISTORY, 60),
163    ProtoFreqRule::non_pageable(QOT_GET_FED_WATCH_TARGET_RATE, 60),
164    ProtoFreqRule::non_pageable(QOT_GET_FED_WATCH_DOT_PLOT, 60),
165    ProtoFreqRule::non_pageable(QOT_GET_EARNINGS_BEAT_RANK, 60),
166    ProtoFreqRule::non_pageable(QOT_GET_DIVIDEND_RANK, 60),
167    ProtoFreqRule::non_pageable(QOT_GET_DIVIDEND_CALENDAR, 60),
168    ProtoFreqRule::non_pageable(QOT_GET_ECONOMIC_CALENDAR, 60),
169    ProtoFreqRule::cpp_default(QOT_GET_US_PRE_MARKET_RANK, 60, PageCursorKind::Offset),
170    ProtoFreqRule::cpp_default(QOT_GET_US_AFTER_HOURS_RANK, 60, PageCursorKind::Offset),
171    ProtoFreqRule::cpp_default(QOT_GET_US_OVERNIGHT_RANK, 60, PageCursorKind::Offset),
172    ProtoFreqRule::cpp_default(QOT_GET_TOP_MOVERS_RANK, 60, PageCursorKind::Offset),
173    ProtoFreqRule::cpp_default(QOT_GET_HOT_LIST, 60, PageCursorKind::Offset),
174    ProtoFreqRule::cpp_default(QOT_GET_SHORT_SELLING_RANK, 60, PageCursorKind::Offset),
175    ProtoFreqRule::cpp_default(QOT_GET_PERIOD_CHANGE_RANK, 60, PageCursorKind::Offset),
176    ProtoFreqRule::cpp_default(QOT_GET_HIGH_DIVIDEND_SOE_RANK, 60, PageCursorKind::Offset),
177    ProtoFreqRule::cpp_default(QOT_GET_INSTITUTION_LIST, 60, PageCursorKind::StringPage),
178    ProtoFreqRule::non_pageable(QOT_GET_INSTITUTION_PROFILE, 60),
179    ProtoFreqRule::non_pageable(QOT_GET_INSTITUTION_DISTRIBUTION, 60),
180    ProtoFreqRule::cpp_default(
181        QOT_GET_INSTITUTION_HOLDING_CHANGE,
182        60,
183        PageCursorKind::StringPage,
184    ),
185    ProtoFreqRule::cpp_default(
186        QOT_GET_INSTITUTION_HOLDING_LIST,
187        60,
188        PageCursorKind::StringPage,
189    ),
190    ProtoFreqRule::cpp_default(QOT_GET_ARK_FUND_HOLDING, 60, PageCursorKind::StringPage),
191    ProtoFreqRule::non_pageable(QOT_GET_ARK_STOCK_DYNAMIC, 60),
192    ProtoFreqRule::cpp_default(
193        QOT_GET_ARK_ACTIVE_TRANSACTION,
194        60,
195        PageCursorKind::StringPage,
196    ),
197    ProtoFreqRule::cpp_default(QOT_GET_RATING_CHANGE, 60, PageCursorKind::StringPage),
198    ProtoFreqRule::cpp_default(
199        QOT_GET_INDUSTRIAL_CHAIN_LIST,
200        60,
201        PageCursorKind::StringPage,
202    ),
203    ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_CHAIN_DETAIL, 60),
204    ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE, 60),
205    ProtoFreqRule::non_pageable(QOT_GET_INDUSTRIAL_PLATE_INFO, 60),
206    ProtoFreqRule::cpp_default(
207        QOT_GET_INDUSTRIAL_PLATE_STOCK,
208        60,
209        PageCursorKind::StringPage,
210    ),
211    ProtoFreqRule::cpp_default(QOT_GET_HEAT_MAP_DATA, 60, PageCursorKind::StringPage),
212    ProtoFreqRule::non_pageable(QOT_GET_RISE_FALL_DISTRIBUTION, 60),
213];
214
215/// 单连接的重放记录
216struct ConnFreqRecord {
217    /// 防重放:上次见到的 serial number
218    last_serial: u32,
219}
220
221impl ConnFreqRecord {
222    fn new() -> Self {
223        Self { last_serial: 0 }
224    }
225}
226
227#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
228struct FreqBucketKey {
229    scope_id: u64,
230    proto_id: u32,
231}
232
233/// 限频保护器
234pub struct ProtectionManager {
235    records: Mutex<HashMap<u64, ConnFreqRecord>>,
236    freq_buckets: Mutex<HashMap<FreqBucketKey, VecDeque<Instant>>>,
237    proto_limits: HashMap<u32, ProtoFreqRule>,
238}
239
240impl ProtectionManager {
241    /// 创建新的 [`ProtectionManager`] 实例。内部自动加载 per-proto_id 默认限
242    /// 频表(对齐 C++ 默认配置),不需额外初始化。
243    pub fn new() -> Self {
244        Self {
245            records: Mutex::new(HashMap::new()),
246            freq_buckets: Mutex::new(HashMap::new()),
247            proto_limits: PROTO_FREQ_RULES
248                .iter()
249                .map(|rule| (rule.proto_id, *rule))
250                .collect(),
251        }
252    }
253
254    /// 检查请求是否超频
255    ///
256    /// 返回 true 表示被限频(应拒绝),false 表示通过
257    pub fn check_freq_limit(&self, conn_id: u64, proto_id: u32) -> bool {
258        self.check_freq_limit_page(conn_id, proto_id, true)
259    }
260
261    /// 检查完整请求是否超频。
262    ///
263    /// 对 pageable API,需要解码请求体判断是否首页;解码失败时按首页处理,
264    /// 与 C++ 入口“无法确认续页就走普通频控”的保守行为一致。
265    pub fn check_request_freq_limit(&self, conn_id: u64, request: &IncomingRequest) -> bool {
266        let mapped_proto = map_proto_to_freq_limit_group(request.proto_id);
267        let is_first_page = self
268            .proto_limits
269            .get(&mapped_proto)
270            .filter(|rule| rule.is_pageable())
271            .and_then(|_| request_is_first_page_for_limit(mapped_proto, request.body.as_ref()))
272            .unwrap_or(true);
273        self.check_freq_limit_page(conn_id, request.proto_id, is_first_page)
274    }
275
276    /// 检查请求是否超频,并带分页首页标记。
277    ///
278    /// C++ 对 pageable API 的规则是:第 1 页计入 30 秒滑动窗口,后续页不计数。
279    /// 非 pageable API 忽略 `is_first_page`,每次请求都计数。
280    pub fn check_freq_limit_page(&self, conn_id: u64, proto_id: u32, is_first_page: bool) -> bool {
281        let mapped_proto = map_proto_to_freq_limit_group(proto_id);
282        let item = match self.proto_limits.get(&mapped_proto) {
283            Some(item) => *item,
284            None => return false, // 无限制的协议
285        };
286
287        if item.is_pageable() && !is_first_page {
288            return false;
289        }
290
291        let mut buckets = self.freq_buckets.lock();
292        let times = buckets
293            .entry(freq_bucket_key(conn_id, mapped_proto, item.scope))
294            .or_default();
295        let now = Instant::now();
296
297        // 清理过期记录
298        while times
299            .front()
300            .is_some_and(|t| now.duration_since(*t) > FREQ_WINDOW)
301        {
302            times.pop_front();
303        }
304
305        if times.len() as u32 >= item.limit {
306            true // 超频
307        } else {
308            times.push_back(now);
309            false
310        }
311    }
312
313    /// 检查防重放(serial number 必须递增)
314    ///
315    /// 返回 true 表示可能是重放攻击,false 表示正常
316    pub fn check_replay(&self, conn_id: u64, serial_no: u32) -> bool {
317        let mut records = self.records.lock();
318        let record = records.entry(conn_id).or_insert_with(ConnFreqRecord::new);
319
320        if serial_no <= record.last_serial {
321            true // 重放
322        } else {
323            record.last_serial = serial_no;
324            false
325        }
326    }
327
328    /// 连接断开时清理
329    pub fn on_disconnect(&self, conn_id: u64) {
330        self.records.lock().remove(&conn_id);
331        self.freq_buckets
332            .lock()
333            .retain(|key, _| key.scope_id != conn_id);
334    }
335}
336
337fn map_proto_to_freq_limit_group(proto_id: u32) -> u32 {
338    if proto_id == futu_core::proto_id::TRD_PLACE_COMBO_ORDER {
339        futu_core::proto_id::TRD_PLACE_ORDER
340    } else {
341        proto_id
342    }
343}
344
345fn freq_bucket_key(conn_id: u64, proto_id: u32, scope: FreqScope) -> FreqBucketKey {
346    FreqBucketKey {
347        scope_id: scope.bucket_scope_id(conn_id),
348        proto_id,
349    }
350}
351
352fn request_is_first_page_for_limit(proto_id: u32, body: &[u8]) -> Option<bool> {
353    match proto_id {
354        QOT_REQUEST_HISTORY_KL => {
355            decode_request::<futu_proto::qot_request_history_kl::Request, _>(body, |req| {
356                bytes_cursor_is_empty(&req.c2s.next_req_key)
357            })
358        }
359        QOT_GET_SHAREHOLDERS_HOLDER_DETAIL => {
360            decode_request::<futu_proto::qot_get_shareholders_holder_detail::Request, _>(
361                body,
362                |req| string_cursor_is_empty(&req.c2s.next_key),
363            )
364        }
365        QOT_GET_INSIDER_HOLDER_LIST => {
366            decode_request::<futu_proto::qot_get_insider_holder_list::Request, _>(body, |req| {
367                string_cursor_is_empty(&req.c2s.next_key)
368            })
369        }
370        QOT_GET_INSIDER_TRADE_LIST => {
371            decode_request::<futu_proto::qot_get_insider_trade_list::Request, _>(body, |req| {
372                string_cursor_is_empty(&req.c2s.next_key)
373            })
374        }
375        QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY => {
376            decode_request::<futu_proto::qot_get_company_operational_efficiency::Request, _>(
377                body,
378                |req| string_cursor_is_empty(&req.c2s.next_key),
379            )
380        }
381        QOT_GET_US_PRE_MARKET_RANK => decode_request::<
382            futu_proto::qot_get_us_pre_market_rank::Request,
383            _,
384        >(body, |req| offset_is_first_page(req.c2s.offset)),
385        QOT_GET_US_AFTER_HOURS_RANK => decode_request::<
386            futu_proto::qot_get_us_after_hours_rank::Request,
387            _,
388        >(body, |req| offset_is_first_page(req.c2s.offset)),
389        QOT_GET_US_OVERNIGHT_RANK => decode_request::<
390            futu_proto::qot_get_us_overnight_rank::Request,
391            _,
392        >(body, |req| offset_is_first_page(req.c2s.offset)),
393        QOT_GET_TOP_MOVERS_RANK => {
394            decode_request::<futu_proto::qot_get_top_movers_rank::Request, _>(body, |req| {
395                offset_is_first_page(req.c2s.offset)
396            })
397        }
398        QOT_GET_HOT_LIST => {
399            decode_request::<futu_proto::qot_get_hot_list::Request, _>(body, |req| {
400                offset_is_first_page(req.c2s.offset)
401            })
402        }
403        QOT_GET_SHORT_SELLING_RANK => decode_request::<
404            futu_proto::qot_get_short_selling_rank::Request,
405            _,
406        >(body, |req| offset_is_first_page(req.c2s.offset)),
407        QOT_GET_PERIOD_CHANGE_RANK => decode_request::<
408            futu_proto::qot_get_period_change_rank::Request,
409            _,
410        >(body, |req| offset_is_first_page(req.c2s.offset)),
411        QOT_GET_HIGH_DIVIDEND_SOE_RANK => {
412            decode_request::<futu_proto::qot_get_high_dividend_soe_rank::Request, _>(body, |req| {
413                offset_is_first_page(req.c2s.offset)
414            })
415        }
416        QOT_GET_INSTITUTION_LIST => decode_request::<
417            futu_proto::qot_get_institution_list::Request,
418            _,
419        >(body, |req| string_cursor_is_empty(&req.c2s.page)),
420        QOT_GET_INSTITUTION_HOLDING_CHANGE => {
421            decode_request::<futu_proto::qot_get_institution_holding_change::Request, _>(
422                body,
423                |req| string_cursor_is_empty(&req.c2s.page),
424            )
425        }
426        QOT_GET_INSTITUTION_HOLDING_LIST => {
427            decode_request::<futu_proto::qot_get_institution_holding_list::Request, _>(
428                body,
429                |req| string_cursor_is_empty(&req.c2s.page),
430            )
431        }
432        QOT_GET_ARK_FUND_HOLDING => decode_request::<
433            futu_proto::qot_get_ark_fund_holding::Request,
434            _,
435        >(body, |req| string_cursor_is_empty(&req.c2s.page)),
436        QOT_GET_ARK_ACTIVE_TRANSACTION => {
437            decode_request::<futu_proto::qot_get_ark_active_transaction::Request, _>(body, |req| {
438                string_cursor_is_empty(&req.c2s.page)
439            })
440        }
441        QOT_GET_RATING_CHANGE => {
442            decode_request::<futu_proto::qot_get_rating_change::Request, _>(body, |req| {
443                string_cursor_is_empty(&req.c2s.page)
444            })
445        }
446        QOT_GET_INDUSTRIAL_CHAIN_LIST => {
447            decode_request::<futu_proto::qot_get_industrial_chain_list::Request, _>(body, |req| {
448                string_cursor_is_empty(&req.c2s.page)
449            })
450        }
451        QOT_GET_INDUSTRIAL_PLATE_STOCK => {
452            decode_request::<futu_proto::qot_get_industrial_plate_stock::Request, _>(body, |req| {
453                string_cursor_is_empty(&req.c2s.page)
454            })
455        }
456        QOT_GET_HEAT_MAP_DATA => {
457            decode_request::<futu_proto::qot_get_heat_map_data::Request, _>(body, |req| {
458                string_cursor_is_empty(&req.c2s.page)
459            })
460        }
461        _ => None,
462    }
463}
464
465#[cfg(test)]
466fn has_first_page_decoder(proto_id: u32) -> bool {
467    matches!(
468        proto_id,
469        QOT_REQUEST_HISTORY_KL
470            | QOT_GET_SHAREHOLDERS_HOLDER_DETAIL
471            | QOT_GET_INSIDER_HOLDER_LIST
472            | QOT_GET_INSIDER_TRADE_LIST
473            | QOT_GET_COMPANY_OPERATIONAL_EFFICIENCY
474            | QOT_GET_US_PRE_MARKET_RANK
475            | QOT_GET_US_AFTER_HOURS_RANK
476            | QOT_GET_US_OVERNIGHT_RANK
477            | QOT_GET_TOP_MOVERS_RANK
478            | QOT_GET_HOT_LIST
479            | QOT_GET_SHORT_SELLING_RANK
480            | QOT_GET_PERIOD_CHANGE_RANK
481            | QOT_GET_HIGH_DIVIDEND_SOE_RANK
482            | QOT_GET_INSTITUTION_LIST
483            | QOT_GET_INSTITUTION_HOLDING_CHANGE
484            | QOT_GET_INSTITUTION_HOLDING_LIST
485            | QOT_GET_ARK_FUND_HOLDING
486            | QOT_GET_ARK_ACTIVE_TRANSACTION
487            | QOT_GET_RATING_CHANGE
488            | QOT_GET_INDUSTRIAL_CHAIN_LIST
489            | QOT_GET_INDUSTRIAL_PLATE_STOCK
490            | QOT_GET_HEAT_MAP_DATA
491    )
492}
493
494fn decode_request<M, F>(body: &[u8], is_first_page: F) -> Option<bool>
495where
496    M: Message + Default,
497    F: FnOnce(M) -> bool,
498{
499    M::decode(body).ok().map(is_first_page)
500}
501
502fn bytes_cursor_is_empty(cursor: &Option<Vec<u8>>) -> bool {
503    cursor.as_ref().is_none_or(Vec::is_empty)
504}
505
506fn string_cursor_is_empty(cursor: &Option<String>) -> bool {
507    cursor.as_deref().unwrap_or("").is_empty()
508}
509
510fn offset_is_first_page(offset: Option<i32>) -> bool {
511    // Ref: C++ APIServer_Qot_*Rank.cpp: `!c2s.has_offset() || c2s.offset() == 0`.
512    offset.unwrap_or(0) == 0
513}
514
515impl Default for ProtectionManager {
516    fn default() -> Self {
517        Self::new()
518    }
519}
520
521#[cfg(test)]
522mod tests;