Skip to main content

futu_cache/
static_data.rs

1// 静态数据缓存:股票列表、经纪商、节假日、停牌
2
3use dashmap::DashMap;
4use std::collections::HashSet;
5use std::sync::RwLock;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8/// v1.4.106 codex 1148 F7 (P2): cache row "完整度 / 来源" 标记。
9///
10/// 此 enum 区分一个 `CachedSecurityInfo` 是从 stock-list 完整 sync 来的
11/// (字段全), 还是 subscribe on-demand CMD 20106 临时补的 (大量字段为空)。
12/// `GetStaticInfo` / `GetSecuritySnapshot` / `make_static_info` 等需要完整
13/// 静态字段的路径在收到 `OnDemandBasic` 行时**应**触发完整 stock-list /
14/// 20106 completed refresh, 或明确返 partial / unavailable, 不假装完整数据。
15///
16/// 来源 audit: `codex/2026-05-01-1148-v1.4.106-codex-qot-static-data-review.md`
17/// Finding 7。
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum SecurityInfoSource {
20    /// 来自 backend stock-list full sync (含 list_time / warrnt_stock_owner
21    /// / exch_type / no_search 等所有字段, 是权威数据)。
22    StockListFull,
23    /// 来自 subscribe / GetStaticInfo / GetSecuritySnapshot 的 on-demand CMD 20106
24    /// 临时补行 — 仅含 stock_id / market / mkt_id / code / name / lot_size /
25    /// sec_type, 其余字段缺省 (list_time="", warrnt_stock_owner=0,
26    /// exch_type=0, no_search=false)。**不**应当作权威静态数据源。
27    ///
28    /// **作 Default**: 保守策略 — 历史 caller 漏标 source 时, 当 partial 处理
29    /// (is_complete()=false), 后续 stock_list_sync / Bootstrap 自动覆写为权威
30    /// 来源。这样 cache miss → on-demand fetch → 默认 source = OnDemandBasic
31    /// 不会被误当 StockListFull 权威数据消费。
32    #[default]
33    OnDemandBasic,
34    /// SQLite bootstrap reload (启动时从本地数据库恢复)。字段完整度同
35    /// `StockListFull` (因为 SQLite 是上次 full sync 的快照), 但**生命期更长**
36    /// (跨 daemon 重启)。一般 caller 当 `StockListFull` 处理。
37    Bootstrap,
38}
39
40impl SecurityInfoSource {
41    /// 是否含完整静态字段 (list_time / warrnt_stock_owner / exch_type /
42    /// no_search 等)。`OnDemandBasic` 缺这些, 其他 source 都齐。
43    #[must_use]
44    pub fn is_complete(self) -> bool {
45        matches!(self, Self::StockListFull | Self::Bootstrap)
46    }
47}
48
49/// 股票静态信息
50///
51/// **v1.4.106 codex F5**: 加 `Default` impl 让 caller 可用
52/// `CachedSecurityInfo { stock_id: ..., market: ..., ..Default::default() }`.
53/// 现有显式 callsite 不动, 新加 callsite 可省冗余字段.
54#[derive(Debug, Clone, Default)]
55pub struct CachedSecurityInfo {
56    pub stock_id: u64,
57    /// FTAPI QotMarket 值 (1=HK_Security, 11=US_Security, 21=CNSH, 22=CNSZ 等)
58    ///
59    /// 由 bridge.rs `market_code_to_qot_market()` 从 backend `market_code`
60    /// (NN_QuoteMktID) 映射而来; 用于构造 cache key `"{market}_{code}"`
61    pub market: i32,
62    /// Backend 原始 NN_QuoteMktID (market_code), **保留子交易所精度**
63    ///
64    /// 用于 cache-first 路由决策: 比如 CME Group 子交易所 (60=NYMEX, 70=COMEX,
65    /// 80=CBOT, 90=CME, 100=CBOE) 都被 `market_code_to_qot_market()` 统一压到
66    /// FTAPI QotMarket=11 (US_Security), 但 `mkt_id` 保留原始 range, 使
67    /// `derive_security_type` / `us_futures_sub_exchange` 等能做 O(1) 范围
68    /// 判别, 替代 CLAUDE.md 坑 #35 的 code-pattern 启发式. SQLite 历史行无此
69    /// 字段时读为 0 (默认 Unknown), 下次 upsert 自动修正.
70    pub mkt_id: u32,
71    pub code: String,
72    pub name: String,
73    pub lot_size: i32,
74    pub sec_type: i32,
75    pub list_time: String,
76    /// 窝轮所属正股 ID, 0 表示无
77    pub warrnt_stock_owner: u64,
78    /// 是否已退市
79    pub delisting: bool,
80    /// 交易所类型 (ExchType)
81    pub exch_type: i32,
82    /// 不可搜索标记 (对齐 C++ no_search 字段)
83    pub no_search: bool,
84    /// v1.4.106 codex F5: 期货主连合约关联 stock_id.
85    ///
86    /// 主连合约 (e.g. `HSImain`, `NQmain`) 此字段非 0, 指向真实月份合约
87    /// stock_id (CMD 6747 拉真实 code 用); 普通合约此字段为 0.
88    ///
89    /// 来源: backend stock_list_sync `CSStockItem.origin_id` (proto field 41).
90    /// PlaceOrder F5 用 `info.future_origin_id != 0` 判 NeedPullFutureContractInfo
91    /// (对齐 C++ `Ndt_Qot_SecInfo::nFutureOriginID` + `APIServer_Trd_PlaceOrder
92    /// .cpp:36-42` `NeedPullFutureContractInfo`).
93    ///
94    /// SQLite 历史行无此列时默认 0, 下次 stock_list sync (CMD 6746) 重新写入.
95    pub future_origin_id: u64,
96    /// v1.4.106 codex F5: 期货主力合约 stock_id.
97    ///
98    /// 主连合约持有此字段, 指向当前主力合约 stock_id (流动性最大). PlaceOrder
99    /// F5 在收到 CMD 6747 响应后用此字段反查真实 code 再下单.
100    ///
101    /// 来源: backend `CSStockItem.zhuli_id` (proto field 40). 普通合约 0.
102    pub zhuli_id: u64,
103    /// v1.4.106 codex 1148 F7 (P2): 数据来源 / 完整度标记。详见
104    /// `SecurityInfoSource` doc。**默认 `OnDemandBasic`** 以保守对待历史 caller
105    /// 漏标场景 (能漏标的就是没填全字段, 当 partial 处理最安全)。
106    pub source: SecurityInfoSource,
107    /// v1.4.110 final E.5 P2#6: 交易所字符串 (proto `CSStockItem.exchange` field 57).
108    ///
109    /// 数据驱动 (pitfall #35 C++ 数据驱动 vs Rust 启发式): trade handler
110    /// `derive_exchange_str` 优先用此字段, 缺失时 fallback 到 code prefix /
111    /// pattern match heuristic. 取值例: "SEHK", "NASDAQ", "NYSE", "SSE", "SZSE", "HKEX".
112    pub exchange: String,
113    /// v1.4.110 final E.5 P2#6: 上市交易所 (proto `CSStockItem.listed_exchange` field 74).
114    ///
115    /// 通常等同 `exchange`, dual-listed ADR / 双重主要上市等场景下可能不同.
116    pub listed_exchange: String,
117}
118
119/// Crypto 交易币对元数据。
120///
121/// C++ `NNProto_Trd_MaxQtyCrypto.cpp` 通过 `INNBiz_Qot_SecList::SearchSecByID`
122/// 读取 `Ndt_Qot_SecInfo::szCCOrigin/szCCDestination`,并把它们分别写入
123/// backend `GetMaxBuySellReq.coin/currency`。Rust 从 stock-list field 60/61
124/// 接入同一份数据,避免在交易路径按 symbol 字符串拆币种。
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct CryptoPairInfo {
127    pub cc_origin: String,
128    pub cc_destination: String,
129}
130
131/// Crypto 交易配置缓存。
132///
133/// C++ `INNData_Trd_CryptoTradeConfig` 以 `(broker_id, symbol, exchange)` 作为
134/// lookup key,MaxBuySellQty 投影用 `minimum_qty` 和 `base_tick_size` 做数量
135/// 步进对齐。这里保存解析后的 nano 整数,避免每个 surface 自己解析 decimal。
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct CryptoTradeConfig {
138    pub symbol: String,
139    pub exchange: String,
140    pub pi_only: bool,
141    pub tick_size_nano: i64,
142    pub min_trade_qty_nano: i64,
143    pub max_trade_qty_nano: i64,
144    pub qty_increment_nano: i64,
145}
146
147impl CachedSecurityInfo {
148    /// v1.4.89 P2-A: 判断 cache row 是否需要后台 mkt_id refresh.
149    ///
150    /// **场景**: SQLite 历史行 (v1.4.77 Phase D 之前 upsert) 没有 `mkt_id` 字段,
151    /// 读取时默认为 0. 这导致 cache-first 路由走 heuristic fallback (坑 #35).
152    ///
153    /// 返 `true` 即需要 refresh; 如果 `mkt_id == 0 && market != 0`, 是历史行
154    /// (有基本 market 但缺 sub-exchange 精度).
155    ///
156    /// **使用**: `StaticDataCache::mark_stale_mkt_id` 在 get 时 opportunistic
157    /// mark stale, 背景 worker 批量 CMD 20106 refresh.
158    #[must_use]
159    pub fn needs_mkt_id_refresh(&self) -> bool {
160        self.mkt_id == 0 && self.market != 0
161    }
162
163    /// v1.4.106 codex 1148 F7: 是否含完整静态字段 (基于 source). 调用方需要
164    /// `list_time` / `warrnt_stock_owner` / `exch_type` 等字段时, 应先 check
165    /// 此 flag, false 时触发 backend 完整 refresh 或返 partial marker。
166    #[must_use]
167    pub fn is_complete(&self) -> bool {
168        self.source.is_complete()
169    }
170}
171
172/// 交易日
173#[derive(Debug, Clone)]
174pub struct CachedTradeDate {
175    pub time: String,
176    pub timestamp: f64,
177}
178
179/// 板块信息
180#[derive(Debug, Clone)]
181pub struct CachedPlateInfo {
182    pub market: i32,
183    pub code: String,
184    pub name: String,
185    pub plate_type: i32,
186}
187
188/// 静态数据缓存
189pub struct StaticDataCache {
190    /// 股票静态信息: "market_code" → info
191    ///
192    /// **v1.4.106 codex 1148 F9**: 对外 read-only。生产代码不应直接 `.insert()`
193    /// 这个 DashMap (会导致 `id_to_key` / `owner_to_warrants` 三索引不一致),
194    /// 必须走 `upsert_full_security_info` / `upsert_basic_security_info` /
195    /// `delete_security_info` 统一写入口。`pub` 仅为 backwards-compat:
196    /// 既有迭代 caller (`snapshot.rs` for-iter `.iter()`) 仍能 read-only 遍历。
197    pub securities: DashMap<String, CachedSecurityInfo>,
198    /// stock_id → "market_code" key (反向映射,用于推送时查找)
199    ///
200    /// **v1.4.106 codex 1148 F9**: 对外 read-only (同 `securities`)。
201    pub id_to_key: DashMap<u64, String>,
202    /// 期货主连/连续合约 push 路由别名: real/origin stock_id → main-link sec_key set.
203    ///
204    /// Backend 的实时 push 可能以真实月份合约 stock_id 下发,而客户端订阅的是
205    /// `HSImain` / `NQmain` 这类主连 symbol。C++ 的 QotSubscribe 用 stock_id
206    /// 级别的主连关系做回投;Rust 这里保留同样的数据驱动关系,来源仅限
207    /// stock-list 下发的 `origin_id` / `zhuli_id` 字段,不按 code 字符串特判。
208    future_main_link_aliases: DashMap<u64, HashSet<String>>,
209    /// Crypto sec_key -> 货币对元数据 (`cc_origin` / `cc_destination`)。
210    crypto_pairs: DashMap<String, CryptoPairInfo>,
211    /// `(broker_id, symbol, exchange)` -> crypto 交易配置。
212    crypto_trade_configs: DashMap<String, CryptoTradeConfig>,
213    /// 交易日: "market:year-month" → Vec<TradeDate>
214    pub trade_dates: DashMap<String, Vec<CachedTradeDate>>,
215    /// 板块: "market:plate_type" → Vec<PlateInfo>
216    pub plates: DashMap<String, Vec<CachedPlateInfo>>,
217    /// 窝轮正股 owner_id → 该正股对应的所有窝轮 stock_id 集合
218    ///
219    /// **v1.4.106 codex 1148 F6**: value 从 `Vec<u64>` 改为 `HashSet<u64>` 防
220    /// 重复 (SQLite reload + stock-list re-sync 同 warrant 多次 push 不会再重复).
221    /// stock-list `delete_flag` 时**反向索引清理**: 旧 owner 下移除旧 warrant
222    /// (`delete_security_info` 内部维护), update 时也维护。
223    pub owner_to_warrants: RwLock<std::collections::HashMap<u64, HashSet<u64>>>,
224
225    /// v1.4.89 P2-A: 需要 mkt_id refresh 的 cache key 集合.
226    ///
227    /// Callers `get_security_info_trigger_refresh` 在返 info 前检查
228    /// `info.needs_mkt_id_refresh()`, 是则 mark key 到这里. 背景 worker
229    /// (gateway bridge) 定期 `drain_stale_mkt_ids()` 批量 CMD 20106 refresh.
230    ///
231    /// 用 DashMap<String, ()> 替代 HashSet<String> 免 lock 竞争.
232    pub stale_mkt_ids: DashMap<String, ()>,
233
234    /// v1.4.89 P2-A: mkt_id refresh 统计计数, 用于 metrics 观察.
235    ///
236    /// - `mkt_id_refresh_marked_total`: 累积 mark stale 次数
237    /// - `mkt_id_refresh_done_total`: 累积 backend CMD 20106 成功 refresh 次数
238    /// - `mkt_id_refresh_failed_total`: 累积 refresh failure 次数
239    pub mkt_id_refresh_marked_total: AtomicU64,
240    pub mkt_id_refresh_done_total: AtomicU64,
241    pub mkt_id_refresh_failed_total: AtomicU64,
242}
243
244impl StaticDataCache {
245    pub fn new() -> Self {
246        Self {
247            securities: DashMap::new(),
248            id_to_key: DashMap::new(),
249            future_main_link_aliases: DashMap::new(),
250            crypto_pairs: DashMap::new(),
251            crypto_trade_configs: DashMap::new(),
252            trade_dates: DashMap::new(),
253            plates: DashMap::new(),
254            owner_to_warrants: RwLock::new(std::collections::HashMap::new()),
255            stale_mkt_ids: DashMap::new(),
256            mkt_id_refresh_marked_total: AtomicU64::new(0),
257            mkt_id_refresh_done_total: AtomicU64::new(0),
258            mkt_id_refresh_failed_total: AtomicU64::new(0),
259        }
260    }
261
262    /// v1.4.89 P2-A: 取 cache info 同时机会性 mark stale (若 mkt_id=0).
263    ///
264    /// 返 Some(info) 如果 cache hit (无论是否 stale). 返 None 如果 miss.
265    ///
266    /// 调 `info.needs_mkt_id_refresh()` 判 stale → mark `stale_mkt_ids`,
267    /// bump `mkt_id_refresh_marked_total` counter. 用 DashMap::insert 幂等
268    /// (同 key 重入不 double mark 但会 bump counter — 可接受).
269    ///
270    /// 替代 `get_security_info` 的推荐路径; 老 method 保留作 lookup-only 接口.
271    pub fn get_security_info_trigger_refresh(&self, key: &str) -> Option<CachedSecurityInfo> {
272        let info = self.get_security_info(key)?;
273        if info.needs_mkt_id_refresh() {
274            self.mark_stale_mkt_id(key);
275        }
276        Some(info)
277    }
278
279    /// v1.4.89 P2-A: 显式 mark key 需要 mkt_id refresh.
280    ///
281    /// 幂等: 同 key 可重入. Counter `mkt_id_refresh_marked_total` 每次都 bump
282    /// (用作 metrics 观察 heuristic fallback 触发频率).
283    pub fn mark_stale_mkt_id(&self, key: &str) {
284        self.stale_mkt_ids.insert(key.to_string(), ());
285        self.mkt_id_refresh_marked_total
286            .fetch_add(1, Ordering::Relaxed);
287    }
288
289    /// v1.4.89 P2-A: drain 所有 stale keys, 清空集合, 返 Vec (给 backend worker
290    /// 批量 CMD 20106 refresh).
291    ///
292    /// 背景 worker 用法 (伪码):
293    /// ```text
294    /// loop {
295    ///     sleep(Duration::from_secs(60)).await;
296    ///     let stale = cache.drain_stale_mkt_ids();
297    ///     if stale.is_empty() { continue; }
298    ///     for chunk in stale.chunks(50) {
299    ///         // CMD 20106 SecuritiesReq for chunk
300    ///         // on success: cache.update_mkt_id(key, new_mkt_id)
301    ///         //              + cache.record_mkt_id_refresh_done()
302    ///         // on failure: cache.record_mkt_id_refresh_failed()
303    ///     }
304    /// }
305    /// ```
306    pub fn drain_stale_mkt_ids(&self) -> Vec<String> {
307        let keys: Vec<String> = self.stale_mkt_ids.iter().map(|e| e.key().clone()).collect();
308        for k in &keys {
309            self.stale_mkt_ids.remove(k);
310        }
311        keys
312    }
313
314    /// v1.4.89 P2-A: 更新已 cache row 的 mkt_id (refresh success 时调).
315    ///
316    /// 只改 mkt_id 字段, 其他字段保留 (info 可能有 SQLite 里更精准的 lot_size /
317    /// list_time 等). 若 key 不在 cache (已被 evict), no-op.
318    pub fn update_mkt_id(&self, key: &str, new_mkt_id: u32) -> bool {
319        if let Some(mut entry) = self.securities.get_mut(key) {
320            entry.mkt_id = new_mkt_id;
321            self.mkt_id_refresh_done_total
322                .fetch_add(1, Ordering::Relaxed);
323            true
324        } else {
325            false
326        }
327    }
328
329    /// v1.4.89 P2-A: 记录 refresh failure (不改 cache row, 让下次 drain 重试).
330    pub fn record_mkt_id_refresh_failed(&self) {
331        self.mkt_id_refresh_failed_total
332            .fetch_add(1, Ordering::Relaxed);
333    }
334
335    /// v1.4.89 P2-A: 当前 stale keys 数 (给 observability / debug).
336    #[must_use]
337    pub fn stale_mkt_ids_count(&self) -> usize {
338        self.stale_mkt_ids.len()
339    }
340
341    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 完整静态行 (`StockListFull` /
342    /// `Bootstrap` source)。同步维护 `securities` + `id_to_key` + 若有 owner
343    /// 还更新 `owner_to_warrants`。**自动 dedup**: 已有同 key 但 `warrnt_stock_owner`
344    /// 变化时, 旧 owner 下移除该 warrant id, 新 owner 下添加。
345    ///
346    /// 替代生产代码里手动调 `securities.insert()` + `id_to_key.insert()` +
347    /// `add_warrant_owner()` 三步骤的 pattern。
348    ///
349    /// **不允许** caller 把不完整的 source 标 `StockListFull`(若 `info.source ==
350    /// OnDemandBasic`, 用 `upsert_basic_security_info` 而非本 fn)。
351    pub fn upsert_full_security_info(&self, key: &str, info: CachedSecurityInfo) {
352        debug_assert!(
353            info.source.is_complete(),
354            "upsert_full_security_info called with non-complete source ({:?})",
355            info.source
356        );
357        self.upsert_with_owner_index_maintenance(key, info);
358    }
359
360    /// 写入 stock-list 下发的 crypto 货币对元数据。
361    pub fn upsert_crypto_pair_info(&self, key: &str, pair: CryptoPairInfo) {
362        if pair.cc_origin.is_empty() && pair.cc_destination.is_empty() {
363            self.crypto_pairs.remove(key);
364        } else {
365            self.crypto_pairs.insert(key.to_string(), pair);
366        }
367    }
368
369    /// 读取 crypto 货币对元数据。
370    pub fn get_crypto_pair_info(&self, key: &str) -> Option<CryptoPairInfo> {
371        self.crypto_pairs.get(key).map(|v| v.clone())
372    }
373
374    fn crypto_trade_config_key(broker_id: u32, symbol: &str, exchange: &str) -> String {
375        format!(
376            "{broker_id}:{}:{}",
377            symbol.trim().to_ascii_uppercase(),
378            exchange.trim().to_ascii_uppercase()
379        )
380    }
381
382    /// 用 backend CMD20102 拉回的配置替换某个 broker 的 crypto trade config。
383    pub fn set_crypto_trade_configs_for_broker(
384        &self,
385        broker_id: u32,
386        configs: Vec<CryptoTradeConfig>,
387    ) {
388        let prefix = format!("{broker_id}:");
389        self.crypto_trade_configs
390            .retain(|key, _| !key.starts_with(&prefix));
391        for config in configs {
392            if config.symbol.trim().is_empty() || config.exchange.trim().is_empty() {
393                continue;
394            }
395            let key = Self::crypto_trade_config_key(broker_id, &config.symbol, &config.exchange);
396            self.crypto_trade_configs.insert(key, config);
397        }
398    }
399
400    /// 查询某个 crypto symbol 的交易配置。
401    pub fn get_crypto_trade_config(
402        &self,
403        broker_id: u32,
404        symbol: &str,
405        exchange: &str,
406    ) -> Option<CryptoTradeConfig> {
407        let key = Self::crypto_trade_config_key(broker_id, symbol, exchange);
408        self.crypto_trade_configs.get(&key).map(|v| v.clone())
409    }
410
411    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 部分静态行 (`OnDemandBasic`
412    /// source)。同步维护 `securities` + `id_to_key`, **不动** `owner_to_warrants`
413    /// (因为 OnDemandBasic 不含 `warrnt_stock_owner` 字段, value 必为 0)。
414    ///
415    /// `info.source` 必须是 `OnDemandBasic` (debug_assert)。
416    pub fn upsert_basic_security_info(&self, key: &str, info: CachedSecurityInfo) {
417        debug_assert!(
418            !info.source.is_complete(),
419            "upsert_basic_security_info called with complete source ({:?}); use upsert_full",
420            info.source
421        );
422        debug_assert_eq!(
423            info.warrnt_stock_owner, 0,
424            "OnDemandBasic must have warrnt_stock_owner=0 (caller didn't query the field)"
425        );
426        // 不维护 owner_to_warrants (basic 没这个字段).
427        // 但仍需检查既有完整行的 owner 是否会被 basic 错误覆盖.
428        // 策略: 如果 key 已有 StockListFull / Bootstrap 行, 不让 basic 行覆盖 (full
429        // 数据更完整). 这处理 case "subscribe on-demand 后, stock-list sync 来时
430        // 应该 prevail; 反过来不行".
431        if let Some(existing) = self.securities.get(key) {
432            if existing.is_complete() {
433                tracing::debug!(
434                    key,
435                    "upsert_basic_security_info skipped: existing complete row prevails"
436                );
437                drop(existing);
438                return;
439            }
440            drop(existing);
441        }
442        if let Some(old_info) = self.securities.get(key).map(|r| r.clone()) {
443            self.remove_future_main_link_aliases(key, &old_info);
444        }
445        self.securities.insert(key.to_string(), info.clone());
446        self.id_to_key.insert(info.stock_id, key.to_string());
447        self.add_future_main_link_aliases(key, &info);
448    }
449
450    /// v1.4.106 codex 1148 F9 (P3): 删除 cache row + 同步清三个索引
451    /// (`securities`, `id_to_key`, `owner_to_warrants`)。
452    ///
453    /// 用于 stock-list `delete_flag = true` 场景。
454    /// 返 `true` 如果 row 存在并被删除, `false` 如果 stock_id 不在 `id_to_key`。
455    pub fn delete_security_info(&self, stock_id: u64) -> bool {
456        let Some((_, key)) = self.id_to_key.remove(&stock_id) else {
457            return false;
458        };
459        // 拿被删 row 的 owner (用于反向索引清理), 如果 key 已不在 securities, owner = 0
460        let old_owner = self
461            .securities
462            .get(&key)
463            .map(|r| r.warrnt_stock_owner)
464            .unwrap_or(0);
465        if let Some(old_info) = self.securities.get(&key).map(|r| r.clone()) {
466            self.remove_future_main_link_aliases(&key, &old_info);
467        }
468        self.securities.remove(&key);
469        self.crypto_pairs.remove(&key);
470        // F6: stock-list delete 时把 warrant 从 old_owner 反向索引里清掉
471        if old_owner != 0
472            && let Ok(mut map) = self.owner_to_warrants.write()
473            && let Some(set) = map.get_mut(&old_owner)
474        {
475            set.remove(&stock_id);
476            if set.is_empty() {
477                map.remove(&old_owner);
478            }
479        }
480        // F6: 该 stock_id 自己也可能是某 owner — 清掉它作为 owner 的 entry
481        if let Ok(mut map) = self.owner_to_warrants.write() {
482            map.remove(&stock_id);
483        }
484        true
485    }
486
487    /// 内部 helper: F9 unified upsert with owner-index maintenance (F6).
488    fn upsert_with_owner_index_maintenance(&self, key: &str, info: CachedSecurityInfo) {
489        // 先看老 row 是否存在 + 旧 owner 是什么 (F6: 如果 owner 变了要清旧索引)
490        let old_info = self.securities.get(key).map(|r| r.clone());
491        let old_owner = old_info.as_ref().map(|r| r.warrnt_stock_owner).unwrap_or(0);
492        let new_owner = info.warrnt_stock_owner;
493
494        if let Some(old) = old_info.as_ref() {
495            self.remove_future_main_link_aliases(key, old);
496        }
497
498        // 写 securities + id_to_key
499        let stock_id = info.stock_id;
500        self.securities.insert(key.to_string(), info.clone());
501        self.id_to_key.insert(stock_id, key.to_string());
502        self.add_future_main_link_aliases(key, &info);
503
504        // F6: 维护反向索引
505        if old_owner != new_owner {
506            // owner 变了 (含 0→X / X→Y / X→0)
507            if let Ok(mut map) = self.owner_to_warrants.write() {
508                if old_owner != 0
509                    && let Some(set) = map.get_mut(&old_owner)
510                {
511                    set.remove(&stock_id);
512                    if set.is_empty() {
513                        map.remove(&old_owner);
514                    }
515                }
516                if new_owner != 0 {
517                    map.entry(new_owner).or_default().insert(stock_id);
518                }
519            }
520        } else if new_owner != 0 {
521            // owner 未变, 但同一 owner 下重 add (idempotent due to HashSet).
522            if let Ok(mut map) = self.owner_to_warrants.write() {
523                map.entry(new_owner).or_default().insert(stock_id);
524            }
525        }
526    }
527
528    fn future_main_link_target_ids(info: &CachedSecurityInfo) -> Vec<u64> {
529        let mut ids = Vec::with_capacity(2);
530        for target in [info.future_origin_id, info.zhuli_id] {
531            if target != 0 && target != info.stock_id && !ids.contains(&target) {
532                ids.push(target);
533            }
534        }
535        ids
536    }
537
538    fn add_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
539        for target in Self::future_main_link_target_ids(info) {
540            self.future_main_link_aliases
541                .entry(target)
542                .or_default()
543                .insert(key.to_string());
544        }
545    }
546
547    fn remove_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
548        for target in Self::future_main_link_target_ids(info) {
549            if let Some(mut aliases) = self.future_main_link_aliases.get_mut(&target) {
550                aliases.remove(key);
551                let empty = aliases.is_empty();
552                drop(aliases);
553                if empty {
554                    self.future_main_link_aliases.remove(&target);
555                }
556            }
557        }
558    }
559
560    /// 查询某个 backend push stock_id 对应的主连/连续合约 sec_key 别名。
561    ///
562    /// 只返回 stock-list 明确下发 `origin_id` / `zhuli_id` 关系的 key;不做
563    /// `HSImain` 等字符串启发式。调用方通常先按 `id_to_key` 处理真实合约,
564    /// 再把这里返回的 main-link key 一并投递。
565    #[must_use]
566    pub fn get_future_main_link_alias_keys(&self, stock_id: u64) -> Vec<String> {
567        let Some(aliases) = self.future_main_link_aliases.get(&stock_id) else {
568            return Vec::new();
569        };
570        let mut keys: Vec<String> = aliases.iter().cloned().collect();
571        keys.sort();
572        keys
573    }
574
575    /// 查询 backend push stock_id 的所有 quote 投递目标。
576    ///
577    /// 顺序保持为:真实 stock_id 对应 key(如有)优先,然后是 stock-list
578    /// `origin_id` / `zhuli_id` 下发的主连/连续合约别名 key。调用方不再直接
579    /// 读取 `id_to_key` 和 `future_main_link_aliases` 两个索引,避免 alias 逻辑
580    /// 分散在 push parser 里。
581    #[must_use]
582    pub fn quote_push_targets_for_stock_id(
583        &self,
584        stock_id: u64,
585    ) -> Vec<(String, CachedSecurityInfo)> {
586        let mut targets = Vec::new();
587
588        if let Some(sec_key_ref) = self.id_to_key.get(&stock_id) {
589            let sec_key = sec_key_ref.clone();
590            drop(sec_key_ref);
591            if let Some(info) = self.get_security_info(&sec_key) {
592                targets.push((sec_key, info));
593            }
594        }
595
596        for alias_key in self.get_future_main_link_alias_keys(stock_id) {
597            if targets.iter().any(|(key, _)| key == &alias_key) {
598                continue;
599            }
600            if let Some(info) = self.get_security_info(&alias_key) {
601                targets.push((alias_key, info));
602            }
603        }
604
605        targets
606    }
607
608    /// **v1.4.110 Phase 2 Slice 5**: broker-aware 推送投递目标查询.
609    ///
610    /// 对应 push parser 从 `SecurityQuote.broker_id` 重建 broker-aware
611    /// `QotStockKey` 的路径 (对齐 C++ `NNBiz_Qot_PushQot.cpp:220-269`).
612    ///
613    /// 语义:
614    /// - `broker_id = None` (C++ `m_hasBroker=false`): 只查 no-broker key,
615    ///   返 `QotSecurityKey::no_broker(public_sec_key, stock_id)` 与
616    ///   `quote_push_targets_for_stock_id` 等价
617    /// - `broker_id = Some(N)` (C++ `m_hasBroker=true`): 沿 stock_id 反向
618    ///   找到 public_sec_key, 再用 `QotSecurityKey::from_broker_id(...)`
619    ///   构造 broker-aware key. 该 broker 下 cache 写入独立桶
620    ///   `"market_code@b{N}"`, 不污染同 stock_id 其他 broker 的 cache.
621    ///
622    /// **Phase 2 默认**: backend 当前对普通股 push 仍不下发 broker_id (=None),
623    /// 与升级前行为完全等价. crypto multi-broker push 会带 broker_id,
624    /// Phase 3 reader caller (handler `GetBasicQot` 等) 替换走 `_broker`
625    /// 版本后, broker-aware cache 才被消费.
626    #[must_use]
627    pub fn quote_push_targets_for_stock_key(
628        &self,
629        stock_id: u64,
630        broker_id: Option<std::num::NonZeroU32>,
631    ) -> Vec<(futu_core::qot_stock_key::QotSecurityKey, CachedSecurityInfo)> {
632        // 先用 no-broker 路径查 stock_id → (public_sec_key, info) 列表
633        // (id_to_key + future_main_link_aliases). broker_id 注入到返 key
634        // 不改变 lookup 逻辑.
635        let bare = self.quote_push_targets_for_stock_id(stock_id);
636        bare.into_iter()
637            .map(|(public_sec_key, info)| {
638                let key = match broker_id {
639                    Some(nz) => futu_core::qot_stock_key::QotSecurityKey::from_broker_id(
640                        public_sec_key,
641                        stock_id,
642                        nz.get(),
643                    ),
644                    None => futu_core::qot_stock_key::QotSecurityKey::no_broker(
645                        public_sec_key,
646                        stock_id,
647                    ),
648                };
649                (key, info)
650            })
651            .collect()
652    }
653
654    /// **deprecated**: 改用 `upsert_full_security_info` /
655    /// `upsert_basic_security_info` 维护三索引一致性。本 method 仅写
656    /// `securities`, **不**更新 `id_to_key` / `owner_to_warrants` — 直接调
657    /// 会产生半索引行 (按 code 查得到, 按 stock_id 反向查不到)。
658    ///
659    /// v1.4.106 codex 1148 F9 起仅保留作 unit test / bench 兼容。生产代码
660    /// 禁用 (review-time grep `set_security_info` 须 0 命中 in `crates/futu-*/src/`)。
661    #[deprecated(
662        since = "1.4.106",
663        note = "use upsert_full_security_info / upsert_basic_security_info / delete_security_info"
664    )]
665    pub fn set_security_info(&self, key: &str, info: CachedSecurityInfo) {
666        self.securities.insert(key.to_string(), info);
667    }
668
669    pub fn get_security_info(&self, key: &str) -> Option<CachedSecurityInfo> {
670        self.securities.get(key).map(|v| v.clone())
671    }
672
673    /// 通过 stock_id 查找股票信息 (使用 id_to_key 反向映射)
674    pub fn get_security_info_by_stock_id(&self, stock_id: u64) -> Option<CachedSecurityInfo> {
675        let key = self.id_to_key.get(&stock_id)?;
676        self.securities.get(key.value().as_str()).map(|v| v.clone())
677    }
678
679    /// 添加窝轮→正股的映射关系
680    ///
681    /// **v1.4.106 codex 1148 F6**: HashSet 自动去重, 重复 add 同 (warrant, owner)
682    /// 是 idempotent — SQLite reload + stock-list sync 不会重复入。
683    pub fn add_warrant_owner(&self, warrant_stock_id: u64, owner_stock_id: u64) {
684        if owner_stock_id == 0 {
685            return;
686        }
687        if let Ok(mut map) = self.owner_to_warrants.write() {
688            map.entry(owner_stock_id)
689                .or_default()
690                .insert(warrant_stock_id);
691        }
692    }
693
694    /// 通过正股 ID 搜索该正股的所有窝轮
695    ///
696    /// **v1.4.106 codex 1148 F6**: 内部 HashSet, 返 Vec (call site backward
697    /// compatible)。返序非确定 (HashSet 不保留 insertion order); call site 若
698    /// 需要稳定序应自己 sort。
699    #[must_use]
700    pub fn search_warrants_by_owner(&self, owner_stock_id: u64) -> Vec<u64> {
701        match self.owner_to_warrants.read() {
702            Ok(map) => map
703                .get(&owner_stock_id)
704                .map(|set| set.iter().copied().collect())
705                .unwrap_or_default(),
706            _ => Vec::new(),
707        }
708    }
709}
710
711impl Default for StaticDataCache {
712    fn default() -> Self {
713        Self::new()
714    }
715}
716
717#[cfg(test)]
718mod tests;