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
16mod capability;
17#[cfg(test)]
18mod capability_tests;
19mod projection;
20mod security_rules;
21
22pub use capability::{
23    QuoteCapability, QuoteCryptoPolicy, QuoteOrderBookPolicy, QuoteSnapshotPolicy,
24    resolve_quote_capability,
25};
26pub use projection::{UsFutureDetailAuths, UsIndexFlags, UsLv2Flags, UsOtcAuths};
27use projection::{
28    cn_clt_to_api, crypto_auth_to_api, has_any_us_lv2_flag, hk_clt_to_api, jp_stock_clt_to_api,
29    my_stock_common_clt_to_api, other_clt_to_api, us_backend_status_to_api, us_clt_to_api,
30    us_future_clt_to_api, us_index_flags_to_api, us_option_clt_to_api, us_otc_comm_auth_to_api,
31};
32pub use security_rules::{
33    basic_qot_uses_us_pre_after_detail, has_merged_lv2_order_subs_for_security, is_crypto_market,
34    lv2_order_uses_jp_main_cache, lv2_order_uses_sg_odd_lot_cache,
35    merged_lv2_order_subs_for_security, order_book_max_depth_for_security,
36    order_book_read_uses_requested_count, order_book_requires_accepted_lv2_push,
37    order_book_requires_backend_full_depth, order_book_uses_backend_side_count,
38    snapshot_masks_hk_bmp_bid_ask, us_lv2_order_uses_exchange_cache,
39};
40
41const QOT_RIGHT_UNKNOWN: i32 = 0;
42const QOT_RIGHT_BMP: i32 = 1;
43const QOT_RIGHT_LEVEL1: i32 = 2;
44const QOT_RIGHT_LEVEL2: i32 = 3;
45pub const QOT_RIGHT_SF: i32 = 4;
46const QOT_RIGHT_NO: i32 = 5;
47const QOT_RIGHT_LEVEL3: i32 = 6;
48const SECURITY_TYPE_INDEX: i32 = 6;
49const SECURITY_TYPE_DRVT: i32 = 8;
50const SECURITY_TYPE_FUTURE: i32 = 10;
51const SECURITY_TYPE_CRYPTO: i32 = 12;
52const QOT_MARKET_CC_SECURITY: i32 = 91;
53pub const US_LV2_ORDER_ARCA: u32 = 1;
54pub const US_LV2_ORDER_NASDAQ_TV: u32 = 4;
55pub const US_LV2_ORDER_OVERNIGHT: u32 = 128;
56pub const LV2_ORDER_US_FUTURE: u32 = 1;
57pub const SG_LV2_ORDER_STOCK: u32 = 1;
58pub const SG_LV2_ORDER_STOCK_ODD_LOT: u32 = 2;
59// Ref: FutuOpenD/Src/NNProtoFile/Server/PB/Quote/FTCmdStockQuoteSubData.proto.
60// JPX values intentionally overlap SG values; routing must always consider market.
61pub const JP_LV2_ORDER_STOCK: u32 = 1;
62pub const JP_LV2_ORDER_STOCK_FULL: u32 = 2;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct Lv2OrderSubDescriptor {
66    pub lv2_type: u32,
67    pub level: u32,
68    pub prob2_v2: bool,
69}
70
71#[derive(Debug, Clone, Copy, Default)]
72pub struct QotRightBackendExtras {
73    pub us_lv2_flags: Option<UsLv2Flags>,
74    pub us_index_flags: Option<UsIndexFlags>,
75    pub us_otc_auths: Option<UsOtcAuths>,
76}
77
78impl QotRightBackendExtras {
79    pub const fn none() -> Self {
80        Self {
81            us_lv2_flags: None,
82            us_index_flags: None,
83            us_otc_auths: None,
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy, Default)]
89pub struct QotRightBackendUpdate {
90    pub hk_got: Option<u32>,
91    pub us_got: Option<u32>,
92    pub cn_got: Option<u32>,
93    pub sh_auth: Option<u32>,
94    pub sz_auth: Option<u32>,
95    pub hk_option: Option<u32>,
96    pub hk_future: Option<u32>,
97    pub us_option: Option<u32>,
98    pub us_future_cme_cboe: Option<u32>,
99    pub us_future_detail: Option<UsFutureDetailAuths>,
100    pub sg_future: Option<u32>,
101    pub jp_future: Option<u32>,
102    pub sg_stock: Option<u32>,
103    pub my_stock: Option<u32>,
104    pub jp_stock: Option<u32>,
105    pub digital_currency_auth: Option<u32>,
106    pub digital_pt_orderbook_auth: Option<u32>,
107    pub sub_limit: Option<u32>,
108    pub kl_limit: Option<u32>,
109    pub extras: QotRightBackendExtras,
110}
111
112#[derive(Debug, Clone)]
113pub struct QotRightData {
114    pub hk_qot_right: i32,
115    pub us_qot_right: i32,
116    /// C++ `INNData_Qot_Right::GetUSQotStatus()` projection for GetUserInfo/Notify.
117    pub api_us_qot_right: i32,
118    pub sh_qot_right: i32,
119    pub sz_qot_right: i32,
120    pub hk_option_qot_right: i32,
121    pub hk_future_qot_right: i32,
122    pub has_us_option_qot_right: bool,
123    pub us_option_qot_right: i32,
124    pub us_index_qot_right: i32,
125    pub us_otc_qot_right: i32,
126    pub us_cme_future_qot_right: i32,
127    pub us_cbot_future_qot_right: i32,
128    pub us_nymex_future_qot_right: i32,
129    pub us_comex_future_qot_right: i32,
130    pub us_cboe_future_qot_right: i32,
131    pub sg_future_qot_right: i32,
132    pub jp_future_qot_right: i32,
133    pub sg_stock_qot_right: i32,
134    pub my_stock_qot_right: i32,
135    pub jp_stock_qot_right: i32,
136    pub cc_qot_right: i32,
137    pub cc_pt_orderbook_qot_right: i32,
138    pub us_lv2_arca_qot_right: bool,
139    pub us_lv2_nyse_qot_right: bool,
140    pub us_lv2_nasdaq_totalview_qot_right: bool,
141    pub sub_quota: i32,
142    pub history_kl_quota: i32,
143    /// v1.4.106 codex 1209 F4: HK option/future orderbook depth.
144    pub hk_option_orderbook_depth: Option<u32>,
145    pub hk_future_orderbook_depth: Option<u32>,
146}
147
148impl Default for QotRightData {
149    fn default() -> Self {
150        const UNKNOWN: i32 = 0;
151        Self {
152            hk_qot_right: UNKNOWN,
153            us_qot_right: UNKNOWN,
154            api_us_qot_right: UNKNOWN,
155            sh_qot_right: UNKNOWN,
156            sz_qot_right: UNKNOWN,
157            hk_option_qot_right: UNKNOWN,
158            hk_future_qot_right: UNKNOWN,
159            // Cold-start UNKNOWN must not pass US option permission gates; after
160            // backend refresh this is recomputed from `us_option_qot_right`.
161            has_us_option_qot_right: false,
162            us_option_qot_right: UNKNOWN,
163            us_index_qot_right: UNKNOWN,
164            us_otc_qot_right: UNKNOWN,
165            us_cme_future_qot_right: UNKNOWN,
166            us_cbot_future_qot_right: UNKNOWN,
167            us_nymex_future_qot_right: UNKNOWN,
168            us_comex_future_qot_right: UNKNOWN,
169            us_cboe_future_qot_right: UNKNOWN,
170            sg_future_qot_right: UNKNOWN,
171            jp_future_qot_right: UNKNOWN,
172            sg_stock_qot_right: UNKNOWN,
173            my_stock_qot_right: UNKNOWN,
174            jp_stock_qot_right: UNKNOWN,
175            cc_qot_right: UNKNOWN,
176            cc_pt_orderbook_qot_right: UNKNOWN,
177            us_lv2_arca_qot_right: false,
178            us_lv2_nyse_qot_right: false,
179            us_lv2_nasdaq_totalview_qot_right: false,
180            sub_quota: 4000,
181            history_kl_quota: 100,
182            hk_option_orderbook_depth: None,
183            hk_future_orderbook_depth: None,
184        }
185    }
186}
187
188// ===== v1.4.106 codex 1217 F1 [P1] — freshness state machine =====
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum QotRightFreshness {
192    Unknown,
193    Pending,
194    Fresh,
195    Stale,
196    Failed { error: String, since_ms: i64 },
197}
198
199impl QotRightFreshness {
200    pub fn is_fresh(&self) -> bool {
201        matches!(self, QotRightFreshness::Fresh)
202    }
203    pub fn is_unconfirmed(&self) -> bool {
204        !self.is_fresh()
205    }
206}
207
208#[derive(Debug, Clone, Default)]
209pub struct PushedQuoteChangeNotify {
210    pub change_items: Vec<(i32, i32, i32)>,
211}
212
213#[derive(Debug, Clone)]
214pub struct QotRightRefreshReport {
215    pub changed: bool,
216    pub fresh_at_ms: i64,
217    pub decoded_fields: Vec<&'static str>,
218    pub freshness_after: QotRightFreshness,
219    pub login_epoch: u64,
220}
221
222#[derive(Debug, Clone)]
223pub struct QotRightStateMeta {
224    pub freshness: QotRightFreshness,
225    pub user_id: Option<u64>,
226    pub login_epoch: u64,
227    pub backend_generation: u64,
228    pub last_refresh_at_ms: i64,
229    pub last_pushed_quote_change_notify: Option<PushedQuoteChangeNotify>,
230}
231
232impl Default for QotRightStateMeta {
233    fn default() -> Self {
234        Self {
235            freshness: QotRightFreshness::Unknown,
236            user_id: None,
237            login_epoch: 0,
238            backend_generation: 0,
239            last_refresh_at_ms: 0,
240            last_pushed_quote_change_notify: None,
241        }
242    }
243}
244
245fn now_ms() -> i64 {
246    use std::time::{SystemTime, UNIX_EPOCH};
247    SystemTime::now()
248        .duration_since(UNIX_EPOCH)
249        .map(|d| d.as_millis() as i64)
250        .unwrap_or(0)
251}
252
253pub struct QotRightCache {
254    data: RwLock<QotRightData>,
255    meta: Mutex<QotRightStateMeta>,
256}
257
258impl Default for QotRightCache {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264impl QotRightCache {
265    pub fn new() -> Self {
266        Self {
267            data: RwLock::new(QotRightData::default()),
268            meta: Mutex::new(QotRightStateMeta::default()),
269        }
270    }
271
272    pub fn get(&self) -> QotRightData {
273        self.data.read().clone()
274    }
275
276    pub fn freshness(&self) -> QotRightFreshness {
277        self.meta.lock().freshness.clone()
278    }
279
280    pub fn is_fresh(&self) -> bool {
281        self.meta.lock().freshness.is_fresh()
282    }
283
284    pub fn meta_snapshot(&self) -> QotRightStateMeta {
285        self.meta.lock().clone()
286    }
287
288    pub fn set_orderbook_depths(&self, hk_option_depth: Option<u32>, hk_future_depth: Option<u32>) {
289        let mut d = self.data.write();
290        if let Some(v) = hk_option_depth {
291            d.hk_option_orderbook_depth = Some(v);
292        }
293        if let Some(v) = hk_future_depth {
294            d.hk_future_orderbook_depth = Some(v);
295        }
296    }
297
298    pub fn mark_stale(&self, reason: &str) {
299        let mut m = self.meta.lock();
300        let old = m.freshness.clone();
301        m.freshness = QotRightFreshness::Stale;
302        tracing::info!(
303            old_freshness = ?old,
304            reason,
305            login_epoch = m.login_epoch,
306            backend_generation = m.backend_generation,
307            "QotRightCache: marked stale"
308        );
309    }
310
311    pub fn advance_login_epoch(&self, new_epoch: u64, user_id: Option<u64>) {
312        let mut m = self.meta.lock();
313        let old_epoch = m.login_epoch;
314        m.login_epoch = new_epoch;
315        m.user_id = user_id;
316        if matches!(m.freshness, QotRightFreshness::Fresh) {
317            m.freshness = QotRightFreshness::Stale;
318        }
319        tracing::info!(
320            old_epoch,
321            new_epoch,
322            user_id = ?user_id,
323            "QotRightCache: login epoch advanced"
324        );
325    }
326
327    pub fn advance_backend_generation(&self, new_generation: u64) {
328        let mut m = self.meta.lock();
329        let old_gen = m.backend_generation;
330        m.backend_generation = new_generation;
331        if matches!(m.freshness, QotRightFreshness::Fresh) {
332            m.freshness = QotRightFreshness::Stale;
333        }
334        tracing::info!(
335            old_generation = old_gen,
336            new_generation,
337            "QotRightCache: backend generation advanced"
338        );
339    }
340
341    pub fn mark_pending(&self) {
342        self.meta.lock().freshness = QotRightFreshness::Pending;
343    }
344
345    pub fn mark_fresh(&self) {
346        let mut m = self.meta.lock();
347        m.freshness = QotRightFreshness::Fresh;
348        m.last_refresh_at_ms = now_ms();
349    }
350
351    pub fn mark_failed(&self, error: impl Into<String>) {
352        self.meta.lock().freshness = QotRightFreshness::Failed {
353            error: error.into(),
354            since_ms: now_ms(),
355        };
356    }
357
358    pub fn last_refresh_at_ms(&self) -> i64 {
359        self.meta.lock().last_refresh_at_ms
360    }
361
362    pub fn set_pushed_quote_change_notify(&self, notify: PushedQuoteChangeNotify) {
363        self.meta.lock().last_pushed_quote_change_notify = Some(notify);
364    }
365
366    pub fn pushed_quote_change_notify(&self) -> Option<PushedQuoteChangeNotify> {
367        self.meta.lock().last_pushed_quote_change_notify.clone()
368    }
369
370    pub fn apply_6651_changes(&self, items: &[(i32, i32, i32)]) -> Vec<i32> {
371        let mut d = self.data.write();
372        let mut changed_types = Vec::new();
373
374        for &(quote_type, before, after) in items {
375            // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:983-1010.
376            // C++ applies 6651 pushes only when old right is known and actually changed.
377            if before == 0 || before == after {
378                continue;
379            }
380            let after_u = after as u32;
381            if apply_qot_right_after_change(&mut d, quote_type, after_u) {
382                changed_types.push(quote_type);
383            }
384        }
385        if !changed_types.is_empty() {
386            drop(d);
387            let mut m = self.meta.lock();
388            m.freshness = QotRightFreshness::Fresh;
389            m.last_refresh_at_ms = now_ms();
390        }
391        changed_types
392    }
393
394    pub fn apply_direct_auth_changes(&self, items: &[(i32, u32)]) -> Vec<i32> {
395        let mut d = self.data.write();
396        let mut changed_types = Vec::new();
397
398        for &(quote_type, after) in items {
399            if apply_qot_right_after_change(&mut d, quote_type, after) {
400                changed_types.push(quote_type);
401            }
402        }
403        if !changed_types.is_empty() {
404            drop(d);
405            let mut m = self.meta.lock();
406            m.freshness = QotRightFreshness::Fresh;
407            m.last_refresh_at_ms = now_ms();
408        }
409        changed_types
410    }
411
412    /// 从后端 CMD 6024 响应更新权限数据.
413    ///
414    /// **v1.4.106 codex 1217 F2 [P1] (5月1日)**: us_future_detail 子字段存在 =
415    /// 覆盖 (含值=0). 之前 `if cme > 0` 跳过 0 让 LV1 → 0 (No) 实质降级被
416    /// silent 保留;但 C++ 逐个 `has_open_api_*_auth()` 判断,父字段存在不代表
417    /// 未返回的子市场可按 0 覆盖。
418    ///
419    /// **v1.4.106 codex 1217 F1**: apply 成功后自动 mark Fresh.
420    pub fn update_from_backend(&self, update: QotRightBackendUpdate) {
421        let QotRightBackendUpdate {
422            hk_got,
423            us_got,
424            cn_got,
425            sh_auth,
426            sz_auth,
427            hk_option,
428            hk_future,
429            us_option,
430            us_future_cme_cboe,
431            us_future_detail,
432            sg_future,
433            jp_future,
434            sg_stock,
435            my_stock,
436            jp_stock,
437            digital_currency_auth,
438            digital_pt_orderbook_auth,
439            sub_limit,
440            kl_limit,
441            extras,
442        } = update;
443        let mut d = self.data.write();
444
445        if let Some(v) = hk_got {
446            d.hk_qot_right = hk_clt_to_api(v);
447        }
448        if let Some(v) = us_got {
449            let right = us_clt_to_api(v);
450            d.us_qot_right = right;
451        }
452        if let Some(right) = us_backend_status_to_api(us_got, extras.us_lv2_flags) {
453            d.api_us_qot_right = right;
454        }
455        if let Some(flags) = extras.us_lv2_flags {
456            let (arca, nyse, nasdaq_tv, _edg, _bzx) = flags;
457            d.us_lv2_arca_qot_right = arca == Some(1);
458            d.us_lv2_nyse_qot_right = nyse == Some(1);
459            d.us_lv2_nasdaq_totalview_qot_right = nasdaq_tv == Some(1);
460            if has_any_us_lv2_flag(flags) && matches!(d.us_qot_right, 2 | 3) {
461                // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:157-180.
462                // US stock LV2 is promoted by independent LV2 venue flags when the
463                // base US right is not BMP/no-right.
464                d.us_qot_right = 3;
465            }
466        }
467        if let Some((dow_jones, nasdaq, standard_poor)) = extras.us_index_flags
468            && let Some(api_right) = us_index_flags_to_api(dow_jones, nasdaq, standard_poor)
469        {
470            d.us_index_qot_right = api_right;
471        }
472        if let Some((deal_data_auth, order_book_auth)) = extras.us_otc_auths {
473            d.us_otc_qot_right = us_otc_comm_auth_to_api(deal_data_auth, order_book_auth);
474        }
475        if let Some(v) = sh_auth {
476            d.sh_qot_right = cn_clt_to_api(v);
477        } else if let Some(v) = cn_got {
478            d.sh_qot_right = cn_clt_to_api(v);
479        }
480        if let Some(v) = sz_auth {
481            d.sz_qot_right = cn_clt_to_api(v);
482        } else if let Some(v) = cn_got {
483            d.sz_qot_right = cn_clt_to_api(v);
484        }
485        if let Some(v) = hk_option {
486            d.hk_option_qot_right = hk_clt_to_api(v);
487        }
488        if let Some(v) = hk_future {
489            d.hk_future_qot_right = hk_clt_to_api(v);
490        }
491        if let Some(v) = us_option {
492            d.us_option_qot_right = us_option_clt_to_api(v);
493            d.has_us_option_qot_right = d.us_option_qot_right != 1;
494        }
495        if let Some(v) = us_future_cme_cboe {
496            d.us_cme_future_qot_right = us_future_clt_to_api(v);
497            // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:474-509.
498            // OpenAPI currently keeps CBOE futures as no-right until the server
499            // provides an open_api CBOE field; do not inherit the legacy
500            // cme_cboe aggregate.
501            d.us_cboe_future_qot_right = 5;
502        }
503
504        // F2 [P1] (v1.4.106 codex 1217): 子字段存在 = 覆盖 (含值=0).
505        // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:396-478.
506        if let Some((cme, cbot, nymex, comex)) = us_future_detail {
507            if let Some(cme) = cme {
508                d.us_cme_future_qot_right = us_future_clt_to_api(cme);
509            }
510            if let Some(cbot) = cbot {
511                d.us_cbot_future_qot_right = us_future_clt_to_api(cbot);
512            }
513            if let Some(nymex) = nymex {
514                d.us_nymex_future_qot_right = us_future_clt_to_api(nymex);
515            }
516            if let Some(comex) = comex {
517                d.us_comex_future_qot_right = us_future_clt_to_api(comex);
518            }
519            // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:474-509.
520            d.us_cboe_future_qot_right = 5;
521        }
522
523        if let Some(v) = sg_future {
524            d.sg_future_qot_right = other_clt_to_api(v);
525        }
526        if let Some(v) = jp_future {
527            d.jp_future_qot_right = other_clt_to_api(v);
528        }
529        if let Some(v) = sg_stock {
530            d.sg_stock_qot_right = other_clt_to_api(v);
531        }
532        if let Some(v) = my_stock {
533            d.my_stock_qot_right = my_stock_common_clt_to_api(v);
534        }
535        if let Some(v) = jp_stock {
536            d.jp_stock_qot_right = jp_stock_clt_to_api(v);
537        }
538        if let Some(v) = digital_currency_auth {
539            d.cc_qot_right = crypto_auth_to_api(v);
540        }
541        if let Some(v) = digital_pt_orderbook_auth {
542            d.cc_pt_orderbook_qot_right = crypto_auth_to_api(v);
543        }
544
545        if let Some(v) = sub_limit
546            && v > 0
547        {
548            d.sub_quota = v as i32;
549        }
550        if let Some(v) = kl_limit
551            && v > 0
552        {
553            d.history_kl_quota = v as i32;
554        }
555
556        // F1 (v1.4.106 codex 1217): apply 成功 → 自动 mark Fresh.
557        drop(d);
558        let mut m = self.meta.lock();
559        m.freshness = QotRightFreshness::Fresh;
560        m.last_refresh_at_ms = now_ms();
561    }
562}
563
564fn apply_qot_right_after_change(data: &mut QotRightData, quote_type: i32, after: u32) -> bool {
565    match quote_type {
566        1 => data.hk_qot_right = hk_clt_to_api(after),
567        3 | 17 => {
568            let right = us_clt_to_api(after);
569            data.us_qot_right = right;
570            data.api_us_qot_right = right;
571        }
572        4 => data.hk_future_qot_right = hk_clt_to_api(after),
573        5 => data.hk_option_qot_right = hk_clt_to_api(after),
574        36 => data.sg_future_qot_right = other_clt_to_api(after),
575        39 => data.jp_future_qot_right = other_clt_to_api(after),
576        20 => data.sg_stock_qot_right = other_clt_to_api(after),
577        29 => data.jp_stock_qot_right = jp_stock_clt_to_api(after),
578        30 => data.us_cme_future_qot_right = us_future_clt_to_api(after),
579        31 => data.us_cbot_future_qot_right = us_future_clt_to_api(after),
580        32 => data.us_nymex_future_qot_right = us_future_clt_to_api(after),
581        33 => data.us_comex_future_qot_right = us_future_clt_to_api(after),
582        37 => data.us_otc_qot_right = if after == 2 { 2 } else { 5 },
583        38 => data.us_index_qot_right = us_clt_to_api(after),
584        40 => data.sh_qot_right = cn_clt_to_api(after),
585        41 => data.sz_qot_right = cn_clt_to_api(after),
586        _ => return false,
587    }
588    true
589}
590
591#[cfg(test)]
592mod tests;