futu_cache/crypto_exchange_cache.rs
1//! v1.4.110 codex QOT Phase 4 Slice 7: Crypto LV2 多交易所缓存.
2//!
3//! 对齐 C++ `APIServer/Business/Quote/QotRealTimeData.cpp` 三个 map +
4//! `INNData_Qot_CryptoExchange::SetLv2RelatedExchange` 缓存写入:
5//!
6//! - **by_broker** (`m_mapCryptoStockBrokerExchange`): `(stock_id, broker_id) →
7//! Vec<CryptoExchangeInfo>` — 18012 response 写入, 表示该 broker 看该 stock
8//! 有哪些 LV2 关联交易所. 通过 `GetLv2RelatedExchangeList(stKey)` 查询.
9//! - **lv2_exchange_cache** (`m_mapLv2ExchangeCache`): `(stock_id, lv2_prob) →
10//! CachedOrderBook` — 单 exchange 的原始 LV2 摆盘缓存. 收到 push 时按
11//! `ExchangeCacheKey` 索引写入.
12//! - **lv2_prob_to_brokers** (`m_mapLv2ProbToBrokers`): `(stock_id, lv2_prob)
13//! → HashSet<broker_id>` — 反向索引. 收到 exchange-level push 时, O(1) 找出
14//! 所有受影响 broker, 然后 per-broker rebuild 调 `merge_multiple_order_book_caches`.
15//!
16//! ## 流程图
17//!
18//! ```text
19//! GetOrderBookHandler: crypto LV2 第 1 次订阅
20//! → fetch_and_cache_exchanges (CMD18012, PT 过滤)
21//! → cache.set_lv2_related_exchange((stock_id, broker_id), Vec<info>)
22//! → 内部更新 by_broker + 重建 lv2_prob_to_brokers
23//! → resubscribe with prob2_v2 (level=60) for each lv2_prob
24//!
25//! Backend push exchange-level LV2 orderbook:
26//! → push_parser 识别 SBIT_US_LV2_ORDER (17) + sec_info.is_crypto + lv2_type
27//! → cache.set_lv2_exchange_cache((stock_id, lv2_prob), s2c)
28//! → cache.get_brokers_affected_by_lv2_prob((stock_id, lv2_prob)) → HashSet
29//! For each broker in HashSet:
30//! → cache.exchanges_for_broker((stock_id, broker_id)) → Vec
31//! → 收集所有 exchange caches → merge_multiple_order_book_caches(40)
32//! → qot_cache.update_order_book_broker(StockKey{stock_id, broker_id})
33//! → cold-cache wait notify (Slice 6c)
34//! ```
35//!
36//! ## C++ 参照
37//!
38//! - `QotRealTimeData.cpp:918-1022` `ParseCryptoToExchangeCache` /
39//! `RebuildCryptoBrokerCache` / `MergeCryptoExchangesToBrokerCache`
40//! - `QotRealTimeData.cpp:1024-1063` `UpdateLv2ProbToBrokersIndex`
41//! - `NNBiz_Qot_CryptoExchange.cpp:131-141` `SetLv2RelatedExchange` 写完后
42//! 触发 `IOMEvent::NotifyEvent(NN_OMEvent_Qot_CryptoExchange_IndexUpdate)` +
43//! `ReSubCryptoOrderBook`.
44
45use std::collections::{HashMap, HashSet};
46use std::sync::Arc;
47use std::sync::atomic::{AtomicU64, Ordering};
48
49use dashmap::DashMap;
50
51use crate::qot_cache::CachedOrderBook;
52
53/// `(stock_id, lv2_prob)` — exchange-level cache key.
54///
55/// 对齐 C++ `ExchangeCacheKey(nStockID, nLv2Prob)` (QotRealTimeData.cpp).
56pub type ExchangeCacheKey = (u64, i32);
57
58/// `(stock_id, broker_id)` — broker-level exchange list key.
59pub type BrokerExchangeKey = (u64, u32);
60
61/// Daemon-side snapshot of one entry in CMD18012 `ExchangeInfo`.
62///
63/// 对齐 C++ `Ndt_Qot_CryptoExchangeInfo` (NNBiz_Qot_CryptoExchange.cpp:55-58).
64///
65/// 在 cache crate 单独定义 (而非引用 futu-backend) 是为了避免 futu-cache →
66/// futu-backend 反向依赖 (C++ 也分 NNData / NNBiz / APIServer 三层, 类似关注点分离).
67#[derive(Debug, Clone, PartialEq)]
68pub struct CryptoExchangeInfo {
69 /// 订阅位 prob — `USLV2OrderSubProb.us_lv2_order_type`.
70 pub lv2_prob: i32,
71 /// 交易所内部 name (e.g. "BINANCE", "OKX", "PT").
72 pub exchange_name: String,
73 /// 上市交易所标识 (FTAPI listed_exchange 字段).
74 pub listed_exchange: String,
75 /// 是否默认选中 (UI 显示用, daemon 一般不消费).
76 pub is_pick: bool,
77}
78
79/// Immutable request-level view of one exact `(stock_id, broker_id)` fact.
80///
81/// The writer publishes version, exact vector, and aggregate as one DashMap value.
82#[derive(Debug, Clone, PartialEq)]
83pub struct CryptoExchangeFact {
84 pub version: u64,
85 pub exchanges: Arc<[CryptoExchangeInfo]>,
86 pub positive_lv2_prob_sum: Option<i32>,
87}
88
89#[derive(Debug, Clone, Default)]
90pub struct CryptoExchangeFactSnapshot {
91 facts: HashMap<BrokerExchangeKey, CryptoExchangeFact>,
92}
93
94impl CryptoExchangeFactSnapshot {
95 pub fn for_broker(&self, stock_id: u64, broker_id: u32) -> Option<&CryptoExchangeFact> {
96 self.facts.get(&(stock_id, broker_id))
97 }
98}
99
100/// Crypto LV2 多交易所缓存. 包装 3 个 DashMap + 反向索引重建逻辑.
101///
102/// **线程安全**: 内部全用 DashMap, 任意线程可同时 read/write.
103/// **lifecycle**: 整个 daemon lifetime, 由 `GatewayBridge::crypto_exchange_cache`
104/// 持有 Arc.
105#[derive(Debug, Default)]
106pub struct CryptoExchangeCache {
107 /// `(stock_id, broker_id) → exchange list (from CMD18012 response)`.
108 by_broker: DashMap<BrokerExchangeKey, CryptoExchangeFact>,
109
110 /// `(stock_id, lv2_prob) → orderbook (exchange-level, raw from push)`.
111 pub lv2_exchange_cache: DashMap<ExchangeCacheKey, CachedOrderBook>,
112
113 /// `(stock_id, lv2_prob) → set<broker_id>` — 反向索引,
114 /// O(1) 查询受 exchange-level push 影响的 broker 集.
115 pub lv2_prob_to_brokers: DashMap<ExchangeCacheKey, HashSet<u32>>,
116
117 /// v1.4.110 R6-5: 串行化所有"写 `by_broker` + 重建反向索引"的写路径
118 /// (`set_lv2_related_exchange` / `clear_stock` / `clear_stock_broker`).
119 ///
120 /// 反向索引重建要遍历 `by_broker` 全表算 `new_index`; 两个并发 writer 各自
121 /// 遍历会拿到不一致的 DashMap 快照, 一个 writer 的 `retain` 可能误删另一个
122 /// writer 刚 `insert` 的 prob entry → 该 prob 反向索引永久丢失 → 之后 crypto
123 /// LV2 push 对该 broker silent miss (坑 #45). R4-2 只修了"新 prob entry 不
124 /// 经历空态", 没修这条跨线程 stale-retain (坑 #44: 同一反向索引并发 invariant
125 /// 第 2 次复发 → 根治). 这把锁让 "insert by_broker + 重建索引" 成为真临界区.
126 /// writer 都是低频 first-sub / unsub 路径, 锁零实际性能影响; reader
127 /// (`get_brokers_affected_by_lv2_prob` 等) 不取锁, 仍走 DashMap lock-free 读.
128 rebuild_lock: parking_lot::Mutex<()>,
129
130 /// Monotonic fact version assigned inside `rebuild_lock`.
131 next_fact_version: AtomicU64,
132}
133
134impl CryptoExchangeCache {
135 pub fn new() -> Arc<Self> {
136 Arc::new(Self::default())
137 }
138
139 /// 设置 `(stock_id, broker_id)` 对应的 exchange 列表 (CMD18012 response).
140 ///
141 /// 副作用:
142 /// 1. 更新 `by_broker[(stock_id, broker_id)] = exchanges`
143 /// 2. 重建 `lv2_prob_to_brokers[(stock_id, *)]` 该 stock 的反向索引
144 /// (先清旧 entries 该 stock 的所有 prob → 再按本次 + 其他 broker 重建)
145 ///
146 /// 对齐 C++ `INNData_Qot_CryptoExchange::SetLv2RelatedExchange(stKey, vExchanges)` +
147 /// `UpdateLv2ProbToBrokersIndex(nStockID)`.
148 pub fn set_lv2_related_exchange(
149 &self,
150 stock_id: u64,
151 broker_id: u32,
152 exchanges: Vec<CryptoExchangeInfo>,
153 ) -> u64 {
154 // v1.4.110 R6-5: 整个 "写 by_broker + 重建反向索引" 在 rebuild_lock 内
155 // 串行化 (见 `rebuild_lock` 字段注释).
156 let _rebuild_guard = self.rebuild_lock.lock();
157 let version = self.next_fact_version.fetch_add(1, Ordering::Relaxed) + 1;
158 let exchanges: Arc<[CryptoExchangeInfo]> = Arc::from(exchanges.into_boxed_slice());
159 let positive_lv2_prob_sum = positive_lv2_prob_sum(&exchanges);
160 self.by_broker.insert(
161 (stock_id, broker_id),
162 CryptoExchangeFact {
163 version,
164 exchanges,
165 positive_lv2_prob_sum,
166 },
167 );
168 self.rebuild_reverse_index_for_stock(stock_id);
169 tracing::debug!(
170 stock_id,
171 broker_id,
172 "v1.4.110 audit Phase 4 Slice 7: set_lv2_related_exchange + rebuild reverse index"
173 );
174 version
175 }
176
177 /// 重建某 stock 的 `lv2_prob_to_brokers` 反向索引 (遍历 `by_broker` 当前
178 /// 全部 `(stock_id, *)` entry 重算 prob → brokers 映射).
179 ///
180 /// **caller 必须持 `rebuild_lock`** —— 本 fn 遍历 `by_broker` 算 `new_index`,
181 /// 多个并发 caller 不串行化会拿到不一致快照, 一个 caller 的 (c) `retain`
182 /// 可能误删另一个 caller 刚 (b) `insert` 的 prob (见 R6-5 / 坑 #44).
183 ///
184 /// v1.4.110 codex audit Round4 R4-2: 不用 "retain 清旧 → for 重建" 两步法 ——
185 /// 两步之间有 empty window: 并发 crypto LV2 push 在此间隙调
186 /// `get_brokers_affected_by_lv2_prob` 会看到空索引 → affected_brokers 空 →
187 /// push 被静默丢 (pitfall #45). 改 overwrite-then-remove:
188 /// (a) 本地算出该 stock 的新 prob → brokers 映射;
189 /// (b) 对每个新 prob `insert` 原子覆盖 —— 已存在的 prob entry 从不经历
190 /// 空态, reader 永远看到 old-set / new-set 二选一;
191 /// (c) 再 `retain` 删掉本 stock 不在新映射里的旧 prob (这些 prob 已无
192 /// broker 订阅, push 本就该丢).
193 fn rebuild_reverse_index_for_stock(&self, stock_id: u64) {
194 let mut new_index: HashMap<i32, HashSet<u32>> = HashMap::new();
195 for entry in self.by_broker.iter() {
196 let (sid, bid) = entry.key();
197 if *sid != stock_id {
198 continue;
199 }
200 for info in entry.value().exchanges.iter() {
201 new_index.entry(info.lv2_prob).or_default().insert(*bid);
202 }
203 }
204 let new_probs: HashSet<i32> = new_index.keys().copied().collect();
205 // (b) 原子覆盖每个新 prob.
206 for (prob, brokers) in new_index.into_iter() {
207 let cache_key: ExchangeCacheKey = (stock_id, prob);
208 self.lv2_prob_to_brokers.insert(cache_key, brokers);
209 }
210 // (c) 删掉本 stock 不在新映射里的旧 prob.
211 self.lv2_prob_to_brokers
212 .retain(|key, _| key.0 != stock_id || new_probs.contains(&key.1));
213 }
214
215 /// 取某 broker 看某 stock 的 exchange 列表.
216 pub fn exchanges_for_broker(
217 &self,
218 stock_id: u64,
219 broker_id: u32,
220 ) -> Option<Vec<CryptoExchangeInfo>> {
221 self.by_broker
222 .get(&(stock_id, broker_id))
223 .map(|fact| fact.exchanges.to_vec())
224 }
225
226 /// Clone all exact broker facts while holding the existing writer
227 /// serialization lock, so every consumer in one request sees one immutable
228 /// view.
229 pub fn fact_snapshot(&self) -> CryptoExchangeFactSnapshot {
230 let _rebuild_guard = self.rebuild_lock.lock();
231 let facts = self
232 .by_broker
233 .iter()
234 .map(|entry| (*entry.key(), entry.value().clone()))
235 .collect();
236 CryptoExchangeFactSnapshot { facts }
237 }
238
239 /// 写 exchange-level cache (单个 exchange 的原始 LV2 摆盘).
240 /// 对齐 C++ `m_mapLv2ExchangeCache[cacheKey] = pbOrderBook` (QotRealTimeData.cpp:938).
241 pub fn set_lv2_exchange_cache(&self, stock_id: u64, lv2_prob: i32, orderbook: CachedOrderBook) {
242 self.lv2_exchange_cache
243 .insert((stock_id, lv2_prob), orderbook);
244 }
245
246 /// 取 exchange-level cache.
247 pub fn get_lv2_exchange_cache(&self, stock_id: u64, lv2_prob: i32) -> Option<CachedOrderBook> {
248 self.lv2_exchange_cache
249 .get(&(stock_id, lv2_prob))
250 .map(|v| v.clone())
251 }
252
253 /// 反向索引查询: `(stock_id, lv2_prob)` 影响哪些 broker.
254 /// 对齐 C++ `setBrokerIDs = m_mapLv2ProbToBrokers[cacheKey]` (QotRealTimeData.cpp:941-944).
255 ///
256 /// v1.4.111 P2-1 Tier 3 audit comment: reverse lookup helper — empty HashSet =
257 /// "no brokers affected by this (stock_id, lv2_prob)", caller iterate 空集合
258 /// 自然跳过. 跟 C++ `setBrokerIDs` empty 行为对齐. 非 silent-success risk
259 /// (audit verified, essentials/2026-05-27).
260 pub fn get_brokers_affected_by_lv2_prob(&self, stock_id: u64, lv2_prob: i32) -> HashSet<u32> {
261 self.lv2_prob_to_brokers
262 .get(&(stock_id, lv2_prob))
263 .map(|s| s.clone())
264 .unwrap_or_default()
265 }
266
267 /// 给定 `(stock_id, broker_id)`, 收集该 broker 的所有 exchange 对应的
268 /// exchange-level cache 列表 (用作 merge 输入).
269 ///
270 /// 对齐 C++ `MergeCryptoExchangesToBrokerCache` 内部逻辑 (line 987-1022).
271 pub fn collect_exchange_caches_for_broker(
272 &self,
273 stock_id: u64,
274 broker_id: u32,
275 ) -> Vec<CachedOrderBook> {
276 let infos = match self.exchanges_for_broker(stock_id, broker_id) {
277 Some(v) => v,
278 None => return vec![],
279 };
280 infos
281 .iter()
282 .filter_map(|info| self.get_lv2_exchange_cache(stock_id, info.lv2_prob))
283 .collect()
284 }
285
286 /// 收集某 stock 的所有 exchange-level orderbook cache.
287 ///
288 /// C++ US stock TotalView/ARCA path does not use broker mappings: it writes
289 /// `m_mapLv2ExchangeCache[(stock_id, lv2_type)]`, then
290 /// `MergeExchangeCacheToMain` merges every exchange cache for that stock.
291 /// Crypto still uses the broker-specific helper above.
292 pub fn collect_exchange_caches_for_stock(&self, stock_id: u64) -> Vec<CachedOrderBook> {
293 let mut entries: Vec<(i32, CachedOrderBook)> = self
294 .lv2_exchange_cache
295 .iter()
296 .filter_map(|entry| {
297 let (sid, lv2_prob) = *entry.key();
298 (sid == stock_id).then(|| (lv2_prob, entry.value().clone()))
299 })
300 .collect();
301 entries.sort_by_key(|(lv2_prob, _)| *lv2_prob);
302 entries.into_iter().map(|(_, cache)| cache).collect()
303 }
304
305 /// 清除某 stock 的所有 cache (退订 / reset 时调).
306 /// 对齐 C++ `ClearStockData(nStockID)`.
307 pub fn clear_stock(&self, stock_id: u64) {
308 // v1.4.110 R6-5: 与 set_lv2_related_exchange / clear_stock_broker 共用
309 // rebuild_lock, 避免整 stock 清与反向索引重建交错.
310 let _rebuild_guard = self.rebuild_lock.lock();
311 self.by_broker.retain(|key, _| key.0 != stock_id);
312 self.lv2_exchange_cache.retain(|key, _| key.0 != stock_id);
313 self.lv2_prob_to_brokers.retain(|key, _| key.0 != stock_id);
314 }
315
316 /// v1.4.110 R6-8: 清除某 `(stock_id, broker_id)` 的 `by_broker` entry +
317 /// 重建该 stock 反向索引. 用于 **部分 broker 退订** —— stock 还有别的 broker
318 /// 在订 (`clear_stock` 整 stock 清不适用), 但本 broker 已全局无订阅, 其
319 /// `by_broker[(stock, broker)]` 会 stale 滞留 → `by_broker` 慢漏累积死 entry.
320 ///
321 /// 移除本 broker 后该 stock 已无任何 broker → 连 `lv2_exchange_cache` 一并清
322 /// (无 broker 引用的 exchange-level cache 也是 stale).
323 pub fn clear_stock_broker(&self, stock_id: u64, broker_id: u32) {
324 let _rebuild_guard = self.rebuild_lock.lock();
325 self.by_broker.remove(&(stock_id, broker_id));
326 self.rebuild_reverse_index_for_stock(stock_id);
327 // 该 stock 已无任何 broker → exchange-level cache 也清.
328 let stock_still_has_broker = self.by_broker.iter().any(|e| e.key().0 == stock_id);
329 if !stock_still_has_broker {
330 self.lv2_exchange_cache.retain(|key, _| key.0 != stock_id);
331 }
332 }
333
334 /// v1.4.110 R6-8: 列出 `by_broker` 里某 stock 当前缓存的所有 broker_id.
335 /// 退订路径用它枚举该 stock 的 broker, 逐个判是否已全局无订阅 → clear.
336 pub fn brokers_for_stock(&self, stock_id: u64) -> Vec<u32> {
337 self.by_broker
338 .iter()
339 .filter(|e| e.key().0 == stock_id)
340 .map(|e| e.key().1)
341 .collect()
342 }
343
344 /// v1.4.110 R6-2 + R6-4: 重建某 `(stock_id, broker_id)` 的 broker-level
345 /// 摆盘缓存 —— 收集该 broker 所有 exchange 的 LV2 cache → merge 40 档 → 写
346 /// `qot_cache.order_books[broker_cache_key]` → 唤醒 cold-cache waiter.
347 ///
348 /// 对齐 C++ `QotRealTimeData::RebuildCryptoBrokerCache` (line 960-985).
349 /// `parse_crypto_lv2_order_book_to_push` (push 路径, 每个 affected broker 调
350 /// 一次) 与 `maybe_fetch_crypto_exchanges` (CMD18012 完成后补 merge, R6-4)
351 /// 共用本 fn.
352 ///
353 /// 返 `Some(merged)` 供 caller 构造 PushEvent; 该 broker 当前无任何 exchange
354 /// cache (collect 空) → `None` (无数据可 merge, 不动 `qot_cache`).
355 ///
356 /// `broker_cache_key` 由 caller 传 (= `QotSecurityKey::cache_key()`,
357 /// `"{public}@b{broker_id}"`) —— public sec_key 构造需要 market/code 上下文,
358 /// 不在本 cache 持有.
359 pub fn rebuild_broker_cache(
360 &self,
361 qot_cache: &crate::qot_cache::QotCache,
362 stock_id: u64,
363 broker_id: u32,
364 broker_cache_key: &str,
365 ) -> Option<CachedOrderBook> {
366 let caches = self.collect_exchange_caches_for_broker(stock_id, broker_id);
367 if caches.is_empty() {
368 return None;
369 }
370 let merged = crate::qot_cache::merge_multiple_order_book_caches(&caches, 40);
371 qot_cache
372 .order_books
373 .insert(broker_cache_key.to_string(), merged.clone());
374 qot_cache.notify_order_book_cold_cache_waiters(broker_cache_key);
375 Some(merged)
376 }
377}
378
379fn positive_lv2_prob_sum(exchanges: &[CryptoExchangeInfo]) -> Option<i32> {
380 let sum = exchanges
381 .iter()
382 .map(|info| i64::from(info.lv2_prob))
383 .sum::<i64>();
384 i32::try_from(sum).ok().filter(|sum| *sum > 0)
385}
386
387#[cfg(test)]
388mod tests;