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 std::sync::Arc;
15
16use parking_lot::{Mutex, RwLock};
17
18mod capability;
19#[cfg(test)]
20mod capability_tests;
21mod projection;
22mod reconnect;
23mod security_rules;
24
25pub use capability::{
26    QuoteCapability, QuoteCryptoPolicy, QuoteOrderBookPolicy, QuoteSnapshotPolicy,
27    resolve_orderbook_entitlement, resolve_quote_capability,
28};
29use futu_domain_qot_right::{
30    QOT_API_QUOTA_DEFAULT, QotRight6651Change, QotRightBackendCurrent, QotRightBackendProjection,
31    QotRightDirectAuthChange, QotRightPushCurrentFacts, QotRightRefreshCurrentFacts,
32    project_qot_right_6651_change_with_current, project_qot_right_backend_update,
33    project_qot_right_direct_auth_change, qot_right_after_push_changes, qot_right_mark_failed,
34    qot_right_mark_fresh, qot_right_mark_pending, qot_right_on_epoch_advanced,
35};
36pub use futu_domain_qot_right::{
37    QOT_MARKET_CC_SECURITY, QOT_RIGHT_BMP, QOT_RIGHT_LEVEL1, QOT_RIGHT_LEVEL2, QOT_RIGHT_LEVEL3,
38    QOT_RIGHT_NO, QOT_RIGHT_SF, QOT_RIGHT_UNKNOWN, QotRightBackendExtras, QotRightBackendUpdate,
39    QotRightFreshness, QotRightPushedQuoteChangeNotify as PushedQuoteChangeNotify,
40    SECURITY_TYPE_CRYPTO, SECURITY_TYPE_DRVT, SECURITY_TYPE_FUTURE, SECURITY_TYPE_INDEX,
41};
42pub use futu_domain_qot_subscription::{
43    JP_LV2_ORDER_STOCK, JP_LV2_ORDER_STOCK_FULL, LV2_ORDER_US_FUTURE, Lv2OrderGroupPlan,
44    SG_LV2_ORDER_STOCK, SG_LV2_ORDER_STOCK_ODD_LOT, US_LV2_ORDER_ARCA, US_LV2_ORDER_NASDAQ_TV,
45    US_LV2_ORDER_OVERNIGHT,
46};
47pub use projection::{UsFutureDetailAuths, UsIndexFlags, UsLv2Flags, UsOtcAuths};
48use reconnect::{QotRightPublicSnapshot, QotRightReconnectSnapshot};
49pub use reconnect::{
50    QotRightReconnectAccumulator, QotRightReconnectClaim, QotRightReconnectClaimKind,
51    QotRightReconnectHandoff, QotRightReconnectLifecycle, QotRightReconnectRegistration,
52    QotRightReconnectToken,
53};
54pub use security_rules::{
55    basic_qot_uses_us_pre_after_detail, has_lv2_order_groups_for_security, is_crypto_market,
56    lv2_order_groups_for_security, lv2_order_groups_for_security_with_dynamic,
57    lv2_order_uses_jp_main_cache, lv2_order_uses_sg_odd_lot_cache,
58    order_book_max_depth_for_security, order_book_read_uses_requested_count,
59    order_book_requires_accepted_lv2_push, order_book_requires_backend_full_depth,
60    order_book_uses_backend_side_count, snapshot_masks_hk_bmp_bid_ask,
61    us_lv2_order_uses_exchange_cache,
62};
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct QotRightData {
66    /// C++ `m_enQotRightHK`; internal `HasSetQotRight` fact.
67    pub hk_own_qot_right: i32,
68    pub hk_qot_right: i32,
69    /// C++ `m_enQotRightUS`; internal `HasSetQotRight` fact.
70    pub us_own_qot_right: i32,
71    pub us_qot_right: i32,
72    /// C++ `INNData_Qot_Right::GetUSQotStatus()` projection for GetUserInfo/Notify.
73    pub api_us_qot_right: i32,
74    /// C++ `INNData_Qot_Right::HasUSUtp()`; drives real CMD3020 quote_level.
75    pub has_us_utp: bool,
76    pub sh_qot_right: i32,
77    pub sz_qot_right: i32,
78    pub hk_option_qot_right: i32,
79    pub hk_future_qot_right: i32,
80    pub has_us_option_qot_right: bool,
81    pub us_option_qot_right: i32,
82    pub us_index_qot_right: i32,
83    pub us_otc_qot_right: i32,
84    pub us_cme_future_qot_right: i32,
85    pub us_cbot_future_qot_right: i32,
86    pub us_nymex_future_qot_right: i32,
87    pub us_comex_future_qot_right: i32,
88    pub us_cboe_future_qot_right: i32,
89    pub sg_future_qot_right: i32,
90    pub jp_future_qot_right: i32,
91    pub sg_stock_qot_right: i32,
92    pub my_stock_qot_right: i32,
93    pub jp_stock_qot_right: i32,
94    pub cc_qot_right: i32,
95    pub cc_pt_orderbook_qot_right: i32,
96    pub event_contract_qot_right: i32,
97    pub event_contract_prohibited_category_ids: Vec<u32>,
98    pub us_lv2_arca_qot_right: bool,
99    pub us_lv2_nyse_qot_right: bool,
100    pub us_lv2_nasdaq_totalview_qot_right: bool,
101    pub sub_quota: i32,
102    pub history_kl_quota: i32,
103    /// v1.4.106 codex 1209 F4: HK option/future orderbook depth.
104    pub hk_option_orderbook_depth: Option<u32>,
105    pub hk_future_orderbook_depth: Option<u32>,
106}
107
108impl Default for QotRightData {
109    fn default() -> Self {
110        const UNKNOWN: i32 = 0;
111        Self {
112            hk_own_qot_right: UNKNOWN,
113            hk_qot_right: UNKNOWN,
114            us_own_qot_right: UNKNOWN,
115            us_qot_right: UNKNOWN,
116            api_us_qot_right: UNKNOWN,
117            has_us_utp: false,
118            sh_qot_right: UNKNOWN,
119            sz_qot_right: UNKNOWN,
120            hk_option_qot_right: UNKNOWN,
121            hk_future_qot_right: UNKNOWN,
122            // Cold-start UNKNOWN must not pass US option permission gates; after
123            // backend refresh this is recomputed from `us_option_qot_right`.
124            has_us_option_qot_right: false,
125            us_option_qot_right: UNKNOWN,
126            us_index_qot_right: UNKNOWN,
127            us_otc_qot_right: UNKNOWN,
128            us_cme_future_qot_right: UNKNOWN,
129            us_cbot_future_qot_right: UNKNOWN,
130            us_nymex_future_qot_right: UNKNOWN,
131            us_comex_future_qot_right: UNKNOWN,
132            us_cboe_future_qot_right: UNKNOWN,
133            sg_future_qot_right: UNKNOWN,
134            jp_future_qot_right: UNKNOWN,
135            sg_stock_qot_right: UNKNOWN,
136            my_stock_qot_right: UNKNOWN,
137            jp_stock_qot_right: UNKNOWN,
138            cc_qot_right: UNKNOWN,
139            cc_pt_orderbook_qot_right: UNKNOWN,
140            event_contract_qot_right: UNKNOWN,
141            event_contract_prohibited_category_ids: Vec::new(),
142            us_lv2_arca_qot_right: false,
143            us_lv2_nyse_qot_right: false,
144            us_lv2_nasdaq_totalview_qot_right: false,
145            sub_quota: QOT_API_QUOTA_DEFAULT,
146            history_kl_quota: QOT_API_QUOTA_DEFAULT,
147            hk_option_orderbook_depth: None,
148            hk_future_orderbook_depth: None,
149        }
150    }
151}
152
153#[derive(Debug, Clone)]
154pub struct QotRightRefreshReport {
155    pub changed: bool,
156    pub reconnect_quote_types: Vec<i32>,
157    pub has_set_qot_right_before: bool,
158    pub has_us_utp_changed: bool,
159    pub quota_data_changed: bool,
160    pub quota_projection_available: bool,
161    pub backend_generation: u64,
162    pub route_publication_epoch: u64,
163    pub request_is_highest: bool,
164    pub force_resubscribe: bool,
165    pub resubscribe_scope: futu_domain_qot_right::QotRightResubscribeScope,
166    pub deferred_reconnect_token: Option<QotRightReconnectToken>,
167    pub deferred_reconnect_blocked: bool,
168    pub fresh_at_ms: i64,
169    pub decoded_fields: Vec<&'static str>,
170    pub freshness_after: QotRightFreshness,
171    pub login_epoch: u64,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct QotRightBackendApplyOutcome {
176    pub public_rights_changed: bool,
177    pub reconnect_quote_types: Vec<i32>,
178    pub has_set_qot_right_before: bool,
179    pub has_us_utp_changed: bool,
180    pub quota_data_changed: bool,
181    pub quota_projection_available: bool,
182}
183
184impl QotRightBackendApplyOutcome {
185    #[must_use]
186    pub fn cpp_reconnect_rights_changed(&self) -> bool {
187        !self.reconnect_quote_types.is_empty()
188    }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct QotRightFullApplyFacts {
193    pub expected_generation: u64,
194    pub is_highest: bool,
195    pub force_resub: bool,
196    pub pushed_notify_version: Option<u64>,
197}
198
199#[must_use]
200pub fn has_set_qot_right_like_cpp(data: &QotRightData) -> bool {
201    // Ref: NNData_Qot_Right.cpp:733-760. This is the exact 23-field OR;
202    // UTP, orderbook depth, quota, venue flags and crypto PT are excluded.
203    [
204        data.hk_own_qot_right,
205        data.hk_option_qot_right,
206        data.hk_qot_right,
207        data.us_qot_right,
208        data.us_own_qot_right,
209        data.sh_qot_right,
210        data.sz_qot_right,
211        data.hk_future_qot_right,
212        data.us_option_qot_right,
213        data.us_cme_future_qot_right,
214        data.us_cbot_future_qot_right,
215        data.us_nymex_future_qot_right,
216        data.us_comex_future_qot_right,
217        data.us_cboe_future_qot_right,
218        data.us_index_qot_right,
219        data.us_otc_qot_right,
220        data.sg_future_qot_right,
221        data.sg_stock_qot_right,
222        data.jp_future_qot_right,
223        data.my_stock_qot_right,
224        data.jp_stock_qot_right,
225        data.cc_qot_right,
226        data.event_contract_qot_right,
227    ]
228    .into_iter()
229    .any(|right| right != QOT_RIGHT_UNKNOWN)
230}
231
232#[derive(Debug, Clone)]
233pub struct QotRightStateMeta {
234    pub freshness: QotRightFreshness,
235    pub user_id: Option<u64>,
236    pub login_epoch: u64,
237    pub backend_generation: u64,
238    pub last_refresh_at_ms: i64,
239    pub last_pushed_quote_change_notify: Option<PushedQuoteChangeNotify>,
240    pub last_pushed_quote_change_notify_version: u64,
241    pub reconnect_lifecycle: QotRightReconnectLifecycle,
242    pub next_reconnect_token: u64,
243    /// C++ preloads EC category data on the first None/Unknown -> Level1
244    /// transition. The cache owns this one-shot handoff so the eventual
245    /// category worker cannot miss the transition between refreshes.
246    pub event_contract_category_preload_pending: bool,
247}
248
249impl Default for QotRightStateMeta {
250    fn default() -> Self {
251        Self {
252            freshness: QotRightFreshness::Unknown,
253            user_id: None,
254            login_epoch: 0,
255            backend_generation: 0,
256            last_refresh_at_ms: 0,
257            last_pushed_quote_change_notify: None,
258            last_pushed_quote_change_notify_version: 0,
259            reconnect_lifecycle: QotRightReconnectLifecycle::Idle,
260            next_reconnect_token: 1,
261            event_contract_category_preload_pending: false,
262        }
263    }
264}
265
266fn now_ms() -> i64 {
267    use std::time::{SystemTime, UNIX_EPOCH};
268    SystemTime::now()
269        .duration_since(UNIX_EPOCH)
270        .map(|d| d.as_millis() as i64)
271        .unwrap_or(0)
272}
273
274fn next_reconnect_token(meta: &mut QotRightStateMeta) -> QotRightReconnectToken {
275    let token = QotRightReconnectToken(meta.next_reconnect_token);
276    meta.next_reconnect_token = meta.next_reconnect_token.saturating_add(1);
277    token
278}
279
280#[derive(Clone)]
281pub struct QotRightCache {
282    data: Arc<RwLock<QotRightData>>,
283    meta: Arc<Mutex<QotRightStateMeta>>,
284}
285
286impl Default for QotRightCache {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292impl QotRightCache {
293    pub fn new() -> Self {
294        Self {
295            data: Arc::new(RwLock::new(QotRightData::default())),
296            meta: Arc::new(Mutex::new(QotRightStateMeta::default())),
297        }
298    }
299
300    pub fn get(&self) -> QotRightData {
301        self.data.read().clone()
302    }
303
304    pub fn refresh_current_facts(&self) -> QotRightRefreshCurrentFacts {
305        let d = self.data.read();
306        QotRightRefreshCurrentFacts {
307            hk_qot_right: d.hk_qot_right,
308            hk_future_qot_right: d.hk_future_qot_right,
309            hk_option_qot_right: d.hk_option_qot_right,
310            api_us_qot_right: d.api_us_qot_right,
311            has_us_utp: d.has_us_utp,
312            us_lv2_nasdaq_totalview_qot_right: d.us_lv2_nasdaq_totalview_qot_right,
313            sg_stock_qot_right: d.sg_stock_qot_right,
314            jp_stock_qot_right: d.jp_stock_qot_right,
315            us_cme_future_qot_right: d.us_cme_future_qot_right,
316            us_cbot_future_qot_right: d.us_cbot_future_qot_right,
317            us_nymex_future_qot_right: d.us_nymex_future_qot_right,
318            us_comex_future_qot_right: d.us_comex_future_qot_right,
319            sg_future_qot_right: d.sg_future_qot_right,
320            us_otc_qot_right: d.us_otc_qot_right,
321            us_index_qot_right: d.us_index_qot_right,
322            jp_future_qot_right: d.jp_future_qot_right,
323            sh_qot_right: d.sh_qot_right,
324            sz_qot_right: d.sz_qot_right,
325        }
326    }
327
328    pub fn freshness(&self) -> QotRightFreshness {
329        self.meta.lock().freshness.clone()
330    }
331
332    pub fn is_fresh(&self) -> bool {
333        self.meta.lock().freshness.is_fresh()
334    }
335
336    pub fn meta_snapshot(&self) -> QotRightStateMeta {
337        self.meta.lock().clone()
338    }
339
340    pub fn take_event_contract_category_preload_request(&self) -> bool {
341        let mut meta = self.meta.lock();
342        std::mem::take(&mut meta.event_contract_category_preload_pending)
343    }
344
345    pub fn set_orderbook_depths(&self, hk_option_depth: Option<u32>, hk_future_depth: Option<u32>) {
346        let mut d = self.data.write();
347        if let Some(v) = hk_option_depth {
348            d.hk_option_orderbook_depth = Some(v);
349        }
350        if let Some(v) = hk_future_depth {
351            d.hk_future_orderbook_depth = Some(v);
352        }
353    }
354
355    pub fn mark_stale(&self, reason: &str) {
356        let mut m = self.meta.lock();
357        let old = m.freshness.clone();
358        m.freshness = QotRightFreshness::Stale;
359        tracing::info!(
360            old_freshness = ?old,
361            reason,
362            login_epoch = m.login_epoch,
363            backend_generation = m.backend_generation,
364            "QotRightCache: marked stale"
365        );
366    }
367
368    pub fn advance_login_epoch(&self, new_epoch: u64, user_id: Option<u64>) {
369        let mut m = self.meta.lock();
370        let old_epoch = m.login_epoch;
371        m.login_epoch = new_epoch;
372        m.user_id = user_id;
373        m.freshness = qot_right_on_epoch_advanced(m.freshness.clone());
374        let user_id_fp = user_id.map(futu_core::log_redact::uid_log_fingerprint);
375        tracing::info!(
376            old_epoch,
377            new_epoch,
378            user_id_fp = ?user_id_fp,
379            "QotRightCache: login epoch advanced"
380        );
381    }
382
383    pub fn advance_backend_generation(&self, new_generation: u64) {
384        let mut m = self.meta.lock();
385        let old_gen = m.backend_generation;
386        m.backend_generation = new_generation;
387        m.freshness = qot_right_on_epoch_advanced(m.freshness.clone());
388        tracing::info!(
389            old_generation = old_gen,
390            new_generation,
391            "QotRightCache: backend generation advanced"
392        );
393    }
394
395    pub fn mark_pending(&self) {
396        self.meta.lock().freshness = qot_right_mark_pending();
397    }
398
399    pub fn mark_pending_if_generation(&self, expected_generation: u64) -> bool {
400        let mut meta = self.meta.lock();
401        if meta.backend_generation != expected_generation {
402            return false;
403        }
404        meta.freshness = qot_right_mark_pending();
405        true
406    }
407
408    pub fn mark_fresh(&self) {
409        let mut m = self.meta.lock();
410        m.freshness = qot_right_mark_fresh();
411        m.last_refresh_at_ms = now_ms();
412    }
413
414    pub fn mark_failed(&self, error: impl Into<String>) {
415        self.meta.lock().freshness = qot_right_mark_failed(error, now_ms());
416    }
417
418    pub fn mark_failed_if_generation(
419        &self,
420        expected_generation: u64,
421        error: impl Into<String>,
422    ) -> bool {
423        let mut meta = self.meta.lock();
424        if meta.backend_generation != expected_generation {
425            return false;
426        }
427        meta.freshness = qot_right_mark_failed(error, now_ms());
428        true
429    }
430
431    pub fn last_refresh_at_ms(&self) -> i64 {
432        self.meta.lock().last_refresh_at_ms
433    }
434
435    pub fn set_pushed_quote_change_notify(&self, notify: PushedQuoteChangeNotify) {
436        let mut meta = self.meta.lock();
437        meta.last_pushed_quote_change_notify_version = meta
438            .last_pushed_quote_change_notify_version
439            .saturating_add(1);
440        meta.last_pushed_quote_change_notify = Some(notify);
441    }
442
443    pub fn pushed_quote_change_notify(&self) -> Option<PushedQuoteChangeNotify> {
444        self.meta.lock().last_pushed_quote_change_notify.clone()
445    }
446
447    pub fn pushed_quote_change_notify_snapshot(&self) -> (u64, Option<PushedQuoteChangeNotify>) {
448        let meta = self.meta.lock();
449        (
450            meta.last_pushed_quote_change_notify_version,
451            meta.last_pushed_quote_change_notify.clone(),
452        )
453    }
454
455    pub fn apply_6651_changes(&self, items: &[(i32, i32, i32)]) -> Vec<i32> {
456        let mut d = self.data.write();
457        let mut changed_types = Vec::new();
458
459        for &(quote_type, before, after) in items {
460            let current = QotRightPushCurrentFacts {
461                api_us_qot_right: d.api_us_qot_right,
462                us_lv2_arca: d.us_lv2_arca_qot_right,
463                us_lv2_nyse: d.us_lv2_nyse_qot_right,
464                us_lv2_nasdaq_totalview: d.us_lv2_nasdaq_totalview_qot_right,
465            };
466            if let Some(projection) = project_qot_right_6651_change_with_current(
467                QotRight6651Change {
468                    quote_type,
469                    before,
470                    after,
471                },
472                current,
473            ) {
474                apply_backend_projection(&mut d, projection.patch);
475                changed_types.push(projection.quote_type);
476            }
477        }
478        if !changed_types.is_empty() {
479            drop(d);
480            let mut m = self.meta.lock();
481            m.freshness = qot_right_after_push_changes(m.freshness.clone(), true);
482            if m.freshness.is_fresh() {
483                m.last_refresh_at_ms = now_ms();
484            }
485        }
486        changed_types
487    }
488
489    pub fn apply_direct_auth_changes(&self, items: &[(i32, u32)]) -> Vec<i32> {
490        let mut d = self.data.write();
491        let mut changed_types = Vec::new();
492
493        for &(quote_type, after) in items {
494            if let Some(projection) =
495                project_qot_right_direct_auth_change(QotRightDirectAuthChange { quote_type, after })
496            {
497                apply_backend_projection(&mut d, projection.patch);
498                changed_types.push(projection.quote_type);
499            }
500        }
501        if !changed_types.is_empty() {
502            drop(d);
503            let mut m = self.meta.lock();
504            m.freshness = qot_right_after_push_changes(m.freshness.clone(), true);
505            if m.freshness.is_fresh() {
506                m.last_refresh_at_ms = now_ms();
507            }
508        }
509        changed_types
510    }
511
512    /// 从后端 CMD 6024 响应更新权限数据.
513    ///
514    /// **v1.4.106 codex 1217 F2 [P1] (5月1日)**: us_future_detail 子字段存在 =
515    /// 覆盖 (含值=0). 之前 `if cme > 0` 跳过 0 让 LV1 → 0 (No) 实质降级被
516    /// silent 保留;但 C++ 逐个 `has_open_api_*_auth()` 判断,父字段存在不代表
517    /// 未返回的子市场可按 0 覆盖。
518    ///
519    /// **v1.4.106 codex 1217 F1**: apply 成功后自动 mark Fresh.
520    pub fn update_from_backend(
521        &self,
522        update: QotRightBackendUpdate,
523    ) -> QotRightBackendApplyOutcome {
524        let mut data = self.data.write();
525        let mut meta = self.meta.lock();
526        let expected_generation = meta.backend_generation;
527        Self::apply_full_backend_update_locked(
528            &mut data,
529            &mut meta,
530            QotRightFullApplyFacts {
531                expected_generation,
532                is_highest: false,
533                force_resub: false,
534                pushed_notify_version: None,
535            },
536            update,
537        )
538    }
539
540    pub fn update_from_backend_if_generation(
541        &self,
542        facts: QotRightFullApplyFacts,
543        update: QotRightBackendUpdate,
544    ) -> Option<QotRightBackendApplyOutcome> {
545        let mut d = self.data.write();
546        let mut m = self.meta.lock();
547        if m.backend_generation != facts.expected_generation {
548            return None;
549        }
550        Some(Self::apply_full_backend_update_locked(
551            &mut d, &mut m, facts, update,
552        ))
553    }
554
555    fn apply_full_backend_update_locked(
556        d: &mut QotRightData,
557        m: &mut QotRightStateMeta,
558        facts: QotRightFullApplyFacts,
559        update: QotRightBackendUpdate,
560    ) -> QotRightBackendApplyOutcome {
561        let has_set_qot_right_before = has_set_qot_right_like_cpp(d);
562        let public_before = QotRightPublicSnapshot::from(&*d);
563        let reconnect_before = QotRightReconnectSnapshot::from(&*d);
564        let has_us_utp_before = d.has_us_utp;
565        let quota_before = (d.sub_quota, d.history_kl_quota);
566        let projection = project_qot_right_backend_update(
567            QotRightBackendCurrent {
568                us_qot_right: d.us_qot_right,
569            },
570            update,
571        );
572        let event_contract_became_level1 = projection.event_contract_qot_right
573            == Some(QOT_RIGHT_LEVEL1)
574            && d.event_contract_qot_right != QOT_RIGHT_LEVEL1;
575        apply_backend_projection(d, projection);
576        let quota_data_changed = quota_before != (d.sub_quota, d.history_kl_quota);
577        let public_rights_changed = public_before != QotRightPublicSnapshot::from(&*d);
578        let reconnect_quote_types = reconnect_before.changed_quote_types(d);
579        let has_us_utp_changed = has_us_utp_before != d.has_us_utp;
580
581        // F1 (v1.4.106 codex 1217): apply 成功 → 自动 mark Fresh.
582        m.freshness = qot_right_mark_fresh();
583        m.last_refresh_at_ms = now_ms();
584        m.event_contract_category_preload_pending |= event_contract_became_level1;
585
586        if facts.is_highest
587            && (!reconnect_quote_types.is_empty() || facts.force_resub)
588            && facts.pushed_notify_version == Some(m.last_pushed_quote_change_notify_version)
589        {
590            m.last_pushed_quote_change_notify = None;
591        }
592
593        QotRightBackendApplyOutcome {
594            public_rights_changed,
595            reconnect_quote_types,
596            has_set_qot_right_before,
597            has_us_utp_changed,
598            quota_data_changed,
599            // `update_from_backend` is the successful full-response apply owner.
600            // Even an absent OpenAPIAuth object yields the current canonical
601            // quota projection for the independent APIQuota fingerprint owner.
602            quota_projection_available: true,
603        }
604    }
605
606    pub fn update_quotas_from_backend_if_generation(
607        &self,
608        expected_generation: u64,
609        update: QotRightBackendUpdate,
610    ) -> Option<QotRightBackendApplyOutcome> {
611        let projection =
612            project_qot_right_backend_update(QotRightBackendCurrent::default(), update);
613        let mut d = self.data.write();
614        let m = self.meta.lock();
615        if m.backend_generation != expected_generation {
616            return None;
617        }
618        let quota_before = (d.sub_quota, d.history_kl_quota);
619        if let Some(value) = projection.sub_quota {
620            d.sub_quota = value;
621        }
622        if let Some(value) = projection.history_kl_quota {
623            d.history_kl_quota = value;
624        }
625        Some(QotRightBackendApplyOutcome {
626            public_rights_changed: false,
627            reconnect_quote_types: Vec::new(),
628            has_set_qot_right_before: false,
629            has_us_utp_changed: false,
630            quota_data_changed: quota_before != (d.sub_quota, d.history_kl_quota),
631            quota_projection_available: true,
632        })
633    }
634}
635
636fn apply_backend_projection(data: &mut QotRightData, projection: QotRightBackendProjection) {
637    if let Some(v) = projection.hk_own_qot_right {
638        data.hk_own_qot_right = v;
639    }
640    if let Some(v) = projection.hk_qot_right {
641        data.hk_qot_right = v;
642    }
643    if let Some(v) = projection.us_own_qot_right {
644        data.us_own_qot_right = v;
645    }
646    if let Some(v) = projection.us_qot_right {
647        data.us_qot_right = v;
648    }
649    if let Some(v) = projection.api_us_qot_right {
650        data.api_us_qot_right = v;
651    }
652    if let Some(v) = projection.has_us_utp {
653        data.has_us_utp = v;
654    }
655    if let Some(v) = projection.sh_qot_right {
656        data.sh_qot_right = v;
657    }
658    if let Some(v) = projection.sz_qot_right {
659        data.sz_qot_right = v;
660    }
661    if let Some(v) = projection.hk_option_qot_right {
662        data.hk_option_qot_right = v;
663    }
664    if let Some(v) = projection.hk_future_qot_right {
665        data.hk_future_qot_right = v;
666    }
667    if let Some(v) = projection.hk_option_orderbook_depth {
668        data.hk_option_orderbook_depth = Some(v);
669    }
670    if let Some(v) = projection.hk_future_orderbook_depth {
671        data.hk_future_orderbook_depth = Some(v);
672    }
673    if let Some(v) = projection.has_us_option_qot_right {
674        data.has_us_option_qot_right = v;
675    }
676    if let Some(v) = projection.us_option_qot_right {
677        data.us_option_qot_right = v;
678    }
679    if let Some(v) = projection.us_index_qot_right {
680        data.us_index_qot_right = v;
681    }
682    if let Some(v) = projection.us_otc_qot_right {
683        data.us_otc_qot_right = v;
684    }
685    if let Some(v) = projection.us_cme_future_qot_right {
686        data.us_cme_future_qot_right = v;
687    }
688    if let Some(v) = projection.us_cbot_future_qot_right {
689        data.us_cbot_future_qot_right = v;
690    }
691    if let Some(v) = projection.us_nymex_future_qot_right {
692        data.us_nymex_future_qot_right = v;
693    }
694    if let Some(v) = projection.us_comex_future_qot_right {
695        data.us_comex_future_qot_right = v;
696    }
697    if let Some(v) = projection.us_cboe_future_qot_right {
698        data.us_cboe_future_qot_right = v;
699    }
700    if let Some(v) = projection.sg_future_qot_right {
701        data.sg_future_qot_right = v;
702    }
703    if let Some(v) = projection.jp_future_qot_right {
704        data.jp_future_qot_right = v;
705    }
706    if let Some(v) = projection.sg_stock_qot_right {
707        data.sg_stock_qot_right = v;
708    }
709    if let Some(v) = projection.my_stock_qot_right {
710        data.my_stock_qot_right = v;
711    }
712    if let Some(v) = projection.jp_stock_qot_right {
713        data.jp_stock_qot_right = v;
714    }
715    if let Some(v) = projection.cc_qot_right {
716        data.cc_qot_right = v;
717    }
718    if let Some(v) = projection.cc_pt_orderbook_qot_right {
719        data.cc_pt_orderbook_qot_right = v;
720    }
721    if let Some(v) = projection.event_contract_qot_right {
722        data.event_contract_qot_right = v;
723    }
724    if let Some(ids) = projection.event_contract_prohibited_category_ids {
725        data.event_contract_prohibited_category_ids = ids;
726    }
727    if let Some(v) = projection.us_lv2_arca_qot_right {
728        data.us_lv2_arca_qot_right = v;
729    }
730    if let Some(v) = projection.us_lv2_nyse_qot_right {
731        data.us_lv2_nyse_qot_right = v;
732    }
733    if let Some(v) = projection.us_lv2_nasdaq_totalview_qot_right {
734        data.us_lv2_nasdaq_totalview_qot_right = v;
735    }
736    if let Some(v) = projection.sub_quota {
737        data.sub_quota = v;
738    }
739    if let Some(v) = projection.history_kl_quota {
740        data.history_kl_quota = v;
741    }
742}
743
744#[cfg(test)]
745mod tests;