Skip to main content

futu_cache/
static_data.rs

1// 静态数据缓存:股票列表、经纪商、节假日、停牌
2
3use dashmap::DashMap;
4use std::collections::HashSet;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, RwLock};
7
8mod types;
9
10pub use types::{
11    CachedPlateInfo, CachedSecurityInfo, CachedTradeDate, CryptoPairInfo, CryptoTradeConfig,
12    OptionContractInfo, SecurityInfoSource,
13};
14
15/// 静态数据缓存
16pub struct StaticDataCache {
17    /// 股票静态信息: "market_code" → info.
18    ///
19    /// 三索引写入只能通过 `upsert_full_security_info` /
20    /// `upsert_basic_security_info` / `delete_security_info`,避免 caller
21    /// 绕过 `id_to_key` / `owner_to_warrants` 维护。
22    securities: DashMap<String, Arc<CachedSecurityInfo>>,
23    /// stock_id → "market_code" key (反向映射,用于推送时查找)
24    ///
25    id_to_key: DashMap<u64, String>,
26    /// 期货主连/连续合约 push 路由别名: real/origin stock_id → main-link sec_key set.
27    ///
28    /// Backend 的实时 push 可能以真实月份合约 stock_id 下发,而客户端订阅的是
29    /// `HSImain` / `NQmain` 这类主连 symbol。C++ 的 QotSubscribe 用 stock_id
30    /// 级别的主连关系做回投;Rust 这里保留同样的数据驱动关系,来源仅限
31    /// stock-list 下发的 `origin_id` / `zhuli_id` 字段,不按 code 字符串特判。
32    future_main_link_aliases: DashMap<u64, HashSet<String>>,
33    /// option stock_id -> contract metadata from CMD20106 `OptionResultInfo`.
34    option_contracts: DashMap<u64, OptionContractInfo>,
35    /// Crypto sec_key -> 货币对元数据 (`cc_origin` / `cc_destination`)。
36    crypto_pairs: DashMap<String, CryptoPairInfo>,
37    /// `(broker_id, symbol, exchange)` -> crypto 交易配置。
38    crypto_trade_configs: DashMap<String, CryptoTradeConfig>,
39    /// 交易日: "market:year-month" → Vec<TradeDate>
40    pub trade_dates: DashMap<String, Vec<CachedTradeDate>>,
41    /// 板块: "market:plate_type" → Vec<PlateInfo>
42    pub plates: DashMap<String, Vec<CachedPlateInfo>>,
43    /// 窝轮正股 owner_id → 该正股对应的所有窝轮 stock_id 集合
44    ///
45    /// **v1.4.106 codex 1148 F6**: value 从 `Vec<u64>` 改为 `HashSet<u64>` 防
46    /// 重复 (SQLite reload + stock-list re-sync 同 warrant 多次 push 不会再重复).
47    /// stock-list `delete_flag` 时**反向索引清理**: 旧 owner 下移除旧 warrant
48    /// (`delete_security_info` 内部维护), update 时也维护。
49    owner_to_warrants: RwLock<std::collections::HashMap<u64, HashSet<u64>>>,
50
51    /// v1.4.89 P2-A: 需要 mkt_id refresh 的 cache key 集合.
52    ///
53    /// Callers `get_security_info_trigger_refresh` 在返 info 前检查
54    /// `info.needs_mkt_id_refresh()`, 是则 mark key 到这里. 背景 worker
55    /// (gateway bridge) 定期 `drain_stale_mkt_ids()` 批量 CMD 20106 refresh.
56    ///
57    /// 用 DashMap<String, ()> 替代 HashSet<String> 免 lock 竞争.
58    pub stale_mkt_ids: DashMap<String, ()>,
59
60    /// v1.4.89 P2-A: mkt_id refresh 统计计数, 用于 metrics 观察.
61    ///
62    /// - `mkt_id_refresh_marked_total`: 累积 mark stale 次数
63    /// - `mkt_id_refresh_done_total`: 累积 backend CMD 20106 成功 refresh 次数
64    /// - `mkt_id_refresh_failed_total`: 累积 refresh failure 次数
65    pub mkt_id_refresh_marked_total: AtomicU64,
66    pub mkt_id_refresh_done_total: AtomicU64,
67    pub mkt_id_refresh_failed_total: AtomicU64,
68}
69
70impl StaticDataCache {
71    pub fn new() -> Self {
72        Self {
73            securities: DashMap::new(),
74            id_to_key: DashMap::new(),
75            future_main_link_aliases: DashMap::new(),
76            option_contracts: DashMap::new(),
77            crypto_pairs: DashMap::new(),
78            crypto_trade_configs: DashMap::new(),
79            trade_dates: DashMap::new(),
80            plates: DashMap::new(),
81            owner_to_warrants: RwLock::new(std::collections::HashMap::new()),
82            stale_mkt_ids: DashMap::new(),
83            mkt_id_refresh_marked_total: AtomicU64::new(0),
84            mkt_id_refresh_done_total: AtomicU64::new(0),
85            mkt_id_refresh_failed_total: AtomicU64::new(0),
86        }
87    }
88
89    /// v1.4.89 P2-A: 取 cache info 同时机会性 mark stale (若 mkt_id=0).
90    ///
91    /// 返 Some(info) 如果 cache hit (无论是否 stale). 返 None 如果 miss.
92    ///
93    /// 调 `info.needs_mkt_id_refresh()` 判 stale → mark `stale_mkt_ids`,
94    /// bump `mkt_id_refresh_marked_total` counter. 用 DashMap::insert 幂等
95    /// (同 key 重入不 double mark 但会 bump counter — 可接受).
96    ///
97    /// 替代 `get_security_info` 的推荐路径; 老 method 保留作 lookup-only 接口.
98    pub fn get_security_info_trigger_refresh(&self, key: &str) -> Option<CachedSecurityInfo> {
99        let info = self.get_security_info(key)?;
100        if info.needs_mkt_id_refresh() {
101            self.mark_stale_mkt_id(key);
102        }
103        Some(info)
104    }
105
106    /// v1.4.89 P2-A: 显式 mark key 需要 mkt_id refresh.
107    ///
108    /// 幂等: 同 key 可重入. Counter `mkt_id_refresh_marked_total` 每次都 bump
109    /// (用作 metrics 观察 heuristic fallback 触发频率).
110    pub fn mark_stale_mkt_id(&self, key: &str) {
111        self.stale_mkt_ids.insert(key.to_string(), ());
112        self.mkt_id_refresh_marked_total
113            .fetch_add(1, Ordering::Relaxed);
114    }
115
116    /// v1.4.89 P2-A: drain 所有 stale keys, 清空集合, 返 Vec (给 backend worker
117    /// 批量 CMD 20106 refresh).
118    ///
119    /// 背景 worker 用法 (伪码):
120    /// ```text
121    /// loop {
122    ///     sleep(Duration::from_secs(60)).await;
123    ///     let stale = cache.drain_stale_mkt_ids();
124    ///     if stale.is_empty() { continue; }
125    ///     for chunk in stale.chunks(50) {
126    ///         // CMD 20106 SecuritiesReq for chunk
127    ///         // on success: cache.update_mkt_id(key, new_mkt_id)
128    ///         //              + cache.record_mkt_id_refresh_done()
129    ///         // on failure: cache.record_mkt_id_refresh_failed()
130    ///     }
131    /// }
132    /// ```
133    pub fn drain_stale_mkt_ids(&self) -> Vec<String> {
134        let keys: Vec<String> = self.stale_mkt_ids.iter().map(|e| e.key().clone()).collect();
135        for k in &keys {
136            self.stale_mkt_ids.remove(k);
137        }
138        keys
139    }
140
141    /// v1.4.89 P2-A: 更新已 cache row 的 mkt_id (refresh success 时调).
142    ///
143    /// 只改 mkt_id 字段, 其他字段保留 (info 可能有 SQLite 里更精准的 lot_size /
144    /// list_time 等). 若 key 不在 cache (已被 evict), no-op.
145    pub fn update_mkt_id(&self, key: &str, new_mkt_id: u32) -> bool {
146        if let Some(mut entry) = self.securities.get_mut(key) {
147            Arc::make_mut(&mut entry).mkt_id = new_mkt_id;
148            self.mkt_id_refresh_done_total
149                .fetch_add(1, Ordering::Relaxed);
150            true
151        } else {
152            false
153        }
154    }
155
156    /// v1.4.89 P2-A: 记录 refresh failure (不改 cache row, 让下次 drain 重试).
157    pub fn record_mkt_id_refresh_failed(&self) {
158        self.mkt_id_refresh_failed_total
159            .fetch_add(1, Ordering::Relaxed);
160    }
161
162    /// v1.4.89 P2-A: 当前 stale keys 数 (给 observability / debug).
163    #[must_use]
164    pub fn stale_mkt_ids_count(&self) -> usize {
165        self.stale_mkt_ids.len()
166    }
167
168    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 完整静态行 (`StockListFull` /
169    /// `Bootstrap` source)。同步维护 `securities` + `id_to_key` + 若有 owner
170    /// 还更新 `owner_to_warrants`。**自动 dedup**: 已有同 key 但 `warrnt_stock_owner`
171    /// 变化时, 旧 owner 下移除该 warrant id, 新 owner 下添加。
172    ///
173    /// 替代生产代码里手动调 `securities.insert()` + `id_to_key.insert()` +
174    /// `add_warrant_owner()` 三步骤的 pattern。
175    ///
176    /// **不允许** caller 把不完整的 source 标 `StockListFull`(若 `info.source ==
177    /// OnDemandBasic`, 用 `upsert_basic_security_info` 而非本 fn)。
178    pub fn upsert_full_security_info(&self, key: &str, info: CachedSecurityInfo) {
179        debug_assert!(
180            info.source.is_complete(),
181            "upsert_full_security_info called with non-complete source ({:?})",
182            info.source
183        );
184        self.upsert_with_owner_index_maintenance(key, info);
185    }
186
187    /// 写入 stock-list 下发的 crypto 货币对元数据。
188    pub fn upsert_crypto_pair_info(&self, key: &str, pair: CryptoPairInfo) {
189        if pair.cc_origin.is_empty() && pair.cc_destination.is_empty() {
190            self.crypto_pairs.remove(key);
191        } else {
192            self.crypto_pairs.insert(key.to_string(), pair);
193        }
194    }
195
196    /// Cache option contract metadata from CMD20106 `OptionResultInfo`.
197    pub fn set_option_contract_info(&self, stock_id: u64, info: OptionContractInfo) {
198        if stock_id == 0 {
199            return;
200        }
201        self.option_contracts.insert(stock_id, info);
202    }
203
204    /// Read option contract metadata by option stock_id.
205    pub fn get_option_contract_info_by_stock_id(
206        &self,
207        stock_id: u64,
208    ) -> Option<OptionContractInfo> {
209        self.option_contracts
210            .get(&stock_id)
211            .map(|entry| *entry.value())
212    }
213
214    /// 读取 crypto 货币对元数据。
215    pub fn get_crypto_pair_info(&self, key: &str) -> Option<CryptoPairInfo> {
216        self.crypto_pairs.get(key).map(|v| v.clone())
217    }
218
219    fn crypto_trade_config_key(broker_id: u32, symbol: &str, exchange: &str) -> String {
220        format!(
221            "{broker_id}:{}:{}",
222            symbol.trim().to_ascii_uppercase(),
223            exchange.trim().to_ascii_uppercase()
224        )
225    }
226
227    /// 用 backend CMD20102 拉回的配置替换某个 broker 的 crypto trade config。
228    pub fn set_crypto_trade_configs_for_broker(
229        &self,
230        broker_id: u32,
231        configs: Vec<CryptoTradeConfig>,
232    ) {
233        let prefix = format!("{broker_id}:");
234        self.crypto_trade_configs
235            .retain(|key, _| !key.starts_with(&prefix));
236        for config in configs {
237            if config.symbol.trim().is_empty() || config.exchange.trim().is_empty() {
238                continue;
239            }
240            let key = Self::crypto_trade_config_key(broker_id, &config.symbol, &config.exchange);
241            self.crypto_trade_configs.insert(key, config);
242        }
243    }
244
245    /// 查询某个 crypto symbol 的交易配置。
246    pub fn get_crypto_trade_config(
247        &self,
248        broker_id: u32,
249        symbol: &str,
250        exchange: &str,
251    ) -> Option<CryptoTradeConfig> {
252        let key = Self::crypto_trade_config_key(broker_id, symbol, exchange);
253        self.crypto_trade_configs.get(&key).map(|v| v.clone())
254    }
255
256    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 部分静态行 (`OnDemandBasic`
257    /// source)。同步维护 `securities` + `id_to_key`, **不动** `owner_to_warrants`
258    /// (因为 OnDemandBasic 不含 `warrnt_stock_owner` 字段, value 必为 0)。
259    ///
260    /// `info.source` 必须是 `OnDemandBasic` (debug_assert)。
261    pub fn upsert_basic_security_info(&self, key: &str, info: CachedSecurityInfo) {
262        debug_assert!(
263            !info.source.is_complete(),
264            "upsert_basic_security_info called with complete source ({:?}); use upsert_full",
265            info.source
266        );
267        debug_assert_eq!(
268            info.warrnt_stock_owner, 0,
269            "OnDemandBasic must have warrnt_stock_owner=0 (caller didn't query the field)"
270        );
271        // 不维护 owner_to_warrants (basic 没这个字段).
272        // 但仍需检查既有完整行的 owner 是否会被 basic 错误覆盖.
273        // 策略: 如果 key 已有 StockListFull / Bootstrap 行, 不让 basic 行覆盖 (full
274        // 数据更完整). 这处理 case "subscribe on-demand 后, stock-list sync 来时
275        // 应该 prevail; 反过来不行".
276        let old_info = if let Some(existing) = self.securities.get(key) {
277            if existing.is_complete() {
278                tracing::debug!(
279                    key,
280                    "upsert_basic_security_info skipped: existing complete row prevails"
281                );
282                return;
283            }
284            Some(Arc::clone(existing.value()))
285        } else {
286            None
287        };
288        if let Some(old_info) = old_info {
289            self.remove_future_main_link_aliases(key, &old_info);
290        }
291        self.securities
292            .insert(key.to_string(), Arc::new(info.clone()));
293        self.id_to_key.insert(info.stock_id, key.to_string());
294        self.add_future_main_link_aliases(key, &info);
295    }
296
297    /// v1.4.106 codex 1148 F9 (P3): 删除 cache row + 同步清三个索引
298    /// (`securities`, `id_to_key`, `owner_to_warrants`)。
299    ///
300    /// 用于 stock-list `delete_flag = true` 场景。
301    /// 返 `true` 如果 row 存在并被删除, `false` 如果 stock_id 不在 `id_to_key`。
302    pub fn delete_security_info(&self, stock_id: u64) -> bool {
303        let Some((_, key)) = self.id_to_key.remove(&stock_id) else {
304            return false;
305        };
306        self.option_contracts.remove(&stock_id);
307        // 拿被删 row 的 owner (用于反向索引清理), 如果 key 已不在 securities, owner = 0
308        let old_info = self.securities.remove(&key).map(|(_, info)| info);
309        let old_owner = old_info.as_ref().map(|r| r.warrnt_stock_owner).unwrap_or(0);
310        if let Some(old_info) = old_info.as_ref() {
311            self.remove_future_main_link_aliases(&key, old_info);
312        }
313        self.crypto_pairs.remove(&key);
314        // F6: stock-list delete 时把 warrant 从 old_owner 反向索引里清掉
315        if old_owner != 0
316            && let Ok(mut map) = self.owner_to_warrants.write()
317            && let Some(set) = map.get_mut(&old_owner)
318        {
319            set.remove(&stock_id);
320            if set.is_empty() {
321                map.remove(&old_owner);
322            }
323        }
324        // F6: 该 stock_id 自己也可能是某 owner — 清掉它作为 owner 的 entry
325        if let Ok(mut map) = self.owner_to_warrants.write() {
326            map.remove(&stock_id);
327        }
328        true
329    }
330
331    /// 内部 helper: F9 unified upsert with owner-index maintenance (F6).
332    fn upsert_with_owner_index_maintenance(&self, key: &str, info: CachedSecurityInfo) {
333        // 先看老 row 是否存在 + 旧 owner 是什么 (F6: 如果 owner 变了要清旧索引)
334        let old_info = self.securities.get(key).map(|r| Arc::clone(r.value()));
335        let old_owner = old_info.as_ref().map(|r| r.warrnt_stock_owner).unwrap_or(0);
336        let new_owner = info.warrnt_stock_owner;
337
338        if let Some(old) = old_info.as_ref() {
339            self.remove_future_main_link_aliases(key, old);
340        }
341
342        // 写 securities + id_to_key
343        let stock_id = info.stock_id;
344        self.securities
345            .insert(key.to_string(), Arc::new(info.clone()));
346        self.id_to_key.insert(stock_id, key.to_string());
347        self.add_future_main_link_aliases(key, &info);
348
349        // F6: 维护反向索引
350        if old_owner != new_owner {
351            // owner 变了 (含 0→X / X→Y / X→0)
352            if let Ok(mut map) = self.owner_to_warrants.write() {
353                if old_owner != 0
354                    && let Some(set) = map.get_mut(&old_owner)
355                {
356                    set.remove(&stock_id);
357                    if set.is_empty() {
358                        map.remove(&old_owner);
359                    }
360                }
361                if new_owner != 0 {
362                    map.entry(new_owner).or_default().insert(stock_id);
363                }
364            }
365        } else if new_owner != 0 {
366            // owner 未变, 但同一 owner 下重 add (idempotent due to HashSet).
367            if let Ok(mut map) = self.owner_to_warrants.write() {
368                map.entry(new_owner).or_default().insert(stock_id);
369            }
370        }
371    }
372
373    fn future_main_link_target_ids(info: &CachedSecurityInfo) -> Vec<u64> {
374        let mut ids = Vec::with_capacity(2);
375        for target in [info.future_origin_id, info.zhuli_id] {
376            if target != 0 && target != info.stock_id && !ids.contains(&target) {
377                ids.push(target);
378            }
379        }
380        ids
381    }
382
383    fn add_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
384        for target in Self::future_main_link_target_ids(info) {
385            self.future_main_link_aliases
386                .entry(target)
387                .or_default()
388                .insert(key.to_string());
389        }
390    }
391
392    fn remove_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
393        for target in Self::future_main_link_target_ids(info) {
394            if let Some(mut aliases) = self.future_main_link_aliases.get_mut(&target) {
395                aliases.remove(key);
396                let empty = aliases.is_empty();
397                drop(aliases);
398                if empty {
399                    self.future_main_link_aliases.remove(&target);
400                }
401            }
402        }
403    }
404
405    /// 查询某个 backend push stock_id 对应的主连/连续合约 sec_key 别名。
406    ///
407    /// 只返回 stock-list 明确下发 `origin_id` / `zhuli_id` 关系的 key;不做
408    /// `HSImain` 等字符串启发式。调用方通常先按 `id_to_key` 处理真实合约,
409    /// 再把这里返回的 main-link key 一并投递。
410    #[must_use]
411    pub fn get_future_main_link_alias_keys(&self, stock_id: u64) -> Vec<String> {
412        let Some(aliases) = self.future_main_link_aliases.get(&stock_id) else {
413            return Vec::new();
414        };
415        let mut keys: Vec<String> = aliases.iter().cloned().collect();
416        keys.sort();
417        keys
418    }
419
420    /// 查询 backend push stock_id 的所有 quote 投递目标。
421    ///
422    /// 顺序保持为:真实 stock_id 对应 key(如有)优先,然后是 stock-list
423    /// `origin_id` / `zhuli_id` 下发的主连/连续合约别名 key。调用方不再直接
424    /// 读取 `id_to_key` 和 `future_main_link_aliases` 两个索引,避免 alias 逻辑
425    /// 分散在 push parser 里。
426    #[must_use]
427    pub fn quote_push_targets_for_stock_id(
428        &self,
429        stock_id: u64,
430    ) -> Vec<(String, Arc<CachedSecurityInfo>)> {
431        let mut targets = Vec::new();
432
433        if let Some(sec_key_ref) = self.id_to_key.get(&stock_id) {
434            let sec_key = sec_key_ref.clone();
435            drop(sec_key_ref);
436            if let Some(info) = self.get_security_info_arc(&sec_key) {
437                targets.push((sec_key, info));
438            }
439        }
440
441        for alias_key in self.get_future_main_link_alias_keys(stock_id) {
442            if targets.iter().any(|(key, _)| key == &alias_key) {
443                continue;
444            }
445            if let Some(info) = self.get_security_info_arc(&alias_key) {
446                targets.push((alias_key, info));
447            }
448        }
449
450        targets
451    }
452
453    /// **v1.4.110 Phase 2 Slice 5**: broker-aware 推送投递目标查询.
454    ///
455    /// 对应 push parser 从 `SecurityQuote.broker_id` 重建 broker-aware
456    /// `QotStockKey` 的路径 (对齐 C++ `NNBiz_Qot_PushQot.cpp:220-269`).
457    ///
458    /// 语义:
459    /// - `broker_id = None` (C++ `m_hasBroker=false`): 只查 no-broker key,
460    ///   返 `QotSecurityKey::no_broker(public_sec_key, stock_id)` 与
461    ///   `quote_push_targets_for_stock_id` 等价
462    /// - `broker_id = Some(N)` (C++ `m_hasBroker=true`): 沿 stock_id 反向
463    ///   找到 public_sec_key, 再用 `QotSecurityKey::from_broker_id(...)`
464    ///   构造 broker-aware key. 该 broker 下 cache 写入独立桶
465    ///   `"market_code@b{N}"`, 不污染同 stock_id 其他 broker 的 cache.
466    ///
467    /// **Phase 2 默认**: backend 当前对普通股 push 仍不下发 broker_id (=None),
468    /// 与升级前行为完全等价. crypto multi-broker push 会带 broker_id,
469    /// Phase 3 reader caller (handler `GetBasicQot` 等) 替换走 `_broker`
470    /// 版本后, broker-aware cache 才被消费.
471    #[must_use]
472    pub fn quote_push_targets_for_stock_key(
473        &self,
474        stock_id: u64,
475        broker_id: Option<std::num::NonZeroU32>,
476    ) -> Vec<(
477        futu_core::qot_stock_key::QotSecurityKey,
478        Arc<CachedSecurityInfo>,
479    )> {
480        // 先用 no-broker 路径查 stock_id → (public_sec_key, info) 列表
481        // (id_to_key + future_main_link_aliases). broker_id 注入到返 key
482        // 不改变 lookup 逻辑.
483        let bare = self.quote_push_targets_for_stock_id(stock_id);
484        bare.into_iter()
485            .map(|(public_sec_key, info)| {
486                let key = match broker_id {
487                    Some(nz) => futu_core::qot_stock_key::QotSecurityKey::from_broker_id(
488                        public_sec_key,
489                        stock_id,
490                        nz.get(),
491                    ),
492                    None => futu_core::qot_stock_key::QotSecurityKey::no_broker(
493                        public_sec_key,
494                        stock_id,
495                    ),
496                };
497                (key, info)
498            })
499            .collect()
500    }
501
502    /// **deprecated**: 改用 `upsert_full_security_info` /
503    /// `upsert_basic_security_info` 显式表达数据完整度。
504    ///
505    /// v1.4.111 codex legacy deep-dive follow-up: 保留此 public wrapper 兼容
506    /// 既有 tests/bench/下游辅助代码,但不再直接写 `securities` 造成半索引行。
507    /// `source.is_complete()` 走 full upsert, 否则走 basic upsert, 始终维护
508    /// `id_to_key` / `owner_to_warrants` 与主表一致。
509    #[deprecated(
510        since = "1.4.106",
511        note = "use upsert_full_security_info / upsert_basic_security_info / delete_security_info"
512    )]
513    pub fn set_security_info(&self, key: &str, info: CachedSecurityInfo) {
514        if info.source.is_complete() {
515            self.upsert_full_security_info(key, info);
516        } else {
517            self.upsert_basic_security_info(key, info);
518        }
519    }
520
521    pub fn get_security_info(&self, key: &str) -> Option<CachedSecurityInfo> {
522        self.get_security_info_arc(key)
523            .map(|info| info.as_ref().clone())
524    }
525
526    pub fn get_security_info_arc(&self, key: &str) -> Option<Arc<CachedSecurityInfo>> {
527        self.securities.get(key).map(|v| Arc::clone(v.value()))
528    }
529
530    pub fn security_id_for_key(&self, key: &str) -> Option<u64> {
531        self.get_security_info(key)
532            .map(|info| info.stock_id)
533            .filter(|stock_id| *stock_id > 0)
534    }
535
536    pub fn security_info_snapshot(&self) -> Vec<CachedSecurityInfo> {
537        self.security_info_snapshot_matching(|_| true)
538    }
539
540    pub fn security_info_snapshot_matching(
541        &self,
542        mut predicate: impl FnMut(&CachedSecurityInfo) -> bool,
543    ) -> Vec<CachedSecurityInfo> {
544        self.securities
545            .iter()
546            .filter_map(|entry| {
547                let info = entry.value();
548                predicate(info.as_ref()).then(|| info.as_ref().clone())
549            })
550            .collect()
551    }
552
553    pub fn security_key_by_stock_id(&self, stock_id: u64) -> Option<String> {
554        self.id_to_key.get(&stock_id).map(|key| key.value().clone())
555    }
556
557    /// 通过 stock_id 查找股票信息 (使用 id_to_key 反向映射)
558    pub fn get_security_info_by_stock_id(&self, stock_id: u64) -> Option<CachedSecurityInfo> {
559        let key = self.security_key_by_stock_id(stock_id)?;
560        self.get_security_info(&key)
561    }
562
563    pub fn get_security_info_by_stock_id_trigger_refresh(
564        &self,
565        stock_id: u64,
566    ) -> Option<CachedSecurityInfo> {
567        let key = self.security_key_by_stock_id(stock_id)?;
568        self.get_security_info_trigger_refresh(&key)
569    }
570
571    /// 添加窝轮→正股的映射关系
572    ///
573    /// **v1.4.106 codex 1148 F6**: HashSet 自动去重, 重复 add 同 (warrant, owner)
574    /// 是 idempotent — SQLite reload + stock-list sync 不会重复入。
575    pub fn add_warrant_owner(&self, warrant_stock_id: u64, owner_stock_id: u64) {
576        if owner_stock_id == 0 {
577            return;
578        }
579        if let Ok(mut map) = self.owner_to_warrants.write() {
580            map.entry(owner_stock_id)
581                .or_default()
582                .insert(warrant_stock_id);
583        }
584    }
585
586    /// 通过正股 ID 搜索该正股的所有窝轮
587    ///
588    /// **v1.4.106 codex 1148 F6**: 内部 HashSet, 返 Vec (call site backward
589    /// compatible)。返序非确定 (HashSet 不保留 insertion order); call site 若
590    /// 需要稳定序应自己 sort。
591    ///
592    /// v1.4.111 P2-1 Tier 3 audit comment: warrant lookup helper — empty Vec =
593    /// "no warrants for this owner_stock_id" (legit "正股无窝轮"), 跟 C++
594    /// `SearchWarrantsByOwner` empty 行为对齐. caller decide 怎么用 (display 0
595    /// warrants 是合理). 非 silent-success risk (audit verified, essentials/
596    /// 2026-05-27).
597    #[must_use]
598    pub fn search_warrants_by_owner(&self, owner_stock_id: u64) -> Vec<u64> {
599        match self.owner_to_warrants.read() {
600            Ok(map) => map
601                .get(&owner_stock_id)
602                .map(|set| set.iter().copied().collect())
603                .unwrap_or_default(),
604            _ => Vec::new(),
605        }
606    }
607}
608
609impl Default for StaticDataCache {
610    fn default() -> Self {
611        Self::new()
612    }
613}
614
615#[cfg(test)]
616mod tests;