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 std::sync::Arc;
18use std::sync::atomic::AtomicUsize;
19use tokio::sync::Notify;
20
21mod kline;
22mod order_book_merge;
23mod spread;
24mod waiters;
25
26pub use kline::{CachedKLine, KlineDims};
27pub use order_book_merge::merge_multiple_order_book_caches;
28pub use spread::CachedSpreadBand;
29use spread::spread_value_raw_from_bands;
30pub use waiters::{
31    basic_qot_wait_key, odd_lot_order_book_cache_key, odd_lot_order_book_wait_key,
32    order_book_wait_key, ticker_wait_key,
33};
34
35// Ref: FutuOpenD/Src/NNBase/NNBase_Define_Enum.h `NN_AppLanguage`
36// and FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_TenBuySellBroker.cpp::GetBrokerName.
37const APP_LANGUAGE_ZH: i32 = 0;
38const APP_LANGUAGE_HK: i32 = 1;
39const APP_LANGUAGE_EN: i32 = 2;
40const APP_LANGUAGE_JA: i32 = 5;
41
42/// 股票行情缓存 key: "market_code" (如 "1_00700") 或 broker-aware "market_code@b1007".
43///
44/// **v1.4.110 Phase 2 Slice 5**: cache key encoding 仍是 String (Phase 5 不
45/// 引入新 hash domain), broker-aware 后缀由 `QotSecurityKey::cache_key()` 编码:
46/// - no_broker: `"91_BTCUSDT"` (与升级前等价)
47/// - broker-aware: `"91_BTCUSDT@b1007"` (Phase 3 之后启用)
48pub type SecurityKey = String;
49
50/// 生成缓存 key
51pub fn make_key(market: i32, code: &str) -> SecurityKey {
52    format!("{market}_{code}")
53}
54
55/// 基本报价缓存
56#[derive(Debug, Clone)]
57pub struct CachedBasicQot {
58    pub cur_price: f64,
59    pub open_price: f64,
60    pub high_price: f64,
61    pub low_price: f64,
62    pub last_close_price: f64,
63    pub volume: i64,
64    pub turnover: f64,
65    pub turnover_rate: f64,
66    pub amplitude: f64,
67    pub is_suspended: bool,
68    pub update_time: String,
69    pub update_timestamp: f64,
70    /// v1.4.72 BUG-006 L3 (external reviewer v1.4.69 P1): US 夜盘 OHLCV 数据。
71    /// backend 推送(CMD 6212 Qot_UpdateBasicQot)的 BasicQot.overnight (field 25)
72    /// 在夜盘时段会填充,regular hours 为 None。push_parser 提取并缓存,让
73    /// 下游 subscribe push + snapshot query 都能看到实时夜盘数据。
74    pub overnight: Option<CachedPreAfterMarketData>,
75    /// v1.4.106 codex 1140 F4 (P2): US 盘前 OHLCV 数据.
76    /// SBIT_US_PREMARKET_AFTERHOURS_DETAIL 推送时由 push_parser 解析填充, US
77    /// 盘前时段会有, regular hours / non-US → None. 下游 read 透传给 ftapi
78    /// `BasicQot.pre_market` (proto Qot_Common.proto:671). audit Finding 4.
79    pub pre_market: Option<CachedPreAfterMarketData>,
80    /// v1.4.106 codex 1140 F4 (P2): US 盘后 OHLCV 数据.
81    /// 同上, 但取 SBIT_US_PREMARKET_AFTERHOURS_DETAIL 的 after_hours 字段.
82    /// 下游 read 透传给 ftapi `BasicQot.after_market`. audit Finding 4.
83    pub after_market: Option<CachedPreAfterMarketData>,
84}
85
86/// v1.4.72 BUG-006 L3: 美股夜盘 OHLCV 数据(对齐 proto `Qot_Common::PreAfterMarketData`)
87///
88/// 同一 struct 在 pre_market / after_market / overnight 三个字段都复用。
89#[derive(Debug, Clone, Default)]
90pub struct CachedPreAfterMarketData {
91    pub price: Option<f64>,
92    pub high_price: Option<f64>,
93    pub low_price: Option<f64>,
94    pub volume: Option<i64>,
95    pub turnover: Option<f64>,
96    pub change_val: Option<f64>,
97    pub change_rate: Option<f64>,
98    pub amplitude: Option<f64>,
99}
100
101/// 摆盘缓存 (对齐 C++ Qot_UpdateOrderBook::S2C)
102#[derive(Debug, Clone, Default)]
103pub struct CachedOrderBook {
104    pub ask_list: Vec<CachedOrderBookLevel>,
105    pub bid_list: Vec<CachedOrderBookLevel>,
106    pub svr_recv_time_bid: Option<String>,
107    pub svr_recv_time_bid_timestamp: Option<f64>,
108    pub svr_recv_time_ask: Option<String>,
109    pub svr_recv_time_ask_timestamp: Option<f64>,
110    /// C++ keeps an explicit `m_setUSLv2OrderPushed` marker and `GetOrderBook`
111    /// waits for it before serving US/Jp/Crypto Lv2 orderbooks. A non-empty
112    /// Lv1 fallback cache is not enough.
113    pub accepted_lv2: bool,
114}
115
116/// 摆盘单层
117#[derive(Debug, Clone)]
118pub struct CachedOrderBookLevel {
119    pub price: f64,
120    pub volume: i64,
121    pub order_count: i32,
122    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): SF 行情订单明细列表.
123    /// backend OrderBookItem.orders (重复 OrderInfo: order_id + order_size).
124    /// 仅 HK SF 行情 + prob=BIT_PROB_ORDER_BOOK_ALL_WITH_ID 时 backend 才返;
125    /// 普通行情 → 空 vec. 下游 ftapi `Qot_Common.OrderBook.detailList` 透传.
126    pub detail_list: Vec<CachedOrderBookDetail>,
127    /// v1.4.110 codex audit Round4 R4-4: 高精度委托数量 (crypto 适用).
128    ///
129    /// crypto 盘口的 `volume` 是放大整数 (`size × 10^order_size_precision`),
130    /// i64 无法表示小数量; `hp_volume = volume / 10^precision` 是真实小数量.
131    /// 普通行情 `volume` 已是精确整数 → `None` (对齐 C++ `has_hpvolume()==false`
132    /// 时 fallback `volume`). 下游 emit 到 ftapi `Qot_Common.OrderBook.hpVolume`.
133    ///
134    /// 对齐 C++ `QotRealTimeData.cpp` `pOrderBookItem->set_hpvolume(...)` +
135    /// merge `gear.dVolume += has_hpvolume() ? hpvolume() : volume()` —— merge
136    /// 累加的是 de-scale 后的真实量, 故按 level 存 (不同交易所 precision 可能不同).
137    pub hp_volume: Option<f64>,
138}
139
140/// v1.4.106 codex 1140 F7 (P2): 摆盘订单明细 (HK SF).
141/// 对齐 ftapi `Qot_Common.OrderBookDetail` (proto field orderID + volume).
142#[derive(Debug, Clone)]
143pub struct CachedOrderBookDetail {
144    pub order_id: i64,
145    pub volume: i64,
146}
147
148/// 逐笔成交缓存 (对齐 C++ Qot_UpdateTicker::S2C)
149///
150/// v1.4.106 codex 1140 F5: 加 `type_sign` 字段 (audit Finding 5),
151/// 对齐 ftapi `Qot_Common.Ticker.typeSign` (proto field 9). 之前 cache 缺
152/// 此字段, push event 与 read response 都没法透传 type_sign.
153#[derive(Debug, Clone)]
154pub struct CachedTicker {
155    pub time: String, // HH:MM:SS 时间字符串 (从 exchange_data_time_ms 派生, 按 market 时区)
156    pub sequence: i64, // tick_key, 用于去重
157    pub dir: i32,     // 1=Bid/卖盘, 2=Ask/买盘, 3=Neutral
158    pub price: f64,
159    pub volume: i64,
160    pub turnover: f64,            // price × volume
161    pub recv_time: Option<f64>,   // server_recv_from_exchange_time_ms (秒)
162    pub ticker_type: Option<i32>, // 逐笔类型 (TickItemType: BUY=1/SELL=2/NEUTRAL=3)
163    /// v1.4.106 codex 1140 F5: 逐笔类型符号 (audit Finding 5).
164    /// 来自 TickItem.trade_type (一个英文字母的 ASCII 码), backend 推送 +
165    /// FTAPI Ticker.typeSign 对外暴露给 UI.
166    pub type_sign: Option<i32>,
167    pub push_data_type: Option<i32>,
168    pub timestamp: Option<f64>,
169}
170
171/// 分时数据点
172#[derive(Debug, Clone)]
173pub struct CachedTimeShare {
174    pub time: String,
175    pub minute: i32,
176    pub price: f64,
177    pub last_close_price: f64,
178    pub avg_price: f64,
179    pub volume: i64,
180    pub turnover: f64,
181    pub timestamp: f64,
182}
183
184/// 经纪队列缓存 (对齐 C++ Qot_UpdateBroker::S2C)
185#[derive(Debug, Clone, Default)]
186pub struct CachedBroker {
187    pub bid_list: Vec<CachedBrokerItem>,
188    pub ask_list: Vec<CachedBrokerItem>,
189}
190
191/// 经纪队列单项
192#[derive(Debug, Clone)]
193pub struct CachedBrokerItem {
194    pub id: i64,
195    pub name: String,
196    pub pos: i32,
197    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): HK SF 行情订单 ID.
198    /// 对齐 ftapi `Qot_Common.Broker.orderID` (proto field 4 optional). 仅
199    /// HK SF 时 backend HKBrokerQueue.order_id_list 含值, 普通行情 → None.
200    pub order_id: Option<i64>,
201    /// v1.4.106 codex 1140 F7 (P2): HK SF 订单股数. 对齐 ftapi
202    /// `Qot_Common.Broker.volume` (proto field 5 optional).
203    pub volume: Option<i64>,
204}
205
206/// v1.4.106 codex 1140 F7 (P2 audit Finding 7): 券商配置表 (broker_id → 名称).
207/// 由 CMD 18008 (NN_ProtoCmd_Qot_Pull_BrokerInfo) 拉取并解析后填充.
208/// 替代旧 `format!("Broker#{bid}")` 占位符进入公开 API 的反模式.
209#[derive(Debug, Clone)]
210pub struct CachedBrokerInfo {
211    /// 中文简称 (sc) — 用作主显示名 (与 C++ GetBrokerName 同语义)
212    pub name_zh_cn: String,
213    /// 英文简称 (en)
214    pub name_en: String,
215    /// 中文繁体简称 (tc)
216    pub name_tc: String,
217}
218
219/// 行情缓存管理器
220pub struct QotCache {
221    /// 基本报价缓存
222    pub basic_qot: DashMap<SecurityKey, CachedBasicQot>,
223    /// US stock overnight-enabled state, keyed by backend stock_id.
224    ///
225    /// C++ stores this as `stockID -> bool` in `INNData_Qot_USStockOvernight`:
226    /// - `NNData_Qot_USStockOvernight.cpp:21-35` (missing key => false)
227    /// - `NNBiz_Qot_USStockState.cpp:180-190` writes `overnight_type == 1`
228    /// - `APIServer_Qot_MarketState.cpp:238-244` reads it for 11 -> 37 projection
229    pub us_stock_overnight: DashMap<u64, bool>,
230    /// K 线缓存: key = "market_code:kl_type"
231    pub klines: DashMap<String, Vec<CachedKLine>>,
232    /// 摆盘缓存
233    pub order_books: DashMap<SecurityKey, CachedOrderBook>,
234    /// 逐笔缓存: 保留最近 N 条
235    pub tickers: DashMap<SecurityKey, Vec<CachedTicker>>,
236    /// 分时缓存
237    /// v1.4.106 codex 1140 F6 (P2 audit Finding 6): RT cache key 加 session
238    /// 维度. 之前 `DashMap<SecurityKey, ...>` 把 RTH/ETH/PRE/AFTER 全部混到
239    /// 同一桶, 客户端订阅 RTH 也能读到 PRE 数据. 现在 key 是
240    /// "sec_key:s{session}" (RequestSection 0=NORMAL/1=FULL/2=PREMARKET/
241    /// 3=AFTERHOURS), 隔离不同 session.
242    pub rt_data: DashMap<String, Vec<CachedTimeShare>>,
243    /// 经纪队列缓存
244    pub brokers: DashMap<SecurityKey, CachedBroker>,
245    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): 券商 ID → 信息映射.
246    /// 由 CMD 18008 拉取后填充, 用于 push parser 从 broker_id 查真名 (替代
247    /// `Broker#{bid}` 占位符).
248    pub broker_dict: DashMap<i64, CachedBrokerInfo>,
249    /// C++ `INNData_Qot_Spread`: spread table code → price bands.
250    ///
251    /// Filled from CMD6503 at QOT handler registration time and refreshed every
252    /// 8h, matching `NNBiz_Qot_Spread::SetTimerUpdateSpreadInfo`. Read paths
253    /// are synchronous and lock-free enough for push hot paths.
254    pub spread_tables: DashMap<i32, Vec<CachedSpreadBand>>,
255    /// v1.4.110 codex Phase 3 Slice 6c: cold-cache wait waiters.
256    ///
257    /// key = `"<cache_key>:<wait_kind>"` (e.g. `"91_BTCUSDT@b1007:basic"` /
258    /// `"1_00700:orderbook"`). value = shared `Arc<Notify>` 让 handler 阻塞等
259    /// push parser 写 cache 后唤醒.
260    ///
261    /// 对齐 C++ `APIServer_Qot_StockBasic.cpp:226-320` `WaitForReady` —
262    /// 已订阅但 cache 未就绪时 handler 主动 Pull_SubData + 等 push 写 cache.
263    ///
264    /// 设计 trade-off:
265    /// - 用 `DashMap<String, Arc<Notify>>` 而非 `RwLock<HashMap>`: 高并发读
266    ///   写不锁全表
267    /// - key 编码 wait_kind 防 basic / orderbook 共用同一 Notify 互相错唤醒
268    /// - update path 调 `notify_waiters` (broadcast 给所有 awaiter) 然后从
269    ///   map 中 remove (Arc 被 awaiter 持有, 自然释放)
270    pub cold_cache_waiters: DashMap<String, Arc<Notify>>,
271    cold_cache_waiter_entries: AtomicUsize,
272}
273
274impl QotCache {
275    pub fn new() -> Self {
276        Self {
277            basic_qot: DashMap::new(),
278            us_stock_overnight: DashMap::new(),
279            klines: DashMap::new(),
280            order_books: DashMap::new(),
281            tickers: DashMap::new(),
282            rt_data: DashMap::new(),
283            brokers: DashMap::new(),
284            // v1.4.106 codex 1140 F7: broker dict 由 CMD 18008 拉取后填充.
285            broker_dict: DashMap::new(),
286            spread_tables: DashMap::new(),
287            // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait waiters.
288            cold_cache_waiters: DashMap::new(),
289            cold_cache_waiter_entries: AtomicUsize::new(0),
290        }
291    }
292
293    /// Replace the whole spread-table cache after a successful CMD6503 pull.
294    ///
295    /// C++ `INNData_Qot_Spread::SetSpreadInfo` installs a flattened full table
296    /// snapshot. We use a code-keyed map but preserve the same replacement
297    /// semantics so stale removed codes do not linger across refreshes.
298    pub fn replace_spread_tables<I>(&self, tables: I)
299    where
300        I: IntoIterator<Item = (i32, Vec<CachedSpreadBand>)>,
301    {
302        self.spread_tables.clear();
303        for (code, bands) in tables {
304            if code != 0 && !bands.is_empty() {
305                self.spread_tables.insert(code, bands);
306            }
307        }
308    }
309
310    /// Return the raw 1e9 fixed-point spread value for a security price.
311    ///
312    /// Mirrors the spread-band selection in
313    /// `NNBiz_Qot_Spread::GetStockSpreadPriceForTade` with `bUp=true` and
314    /// `enTrdMarket=Unknown`, which is what quote snapshot / BasicQot push use.
315    /// Missing table returns 0, matching C++ cache miss falling through with
316    /// initial `nPriceSpread=0` at the API projection layer.
317    pub fn spread_value_raw(&self, spread_code: u32, price_raw: i64) -> i64 {
318        let Some(bands) = self.spread_tables.get(&(spread_code as i32)) else {
319            return 0;
320        };
321        spread_value_raw_from_bands(&bands, price_raw)
322    }
323
324    /// Project `BasicQot.priceSpread` / `SnapshotBasicData.priceSpread`.
325    pub fn price_spread_for_raw_price(&self, spread_code: u32, price_raw: i64) -> f64 {
326        self.spread_value_raw(spread_code, price_raw) as f64 / 1_000_000_000.0
327    }
328
329    /// Project `priceSpread` from a floating-point API price.
330    pub fn price_spread_for_price(&self, spread_code: u32, price: f64) -> f64 {
331        if spread_code == 0 || price <= 0.0 {
332            return 0.0;
333        }
334        self.price_spread_for_raw_price(spread_code, (price * 1_000_000_000.0) as i64)
335    }
336
337    /// Update C++-style US overnight stock state (`stockID -> bool`).
338    ///
339    /// Ref: `NNData_Qot_USStockOvernight.cpp:21-35` and
340    /// `NNBiz_Qot_USStockState.cpp:180-190`.
341    pub fn set_us_stock_overnight_state(&self, stock_id: u64, is_overnight: bool) {
342        if stock_id == 0 {
343            return;
344        }
345        self.us_stock_overnight.insert(stock_id, is_overnight);
346    }
347
348    /// Query whether a US stock is currently in overnight trading.
349    ///
350    /// C++ cache miss returns false (`NNData_Qot_USStockOvernight.cpp:29-34`).
351    pub fn is_us_stock_overnight(&self, stock_id: u64) -> bool {
352        self.us_stock_overnight
353            .get(&stock_id)
354            .map(|v| *v)
355            .unwrap_or(false)
356    }
357
358    /// v1.4.106 codex 1140 F7 (P2): 查 broker_id → broker name (中文简称).
359    /// cache miss → None, 调用方决定 fallback 策略 (push parser 用
360    /// `Broker#{id}` 作 emergency fallback, 但同时 warn-log 提示 dict 未加载).
361    pub fn get_broker_name(&self, broker_id: i64) -> Option<String> {
362        self.get_broker_name_for_app_lang(broker_id, APP_LANGUAGE_ZH)
363    }
364
365    /// latest C++ `GetBrokerName` 按 App 语言选择券商简称,并在缺省时
366    /// fallback 到 en/tc/sc。Rust 当前 CMD 18008 cache 只保存简称三语字段;
367    /// 因此这里对齐 C++ 的 abbreviation-first 分支,完整名 fallback 等
368    /// cache 结构扩展后再自然接入。
369    pub fn get_broker_name_for_app_lang(&self, broker_id: i64, app_lang: i32) -> Option<String> {
370        self.broker_dict.get(&broker_id).and_then(|info| {
371            let candidates: [&str; 4] = match app_lang {
372                APP_LANGUAGE_EN | APP_LANGUAGE_JA => [
373                    &info.name_en,
374                    &info.name_en,
375                    &info.name_tc,
376                    &info.name_zh_cn,
377                ],
378                APP_LANGUAGE_HK => [
379                    &info.name_tc,
380                    &info.name_en,
381                    &info.name_tc,
382                    &info.name_zh_cn,
383                ],
384                APP_LANGUAGE_ZH => [
385                    &info.name_zh_cn,
386                    &info.name_en,
387                    &info.name_tc,
388                    &info.name_zh_cn,
389                ],
390                _ => [
391                    &info.name_zh_cn,
392                    &info.name_en,
393                    &info.name_tc,
394                    &info.name_zh_cn,
395                ],
396            };
397            candidates
398                .into_iter()
399                .find(|name| !name.is_empty())
400                .map(ToOwned::to_owned)
401        })
402    }
403
404    /// v1.4.106 codex 1140 F7 (P2): 批量写入 broker dict (CMD 18008 解析后调).
405    pub fn install_broker_dict(&self, entries: Vec<(i64, CachedBrokerInfo)>) {
406        for (id, info) in entries {
407            self.broker_dict.insert(id, info);
408        }
409    }
410
411    /// 更新基本报价
412    pub fn update_basic_qot(&self, key: &str, qot: CachedBasicQot) {
413        self.basic_qot.insert(key.to_string(), qot);
414        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
415        self.notify_basic_qot_cold_cache_waiters(key);
416    }
417
418    /// 获取基本报价
419    pub fn get_basic_qot(&self, key: &str) -> Option<CachedBasicQot> {
420        self.basic_qot.get(key).map(|v| v.clone())
421    }
422
423    /// **v1.4.110 Phase 2 Slice 5**: 更新基本报价 (broker-aware).
424    ///
425    /// 用 `QotSecurityKey::cache_key()` 派生 String key. broker_id=None → 与
426    /// `update_basic_qot(public_sec_key, ...)` 等价; broker_id=Some(N) → 写
427    /// 独立 cache key `"market_code@b{N}"` (crypto multi-broker isolation).
428    pub fn update_basic_qot_broker(&self, key: &QotSecurityKey, qot: CachedBasicQot) {
429        let cache_key = key.cache_key();
430        self.basic_qot.insert(cache_key.clone(), qot);
431        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
432        self.notify_basic_qot_cold_cache_waiters(&cache_key);
433    }
434
435    /// **v1.4.110 Phase 2 Slice 5**: 获取基本报价 (broker-aware).
436    pub fn get_basic_qot_broker(&self, key: &QotSecurityKey) -> Option<CachedBasicQot> {
437        self.basic_qot.get(&key.cache_key()).map(|v| v.clone())
438    }
439
440    /// 构造 RT 分时 cache key (v1.4.106 codex 1140 F6).
441    ///
442    /// 之前 key 仅 sec_key, RTH/ETH/PRE/AFTER/OVERNIGHT 混桶. 现在
443    /// "sec_key:s{session}" 隔离. session 来自 push 解析的
444    /// `TimeSharingPlans.section_type[0]` (RequestSection enum):
445    /// 0=NORMAL/1=FULL/2=PREMARKET/3=AFTERHOURS/5=OVERNIGHT.
446    /// GetRT handler 对 C++ 的 Session_ETH/Session_ALL 读取语义做动态拼接,
447    /// 不依赖额外 aggregate cache 桶。
448    pub fn make_rt_key(sec_key: &str, session: i32) -> String {
449        format!("{sec_key}:s{session}")
450    }
451
452    /// **v1.4.110 Phase 2 Slice 5**: 构造 RT 分时 cache key (broker-aware).
453    ///
454    /// 用 `QotSecurityKey::cache_key()` 作 prefix. broker_id=None → 与
455    /// `make_rt_key(public_sec_key, session)` 等价; broker_id=Some(N) → 写
456    /// 独立 cache key `"market_code@b{N}:s{session}"`.
457    pub fn make_rt_key_broker(key: &QotSecurityKey, session: i32) -> String {
458        format!("{}:s{}", key.cache_key(), session)
459    }
460
461    /// **v1.4.110 Phase 2 Slice 5**: 更新 RT 分时 (broker-aware).
462    pub fn update_rt_data_broker(
463        &self,
464        key: &QotSecurityKey,
465        session: i32,
466        rt_data: Vec<CachedTimeShare>,
467    ) {
468        let cache_key = Self::make_rt_key_broker(key, session);
469        self.rt_data.insert(cache_key, rt_data);
470    }
471
472    /// **v1.4.110 Phase 2 Slice 5**: 获取 RT 分时 (broker-aware).
473    pub fn get_rt_data_broker(
474        &self,
475        key: &QotSecurityKey,
476        session: i32,
477    ) -> Option<Vec<CachedTimeShare>> {
478        let cache_key = Self::make_rt_key_broker(key, session);
479        self.rt_data.get(&cache_key).map(|v| v.clone())
480    }
481
482    /// 更新摆盘
483    pub fn update_order_book(&self, key: &str, ob: CachedOrderBook) {
484        self.order_books.insert(key.to_string(), ob);
485        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
486        self.notify_order_book_cold_cache_waiters(key);
487    }
488
489    /// **v1.4.110 Phase 2 Slice 5**: 更新摆盘 (broker-aware).
490    pub fn update_order_book_broker(&self, key: &QotSecurityKey, ob: CachedOrderBook) {
491        let cache_key = key.cache_key();
492        self.order_books.insert(cache_key.clone(), ob);
493        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
494        self.notify_order_book_cold_cache_waiters(&cache_key);
495    }
496
497    /// Update odd-lot order book cache (MY/SG only in C++ 10.7).
498    pub fn update_odd_lot_order_book_broker(&self, key: &QotSecurityKey, ob: CachedOrderBook) {
499        let cache_key = key.cache_key();
500        let odd_key = odd_lot_order_book_cache_key(&cache_key);
501        self.order_books.insert(odd_key.clone(), ob);
502        self.notify_odd_lot_order_book_cold_cache_waiters(&cache_key);
503    }
504
505    /// **v1.4.110 Phase 2 Slice 5**: 获取摆盘 (broker-aware).
506    pub fn get_order_book_broker(&self, key: &QotSecurityKey) -> Option<CachedOrderBook> {
507        self.order_books.get(&key.cache_key()).map(|v| v.clone())
508    }
509
510    /// Get odd-lot order book cache (MY/SG only in C++ 10.7).
511    pub fn get_odd_lot_order_book_broker(&self, key: &QotSecurityKey) -> Option<CachedOrderBook> {
512        let cache_key = key.cache_key();
513        self.order_books
514            .get(&odd_lot_order_book_cache_key(&cache_key))
515            .map(|v| v.clone())
516    }
517
518    /// 追加逐笔(保留最近 1000 条)
519    pub fn append_tickers(&self, key: &str, new_tickers: Vec<CachedTicker>) {
520        let mut entry = self.tickers.entry(key.to_string()).or_default();
521        entry.extend(new_tickers);
522        if entry.len() > 1000 {
523            let drain_count = entry.len() - 1000;
524            entry.drain(..drain_count);
525        }
526        // C++ `APIServer_Qot_Ticker` wakes waiters when ticker data arrives
527        // through `NN_OMEvent_Qot_Update_Ticker`; Rust uses the cache append
528        // point as the equivalent notify source for active-pull and push writes.
529        self.notify_ticker_cold_cache_waiters(key);
530    }
531
532    /// **v1.4.110 Phase 2 Slice 5**: 追加逐笔 (broker-aware).
533    pub fn append_tickers_broker(&self, key: &QotSecurityKey, new_tickers: Vec<CachedTicker>) {
534        let cache_key = key.cache_key();
535        let mut entry = self.tickers.entry(cache_key.clone()).or_default();
536        entry.extend(new_tickers);
537        if entry.len() > 1000 {
538            let drain_count = entry.len() - 1000;
539            entry.drain(..drain_count);
540        }
541        self.notify_ticker_cold_cache_waiters(&cache_key);
542    }
543
544    /// **v1.4.110 Phase 2 Slice 5**: 获取逐笔 (broker-aware).
545    pub fn get_tickers_broker(&self, key: &QotSecurityKey) -> Option<Vec<CachedTicker>> {
546        self.tickers.get(&key.cache_key()).map(|v| v.clone())
547    }
548
549    /// 更新经纪队列
550    pub fn update_broker(&self, key: &str, broker: CachedBroker) {
551        self.brokers.insert(key.to_string(), broker);
552    }
553
554    /// 获取经纪队列
555    pub fn get_broker(&self, key: &str) -> Option<CachedBroker> {
556        self.brokers.get(key).map(|v| v.clone())
557    }
558
559    /// 清除指定股票的所有缓存
560    pub fn clear_security(&self, key: &str) {
561        self.basic_qot.remove(key);
562        self.order_books.remove(key);
563        self.tickers.remove(key);
564        self.brokers.remove(key);
565        // v1.4.106 codex 1140 F3: K 线 key 是 "sec_key:r{rehab}:k{kl_type}:s{session}"
566        // 4-tuple, 仍是 sec_key prefix 起头, retain prefix match 仍正确清所有维度.
567        let prefix = format!("{key}:");
568        self.klines.retain(|k, _| !k.starts_with(&prefix));
569        // v1.4.106 codex 1140 F6: rt_data key 也加 session 维度后变为
570        // "sec_key:s{session}", 同样 prefix-match 清 RTH/ETH/ALL 全部桶.
571        self.rt_data.retain(|k, _| !k.starts_with(&prefix));
572    }
573
574    /// **v1.4.110 Phase 2 Slice 5**: 清除指定股票的所有缓存 (broker-aware).
575    ///
576    /// 用 `QotSecurityKey::cache_key()` 派生 cache key 字符串. broker_id=None
577    /// → 与 `clear_security(public_sec_key)` 等价; broker_id=Some(N) → 只清
578    /// 该 broker 下的 cache (其他 broker 下同 stock_id 的 cache 保留).
579    pub fn clear_security_broker(&self, key: &QotSecurityKey) {
580        let cache_key = key.cache_key();
581        self.basic_qot.remove(&cache_key);
582        self.order_books.remove(&cache_key);
583        self.tickers.remove(&cache_key);
584        self.brokers.remove(&cache_key);
585        let prefix = format!("{cache_key}:");
586        self.klines.retain(|k, _| !k.starts_with(&prefix));
587        self.rt_data.retain(|k, _| !k.starts_with(&prefix));
588    }
589}
590
591impl Default for QotCache {
592    fn default() -> Self {
593        Self::new()
594    }
595}
596
597#[cfg(test)]
598mod merge_tests;
599
600#[cfg(test)]
601mod tests;