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