Skip to main content

futu_cache/
qot_cache.rs

1// 行情数据缓存
2//
3// 对应 C++ NNDataCenter 中的 INNData_Qot_SecQot / INNData_Qot_KLRT 等
4// 使用 DashMap 实现并发安全的内存缓存
5//
6// ## v1.4.110 Phase 2 Slice 5: broker-aware overloads
7//
8// 加 broker-aware overload (`*_broker` 后缀) 让 crypto multi-broker push 写
9// 入独立 cache key (e.g. `"91_BTCUSDT@b1007"` vs `"91_BTCUSDT@b1008"`).
10//
11// 老 API 保留 — broker_id=None 时 `QotSecurityKey::cache_key()` 退化到原
12// `"market_code"` 形式, 与升级前行为完全等价. Phase 3 才会替换 reader caller
13// 改走 `*_broker` 版本 (handler `GetBasicQot` 等).
14
15use dashmap::DashMap;
16use futu_core::qot_stock_key::QotSecurityKey;
17use parking_lot::RwLock;
18use std::sync::Arc;
19use std::sync::atomic::AtomicUsize;
20use tokio::sync::{Notify, watch};
21
22mod kline;
23mod order_book_merge;
24mod rt;
25mod spread;
26mod waiters;
27pub use rt::{RtAverageMode, RtPullFlightGuard, RtPullGeneration, RtPushApplyOutcome};
28
29pub use kline::{CachedKLine, KlineDims};
30pub use order_book_merge::merge_multiple_order_book_caches;
31pub use spread::CachedSpreadBand;
32use spread::spread_value_raw_from_bands;
33pub use waiters::{
34    basic_qot_wait_key, odd_lot_order_book_cache_key, odd_lot_order_book_wait_key,
35    order_book_wait_key, ticker_wait_key,
36};
37
38// Ref: FutuOpenD/Src/NNBase/NNBase_Define_Enum.h `NN_AppLanguage`
39// and FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_TenBuySellBroker.cpp::GetBrokerName.
40const APP_LANGUAGE_ZH: i32 = 0;
41const APP_LANGUAGE_HK: i32 = 1;
42const APP_LANGUAGE_EN: i32 = 2;
43const APP_LANGUAGE_JA: i32 = 5;
44
45/// 股票行情缓存 key: "market_code" (如 "1_00700") 或 broker-aware "market_code@b1007".
46///
47/// **v1.4.110 Phase 2 Slice 5**: cache key encoding 仍是 String (Phase 5 不
48/// 引入新 hash domain), broker-aware 后缀由 `QotSecurityKey::cache_key()` 编码:
49/// - no_broker: `"91_BTCUSDT"` (与升级前等价)
50/// - broker-aware: `"91_BTCUSDT@b1007"` (Phase 3 之后启用)
51pub type SecurityKey = String;
52
53/// 生成缓存 key
54pub fn make_key(market: i32, code: &str) -> SecurityKey {
55    format!("{market}_{code}")
56}
57
58/// 基本报价缓存
59#[derive(Debug, Clone)]
60pub struct CachedBasicQot {
61    pub cur_price: f64,
62    pub open_price: f64,
63    pub high_price: f64,
64    pub low_price: f64,
65    pub last_close_price: f64,
66    pub volume: i64,
67    pub turnover: f64,
68    pub turnover_rate: f64,
69    pub amplitude: f64,
70    pub is_suspended: bool,
71    pub update_time: String,
72    pub update_timestamp: f64,
73    /// v1.4.72 BUG-006 L3 (external reviewer v1.4.69 P1): US 夜盘 OHLCV 数据。
74    /// backend 推送(CMD 6212 Qot_UpdateBasicQot)的 BasicQot.overnight (field 25)
75    /// 在夜盘时段会填充,regular hours 为 None。push_parser 提取并缓存,让
76    /// 下游 subscribe push + snapshot query 都能看到实时夜盘数据。
77    pub overnight: Option<CachedPreAfterMarketData>,
78    /// v1.4.106 codex 1140 F4 (P2): US 盘前 OHLCV 数据.
79    /// SBIT_US_PREMARKET_AFTERHOURS_DETAIL 推送时由 push_parser 解析填充, US
80    /// 盘前时段会有, regular hours / non-US → None. 下游 read 透传给 ftapi
81    /// `BasicQot.pre_market` (proto Qot_Common.proto:671). audit Finding 4.
82    pub pre_market: Option<CachedPreAfterMarketData>,
83    /// v1.4.106 codex 1140 F4 (P2): US 盘后 OHLCV 数据.
84    /// 同上, 但取 SBIT_US_PREMARKET_AFTERHOURS_DETAIL 的 after_hours 字段.
85    /// 下游 read 透传给 ftapi `BasicQot.after_market`. audit Finding 4.
86    pub after_market: Option<CachedPreAfterMarketData>,
87}
88
89/// v1.4.72 BUG-006 L3: 美股夜盘 OHLCV 数据(对齐 proto `Qot_Common::PreAfterMarketData`)
90///
91/// 同一 struct 在 pre_market / after_market / overnight 三个字段都复用。
92#[derive(Debug, Clone, Default)]
93pub struct CachedPreAfterMarketData {
94    pub price: Option<f64>,
95    pub high_price: Option<f64>,
96    pub low_price: Option<f64>,
97    pub volume: Option<i64>,
98    pub turnover: Option<f64>,
99    pub change_val: Option<f64>,
100    pub change_rate: Option<f64>,
101    pub amplitude: Option<f64>,
102}
103
104/// 摆盘缓存 (对齐 C++ Qot_UpdateOrderBook::S2C)
105#[derive(Debug, Clone, Default)]
106pub struct CachedOrderBook {
107    pub ask_list: Vec<CachedOrderBookLevel>,
108    pub bid_list: Vec<CachedOrderBookLevel>,
109    pub svr_recv_time_bid: Option<String>,
110    pub svr_recv_time_bid_timestamp: Option<f64>,
111    pub svr_recv_time_ask: Option<String>,
112    pub svr_recv_time_ask_timestamp: Option<f64>,
113    /// C++ keeps an explicit `m_setUSLv2OrderPushed` marker and `GetOrderBook`
114    /// waits for it before serving US/Jp/Crypto Lv2 orderbooks. A non-empty
115    /// Lv1 fallback cache is not enough.
116    pub accepted_lv2: bool,
117}
118
119/// 摆盘单层
120#[derive(Debug, Clone)]
121pub struct CachedOrderBookLevel {
122    pub price: f64,
123    pub volume: i64,
124    pub order_count: i32,
125    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): SF 行情订单明细列表.
126    /// backend OrderBookItem.orders (重复 OrderInfo: order_id + order_size).
127    /// 仅 HK SF 行情 + prob=BIT_PROB_ORDER_BOOK_ALL_WITH_ID 时 backend 才返;
128    /// 普通行情 → 空 vec. 下游 ftapi `Qot_Common.OrderBook.detailList` 透传.
129    pub detail_list: Vec<CachedOrderBookDetail>,
130    /// v1.4.110 codex audit Round4 R4-4: 高精度委托数量 (crypto 适用).
131    ///
132    /// crypto 盘口的 `volume` 是放大整数 (`size × 10^order_size_precision`),
133    /// i64 无法表示小数量; `hp_volume = volume / 10^precision` 是真实小数量.
134    /// 普通行情 `volume` 已是精确整数 → `None` (对齐 C++ `has_hpvolume()==false`
135    /// 时 fallback `volume`). 下游 emit 到 ftapi `Qot_Common.OrderBook.hpVolume`.
136    ///
137    /// 对齐 C++ `QotRealTimeData.cpp` `pOrderBookItem->set_hpvolume(...)` +
138    /// merge `gear.dVolume += has_hpvolume() ? hpvolume() : volume()` —— merge
139    /// 累加的是 de-scale 后的真实量, 故按 level 存 (不同交易所 precision 可能不同).
140    pub hp_volume: Option<f64>,
141}
142
143/// v1.4.106 codex 1140 F7 (P2): 摆盘订单明细 (HK SF).
144/// 对齐 ftapi `Qot_Common.OrderBookDetail` (proto field orderID + volume).
145#[derive(Debug, Clone)]
146pub struct CachedOrderBookDetail {
147    pub order_id: i64,
148    pub volume: i64,
149}
150
151/// 逐笔成交缓存 (对齐 C++ Qot_UpdateTicker::S2C)
152///
153/// v1.4.106 codex 1140 F5: 加 `type_sign` 字段 (audit Finding 5),
154/// 对齐 ftapi `Qot_Common.Ticker.typeSign` (proto field 9). 之前 cache 缺
155/// 此字段, push event 与 read response 都没法透传 type_sign.
156#[derive(Debug, Clone)]
157pub struct CachedTicker {
158    pub time: String, // HH:MM:SS 时间字符串 (从 exchange_data_time_ms 派生, 按 market 时区)
159    pub sequence: i64, // tick_key, 用于去重
160    pub dir: i32,     // 1=Bid/卖盘, 2=Ask/买盘, 3=Neutral
161    pub price: f64,
162    pub volume: i64,
163    pub hp_volume: f64,
164    /// C++ `QotRealTimeData::AddTickerData` / `NNBiz_Qot_PullQot::OnReply`
165    /// retain the event-contract price and direction beside the legacy ticker
166    /// price. The legacy public `Qot_Common.Ticker` has no matching fields, so
167    /// this metadata remains typed cache state for event-contract consumers.
168    pub no_price: Option<f64>,
169    pub event_contract_direction: Option<i32>,
170    pub backend_strategy: Option<u32>,
171    pub backend_order_type: Option<u64>,
172    pub backend_hp_turnover: Option<f64>,
173    pub turnover: f64,            // price × volume
174    pub recv_time: Option<f64>,   // server_recv_from_exchange_time_ms (秒)
175    pub ticker_type: Option<i32>, // 逐笔类型 (TickItemType: BUY=1/SELL=2/NEUTRAL=3)
176    /// v1.4.106 codex 1140 F5: 逐笔类型符号 (audit Finding 5).
177    /// 来自 TickItem.trade_type (一个英文字母的 ASCII 码), backend 推送 +
178    /// FTAPI Ticker.typeSign 对外暴露给 UI.
179    pub type_sign: Option<i32>,
180    pub push_data_type: Option<i32>,
181    pub timestamp: Option<f64>,
182}
183
184/// 分时数据点
185#[derive(Debug, Clone)]
186pub struct CachedTimeShare {
187    pub time: String,
188    pub minute: i32,
189    pub is_blank: bool,
190    pub price: f64,
191    pub last_close_price: f64,
192    pub avg_price: f64,
193    pub volume: i64,
194    pub hp_volume: f64,
195    pub turnover: f64,
196    pub timestamp: f64,
197}
198
199/// 经纪队列缓存 (对齐 C++ Qot_UpdateBroker::S2C)
200#[derive(Debug, Clone, Default)]
201pub struct CachedBroker {
202    pub bid_list: Vec<CachedBrokerItem>,
203    pub ask_list: Vec<CachedBrokerItem>,
204}
205
206/// 经纪队列单项
207#[derive(Debug, Clone)]
208pub struct CachedBrokerItem {
209    pub id: i64,
210    pub name: String,
211    pub pos: i32,
212    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): HK SF 行情订单 ID.
213    /// 对齐 ftapi `Qot_Common.Broker.orderID` (proto field 4 optional). 仅
214    /// HK SF 时 backend HKBrokerQueue.order_id_list 含值, 普通行情 → None.
215    pub order_id: Option<i64>,
216    /// v1.4.106 codex 1140 F7 (P2): HK SF 订单股数. 对齐 ftapi
217    /// `Qot_Common.Broker.volume` (proto field 5 optional).
218    pub volume: Option<i64>,
219}
220
221/// v1.4.106 codex 1140 F7 (P2 audit Finding 7): 券商配置表 (broker_id → 名称).
222/// 由 CMD 18008 (NN_ProtoCmd_Qot_Pull_BrokerInfo) 拉取并解析后填充.
223/// 替代旧 `format!("Broker#{bid}")` 占位符进入公开 API 的反模式.
224#[derive(Debug, Clone)]
225pub struct CachedBrokerInfo {
226    /// 中文简称 (sc) — 用作主显示名 (与 C++ GetBrokerName 同语义)
227    pub name_zh_cn: String,
228    /// 英文简称 (en)
229    pub name_en: String,
230    /// 中文繁体简称 (tc)
231    pub name_tc: String,
232}
233
234/// 行情缓存管理器
235pub struct QotCache {
236    /// 基本报价缓存
237    pub basic_qot: DashMap<SecurityKey, CachedBasicQot>,
238    /// US stock overnight-enabled state, keyed by backend stock_id.
239    ///
240    /// C++ stores this as `stockID -> bool` in `INNData_Qot_USStockOvernight`:
241    /// - `NNData_Qot_USStockOvernight.cpp:21-35` (missing key => false)
242    /// - `NNBiz_Qot_USStockState.cpp:180-190` writes `overnight_type == 1`
243    /// - `APIServer_Qot_MarketState.cpp:238-244` reads it for 11 -> 37 projection
244    pub us_stock_overnight: DashMap<u64, bool>,
245    /// K 线缓存: key = `sec:r{rehab}:k{type}:s{aggregate}` where aggregate is
246    /// typed RTH/ETH/ALL, not a raw backend RequestSection.
247    pub klines: DashMap<String, Vec<CachedKLine>>,
248    /// C++ mutates every RTH/ETH/ALL KLine aggregate under one cache lock.
249    /// All production KLine readers and writers take this owner so a pull
250    /// replacement cannot interleave with a multi-aggregate push update.
251    kline_data_lock: RwLock<()>,
252    /// 摆盘缓存
253    pub order_books: DashMap<SecurityKey, CachedOrderBook>,
254    /// 逐笔缓存: 保留最近 N 条
255    pub tickers: DashMap<SecurityKey, Vec<CachedTicker>>,
256    /// 分时缓存
257    /// v1.4.106 codex 1140 F6 (P2 audit Finding 6): RT cache key 加 session
258    /// 维度. 之前 `DashMap<SecurityKey, ...>` 把 RTH/ETH/PRE/AFTER 全部混到
259    /// 同一桶, 客户端订阅 RTH 也能读到 PRE 数据. 现在 key 是
260    /// "sec_key:s{session}" (RequestSection 0=NORMAL/1=FULL/2=PREMARKET/
261    /// 3=AFTERHOURS), 隔离不同 session.
262    pub rt_data: DashMap<String, Vec<CachedTimeShare>>,
263    rt_pull_in_flight: DashMap<String, watch::Sender<bool>>,
264    rt_data_publish_lock: RwLock<()>,
265    rt_pull_generations: DashMap<String, RtPullGeneration>,
266    /// 经纪队列缓存
267    pub brokers: DashMap<SecurityKey, CachedBroker>,
268    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): 券商 ID → 信息映射.
269    /// 由 CMD 18008 拉取后填充, 用于 push parser 从 broker_id 查真名 (替代
270    /// `Broker#{bid}` 占位符).
271    pub broker_dict: DashMap<i64, CachedBrokerInfo>,
272    /// C++ `INNData_Qot_Spread`: spread table code → price bands.
273    ///
274    /// Filled from CMD6503 at QOT handler registration time and refreshed every
275    /// 8h, matching `NNBiz_Qot_Spread::SetTimerUpdateSpreadInfo`. Read paths
276    /// are synchronous and lock-free enough for push hot paths.
277    pub spread_tables: DashMap<i32, Vec<CachedSpreadBand>>,
278    /// v1.4.110 codex Phase 3 Slice 6c: cold-cache wait waiters.
279    ///
280    /// key = `"<cache_key>:<wait_kind>"` (e.g. `"91_BTCUSDT@b1007:basic"` /
281    /// `"1_00700:orderbook"`). value = shared `Arc<Notify>` 让 handler 阻塞等
282    /// push parser 写 cache 后唤醒.
283    ///
284    /// 对齐 C++ `APIServer_Qot_StockBasic.cpp:226-320` `WaitForReady` —
285    /// 已订阅但 cache 未就绪时 handler 主动 Pull_SubData + 等 push 写 cache.
286    ///
287    /// 设计 trade-off:
288    /// - 用 `DashMap<String, Arc<Notify>>` 而非 `RwLock<HashMap>`: 高并发读
289    ///   写不锁全表
290    /// - key 编码 wait_kind 防 basic / orderbook 共用同一 Notify 互相错唤醒
291    /// - update path 调 `notify_waiters` (broadcast 给所有 awaiter) 然后从
292    ///   map 中 remove (Arc 被 awaiter 持有, 自然释放)
293    pub cold_cache_waiters: DashMap<String, Arc<Notify>>,
294    cold_cache_waiter_entries: AtomicUsize,
295}
296
297impl QotCache {
298    pub fn new() -> Self {
299        Self {
300            basic_qot: DashMap::new(),
301            us_stock_overnight: DashMap::new(),
302            klines: DashMap::new(),
303            kline_data_lock: RwLock::new(()),
304            order_books: DashMap::new(),
305            tickers: DashMap::new(),
306            rt_data: DashMap::new(),
307            rt_pull_in_flight: DashMap::new(),
308            rt_data_publish_lock: RwLock::new(()),
309            rt_pull_generations: DashMap::new(),
310            brokers: DashMap::new(),
311            // v1.4.106 codex 1140 F7: broker dict 由 CMD 18008 拉取后填充.
312            broker_dict: DashMap::new(),
313            spread_tables: DashMap::new(),
314            // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait waiters.
315            cold_cache_waiters: DashMap::new(),
316            cold_cache_waiter_entries: AtomicUsize::new(0),
317        }
318    }
319
320    /// Replace the whole spread-table cache after a successful CMD6503 pull.
321    ///
322    /// C++ `INNData_Qot_Spread::SetSpreadInfo` installs a flattened full table
323    /// snapshot. We use a code-keyed map but preserve the same replacement
324    /// semantics so stale removed codes do not linger across refreshes.
325    pub fn replace_spread_tables<I>(&self, tables: I)
326    where
327        I: IntoIterator<Item = (i32, Vec<CachedSpreadBand>)>,
328    {
329        self.spread_tables.clear();
330        for (code, bands) in tables {
331            if code != 0 && !bands.is_empty() {
332                self.spread_tables.insert(code, bands);
333            }
334        }
335    }
336
337    /// Return the raw 1e9 fixed-point spread value for a security price.
338    ///
339    /// Mirrors the spread-band selection in
340    /// `NNBiz_Qot_Spread::GetStockSpreadPriceForTade` with `bUp=true` and
341    /// `enTrdMarket=Unknown`, which is what quote snapshot / BasicQot push use.
342    /// Missing table returns 0, matching C++ cache miss falling through with
343    /// initial `nPriceSpread=0` at the API projection layer.
344    pub fn spread_value_raw(&self, spread_code: u32, price_raw: i64) -> i64 {
345        let Some(bands) = self.spread_tables.get(&(spread_code as i32)) else {
346            return 0;
347        };
348        spread_value_raw_from_bands(&bands, price_raw)
349    }
350
351    /// Event Contract contract-list projection uses the first configured band
352    /// as the contract tick size, independent of a current quote price.
353    ///
354    /// Ref: frozen C++ 10.9.6918
355    /// `APIServer_Qot_GetEventContract.cpp`, `GetSpreadInfo(...)[0]`.
356    pub fn first_spread_value_raw(&self, spread_code: u32) -> i64 {
357        self.spread_tables
358            .get(&(spread_code as i32))
359            .and_then(|bands| bands.first().map(|band| band.value))
360            .unwrap_or(0)
361    }
362
363    /// Project `BasicQot.priceSpread` / `SnapshotBasicData.priceSpread`.
364    pub fn price_spread_for_raw_price(&self, spread_code: u32, price_raw: i64) -> f64 {
365        self.spread_value_raw(spread_code, price_raw) as f64 / 1_000_000_000.0
366    }
367
368    /// Project `priceSpread` from a floating-point API price.
369    pub fn price_spread_for_price(&self, spread_code: u32, price: f64) -> f64 {
370        if spread_code == 0 || price <= 0.0 {
371            return 0.0;
372        }
373        self.price_spread_for_raw_price(spread_code, (price * 1_000_000_000.0) as i64)
374    }
375
376    /// Update C++-style US overnight stock state (`stockID -> bool`).
377    ///
378    /// Ref: `NNData_Qot_USStockOvernight.cpp:21-35` and
379    /// `NNBiz_Qot_USStockState.cpp:180-190`.
380    pub fn set_us_stock_overnight_state(&self, stock_id: u64, is_overnight: bool) {
381        if stock_id == 0 {
382            return;
383        }
384        self.us_stock_overnight.insert(stock_id, is_overnight);
385    }
386
387    /// Query whether a US stock is currently in overnight trading.
388    ///
389    /// C++ cache miss returns false (`NNData_Qot_USStockOvernight.cpp:29-34`).
390    pub fn is_us_stock_overnight(&self, stock_id: u64) -> bool {
391        self.us_stock_overnight
392            .get(&stock_id)
393            .map(|v| *v)
394            .unwrap_or(false)
395    }
396
397    /// v1.4.106 codex 1140 F7 (P2): 查 broker_id → broker name (中文简称).
398    /// cache miss → None, 调用方决定 fallback 策略 (push parser 用
399    /// `Broker#{id}` 作 emergency fallback, 但同时 warn-log 提示 dict 未加载).
400    pub fn get_broker_name(&self, broker_id: i64) -> Option<String> {
401        self.get_broker_name_for_app_lang(broker_id, APP_LANGUAGE_ZH)
402    }
403
404    /// latest C++ `GetBrokerName` 按 App 语言选择券商简称,并在缺省时
405    /// fallback 到 en/tc/sc。Rust 当前 CMD 18008 cache 只保存简称三语字段;
406    /// 因此这里对齐 C++ 的 abbreviation-first 分支,完整名 fallback 等
407    /// cache 结构扩展后再自然接入。
408    pub fn get_broker_name_for_app_lang(&self, broker_id: i64, app_lang: i32) -> Option<String> {
409        self.broker_dict.get(&broker_id).and_then(|info| {
410            let candidates: [&str; 4] = match app_lang {
411                APP_LANGUAGE_EN | APP_LANGUAGE_JA => [
412                    &info.name_en,
413                    &info.name_en,
414                    &info.name_tc,
415                    &info.name_zh_cn,
416                ],
417                APP_LANGUAGE_HK => [
418                    &info.name_tc,
419                    &info.name_en,
420                    &info.name_tc,
421                    &info.name_zh_cn,
422                ],
423                APP_LANGUAGE_ZH => [
424                    &info.name_zh_cn,
425                    &info.name_en,
426                    &info.name_tc,
427                    &info.name_zh_cn,
428                ],
429                _ => [
430                    &info.name_zh_cn,
431                    &info.name_en,
432                    &info.name_tc,
433                    &info.name_zh_cn,
434                ],
435            };
436            candidates
437                .into_iter()
438                .find(|name| !name.is_empty())
439                .map(ToOwned::to_owned)
440        })
441    }
442
443    /// v1.4.106 codex 1140 F7 (P2): 批量写入 broker dict (CMD 18008 解析后调).
444    pub fn install_broker_dict(&self, entries: Vec<(i64, CachedBrokerInfo)>) {
445        for (id, info) in entries {
446            self.broker_dict.insert(id, info);
447        }
448    }
449
450    /// 更新基本报价
451    pub fn update_basic_qot(&self, key: &str, qot: CachedBasicQot) {
452        self.basic_qot.insert(key.to_string(), qot);
453        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
454        self.notify_basic_qot_cold_cache_waiters(key);
455    }
456
457    /// 获取基本报价
458    pub fn get_basic_qot(&self, key: &str) -> Option<CachedBasicQot> {
459        self.basic_qot.get(key).map(|v| v.clone())
460    }
461
462    pub fn get_basic_qot_by_cache_key(&self, cache_key: &str) -> Option<CachedBasicQot> {
463        self.get_basic_qot(cache_key)
464    }
465
466    pub fn basic_qot_last_close_by_cache_key(&self, cache_key: &str) -> Option<f64> {
467        self.basic_qot
468            .get(cache_key)
469            .map(|quote| quote.last_close_price)
470    }
471
472    /// **v1.4.110 Phase 2 Slice 5**: 更新基本报价 (broker-aware).
473    ///
474    /// 用 `QotSecurityKey::cache_key()` 派生 String key. broker_id=None → 与
475    /// `update_basic_qot(public_sec_key, ...)` 等价; broker_id=Some(N) → 写
476    /// 独立 cache key `"market_code@b{N}"` (crypto multi-broker isolation).
477    pub fn update_basic_qot_broker(&self, key: &QotSecurityKey, qot: CachedBasicQot) {
478        let cache_key = key.cache_key();
479        self.basic_qot.insert(cache_key.clone(), qot);
480        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
481        self.notify_basic_qot_cold_cache_waiters(&cache_key);
482    }
483
484    /// **v1.4.110 Phase 2 Slice 5**: 获取基本报价 (broker-aware).
485    pub fn get_basic_qot_broker(&self, key: &QotSecurityKey) -> Option<CachedBasicQot> {
486        self.get_basic_qot_by_cache_key(key.cache_key_cow().as_ref())
487    }
488
489    /// 更新摆盘
490    pub fn update_order_book(&self, key: &str, ob: CachedOrderBook) {
491        self.order_books.insert(key.to_string(), ob);
492        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
493        self.notify_order_book_cold_cache_waiters(key);
494    }
495
496    /// **v1.4.110 Phase 2 Slice 5**: 更新摆盘 (broker-aware).
497    pub fn update_order_book_broker(&self, key: &QotSecurityKey, ob: CachedOrderBook) {
498        let cache_key = key.cache_key();
499        self.order_books.insert(cache_key.clone(), ob);
500        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
501        self.notify_order_book_cold_cache_waiters(&cache_key);
502    }
503
504    /// Update odd-lot order book cache (MY/SG only in C++ 10.7).
505    pub fn update_odd_lot_order_book_broker(&self, key: &QotSecurityKey, ob: CachedOrderBook) {
506        let cache_key = key.cache_key();
507        let odd_key = odd_lot_order_book_cache_key(&cache_key);
508        self.order_books.insert(odd_key.clone(), ob);
509        self.notify_odd_lot_order_book_cold_cache_waiters(&cache_key);
510    }
511
512    /// **v1.4.110 Phase 2 Slice 5**: 获取摆盘 (broker-aware).
513    pub fn get_order_book_broker(&self, key: &QotSecurityKey) -> Option<CachedOrderBook> {
514        self.order_books.get(&key.cache_key()).map(|v| v.clone())
515    }
516
517    /// Get odd-lot order book cache (MY/SG only in C++ 10.7).
518    pub fn get_odd_lot_order_book_broker(&self, key: &QotSecurityKey) -> Option<CachedOrderBook> {
519        let cache_key = key.cache_key();
520        self.order_books
521            .get(&odd_lot_order_book_cache_key(&cache_key))
522            .map(|v| v.clone())
523    }
524
525    /// 追加逐笔(保留最近 1000 条)
526    pub fn append_tickers(&self, key: &str, new_tickers: Vec<CachedTicker>) {
527        let mut entry = self.tickers.entry(key.to_string()).or_default();
528        upsert_tickers_by_sequence(&mut entry, new_tickers);
529        // C++ `APIServer_Qot_Ticker` wakes waiters when ticker data arrives
530        // through `NN_OMEvent_Qot_Update_Ticker`; Rust uses the cache append
531        // point as the equivalent notify source for active-pull and push writes.
532        self.notify_ticker_cold_cache_waiters(key);
533    }
534
535    /// **v1.4.110 Phase 2 Slice 5**: 追加逐笔 (broker-aware).
536    pub fn append_tickers_broker(&self, key: &QotSecurityKey, new_tickers: Vec<CachedTicker>) {
537        let cache_key = key.cache_key();
538        let mut entry = self.tickers.entry(cache_key.clone()).or_default();
539        upsert_tickers_by_sequence(&mut entry, new_tickers);
540        self.notify_ticker_cold_cache_waiters(&cache_key);
541    }
542
543    /// **v1.4.110 Phase 2 Slice 5**: 获取逐笔 (broker-aware).
544    pub fn get_tickers_broker(&self, key: &QotSecurityKey) -> Option<Vec<CachedTicker>> {
545        self.get_tickers_by_cache_key(key.cache_key_cow().as_ref())
546    }
547
548    pub fn get_tickers_by_cache_key(&self, cache_key: &str) -> Option<Vec<CachedTicker>> {
549        self.tickers.get(cache_key).map(|tickers| tickers.clone())
550    }
551
552    pub fn ticker_count_by_cache_key(&self, cache_key: &str) -> usize {
553        self.tickers
554            .get(cache_key)
555            .map_or(0, |tickers| tickers.len())
556    }
557
558    pub fn recent_tickers_by_cache_key(
559        &self,
560        cache_key: &str,
561        count: usize,
562    ) -> Option<Vec<CachedTicker>> {
563        self.tickers.get(cache_key).map(|tickers| {
564            let start = tickers.len().saturating_sub(count);
565            tickers[start..].to_vec()
566        })
567    }
568
569    /// 更新经纪队列
570    pub fn update_broker(&self, key: &str, broker: CachedBroker) {
571        self.brokers.insert(key.to_string(), broker);
572    }
573
574    /// 获取经纪队列
575    pub fn get_broker(&self, key: &str) -> Option<CachedBroker> {
576        self.brokers.get(key).map(|v| v.clone())
577    }
578
579    /// 清除指定股票的所有缓存
580    pub fn clear_security(&self, key: &str) {
581        self.basic_qot.remove(key);
582        self.order_books.remove(key);
583        self.tickers.remove(key);
584        self.brokers.remove(key);
585        // v1.4.106 codex 1140 F3: K 线 key 是 "sec_key:r{rehab}:k{kl_type}:s{session}"
586        // 4-tuple, 仍是 sec_key prefix 起头, retain prefix match 仍正确清所有维度.
587        let prefix = format!("{key}:");
588        {
589            let _owner = self.kline_data_lock.write();
590            self.klines.retain(|k, _| !k.starts_with(&prefix));
591        }
592        // v1.4.106 codex 1140 F6: rt_data key 也加 session 维度后变为
593        // "sec_key:s{session}", 同样 prefix-match 清 RTH/ETH/ALL 全部桶.
594        let _publish = self.rt_data_publish_lock.write();
595        self.rt_data.retain(|k, _| !k.starts_with(&prefix));
596        self.rt_pull_generations.remove(key);
597    }
598
599    /// **v1.4.110 Phase 2 Slice 5**: 清除指定股票的所有缓存 (broker-aware).
600    ///
601    /// 用 `QotSecurityKey::cache_key()` 派生 cache key 字符串. broker_id=None
602    /// → 与 `clear_security(public_sec_key)` 等价; broker_id=Some(N) → 只清
603    /// 该 broker 下的 cache (其他 broker 下同 stock_id 的 cache 保留).
604    pub fn clear_security_broker(&self, key: &QotSecurityKey) {
605        let cache_key = key.cache_key();
606        self.basic_qot.remove(&cache_key);
607        self.order_books.remove(&cache_key);
608        self.tickers.remove(&cache_key);
609        self.brokers.remove(&cache_key);
610        let prefix = format!("{cache_key}:");
611        {
612            let _owner = self.kline_data_lock.write();
613            self.klines.retain(|k, _| !k.starts_with(&prefix));
614        }
615        let _publish = self.rt_data_publish_lock.write();
616        self.rt_data.retain(|k, _| !k.starts_with(&prefix));
617        self.rt_pull_generations.remove(&cache_key);
618    }
619
620    /// Clear only the C++ `ReSub()` realtime cache families for one backend
621    /// quote-market bucket.
622    ///
623    /// Ref: `QotRealTimeData.cpp:613-671` clears Basic, OrderBook (including
624    /// odd-lot), Broker and Ticker before a non-CMD6304 replay. KLine and RT
625    /// are deliberately retained. Market membership is derived from each
626    /// cache key's public FTAPI market and the canonical FTAPI -> backend QOT
627    /// mapping; string prefixes are not used as market identity.
628    pub fn clear_realtime_quote_market(&self, quote_market_type: u8) {
629        if quote_market_type == 0 {
630            return;
631        }
632        self.clear_realtime_quotes_where(|key| {
633            cache_key_matches_quote_market(key, quote_market_type)
634        });
635    }
636
637    /// Clear the C++ `ReSub()` realtime cache families selected by an
638    /// owner-supplied market predicate.
639    ///
640    /// The gateway supplies static-security-aware ownership for ordinary
641    /// market replay. Cache-only callers may continue using
642    /// `clear_realtime_quote_market`, whose public-market mapping remains
643    /// correct for crypto exchange-ready replay.
644    pub fn clear_realtime_quotes_where(&self, belongs_to_target: impl Fn(&str) -> bool) {
645        self.basic_qot.retain(|key, _| !belongs_to_target(key));
646        self.order_books.retain(|key, _| {
647            let base = key.strip_suffix(":orderbook_odd").unwrap_or(key);
648            !belongs_to_target(base)
649        });
650        self.tickers.retain(|key, _| !belongs_to_target(key));
651        self.brokers.retain(|key, _| !belongs_to_target(key));
652    }
653
654    /// Clear all C++ `ReSubAll()` realtime cache families.
655    ///
656    /// C++ constructs every supported `MktQotSub` bucket up front and calls
657    /// `ReSub()` on every bucket after reconnect. Each ordinary `ReSub()`
658    /// invokes `QotRealTimeData::OnClearRealTimeData` before rebuilding the
659    /// backend desired set. KLine, RT and reference/configuration caches are
660    /// deliberately retained.
661    pub fn clear_all_realtime_quotes(&self) {
662        self.basic_qot.clear();
663        self.order_books.clear();
664        self.tickers.clear();
665        self.brokers.clear();
666    }
667}
668
669/// C++ `AddTickerList` sorts/deduplicates an incoming batch by ticker key and
670/// replaces an existing row when the same key is observed again. Keep that
671/// identity rule in the single cache write boundary shared by pull and push.
672fn upsert_tickers_by_sequence(entry: &mut Vec<CachedTicker>, mut incoming: Vec<CachedTicker>) {
673    incoming.sort_by_key(|ticker| ticker.sequence);
674    incoming.dedup_by_key(|ticker| ticker.sequence);
675
676    // Older cache generations may already contain duplicates. Normalize them
677    // before applying the new authoritative rows so an upgrade repairs the
678    // bucket on its first write.
679    let mut existing = std::mem::take(entry);
680    existing.sort_by_key(|ticker| ticker.sequence);
681    existing.dedup_by_key(|ticker| ticker.sequence);
682
683    let mut existing = existing.into_iter().peekable();
684    let mut incoming = incoming.into_iter().peekable();
685    let mut merged = Vec::with_capacity(existing.len() + incoming.len());
686    while let (Some(old), Some(new)) = (existing.peek(), incoming.peek()) {
687        match old.sequence.cmp(&new.sequence) {
688            std::cmp::Ordering::Less => {
689                if let Some(ticker) = existing.next() {
690                    merged.push(ticker);
691                }
692            }
693            std::cmp::Ordering::Greater => {
694                if let Some(ticker) = incoming.next() {
695                    merged.push(ticker);
696                }
697            }
698            std::cmp::Ordering::Equal => {
699                drop(existing.next());
700                if let Some(ticker) = incoming.next() {
701                    merged.push(ticker);
702                }
703            }
704        }
705    }
706    merged.extend(existing);
707    merged.extend(incoming);
708
709    const TICKER_CACHE_MAX_ITEMS: usize = 1000;
710    if merged.len() > TICKER_CACHE_MAX_ITEMS {
711        let drain_count = merged.len() - TICKER_CACHE_MAX_ITEMS;
712        merged.drain(..drain_count);
713    }
714    *entry = merged;
715}
716
717fn cache_key_matches_quote_market(cache_key: &str, quote_market_type: u8) -> bool {
718    QotSecurityKey::parse_cache_key(cache_key)
719        .and_then(|(public, _)| {
720            QotSecurityKey::parse_public_sec_key(&public).map(|parsed| parsed.market)
721        })
722        .map(futu_core::qot_subscription::ftapi_market_to_quote_mkt)
723        == Some(quote_market_type)
724}
725
726impl Default for QotCache {
727    fn default() -> Self {
728        Self::new()
729    }
730}
731
732#[cfg(test)]
733mod merge_tests;
734
735#[cfg(test)]
736mod tests;