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