Skip to main content

futu_cache/
qot_right.rs

1// 行情权限缓存
2// 存储从后端 CMD 6024 获取的行情权限和 API 额度信息
3// 对应 C++ INNData_Qot_Right + INNData_APIInterLimit
4//
5// v1.4.106 codex 1217 (5月1日) 7 finding 全 default ON 真实装:
6//   F1 [P1] epoch / freshness 状态机 + relogin/reconnect/replace 触发
7//   F2 [P1] US futures detail 0 值覆盖旧权限 (字段存在=覆盖)
8//   F3 [P2] CMD6024 validity predicate 扩为"任意 qot right 字段存在" (push_parser)
9//   F4 [P2] startup async refresh window 不暴露默认 quota / Unknown 权限 (sys.rs)
10//   F5 [P2] 6651/6006 push 闭环 + NotifyType_QotRight 广播 (push_parser + bridge)
11//   F6 [P2] request_qot_right 携带 quote_change_notify (双模式) (push_parser)
12//   F7 [P2] TestCmd / RemoteCmd 返结构化 RefreshReport (sys.rs)
13
14use parking_lot::{Mutex, RwLock};
15
16use crate::static_data::CachedSecurityInfo;
17
18const QOT_RIGHT_UNKNOWN: i32 = 0;
19const QOT_RIGHT_BMP: i32 = 1;
20const QOT_RIGHT_LEVEL1: i32 = 2;
21const QOT_RIGHT_LEVEL2: i32 = 3;
22pub const QOT_RIGHT_SF: i32 = 4;
23const QOT_RIGHT_NO: i32 = 5;
24const SECURITY_TYPE_INDEX: i32 = 6;
25const SECURITY_TYPE_DRVT: i32 = 8;
26const SECURITY_TYPE_FUTURE: i32 = 10;
27const SECURITY_TYPE_CRYPTO: i32 = 12;
28const QOT_MARKET_CC_SECURITY: i32 = 91;
29pub const US_LV2_ORDER_ARCA: u32 = 1;
30pub const US_LV2_ORDER_NASDAQ_TV: u32 = 4;
31pub const US_LV2_ORDER_OVERNIGHT: u32 = 128;
32pub const LV2_ORDER_US_FUTURE: u32 = 1;
33
34fn hk_clt_to_api(clt: u32) -> i32 {
35    match clt {
36        1 => 3,
37        2 => 1,
38        3 => 2,
39        4 => 4,
40        _ => 0,
41    }
42}
43
44fn us_clt_to_api(clt: u32) -> i32 {
45    match clt {
46        1 => 2,
47        2 => 5,
48        _ => 5,
49    }
50}
51
52fn cn_clt_to_api(clt: u32) -> i32 {
53    match clt {
54        1 => 2,
55        2 => 3,
56        3 => 5,
57        _ => 5,
58    }
59}
60
61fn other_clt_to_api(clt: u32) -> i32 {
62    match clt {
63        1 => 3,
64        2 => 2,
65        _ => 5,
66    }
67}
68
69fn us_future_clt_to_api(clt: u32) -> i32 {
70    match clt {
71        0 => 5,
72        1 => 3,
73        2 => 1,
74        3 => 3,
75        4 => 2,
76        _ => 5,
77    }
78}
79
80fn us_option_clt_to_api(clt: u32) -> i32 {
81    match clt {
82        0 => 1,
83        1 => 2,
84        _ => 1,
85    }
86}
87
88fn us_otc_comm_auth_to_api(deal_data_auth: Option<u32>, order_book_auth: Option<u32>) -> i32 {
89    // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:572-604.
90    // C++ treats missing OTC fields as COMM_AUTH_RT; OTC is usable only when
91    // both deal data and orderbook are realtime.
92    const COMM_AUTH_RT: u32 = 2;
93    const QOT_RIGHT_LEVEL1: i32 = 2;
94    const QOT_RIGHT_NO: i32 = 5;
95    let deal = deal_data_auth.unwrap_or(COMM_AUTH_RT);
96    let order_book = order_book_auth.unwrap_or(COMM_AUTH_RT);
97    if deal == COMM_AUTH_RT && order_book == COMM_AUTH_RT {
98        QOT_RIGHT_LEVEL1
99    } else {
100        QOT_RIGHT_NO
101    }
102}
103
104fn crypto_auth_to_api(auth: u32) -> i32 {
105    if auth == 1 {
106        QOT_RIGHT_LEVEL1
107    } else {
108        QOT_RIGHT_NO
109    }
110}
111
112fn us_index_flags_to_api(
113    dow_jones: Option<u32>,
114    nasdaq: Option<u32>,
115    standard_poor: Option<u32>,
116) -> Option<i32> {
117    // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:529-566.
118    // Any present index flag makes the aggregate US index right known; any
119    // positive family grants Level1 for all US index symbols.
120    const QOT_RIGHT_LEVEL1: i32 = 2;
121    const QOT_RIGHT_NO: i32 = 5;
122    if dow_jones.is_none() && nasdaq.is_none() && standard_poor.is_none() {
123        return None;
124    }
125    let has_any = dow_jones == Some(1) || nasdaq == Some(1) || standard_poor == Some(1);
126    Some(if has_any {
127        QOT_RIGHT_LEVEL1
128    } else {
129        QOT_RIGHT_NO
130    })
131}
132
133fn has_any_us_lv2_flag(flags: UsLv2Flags) -> bool {
134    let (arca, nyse, nasdaq_tv, edg, bzx) = flags;
135    arca == Some(1) || nyse == Some(1) || nasdaq_tv == Some(1) || edg == Some(1) || bzx == Some(1)
136}
137
138pub type UsLv2Flags = (
139    Option<u32>,
140    Option<u32>,
141    Option<u32>,
142    Option<u32>,
143    Option<u32>,
144);
145pub type UsIndexFlags = (Option<u32>, Option<u32>, Option<u32>);
146pub type UsOtcAuths = (Option<u32>, Option<u32>);
147pub type UsFutureDetailAuths = (Option<u32>, Option<u32>, Option<u32>, Option<u32>);
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct Lv2OrderSubDescriptor {
151    pub lv2_type: u32,
152    pub level: u32,
153    pub prob2_v2: bool,
154}
155
156#[derive(Debug, Clone, Copy, Default)]
157pub struct QotRightBackendExtras {
158    pub us_lv2_flags: Option<UsLv2Flags>,
159    pub us_index_flags: Option<UsIndexFlags>,
160    pub us_otc_auths: Option<UsOtcAuths>,
161}
162
163impl QotRightBackendExtras {
164    pub const fn none() -> Self {
165        Self {
166            us_lv2_flags: None,
167            us_index_flags: None,
168            us_otc_auths: None,
169        }
170    }
171}
172
173#[derive(Debug, Clone, Copy, Default)]
174pub struct QotRightBackendUpdate {
175    pub hk_got: Option<u32>,
176    pub us_got: Option<u32>,
177    pub cn_got: Option<u32>,
178    pub sh_auth: Option<u32>,
179    pub sz_auth: Option<u32>,
180    pub hk_option: Option<u32>,
181    pub hk_future: Option<u32>,
182    pub us_option: Option<u32>,
183    pub us_future_cme_cboe: Option<u32>,
184    pub us_future_detail: Option<UsFutureDetailAuths>,
185    pub sg_future: Option<u32>,
186    pub jp_future: Option<u32>,
187    pub digital_currency_auth: Option<u32>,
188    pub digital_pt_orderbook_auth: Option<u32>,
189    pub sub_limit: Option<u32>,
190    pub kl_limit: Option<u32>,
191    pub extras: QotRightBackendExtras,
192}
193
194#[derive(Debug, Clone)]
195pub struct QotRightData {
196    pub hk_qot_right: i32,
197    pub us_qot_right: i32,
198    /// C++ `INNData_Qot_Right::GetUSQotStatus()` projection for GetUserInfo/Notify.
199    pub api_us_qot_right: i32,
200    pub sh_qot_right: i32,
201    pub sz_qot_right: i32,
202    pub hk_option_qot_right: i32,
203    pub hk_future_qot_right: i32,
204    pub has_us_option_qot_right: bool,
205    pub us_option_qot_right: i32,
206    pub us_index_qot_right: i32,
207    pub us_otc_qot_right: i32,
208    pub us_cme_future_qot_right: i32,
209    pub us_cbot_future_qot_right: i32,
210    pub us_nymex_future_qot_right: i32,
211    pub us_comex_future_qot_right: i32,
212    pub us_cboe_future_qot_right: i32,
213    pub sg_future_qot_right: i32,
214    pub jp_future_qot_right: i32,
215    pub cc_qot_right: i32,
216    pub cc_pt_orderbook_qot_right: i32,
217    pub us_lv2_arca_qot_right: bool,
218    pub us_lv2_nyse_qot_right: bool,
219    pub us_lv2_nasdaq_totalview_qot_right: bool,
220    pub sub_quota: i32,
221    pub history_kl_quota: i32,
222    /// v1.4.106 codex 1209 F4: HK option/future orderbook depth.
223    pub hk_option_orderbook_depth: Option<u32>,
224    pub hk_future_orderbook_depth: Option<u32>,
225}
226
227impl Default for QotRightData {
228    fn default() -> Self {
229        const UNKNOWN: i32 = 0;
230        Self {
231            hk_qot_right: UNKNOWN,
232            us_qot_right: UNKNOWN,
233            api_us_qot_right: UNKNOWN,
234            sh_qot_right: UNKNOWN,
235            sz_qot_right: UNKNOWN,
236            hk_option_qot_right: UNKNOWN,
237            hk_future_qot_right: UNKNOWN,
238            has_us_option_qot_right: true,
239            us_option_qot_right: UNKNOWN,
240            us_index_qot_right: UNKNOWN,
241            us_otc_qot_right: UNKNOWN,
242            us_cme_future_qot_right: UNKNOWN,
243            us_cbot_future_qot_right: UNKNOWN,
244            us_nymex_future_qot_right: UNKNOWN,
245            us_comex_future_qot_right: UNKNOWN,
246            us_cboe_future_qot_right: UNKNOWN,
247            sg_future_qot_right: UNKNOWN,
248            jp_future_qot_right: UNKNOWN,
249            cc_qot_right: UNKNOWN,
250            cc_pt_orderbook_qot_right: UNKNOWN,
251            us_lv2_arca_qot_right: false,
252            us_lv2_nyse_qot_right: false,
253            us_lv2_nasdaq_totalview_qot_right: false,
254            sub_quota: 4000,
255            history_kl_quota: 100,
256            hk_option_orderbook_depth: None,
257            hk_future_orderbook_depth: None,
258        }
259    }
260}
261
262// ===== v1.4.106 codex 1217 F1 [P1] — freshness state machine =====
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum QotRightFreshness {
266    Unknown,
267    Pending,
268    Fresh,
269    Stale,
270    Failed { error: String, since_ms: i64 },
271}
272
273impl QotRightFreshness {
274    pub fn is_fresh(&self) -> bool {
275        matches!(self, QotRightFreshness::Fresh)
276    }
277    pub fn is_unconfirmed(&self) -> bool {
278        !self.is_fresh()
279    }
280}
281
282#[derive(Debug, Clone, Default)]
283pub struct PushedQuoteChangeNotify {
284    pub change_items: Vec<(i32, i32, i32)>,
285}
286
287#[derive(Debug, Clone)]
288pub struct QotRightRefreshReport {
289    pub changed: bool,
290    pub fresh_at_ms: i64,
291    pub decoded_fields: Vec<&'static str>,
292    pub freshness_after: QotRightFreshness,
293    pub login_epoch: u64,
294}
295
296#[derive(Debug, Clone)]
297pub struct QotRightStateMeta {
298    pub freshness: QotRightFreshness,
299    pub user_id: Option<u64>,
300    pub login_epoch: u64,
301    pub backend_generation: u64,
302    pub last_refresh_at_ms: i64,
303    pub last_pushed_quote_change_notify: Option<PushedQuoteChangeNotify>,
304}
305
306impl Default for QotRightStateMeta {
307    fn default() -> Self {
308        Self {
309            freshness: QotRightFreshness::Unknown,
310            user_id: None,
311            login_epoch: 0,
312            backend_generation: 0,
313            last_refresh_at_ms: 0,
314            last_pushed_quote_change_notify: None,
315        }
316    }
317}
318
319fn now_ms() -> i64 {
320    use std::time::{SystemTime, UNIX_EPOCH};
321    SystemTime::now()
322        .duration_since(UNIX_EPOCH)
323        .map(|d| d.as_millis() as i64)
324        .unwrap_or(0)
325}
326
327fn is_hk_future_market(info: &CachedSecurityInfo) -> bool {
328    info.sec_type == SECURITY_TYPE_FUTURE && matches!(info.mkt_id, 5 | 6 | 110..=119 | 1400..=1499)
329}
330
331fn is_us_future_market(info: &CachedSecurityInfo) -> bool {
332    info.sec_type == SECURITY_TYPE_FUTURE && (60..=109).contains(&info.mkt_id)
333}
334
335fn is_sg_future_market(info: &CachedSecurityInfo) -> bool {
336    info.sec_type == SECURITY_TYPE_FUTURE && (160..=179).contains(&info.mkt_id)
337}
338
339fn is_jp_future_market(info: &CachedSecurityInfo) -> bool {
340    info.sec_type == SECURITY_TYPE_FUTURE && (185..=194).contains(&info.mkt_id)
341}
342
343fn is_hk_option_market(info: &CachedSecurityInfo) -> bool {
344    info.sec_type == SECURITY_TYPE_DRVT && matches!(info.mkt_id, 7 | 8 | 570..=579)
345}
346
347fn is_us_option_market(info: &CachedSecurityInfo) -> bool {
348    info.sec_type == SECURITY_TYPE_DRVT && (41..=49).contains(&info.mkt_id)
349}
350
351fn is_hk_security_market(info: &CachedSecurityInfo) -> bool {
352    matches!(info.mkt_id, 1..=4 | 1000..=1049)
353        || (info.market == 1
354            && !is_hk_future_market(info)
355            && !is_hk_option_market(info)
356            && info.sec_type != SECURITY_TYPE_FUTURE
357            && info.sec_type != SECURITY_TYPE_DRVT)
358}
359
360/// C++ `APIServer_Qot_StockSnapshot.cpp:18-43` (`IsHKBMP_OneStock`).
361///
362/// When this returns true, GetSecuritySnapshot must omit bid/ask price and
363/// volume fields instead of returning delayed BMP values.
364pub fn snapshot_masks_hk_bmp_bid_ask(info: &CachedSecurityInfo, qr: &QotRightData) -> bool {
365    let is_hk = matches!(info.market, 1 | 2);
366    if !is_hk {
367        return false;
368    }
369
370    if is_hk_option_market(info) {
371        return qr.hk_option_qot_right == QOT_RIGHT_BMP;
372    }
373    if is_hk_future_market(info) {
374        return qr.hk_future_qot_right == QOT_RIGHT_BMP;
375    }
376    is_hk_security_market(info) && qr.hk_qot_right == QOT_RIGHT_BMP
377}
378
379fn is_us_security_market(info: &CachedSecurityInfo) -> bool {
380    matches!(info.mkt_id, 10..=29 | 1200..=1249)
381        || (info.market == 11
382            && !is_us_future_market(info)
383            && !is_us_option_market(info)
384            && info.sec_type != SECURITY_TYPE_FUTURE
385            && info.sec_type != SECURITY_TYPE_DRVT)
386}
387
388/// v1.4.110 codex Phase 4 Slice 7: 把 crypto detection helper 暴露给 push_parser
389/// / handler (crypto LV2 sub-system 入口).
390///
391/// 对齐 C++ `IsCrypto(stockID)` (NNBiz_Qot_SecList.cpp). 三个条件任一: sec_type==Crypto
392/// / FTAPI QotMarket=91 (CC_Security) / mkt_id ∈ [360,459] (DigitalCcy range).
393pub fn is_crypto_market(info: &CachedSecurityInfo) -> bool {
394    info.sec_type == SECURITY_TYPE_CRYPTO
395        || info.market == QOT_MARKET_CC_SECURITY
396        || (360..=459).contains(&info.mkt_id)
397}
398
399/// C++ `SubBitUtil::GetPushTypeSvrSubBit(NN_PushQot_Type_Most)` 对 US /
400/// US options / US futures 保留 `SBIT_US_PREMARKET_AFTERHOURS_DETAIL`, 让
401/// BasicQot 可以随订阅收到 preMarket / afterMarket / overnight。
402///
403/// Ref:
404/// - `FutuOpenD/Src/NNProtoCenter/Quote/SubBitUtil.cpp:7-17`
405/// - `FutuOpenD/Src/NNProtoCenter/Quote/SubBitUtil.cpp:91-95`
406pub fn basic_qot_uses_us_pre_after_detail(info: &CachedSecurityInfo) -> bool {
407    matches!(info.mkt_id, 10..=29 | 41..=45 | 60..=109 | 1200..=1249) || info.market == 11
408}
409
410fn is_sh_market(info: &CachedSecurityInfo) -> bool {
411    matches!(info.mkt_id, 30 | 32 | 33 | 34 | 36..=40) || info.market == 21
412}
413
414fn is_sz_market(info: &CachedSecurityInfo) -> bool {
415    matches!(info.mkt_id, 31 | 35) || info.market == 22
416}
417
418fn us_future_right_for_market(qr: &QotRightData, mkt_id: u32) -> i32 {
419    match mkt_id {
420        60..=69 => qr.us_nymex_future_qot_right,
421        70..=79 => qr.us_comex_future_qot_right,
422        80..=89 => qr.us_cbot_future_qot_right,
423        90..=99 => qr.us_cme_future_qot_right,
424        100..=109 => qr.us_cboe_future_qot_right,
425        _ => QOT_RIGHT_UNKNOWN,
426    }
427}
428
429pub fn merged_lv2_order_subs_for_security(
430    info: &CachedSecurityInfo,
431    qr: &QotRightData,
432    _extended_time: bool,
433) -> Vec<Lv2OrderSubDescriptor> {
434    if is_us_future_market(info) {
435        if us_future_right_for_market(qr, info.mkt_id) == QOT_RIGHT_LEVEL2 {
436            return vec![Lv2OrderSubDescriptor {
437                lv2_type: LV2_ORDER_US_FUTURE,
438                level: 60,
439                prob2_v2: true,
440            }];
441        }
442        return vec![];
443    }
444
445    if !is_us_security_market(info) || info.sec_type == SECURITY_TYPE_INDEX {
446        return vec![];
447    }
448    if !matches!(qr.us_qot_right, QOT_RIGHT_LEVEL1 | QOT_RIGHT_LEVEL2) {
449        return vec![];
450    }
451
452    let mut subs = Vec::new();
453    // Ref: FutuOpenD/Src/NNProtoCenter/Quote/MktQotSubInstance.cpp:118-137.
454    // C++ adds E_US_LV2_ORDER_OVERNIGHT for US stock OrderBook whenever any
455    // US LV2 orderbook right exists; it does not gate this on Qot_Sub.extendedTime.
456    if qr.us_lv2_arca_qot_right || qr.us_lv2_nyse_qot_right || qr.us_lv2_nasdaq_totalview_qot_right
457    {
458        subs.push(Lv2OrderSubDescriptor {
459            lv2_type: US_LV2_ORDER_OVERNIGHT,
460            level: 60,
461            prob2_v2: true,
462        });
463    }
464    if qr.us_lv2_nasdaq_totalview_qot_right {
465        subs.push(Lv2OrderSubDescriptor {
466            lv2_type: US_LV2_ORDER_NASDAQ_TV,
467            level: 60,
468            prob2_v2: false,
469        });
470    }
471    if qr.us_lv2_arca_qot_right {
472        subs.push(Lv2OrderSubDescriptor {
473            lv2_type: US_LV2_ORDER_ARCA,
474            level: 60,
475            prob2_v2: false,
476        });
477    }
478    subs
479}
480
481pub fn has_merged_lv2_order_subs_for_security(
482    info: &CachedSecurityInfo,
483    qr: &QotRightData,
484) -> bool {
485    !merged_lv2_order_subs_for_security(info, qr, true).is_empty()
486}
487
488/// C++ `GetOrderBookMaxNum` 对齐版:根据证券静态市场 + 行情权限决定
489/// orderbook push/cache 应保留的档位数。
490///
491/// Ref:
492/// - `FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:4331-4412`
493/// - `FutuOpenD/Src/APIServer/Business/Quote/QotRealTimeData.cpp:1211-1215`
494///
495/// Hardcoded / Assumption Ledger:
496/// - `mkt_id` 范围复用 backend stock-list 的 NN_QuoteMktID 区间;`1400..=1499`
497///   只在 `sec_type=Future` 时作为 live HK futures 私有 market_code 兜底。
498/// - HK option/future depth 缺省为 0,和 C++ `NNData_Qot_Right` 构造默认值一致。
499/// - 普通 HK/US/CN 股票在 Unknown 权限下保留 C++ 初始化默认值/分支语义,
500///   避免 push parser 在权限刷新窗口内把有效 backend push 误清空。
501pub fn order_book_max_depth_for_security(info: &CachedSecurityInfo, qr: &QotRightData) -> usize {
502    if is_crypto_market(info) {
503        return if qr.cc_qot_right == QOT_RIGHT_LEVEL1 {
504            40
505        } else {
506            0
507        };
508    }
509
510    if is_us_option_market(info) {
511        return if qr.us_option_qot_right == QOT_RIGHT_BMP {
512            0
513        } else {
514            1
515        };
516    }
517
518    if is_us_future_market(info) {
519        return match us_future_right_for_market(qr, info.mkt_id) {
520            QOT_RIGHT_LEVEL1 => 1,
521            QOT_RIGHT_LEVEL2 => 40,
522            _ => 0,
523        };
524    }
525
526    if is_hk_option_market(info) {
527        return if qr.hk_option_qot_right == QOT_RIGHT_BMP {
528            0
529        } else {
530            qr.hk_option_orderbook_depth.unwrap_or(0) as usize
531        };
532    }
533
534    if is_hk_future_market(info) {
535        return if qr.hk_future_qot_right == QOT_RIGHT_BMP {
536            0
537        } else {
538            qr.hk_future_orderbook_depth.unwrap_or(0) as usize
539        };
540    }
541
542    if is_hk_security_market(info) {
543        return match qr.hk_qot_right {
544            QOT_RIGHT_BMP | QOT_RIGHT_NO => 0,
545            QOT_RIGHT_LEVEL1 => 1,
546            QOT_RIGHT_LEVEL2 | QOT_RIGHT_SF => 10,
547            _ => 10,
548        };
549    }
550
551    if is_us_security_market(info) {
552        return if info.sec_type == SECURITY_TYPE_INDEX || qr.us_qot_right == QOT_RIGHT_BMP {
553            0
554        } else {
555            1
556        };
557    }
558
559    if is_sh_market(info) {
560        return if qr.sh_qot_right == QOT_RIGHT_BMP {
561            0
562        } else {
563            10
564        };
565    }
566
567    if is_sz_market(info) {
568        return if qr.sz_qot_right == QOT_RIGHT_BMP {
569            0
570        } else {
571            10
572        };
573    }
574
575    if is_sg_future_market(info) {
576        return match qr.sg_future_qot_right {
577            QOT_RIGHT_LEVEL1 => 1,
578            QOT_RIGHT_LEVEL2 => 40,
579            _ => 0,
580        };
581    }
582
583    if is_jp_future_market(info) {
584        return match qr.jp_future_qot_right {
585            QOT_RIGHT_LEVEL1 => 1,
586            QOT_RIGHT_LEVEL2 => 40,
587            _ => 0,
588        };
589    }
590
591    10
592}
593
594/// C++ `IsHKSF`: HK 股票 + HK SF 权限要求 backend 全档摆盘。
595///
596/// Ref:
597/// - `FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:900-913`
598/// - `FutuOpenD/Src/APIServer/Business/Quote/QotSubscribe.cpp:1282-1286`
599pub fn order_book_requires_backend_full_depth(
600    info: &CachedSecurityInfo,
601    qr: &QotRightData,
602) -> bool {
603    qr.hk_qot_right == QOT_RIGHT_SF && is_hk_security_market(info)
604}
605
606pub fn order_book_uses_backend_side_count(info: &CachedSecurityInfo, qr: &QotRightData) -> bool {
607    order_book_requires_backend_full_depth(info, qr)
608}
609
610/// C++ `GetOrderBook` read path skips `GetOrderBookMaxNum` clipping for HK SF
611/// and US TotalView Lv2.
612///
613/// Ref:
614/// - `FutuOpenD/Src/APIServer/Business/Quote/APIServer_Qot_OrderBook.cpp:86-89`
615/// - `FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:900-929`
616pub fn order_book_read_uses_requested_count(info: &CachedSecurityInfo, qr: &QotRightData) -> bool {
617    order_book_uses_backend_side_count(info, qr)
618        || (qr.us_lv2_nasdaq_totalview_qot_right && is_us_security_market(info))
619}
620
621/// C++ `GetOrderBook` 对 US TotalView Lv2 / Crypto orderbook 还有一层
622/// `IsHadAcceptUSLv2TotalViewData` gate: 普通 orderbook cache 存在不等于
623/// LV2 真盘口已经到达。
624///
625/// Ref:
626/// - `FutuOpenD/Src/APIServer/Business/Quote/APIServer_Qot_OrderBook.cpp:93-99`
627/// - `FutuOpenD/Src/APIServer/Business/Quote/QotRealTimeData.cpp:576-579`
628pub fn order_book_requires_accepted_lv2_push(info: &CachedSecurityInfo, qr: &QotRightData) -> bool {
629    (qr.us_lv2_nasdaq_totalview_qot_right && is_us_security_market(info)) || is_crypto_market(info)
630}
631
632pub struct QotRightCache {
633    data: RwLock<QotRightData>,
634    meta: Mutex<QotRightStateMeta>,
635}
636
637impl Default for QotRightCache {
638    fn default() -> Self {
639        Self::new()
640    }
641}
642
643impl QotRightCache {
644    pub fn new() -> Self {
645        Self {
646            data: RwLock::new(QotRightData::default()),
647            meta: Mutex::new(QotRightStateMeta::default()),
648        }
649    }
650
651    pub fn get(&self) -> QotRightData {
652        self.data.read().clone()
653    }
654
655    pub fn freshness(&self) -> QotRightFreshness {
656        self.meta.lock().freshness.clone()
657    }
658
659    pub fn is_fresh(&self) -> bool {
660        self.meta.lock().freshness.is_fresh()
661    }
662
663    pub fn meta_snapshot(&self) -> QotRightStateMeta {
664        self.meta.lock().clone()
665    }
666
667    pub fn set_orderbook_depths(&self, hk_option_depth: Option<u32>, hk_future_depth: Option<u32>) {
668        let mut d = self.data.write();
669        if let Some(v) = hk_option_depth {
670            d.hk_option_orderbook_depth = Some(v);
671        }
672        if let Some(v) = hk_future_depth {
673            d.hk_future_orderbook_depth = Some(v);
674        }
675    }
676
677    pub fn mark_stale(&self, reason: &str) {
678        let mut m = self.meta.lock();
679        let old = m.freshness.clone();
680        m.freshness = QotRightFreshness::Stale;
681        tracing::info!(
682            old_freshness = ?old,
683            reason,
684            login_epoch = m.login_epoch,
685            backend_generation = m.backend_generation,
686            "QotRightCache: marked stale"
687        );
688    }
689
690    pub fn advance_login_epoch(&self, new_epoch: u64, user_id: Option<u64>) {
691        let mut m = self.meta.lock();
692        let old_epoch = m.login_epoch;
693        m.login_epoch = new_epoch;
694        m.user_id = user_id;
695        if matches!(m.freshness, QotRightFreshness::Fresh) {
696            m.freshness = QotRightFreshness::Stale;
697        }
698        tracing::info!(
699            old_epoch,
700            new_epoch,
701            user_id = ?user_id,
702            "QotRightCache: login epoch advanced"
703        );
704    }
705
706    pub fn advance_backend_generation(&self, new_generation: u64) {
707        let mut m = self.meta.lock();
708        let old_gen = m.backend_generation;
709        m.backend_generation = new_generation;
710        if matches!(m.freshness, QotRightFreshness::Fresh) {
711            m.freshness = QotRightFreshness::Stale;
712        }
713        tracing::info!(
714            old_generation = old_gen,
715            new_generation,
716            "QotRightCache: backend generation advanced"
717        );
718    }
719
720    pub fn mark_pending(&self) {
721        self.meta.lock().freshness = QotRightFreshness::Pending;
722    }
723
724    pub fn mark_fresh(&self) {
725        let mut m = self.meta.lock();
726        m.freshness = QotRightFreshness::Fresh;
727        m.last_refresh_at_ms = now_ms();
728    }
729
730    pub fn mark_failed(&self, error: impl Into<String>) {
731        self.meta.lock().freshness = QotRightFreshness::Failed {
732            error: error.into(),
733            since_ms: now_ms(),
734        };
735    }
736
737    pub fn last_refresh_at_ms(&self) -> i64 {
738        self.meta.lock().last_refresh_at_ms
739    }
740
741    pub fn set_pushed_quote_change_notify(&self, notify: PushedQuoteChangeNotify) {
742        self.meta.lock().last_pushed_quote_change_notify = Some(notify);
743    }
744
745    pub fn pushed_quote_change_notify(&self) -> Option<PushedQuoteChangeNotify> {
746        self.meta.lock().last_pushed_quote_change_notify.clone()
747    }
748
749    pub fn apply_6651_changes(&self, items: &[(i32, i32, i32)]) -> Vec<i32> {
750        let mut d = self.data.write();
751        let mut changed_types = Vec::new();
752
753        for &(quote_type, before, after) in items {
754            // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:983-1010.
755            // C++ applies 6651 pushes only when old right is known and actually changed.
756            if before == 0 || before == after {
757                continue;
758            }
759            let after_u = after as u32;
760            if apply_qot_right_after_change(&mut d, quote_type, after_u) {
761                changed_types.push(quote_type);
762            }
763        }
764        if !changed_types.is_empty() {
765            drop(d);
766            let mut m = self.meta.lock();
767            m.freshness = QotRightFreshness::Fresh;
768            m.last_refresh_at_ms = now_ms();
769        }
770        changed_types
771    }
772
773    pub fn apply_direct_auth_changes(&self, items: &[(i32, u32)]) -> Vec<i32> {
774        let mut d = self.data.write();
775        let mut changed_types = Vec::new();
776
777        for &(quote_type, after) in items {
778            if apply_qot_right_after_change(&mut d, quote_type, after) {
779                changed_types.push(quote_type);
780            }
781        }
782        if !changed_types.is_empty() {
783            drop(d);
784            let mut m = self.meta.lock();
785            m.freshness = QotRightFreshness::Fresh;
786            m.last_refresh_at_ms = now_ms();
787        }
788        changed_types
789    }
790
791    /// 从后端 CMD 6024 响应更新权限数据.
792    ///
793    /// **v1.4.106 codex 1217 F2 [P1] (5月1日)**: us_future_detail 子字段存在 =
794    /// 覆盖 (含值=0). 之前 `if cme > 0` 跳过 0 让 LV1 → 0 (No) 实质降级被
795    /// silent 保留;但 C++ 逐个 `has_open_api_*_auth()` 判断,父字段存在不代表
796    /// 未返回的子市场可按 0 覆盖。
797    ///
798    /// **v1.4.106 codex 1217 F1**: apply 成功后自动 mark Fresh.
799    pub fn update_from_backend(&self, update: QotRightBackendUpdate) {
800        let QotRightBackendUpdate {
801            hk_got,
802            us_got,
803            cn_got,
804            sh_auth,
805            sz_auth,
806            hk_option,
807            hk_future,
808            us_option,
809            us_future_cme_cboe,
810            us_future_detail,
811            sg_future,
812            jp_future,
813            digital_currency_auth,
814            digital_pt_orderbook_auth,
815            sub_limit,
816            kl_limit,
817            extras,
818        } = update;
819        let mut d = self.data.write();
820
821        if let Some(v) = hk_got {
822            d.hk_qot_right = hk_clt_to_api(v);
823        }
824        if let Some(v) = us_got {
825            let right = us_clt_to_api(v);
826            d.us_qot_right = right;
827        }
828        if let Some(flags) = extras.us_lv2_flags {
829            let (arca, nyse, nasdaq_tv, _edg, _bzx) = flags;
830            d.us_lv2_arca_qot_right = arca == Some(1);
831            d.us_lv2_nyse_qot_right = nyse == Some(1);
832            d.us_lv2_nasdaq_totalview_qot_right = nasdaq_tv == Some(1);
833            if has_any_us_lv2_flag(flags) && matches!(d.us_qot_right, 2 | 3) {
834                // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:157-180.
835                // US stock LV2 is promoted by independent LV2 venue flags when the
836                // base US right is not BMP/no-right.
837                d.us_qot_right = 3;
838            }
839        }
840        if let Some((dow_jones, nasdaq, standard_poor)) = extras.us_index_flags
841            && let Some(api_right) = us_index_flags_to_api(dow_jones, nasdaq, standard_poor)
842        {
843            d.us_index_qot_right = api_right;
844        }
845        if let Some((deal_data_auth, order_book_auth)) = extras.us_otc_auths {
846            d.us_otc_qot_right = us_otc_comm_auth_to_api(deal_data_auth, order_book_auth);
847        }
848        if let Some(v) = sh_auth {
849            d.sh_qot_right = cn_clt_to_api(v);
850        } else if let Some(v) = cn_got {
851            d.sh_qot_right = cn_clt_to_api(v);
852        }
853        if let Some(v) = sz_auth {
854            d.sz_qot_right = cn_clt_to_api(v);
855        } else if let Some(v) = cn_got {
856            d.sz_qot_right = cn_clt_to_api(v);
857        }
858        if let Some(v) = hk_option {
859            d.hk_option_qot_right = hk_clt_to_api(v);
860        }
861        if let Some(v) = hk_future {
862            d.hk_future_qot_right = hk_clt_to_api(v);
863        }
864        if let Some(v) = us_option {
865            d.us_option_qot_right = us_option_clt_to_api(v);
866            d.has_us_option_qot_right = d.us_option_qot_right != 1;
867        }
868        if let Some(v) = us_future_cme_cboe {
869            d.us_cme_future_qot_right = us_future_clt_to_api(v);
870            // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:474-509.
871            // OpenAPI currently keeps CBOE futures as no-right until the server
872            // provides an open_api CBOE field; do not inherit the legacy
873            // cme_cboe aggregate.
874            d.us_cboe_future_qot_right = 5;
875        }
876
877        // F2 [P1] (v1.4.106 codex 1217): 子字段存在 = 覆盖 (含值=0).
878        // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:396-478.
879        if let Some((cme, cbot, nymex, comex)) = us_future_detail {
880            if let Some(cme) = cme {
881                d.us_cme_future_qot_right = us_future_clt_to_api(cme);
882            }
883            if let Some(cbot) = cbot {
884                d.us_cbot_future_qot_right = us_future_clt_to_api(cbot);
885            }
886            if let Some(nymex) = nymex {
887                d.us_nymex_future_qot_right = us_future_clt_to_api(nymex);
888            }
889            if let Some(comex) = comex {
890                d.us_comex_future_qot_right = us_future_clt_to_api(comex);
891            }
892            // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:474-509.
893            d.us_cboe_future_qot_right = 5;
894        }
895
896        if let Some(v) = sg_future {
897            d.sg_future_qot_right = other_clt_to_api(v);
898        }
899        if let Some(v) = jp_future {
900            d.jp_future_qot_right = other_clt_to_api(v);
901        }
902        if let Some(v) = digital_currency_auth {
903            d.cc_qot_right = crypto_auth_to_api(v);
904        }
905        if let Some(v) = digital_pt_orderbook_auth {
906            d.cc_pt_orderbook_qot_right = crypto_auth_to_api(v);
907        }
908
909        if let Some(v) = sub_limit
910            && v > 0
911        {
912            d.sub_quota = v as i32;
913        }
914        if let Some(v) = kl_limit
915            && v > 0
916        {
917            d.history_kl_quota = v as i32;
918        }
919
920        // F1 (v1.4.106 codex 1217): apply 成功 → 自动 mark Fresh.
921        drop(d);
922        let mut m = self.meta.lock();
923        m.freshness = QotRightFreshness::Fresh;
924        m.last_refresh_at_ms = now_ms();
925    }
926}
927
928fn apply_qot_right_after_change(data: &mut QotRightData, quote_type: i32, after: u32) -> bool {
929    match quote_type {
930        1 => data.hk_qot_right = hk_clt_to_api(after),
931        3 | 17 => {
932            let right = us_clt_to_api(after);
933            data.us_qot_right = right;
934            data.api_us_qot_right = right;
935        }
936        4 => data.hk_future_qot_right = hk_clt_to_api(after),
937        5 => data.hk_option_qot_right = hk_clt_to_api(after),
938        36 => data.sg_future_qot_right = other_clt_to_api(after),
939        39 => data.jp_future_qot_right = other_clt_to_api(after),
940        30 => data.us_cme_future_qot_right = us_future_clt_to_api(after),
941        31 => data.us_cbot_future_qot_right = us_future_clt_to_api(after),
942        32 => data.us_nymex_future_qot_right = us_future_clt_to_api(after),
943        33 => data.us_comex_future_qot_right = us_future_clt_to_api(after),
944        37 => data.us_otc_qot_right = if after == 2 { 2 } else { 5 },
945        38 => data.us_index_qot_right = us_clt_to_api(after),
946        40 => data.sh_qot_right = cn_clt_to_api(after),
947        41 => data.sz_qot_right = cn_clt_to_api(after),
948        _ => return false,
949    }
950    true
951}
952
953#[cfg(test)]
954mod tests;