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 tokio::sync::Notify;
19
20mod order_book_merge;
21mod spread;
22
23pub use order_book_merge::merge_multiple_order_book_caches;
24pub use spread::CachedSpreadBand;
25use spread::spread_value_raw_from_bands;
26
27/// v1.4.110 codex Phase 3 Slice 6c: cold-cache wait key for BasicQot.
28///
29/// 输入 cache_key (legacy `"market_code"` 或 broker-aware
30/// `"market_code@b1007"`), 输出 `"<cache_key>:basic"` 形式 wait key.
31/// 与 OrderBook wait key 分桶, 避免互相错唤醒.
32#[inline]
33pub fn basic_qot_wait_key(cache_key: &str) -> String {
34    format!("{cache_key}:basic")
35}
36
37/// v1.4.110 codex Phase 3 Slice 6c: cold-cache wait key for OrderBook.
38#[inline]
39pub fn order_book_wait_key(cache_key: &str) -> String {
40    format!("{cache_key}:orderbook")
41}
42
43#[inline]
44pub fn odd_lot_order_book_cache_key(cache_key: &str) -> String {
45    format!("{cache_key}:orderbook_odd")
46}
47
48#[inline]
49pub fn odd_lot_order_book_wait_key(cache_key: &str) -> String {
50    format!("{cache_key}:orderbook_odd")
51}
52
53pub fn ticker_wait_key(cache_key: &str) -> String {
54    format!("{cache_key}:ticker")
55}
56
57/// 股票行情缓存 key: "market_code" (如 "1_00700") 或 broker-aware "market_code@b1007".
58///
59/// **v1.4.110 Phase 2 Slice 5**: cache key encoding 仍是 String (Phase 5 不
60/// 引入新 hash domain), broker-aware 后缀由 `QotSecurityKey::cache_key()` 编码:
61/// - no_broker: `"91_BTCUSDT"` (与升级前等价)
62/// - broker-aware: `"91_BTCUSDT@b1007"` (Phase 3 之后启用)
63pub type SecurityKey = String;
64
65/// 生成缓存 key
66pub fn make_key(market: i32, code: &str) -> SecurityKey {
67    format!("{market}_{code}")
68}
69
70/// 基本报价缓存
71#[derive(Debug, Clone)]
72pub struct CachedBasicQot {
73    pub cur_price: f64,
74    pub open_price: f64,
75    pub high_price: f64,
76    pub low_price: f64,
77    pub last_close_price: f64,
78    pub volume: i64,
79    pub turnover: f64,
80    pub turnover_rate: f64,
81    pub amplitude: f64,
82    pub is_suspended: bool,
83    pub update_time: String,
84    pub update_timestamp: f64,
85    /// v1.4.72 BUG-006 L3 (external reviewer v1.4.69 P1): US 夜盘 OHLCV 数据。
86    /// backend 推送(CMD 6212 Qot_UpdateBasicQot)的 BasicQot.overnight (field 25)
87    /// 在夜盘时段会填充,regular hours 为 None。push_parser 提取并缓存,让
88    /// 下游 subscribe push + snapshot query 都能看到实时夜盘数据。
89    pub overnight: Option<CachedPreAfterMarketData>,
90    /// v1.4.106 codex 1140 F4 (P2): US 盘前 OHLCV 数据.
91    /// SBIT_US_PREMARKET_AFTERHOURS_DETAIL 推送时由 push_parser 解析填充, US
92    /// 盘前时段会有, regular hours / non-US → None. 下游 read 透传给 ftapi
93    /// `BasicQot.pre_market` (proto Qot_Common.proto:671). audit Finding 4.
94    pub pre_market: Option<CachedPreAfterMarketData>,
95    /// v1.4.106 codex 1140 F4 (P2): US 盘后 OHLCV 数据.
96    /// 同上, 但取 SBIT_US_PREMARKET_AFTERHOURS_DETAIL 的 after_hours 字段.
97    /// 下游 read 透传给 ftapi `BasicQot.after_market`. audit Finding 4.
98    pub after_market: Option<CachedPreAfterMarketData>,
99}
100
101/// v1.4.72 BUG-006 L3: 美股夜盘 OHLCV 数据(对齐 proto `Qot_Common::PreAfterMarketData`)
102///
103/// 同一 struct 在 pre_market / after_market / overnight 三个字段都复用。
104#[derive(Debug, Clone, Default)]
105pub struct CachedPreAfterMarketData {
106    pub price: Option<f64>,
107    pub high_price: Option<f64>,
108    pub low_price: Option<f64>,
109    pub volume: Option<i64>,
110    pub turnover: Option<f64>,
111    pub change_val: Option<f64>,
112    pub change_rate: Option<f64>,
113    pub amplitude: Option<f64>,
114}
115
116/// K 线缓存
117#[derive(Debug, Clone)]
118pub struct CachedKLine {
119    pub time: String,
120    pub open_price: f64,
121    pub high_price: f64,
122    pub low_price: f64,
123    pub close_price: f64,
124    pub volume: i64,
125    pub turnover: f64,
126}
127
128/// 摆盘缓存 (对齐 C++ Qot_UpdateOrderBook::S2C)
129#[derive(Debug, Clone, Default)]
130pub struct CachedOrderBook {
131    pub ask_list: Vec<CachedOrderBookLevel>,
132    pub bid_list: Vec<CachedOrderBookLevel>,
133    pub svr_recv_time_bid: Option<String>,
134    pub svr_recv_time_bid_timestamp: Option<f64>,
135    pub svr_recv_time_ask: Option<String>,
136    pub svr_recv_time_ask_timestamp: Option<f64>,
137}
138
139/// 摆盘单层
140#[derive(Debug, Clone)]
141pub struct CachedOrderBookLevel {
142    pub price: f64,
143    pub volume: i64,
144    pub order_count: i32,
145    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): SF 行情订单明细列表.
146    /// backend OrderBookItem.orders (重复 OrderInfo: order_id + order_size).
147    /// 仅 HK SF 行情 + prob=BIT_PROB_ORDER_BOOK_ALL_WITH_ID 时 backend 才返;
148    /// 普通行情 → 空 vec. 下游 ftapi `Qot_Common.OrderBook.detailList` 透传.
149    pub detail_list: Vec<CachedOrderBookDetail>,
150    /// v1.4.110 codex audit Round4 R4-4: 高精度委托数量 (crypto 适用).
151    ///
152    /// crypto 盘口的 `volume` 是放大整数 (`size × 10^order_size_precision`),
153    /// i64 无法表示小数量; `hp_volume = volume / 10^precision` 是真实小数量.
154    /// 普通行情 `volume` 已是精确整数 → `None` (对齐 C++ `has_hpvolume()==false`
155    /// 时 fallback `volume`). 下游 emit 到 ftapi `Qot_Common.OrderBook.hpVolume`.
156    ///
157    /// 对齐 C++ `QotRealTimeData.cpp` `pOrderBookItem->set_hpvolume(...)` +
158    /// merge `gear.dVolume += has_hpvolume() ? hpvolume() : volume()` —— merge
159    /// 累加的是 de-scale 后的真实量, 故按 level 存 (不同交易所 precision 可能不同).
160    pub hp_volume: Option<f64>,
161}
162
163/// v1.4.106 codex 1140 F7 (P2): 摆盘订单明细 (HK SF).
164/// 对齐 ftapi `Qot_Common.OrderBookDetail` (proto field orderID + volume).
165#[derive(Debug, Clone)]
166pub struct CachedOrderBookDetail {
167    pub order_id: i64,
168    pub volume: i64,
169}
170
171/// 逐笔成交缓存 (对齐 C++ Qot_UpdateTicker::S2C)
172///
173/// v1.4.106 codex 1140 F5: 加 `type_sign` 字段 (audit Finding 5),
174/// 对齐 ftapi `Qot_Common.Ticker.typeSign` (proto field 9). 之前 cache 缺
175/// 此字段, push event 与 read response 都没法透传 type_sign.
176#[derive(Debug, Clone)]
177pub struct CachedTicker {
178    pub time: String, // HH:MM:SS 时间字符串 (从 exchange_data_time_ms 派生, 按 market 时区)
179    pub sequence: i64, // tick_key, 用于去重
180    pub dir: i32,     // 1=Bid/卖盘, 2=Ask/买盘, 3=Neutral
181    pub price: f64,
182    pub volume: i64,
183    pub turnover: f64,            // price × volume
184    pub recv_time: Option<f64>,   // server_recv_from_exchange_time_ms (秒)
185    pub ticker_type: Option<i32>, // 逐笔类型 (TickItemType: BUY=1/SELL=2/NEUTRAL=3)
186    /// v1.4.106 codex 1140 F5: 逐笔类型符号 (audit Finding 5).
187    /// 来自 TickItem.trade_type (一个英文字母的 ASCII 码), backend 推送 +
188    /// FTAPI Ticker.typeSign 对外暴露给 UI.
189    pub type_sign: Option<i32>,
190    pub push_data_type: Option<i32>,
191    pub timestamp: Option<f64>,
192}
193
194/// 分时数据点
195#[derive(Debug, Clone)]
196pub struct CachedTimeShare {
197    pub time: String,
198    pub minute: i32,
199    pub price: f64,
200    pub last_close_price: f64,
201    pub avg_price: f64,
202    pub volume: i64,
203    pub turnover: f64,
204    pub timestamp: f64,
205}
206
207/// 经纪队列缓存 (对齐 C++ Qot_UpdateBroker::S2C)
208#[derive(Debug, Clone, Default)]
209pub struct CachedBroker {
210    pub bid_list: Vec<CachedBrokerItem>,
211    pub ask_list: Vec<CachedBrokerItem>,
212}
213
214/// 经纪队列单项
215#[derive(Debug, Clone)]
216pub struct CachedBrokerItem {
217    pub id: i64,
218    pub name: String,
219    pub pos: i32,
220    /// v1.4.106 codex 1140 F7 (P2 audit Finding 7): HK SF 行情订单 ID.
221    /// 对齐 ftapi `Qot_Common.Broker.orderID` (proto field 4 optional). 仅
222    /// HK SF 时 backend HKBrokerQueue.order_id_list 含值, 普通行情 → None.
223    pub order_id: Option<i64>,
224    /// v1.4.106 codex 1140 F7 (P2): HK SF 订单股数. 对齐 ftapi
225    /// `Qot_Common.Broker.volume` (proto field 5 optional).
226    pub volume: Option<i64>,
227}
228
229/// v1.4.106 codex 1140 F7 (P2 audit Finding 7): 券商配置表 (broker_id → 名称).
230/// 由 CMD 18008 (NN_ProtoCmd_Qot_Pull_BrokerInfo) 拉取并解析后填充.
231/// 替代旧 `format!("Broker#{bid}")` 占位符进入公开 API 的反模式.
232#[derive(Debug, Clone)]
233pub struct CachedBrokerInfo {
234    /// 中文简称 (sc) — 用作主显示名 (与 C++ GetBrokerName 同语义)
235    pub name_zh_cn: String,
236    /// 英文简称 (en)
237    pub name_en: String,
238    /// 中文繁体简称 (tc)
239    pub name_tc: String,
240}
241
242/// 行情缓存管理器
243pub struct QotCache {
244    /// 基本报价缓存
245    pub basic_qot: DashMap<SecurityKey, CachedBasicQot>,
246    /// US stock overnight-enabled state, keyed by backend stock_id.
247    ///
248    /// C++ stores this as `stockID -> bool` in `INNData_Qot_USStockOvernight`:
249    /// - `NNData_Qot_USStockOvernight.cpp:21-35` (missing key => false)
250    /// - `NNBiz_Qot_USStockState.cpp:180-190` writes `overnight_type == 1`
251    /// - `APIServer_Qot_MarketState.cpp:238-244` reads it for 11 -> 37 projection
252    pub us_stock_overnight: DashMap<u64, bool>,
253    /// K 线缓存: key = "market_code:kl_type"
254    pub klines: DashMap<String, Vec<CachedKLine>>,
255    /// 摆盘缓存
256    pub order_books: DashMap<SecurityKey, CachedOrderBook>,
257    /// 逐笔缓存: 保留最近 N 条
258    pub tickers: DashMap<SecurityKey, Vec<CachedTicker>>,
259    /// 分时缓存
260    /// v1.4.106 codex 1140 F6 (P2 audit Finding 6): RT cache key 加 session
261    /// 维度. 之前 `DashMap<SecurityKey, ...>` 把 RTH/ETH/PRE/AFTER 全部混到
262    /// 同一桶, 客户端订阅 RTH 也能读到 PRE 数据. 现在 key 是
263    /// "sec_key:s{session}" (RequestSection 0=NORMAL/1=FULL/2=PREMARKET/
264    /// 3=AFTERHOURS), 隔离不同 session.
265    pub rt_data: DashMap<String, Vec<CachedTimeShare>>,
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}
295
296impl QotCache {
297    pub fn new() -> Self {
298        Self {
299            basic_qot: DashMap::new(),
300            us_stock_overnight: DashMap::new(),
301            klines: DashMap::new(),
302            order_books: DashMap::new(),
303            tickers: DashMap::new(),
304            rt_data: DashMap::new(),
305            brokers: DashMap::new(),
306            // v1.4.106 codex 1140 F7: broker dict 由 CMD 18008 拉取后填充.
307            broker_dict: DashMap::new(),
308            spread_tables: DashMap::new(),
309            // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait waiters.
310            cold_cache_waiters: DashMap::new(),
311        }
312    }
313
314    /// Replace the whole spread-table cache after a successful CMD6503 pull.
315    ///
316    /// C++ `INNData_Qot_Spread::SetSpreadInfo` installs a flattened full table
317    /// snapshot. We use a code-keyed map but preserve the same replacement
318    /// semantics so stale removed codes do not linger across refreshes.
319    pub fn replace_spread_tables<I>(&self, tables: I)
320    where
321        I: IntoIterator<Item = (i32, Vec<CachedSpreadBand>)>,
322    {
323        self.spread_tables.clear();
324        for (code, bands) in tables {
325            if code != 0 && !bands.is_empty() {
326                self.spread_tables.insert(code, bands);
327            }
328        }
329    }
330
331    /// Return the raw 1e9 fixed-point spread value for a security price.
332    ///
333    /// Mirrors the spread-band selection in
334    /// `NNBiz_Qot_Spread::GetStockSpreadPriceForTade` with `bUp=true` and
335    /// `enTrdMarket=Unknown`, which is what quote snapshot / BasicQot push use.
336    /// Missing table returns 0, matching C++ cache miss falling through with
337    /// initial `nPriceSpread=0` at the API projection layer.
338    pub fn spread_value_raw(&self, spread_code: u32, price_raw: i64) -> i64 {
339        let Some(bands) = self.spread_tables.get(&(spread_code as i32)) else {
340            return 0;
341        };
342        spread_value_raw_from_bands(&bands, price_raw)
343    }
344
345    /// Project `BasicQot.priceSpread` / `SnapshotBasicData.priceSpread`.
346    pub fn price_spread_for_raw_price(&self, spread_code: u32, price_raw: i64) -> f64 {
347        self.spread_value_raw(spread_code, price_raw) as f64 / 1_000_000_000.0
348    }
349
350    /// Project `priceSpread` from a floating-point API price.
351    pub fn price_spread_for_price(&self, spread_code: u32, price: f64) -> f64 {
352        if spread_code == 0 || price <= 0.0 {
353            return 0.0;
354        }
355        self.price_spread_for_raw_price(spread_code, (price * 1_000_000_000.0) as i64)
356    }
357
358    /// v1.4.110 codex Phase 3 Slice 6c: 注册 cold-cache wait waiter.
359    ///
360    /// 返已存在或新建的 `Arc<Notify>`. handler 调:
361    /// 1. `register_cold_cache_waiter("91_BTCUSDT@b1007:basic")` 获 Notify
362    /// 2. 主动发 Pull_SubData CMD6824
363    /// 3. `tokio::time::timeout(Duration::from_secs(3), notify.notified())` 等
364    /// 4. 再 `get_basic_qot_broker(&key)` 读 cache (可能仍 None — 真 timeout)
365    ///
366    /// `wait_kind` 推荐: `"basic"` / `"orderbook"`. 不混 sub_type 数字防误唤.
367    pub fn register_cold_cache_waiter(&self, wait_key: &str) -> Arc<Notify> {
368        self.cold_cache_waiters
369            .entry(wait_key.to_string())
370            .or_insert_with(|| Arc::new(Notify::new()))
371            .clone()
372    }
373
374    /// v1.4.110 codex Phase 3 Slice 6c: 唤醒指定 cold-cache wait waiter.
375    ///
376    /// push parser update path 调 (`update_basic_qot` / `update_order_book` /
377    /// `_broker` 变种). 没 waiter → no-op. 有 waiter → `notify_waiters()`
378    /// broadcast 给所有 awaiter, 然后 remove (Arc 仍被 awaiter 持有, 自然释放).
379    pub fn notify_cold_cache_waiters(&self, wait_key: &str) {
380        if let Some((_, n)) = self.cold_cache_waiters.remove(wait_key) {
381            n.notify_waiters();
382        }
383    }
384
385    /// v1.4.110 codex audit Round3 #22: cold-cache wait timeout 后清 idle waiter.
386    ///
387    /// `wait_for_basic_cache` / `wait_for_order_book_cache` 3s timeout 仍 cache
388    /// miss 时调. 若 push 始终没来, `notify_cold_cache_waiters` 不会触发, entry
389    /// 会一直留在 `cold_cache_waiters` map (虽 bounded by distinct wait_key 数,
390    /// 仍是慢速 leak).
391    ///
392    /// **只删 caller 自己注册的那个 entry, 且无其他并发 awaiter 时才删**:
393    /// `remove_if` closure 在 entry lock 下原子检查两条:
394    /// 1. `Arc::ptr_eq(stored, caller_notify)` — stored 必须就是 caller 当初
395    ///    `register_cold_cache_waiter` 拿到的同一 Arc. 防 race: caller timeout
396    ///    后到本调用之间, 若 push 触发 `notify_cold_cache_waiters` 删了旧 entry,
397    ///    另一个 `wait_for_*` 又 register 建了**新** entry (不同 Arc), `ptr_eq`
398    ///    false → 不误删别人的新 entry.
399    /// 2. `Arc::strong_count(stored) <= 2` — DashMap stored Arc 1 + caller
400    ///    持有的 `caller_notify` 1. `> 2` 表示有其他 `wait_for_*` 仍 await 同
401    ///    entry → 保留让它们能被 notify 唤醒.
402    ///
403    /// caller 约定: 必须把 `register_cold_cache_waiter` 返回的 `Arc<Notify>`
404    /// 原样传进来 (caller 全程持有未 drop).
405    pub fn cleanup_cold_cache_waiter_if_idle(&self, wait_key: &str, caller_notify: &Arc<Notify>) {
406        self.cold_cache_waiters.remove_if(wait_key, |_, stored| {
407            Arc::ptr_eq(stored, caller_notify) && Arc::strong_count(stored) <= 2
408        });
409    }
410
411    /// Update C++-style US overnight stock state (`stockID -> bool`).
412    ///
413    /// Ref: `NNData_Qot_USStockOvernight.cpp:21-35` and
414    /// `NNBiz_Qot_USStockState.cpp:180-190`.
415    pub fn set_us_stock_overnight_state(&self, stock_id: u64, is_overnight: bool) {
416        if stock_id == 0 {
417            return;
418        }
419        self.us_stock_overnight.insert(stock_id, is_overnight);
420    }
421
422    /// Query whether a US stock is currently in overnight trading.
423    ///
424    /// C++ cache miss returns false (`NNData_Qot_USStockOvernight.cpp:29-34`).
425    pub fn is_us_stock_overnight(&self, stock_id: u64) -> bool {
426        self.us_stock_overnight
427            .get(&stock_id)
428            .map(|v| *v)
429            .unwrap_or(false)
430    }
431
432    /// v1.4.106 codex 1140 F7 (P2): 查 broker_id → broker name (中文简称).
433    /// cache miss → None, 调用方决定 fallback 策略 (push parser 用
434    /// `Broker#{id}` 作 emergency fallback, 但同时 warn-log 提示 dict 未加载).
435    pub fn get_broker_name(&self, broker_id: i64) -> Option<String> {
436        self.broker_dict
437            .get(&broker_id)
438            .map(|info| info.name_zh_cn.clone())
439    }
440
441    /// v1.4.106 codex 1140 F7 (P2): 批量写入 broker dict (CMD 18008 解析后调).
442    pub fn install_broker_dict(&self, entries: Vec<(i64, CachedBrokerInfo)>) {
443        for (id, info) in entries {
444            self.broker_dict.insert(id, info);
445        }
446    }
447
448    /// 更新基本报价
449    pub fn update_basic_qot(&self, key: &str, qot: CachedBasicQot) {
450        self.basic_qot.insert(key.to_string(), qot);
451        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
452        self.notify_cold_cache_waiters(&basic_qot_wait_key(key));
453    }
454
455    /// 获取基本报价
456    pub fn get_basic_qot(&self, key: &str) -> Option<CachedBasicQot> {
457        self.basic_qot.get(key).map(|v| v.clone())
458    }
459
460    /// **v1.4.110 Phase 2 Slice 5**: 更新基本报价 (broker-aware).
461    ///
462    /// 用 `QotSecurityKey::cache_key()` 派生 String key. broker_id=None → 与
463    /// `update_basic_qot(public_sec_key, ...)` 等价; broker_id=Some(N) → 写
464    /// 独立 cache key `"market_code@b{N}"` (crypto multi-broker isolation).
465    pub fn update_basic_qot_broker(&self, key: &QotSecurityKey, qot: CachedBasicQot) {
466        let cache_key = key.cache_key();
467        self.basic_qot.insert(cache_key.clone(), qot);
468        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
469        self.notify_cold_cache_waiters(&basic_qot_wait_key(&cache_key));
470    }
471
472    /// **v1.4.110 Phase 2 Slice 5**: 获取基本报价 (broker-aware).
473    pub fn get_basic_qot_broker(&self, key: &QotSecurityKey) -> Option<CachedBasicQot> {
474        self.basic_qot.get(&key.cache_key()).map(|v| v.clone())
475    }
476
477    /// 构造 RT 分时 cache key (v1.4.106 codex 1140 F6).
478    ///
479    /// 之前 key 仅 sec_key, RTH/ETH/PRE/AFTER/OVERNIGHT 混桶. 现在
480    /// "sec_key:s{session}" 隔离. session 来自 push 解析的
481    /// `TimeSharingPlans.section_type[0]` (RequestSection enum):
482    /// 0=NORMAL/1=FULL/2=PREMARKET/3=AFTERHOURS/5=OVERNIGHT.
483    /// GetRT handler 对 C++ 的 Session_ETH/Session_ALL 读取语义做动态拼接,
484    /// 不依赖额外 aggregate cache 桶。
485    pub fn make_rt_key(sec_key: &str, session: i32) -> String {
486        format!("{sec_key}:s{session}")
487    }
488
489    /// **v1.4.110 Phase 2 Slice 5**: 构造 RT 分时 cache key (broker-aware).
490    ///
491    /// 用 `QotSecurityKey::cache_key()` 作 prefix. broker_id=None → 与
492    /// `make_rt_key(public_sec_key, session)` 等价; broker_id=Some(N) → 写
493    /// 独立 cache key `"market_code@b{N}:s{session}"`.
494    pub fn make_rt_key_broker(key: &QotSecurityKey, session: i32) -> String {
495        format!("{}:s{}", key.cache_key(), session)
496    }
497
498    /// **v1.4.110 Phase 2 Slice 5**: 更新 RT 分时 (broker-aware).
499    pub fn update_rt_data_broker(
500        &self,
501        key: &QotSecurityKey,
502        session: i32,
503        rt_data: Vec<CachedTimeShare>,
504    ) {
505        let cache_key = Self::make_rt_key_broker(key, session);
506        self.rt_data.insert(cache_key, rt_data);
507    }
508
509    /// **v1.4.110 Phase 2 Slice 5**: 获取 RT 分时 (broker-aware).
510    pub fn get_rt_data_broker(
511        &self,
512        key: &QotSecurityKey,
513        session: i32,
514    ) -> Option<Vec<CachedTimeShare>> {
515        let cache_key = Self::make_rt_key_broker(key, session);
516        self.rt_data.get(&cache_key).map(|v| v.clone())
517    }
518
519    /// 构造 K 线 cache key (v1.4.106 codex 1140 F3 4-tuple).
520    ///
521    /// 之前 key 仅 `(sec_key, kl_type)` 2-tuple, 同股票同 KLType 但前复权 vs
522    /// 后复权 / RTH vs ETH 数据互相覆盖. 对齐 C++ APIServer_Qot_KL.cpp:
523    /// `GetNewestKLByCount(stock_id, enRehabType, enKLType, num, session, ...)`
524    /// 用 4 维 key.
525    ///
526    /// - `rehab`: proto Qot_Common.RehabType (0=None, 1=Forward, 2=Backward),
527    ///   对齐 backend `FTCmdKline.ExrightType`. 同一股票同一 kl_type 不同 rehab
528    ///   走独立 cache, 不互相覆盖.
529    /// - `kl_type`: proto Qot_Common.KLType (1=1Min, 2=Day, ..., 11=Quarter).
530    /// - `session`: proto FTCmdKline.RequestSection (0=NORMAL, 1=FULL,
531    ///   2=PREMARKET, 3=AFTERHOURS). RTH/ETH 隔离, push 来自 backend 的
532    ///   `point.section_type[0]` 决定写入桶, read 由 client subscription
533    ///   session 决定 (尚无, 默认 NORMAL).
534    pub fn make_kline_key(sec_key: &str, rehab: i32, kl_type: i32, session: i32) -> String {
535        format!("{sec_key}:r{rehab}:k{kl_type}:s{session}")
536    }
537
538    /// 更新 K 线 (v1.4.106 codex 1140 F3: 4-tuple key, rehab + session 隔离).
539    pub fn update_klines(
540        &self,
541        sec_key: &str,
542        rehab: i32,
543        kl_type: i32,
544        session: i32,
545        klines: Vec<CachedKLine>,
546    ) {
547        let cache_key = Self::make_kline_key(sec_key, rehab, kl_type, session);
548        self.klines.insert(cache_key, klines);
549    }
550
551    /// 获取 K 线 (v1.4.106 codex 1140 F3: 4-tuple key, rehab + session 隔离).
552    pub fn get_klines(
553        &self,
554        sec_key: &str,
555        rehab: i32,
556        kl_type: i32,
557        session: i32,
558    ) -> Option<Vec<CachedKLine>> {
559        let cache_key = Self::make_kline_key(sec_key, rehab, kl_type, session);
560        self.klines.get(&cache_key).map(|v| v.clone())
561    }
562
563    /// **v1.4.110 Phase 2 Slice 5**: 更新 K 线 (broker-aware).
564    ///
565    /// 用 `QotSecurityKey::cache_key()` 作 prefix, broker_id=None 时退化到原行为.
566    /// composite 维度仍是 4-tuple `(rehab, kl_type, session)`, broker_id 是第 5
567    /// 维通过 `QotSecurityKey` 注入到 prefix.
568    pub fn update_klines_broker(
569        &self,
570        key: &QotSecurityKey,
571        rehab: i32,
572        kl_type: i32,
573        session: i32,
574        klines: Vec<CachedKLine>,
575    ) {
576        let cache_key = Self::make_kline_key(&key.cache_key(), rehab, kl_type, session);
577        self.klines.insert(cache_key, klines);
578    }
579
580    /// **v1.4.110 Phase 2 Slice 5**: 获取 K 线 (broker-aware).
581    pub fn get_klines_broker(
582        &self,
583        key: &QotSecurityKey,
584        rehab: i32,
585        kl_type: i32,
586        session: i32,
587    ) -> Option<Vec<CachedKLine>> {
588        let cache_key = Self::make_kline_key(&key.cache_key(), rehab, kl_type, session);
589        self.klines.get(&cache_key).map(|v| v.clone())
590    }
591
592    /// 更新摆盘
593    pub fn update_order_book(&self, key: &str, ob: CachedOrderBook) {
594        self.order_books.insert(key.to_string(), ob);
595        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
596        self.notify_cold_cache_waiters(&order_book_wait_key(key));
597    }
598
599    /// **v1.4.110 Phase 2 Slice 5**: 更新摆盘 (broker-aware).
600    pub fn update_order_book_broker(&self, key: &QotSecurityKey, ob: CachedOrderBook) {
601        let cache_key = key.cache_key();
602        self.order_books.insert(cache_key.clone(), ob);
603        // v1.4.110 codex Phase 3 Slice 6c: cold-cache wait notify.
604        self.notify_cold_cache_waiters(&order_book_wait_key(&cache_key));
605    }
606
607    /// Update odd-lot order book cache (MY/SG only in C++ 10.7).
608    pub fn update_odd_lot_order_book_broker(&self, key: &QotSecurityKey, ob: CachedOrderBook) {
609        let cache_key = key.cache_key();
610        let odd_key = odd_lot_order_book_cache_key(&cache_key);
611        self.order_books.insert(odd_key.clone(), ob);
612        self.notify_cold_cache_waiters(&odd_lot_order_book_wait_key(&cache_key));
613    }
614
615    /// **v1.4.110 Phase 2 Slice 5**: 获取摆盘 (broker-aware).
616    pub fn get_order_book_broker(&self, key: &QotSecurityKey) -> Option<CachedOrderBook> {
617        self.order_books.get(&key.cache_key()).map(|v| v.clone())
618    }
619
620    /// Get odd-lot order book cache (MY/SG only in C++ 10.7).
621    pub fn get_odd_lot_order_book_broker(&self, key: &QotSecurityKey) -> Option<CachedOrderBook> {
622        let cache_key = key.cache_key();
623        self.order_books
624            .get(&odd_lot_order_book_cache_key(&cache_key))
625            .map(|v| v.clone())
626    }
627
628    /// 追加逐笔(保留最近 1000 条)
629    pub fn append_tickers(&self, key: &str, new_tickers: Vec<CachedTicker>) {
630        let mut entry = self.tickers.entry(key.to_string()).or_default();
631        entry.extend(new_tickers);
632        if entry.len() > 1000 {
633            let drain_count = entry.len() - 1000;
634            entry.drain(..drain_count);
635        }
636        // C++ `APIServer_Qot_Ticker` wakes waiters when ticker data arrives
637        // through `NN_OMEvent_Qot_Update_Ticker`; Rust uses the cache append
638        // point as the equivalent notify source for active-pull and push writes.
639        self.notify_cold_cache_waiters(&ticker_wait_key(key));
640    }
641
642    /// **v1.4.110 Phase 2 Slice 5**: 追加逐笔 (broker-aware).
643    pub fn append_tickers_broker(&self, key: &QotSecurityKey, new_tickers: Vec<CachedTicker>) {
644        let cache_key = key.cache_key();
645        let mut entry = self.tickers.entry(cache_key.clone()).or_default();
646        entry.extend(new_tickers);
647        if entry.len() > 1000 {
648            let drain_count = entry.len() - 1000;
649            entry.drain(..drain_count);
650        }
651        self.notify_cold_cache_waiters(&ticker_wait_key(&cache_key));
652    }
653
654    /// **v1.4.110 Phase 2 Slice 5**: 获取逐笔 (broker-aware).
655    pub fn get_tickers_broker(&self, key: &QotSecurityKey) -> Option<Vec<CachedTicker>> {
656        self.tickers.get(&key.cache_key()).map(|v| v.clone())
657    }
658
659    /// 更新经纪队列
660    pub fn update_broker(&self, key: &str, broker: CachedBroker) {
661        self.brokers.insert(key.to_string(), broker);
662    }
663
664    /// 获取经纪队列
665    pub fn get_broker(&self, key: &str) -> Option<CachedBroker> {
666        self.brokers.get(key).map(|v| v.clone())
667    }
668
669    /// 清除指定股票的所有缓存
670    pub fn clear_security(&self, key: &str) {
671        self.basic_qot.remove(key);
672        self.order_books.remove(key);
673        self.tickers.remove(key);
674        self.brokers.remove(key);
675        // v1.4.106 codex 1140 F3: K 线 key 是 "sec_key:r{rehab}:k{kl_type}:s{session}"
676        // 4-tuple, 仍是 sec_key prefix 起头, retain prefix match 仍正确清所有维度.
677        let prefix = format!("{key}:");
678        self.klines.retain(|k, _| !k.starts_with(&prefix));
679        // v1.4.106 codex 1140 F6: rt_data key 也加 session 维度后变为
680        // "sec_key:s{session}", 同样 prefix-match 清 RTH/ETH/ALL 全部桶.
681        self.rt_data.retain(|k, _| !k.starts_with(&prefix));
682    }
683
684    /// **v1.4.110 Phase 2 Slice 5**: 清除指定股票的所有缓存 (broker-aware).
685    ///
686    /// 用 `QotSecurityKey::cache_key()` 派生 cache key 字符串. broker_id=None
687    /// → 与 `clear_security(public_sec_key)` 等价; broker_id=Some(N) → 只清
688    /// 该 broker 下的 cache (其他 broker 下同 stock_id 的 cache 保留).
689    pub fn clear_security_broker(&self, key: &QotSecurityKey) {
690        let cache_key = key.cache_key();
691        self.basic_qot.remove(&cache_key);
692        self.order_books.remove(&cache_key);
693        self.tickers.remove(&cache_key);
694        self.brokers.remove(&cache_key);
695        let prefix = format!("{cache_key}:");
696        self.klines.retain(|k, _| !k.starts_with(&prefix));
697        self.rt_data.retain(|k, _| !k.starts_with(&prefix));
698    }
699}
700
701impl Default for QotCache {
702    fn default() -> Self {
703        Self::new()
704    }
705}
706
707#[cfg(test)]
708mod merge_tests;
709
710#[cfg(test)]
711mod tests;