Skip to main content

futu_cache/
static_data.rs

1// 静态数据缓存:股票列表、经纪商、节假日、停牌
2
3use dashmap::DashMap;
4use std::collections::{BTreeSet, HashSet};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Mutex, RwLock};
7
8mod readiness;
9mod security_identity;
10mod sync_status;
11mod types;
12
13pub use readiness::{StaticDataReadiness, StockListSyncStatus};
14use sync_status::StockListSyncCounters;
15pub use types::{
16    CachedPlateInfo, CachedSecurityInfo, CachedTradeDate, CryptoPairInfo, CryptoTradeConfig,
17    OptionContractInfo, SecurityInfoSource,
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum OnDemandSecurityPublishOutcome {
22    BasicPublished,
23    ZeroIdRepaired,
24    CompleteMktIdUpdated,
25    Rejected,
26}
27
28/// 静态数据缓存
29pub struct StaticDataCache {
30    /// 股票静态信息: "market_code" → info.
31    ///
32    /// 三索引写入只能通过 `upsert_full_security_info` /
33    /// `upsert_basic_security_info` / `delete_security_info`,避免 caller
34    /// 绕过 `id_to_key` / `owner_to_warrants` 维护。
35    securities: DashMap<String, Arc<CachedSecurityInfo>>,
36    /// 精确 stock_id → info 视图。
37    ///
38    /// C++ `SearchSecByID` 保留 `no_search` venue 行,而 public
39    /// `SearchSecByCode` 会过滤这些行。同一个 crypto code 因此可以同时拥有
40    /// composite 与多个 venue 身份,不能共用 `securities` 的 code key。
41    securities_by_stock_id: DashMap<u64, Arc<CachedSecurityInfo>>,
42    /// stock_id → "market_code" key (反向映射,用于推送时查找)
43    ///
44    id_to_key: DashMap<u64, String>,
45    /// stock_id → public request aliases discovered by on-demand lookup.
46    ///
47    /// C++ `SearchSecByCode` keeps code lookup separate from exact
48    /// `SearchSecByID`: an on-demand spelling such as `.SPX` may resolve to the
49    /// same exact row later published by stock-list as `SPX`. These aliases
50    /// therefore follow the exact stock-id identity without replacing its
51    /// canonical `id_to_key` entry or counting as another row in canonical
52    /// enumeration. Ref:
53    /// `NNBiz_Qot_SecList.cpp:472-529`, `SecListDBHelper.cpp:753-769`.
54    security_request_aliases: DashMap<u64, HashSet<String>>,
55    /// Public key -> exact positive stock ids that can answer that key.
56    ///
57    /// This is the in-memory equivalent of C++ SQLite `index_code`: canonical
58    /// keys and request aliases share one ordered candidate set, while
59    /// `no_search` remains an exact-row property filtered at lookup time.
60    /// All membership changes are serialized by `zero_id_repair` together
61    /// with `id_to_key` and `security_request_aliases`.
62    public_stock_ids_by_key: DashMap<String, BTreeSet<u64>>,
63    /// 期货主连/连续合约 push 路由别名: real/origin stock_id → main-link sec_key set.
64    ///
65    /// Backend 的实时 push 可能以真实月份合约 stock_id 下发,而客户端订阅的是
66    /// `HSImain` / `NQmain` 这类主连 symbol。C++ 的 QotSubscribe 用 stock_id
67    /// 级别的主连关系做回投;Rust 这里保留同样的数据驱动关系,来源仅限
68    /// stock-list 下发的 `origin_id` / `zhuli_id` 字段,不按 code 字符串特判。
69    future_main_link_aliases: DashMap<u64, HashSet<String>>,
70    /// option stock_id -> contract metadata from CMD20106 `OptionResultInfo`.
71    option_contracts: DashMap<u64, OptionContractInfo>,
72    /// Crypto sec_key -> 货币对元数据 (`cc_origin` / `cc_destination`)。
73    crypto_pairs: DashMap<String, CryptoPairInfo>,
74    /// 精确 stock_id -> 货币对元数据;public `crypto_pairs` 随 canonical
75    /// searchable row 投影,venue 删除不得误删 composite 元数据。
76    crypto_pairs_by_stock_id: DashMap<u64, CryptoPairInfo>,
77    /// `(broker_id, symbol, exchange)` -> crypto 交易配置。
78    crypto_trade_configs: DashMap<String, CryptoTradeConfig>,
79    /// 交易日: "market:year-month" → Vec<TradeDate>
80    pub trade_dates: DashMap<String, Vec<CachedTradeDate>>,
81    /// 板块: "market:plate_type" → Vec<PlateInfo>
82    pub plates: DashMap<String, Vec<CachedPlateInfo>>,
83    /// 窝轮正股 owner_id → 该正股对应的所有窝轮 stock_id 集合
84    ///
85    /// **v1.4.106 codex 1148 F6**: value 从 `Vec<u64>` 改为 `HashSet<u64>` 防
86    /// 重复 (SQLite reload + stock-list re-sync 同 warrant 多次 push 不会再重复).
87    /// stock-list `delete_flag` 时**反向索引清理**: 旧 owner 下移除旧 warrant
88    /// (`delete_security_info` 内部维护), update 时也维护。
89    owner_to_warrants: RwLock<std::collections::HashMap<u64, HashSet<u64>>>,
90
91    /// Serializes the bounded zero-id repair transaction across all cache
92    /// indices. The repair path runs only after a completed on-demand backend
93    /// response; no network operation occurs while this lock is held.
94    zero_id_repair: Mutex<()>,
95
96    /// Test-only work counter for the public identity candidate planner.
97    /// Counts inspected identity mappings, not planner calls, so scale tests
98    /// can distinguish indexed lookup from a hidden whole-cache scan.
99    #[cfg(test)]
100    public_security_candidate_inspections: AtomicU64,
101
102    /// v1.4.89 P2-A: 需要 mkt_id refresh 的 cache key 集合.
103    ///
104    /// Callers `get_security_info_trigger_refresh` 在返 info 前检查
105    /// `info.needs_mkt_id_refresh()`, 是则 mark key 到这里. 背景 worker
106    /// (gateway bridge) 定期 `drain_stale_mkt_ids()` 批量 CMD 20106 refresh.
107    ///
108    /// 用 DashMap<String, ()> 替代 HashSet<String> 免 lock 竞争.
109    pub stale_mkt_ids: DashMap<String, ()>,
110
111    /// v1.4.89 P2-A: mkt_id refresh 统计计数, 用于 metrics 观察.
112    ///
113    /// - `mkt_id_refresh_marked_total`: 累积 mark stale 次数
114    /// - `mkt_id_refresh_done_total`: 累积 backend CMD 20106 成功 refresh 次数
115    /// - `mkt_id_refresh_failed_total`: 累积 refresh failure 次数
116    pub mkt_id_refresh_marked_total: AtomicU64,
117    pub mkt_id_refresh_done_total: AtomicU64,
118    pub mkt_id_refresh_failed_total: AtomicU64,
119
120    /// Stock-list SQLite bootstrap / backend sync readiness diagnostics.
121    ///
122    /// C++ OpenD opens and validates its SecList DB before APIServer becomes
123    /// ready; Rust also needs a concrete readiness signal so static-data
124    /// comparisons do not race the background CMD6746 worker on cold start.
125    stock_list_sync: StockListSyncCounters,
126}
127
128impl StaticDataCache {
129    pub fn new() -> Self {
130        Self {
131            securities: DashMap::new(),
132            securities_by_stock_id: DashMap::new(),
133            id_to_key: DashMap::new(),
134            security_request_aliases: DashMap::new(),
135            public_stock_ids_by_key: DashMap::new(),
136            future_main_link_aliases: DashMap::new(),
137            option_contracts: DashMap::new(),
138            crypto_pairs: DashMap::new(),
139            crypto_pairs_by_stock_id: DashMap::new(),
140            crypto_trade_configs: DashMap::new(),
141            trade_dates: DashMap::new(),
142            plates: DashMap::new(),
143            owner_to_warrants: RwLock::new(std::collections::HashMap::new()),
144            zero_id_repair: Mutex::new(()),
145            #[cfg(test)]
146            public_security_candidate_inspections: AtomicU64::new(0),
147            stale_mkt_ids: DashMap::new(),
148            mkt_id_refresh_marked_total: AtomicU64::new(0),
149            mkt_id_refresh_done_total: AtomicU64::new(0),
150            mkt_id_refresh_failed_total: AtomicU64::new(0),
151            stock_list_sync: StockListSyncCounters::new(),
152        }
153    }
154
155    pub fn record_stock_list_sync_started(&self) {
156        self.stock_list_sync.record_started();
157    }
158
159    pub fn record_stock_list_sync_finished(
160        &self,
161        version: u64,
162        total_stocks: u64,
163        cached_count: u64,
164        finished_ms: u64,
165    ) {
166        self.stock_list_sync
167            .record_finished(version, total_stocks, cached_count, finished_ms);
168    }
169
170    pub fn record_stock_list_sync_failed(&self) {
171        self.stock_list_sync.record_failed();
172    }
173
174    pub fn record_stock_list_sync_recoverable_retry(&self) {
175        self.stock_list_sync.record_recoverable_retry();
176    }
177
178    pub fn stock_list_sync_status(&self) -> StockListSyncStatus {
179        self.stock_list_sync.status()
180    }
181
182    pub fn security_info_count(&self) -> usize {
183        let mut positive_ids = HashSet::new();
184        let mut zero_id_rows = 0usize;
185        for info in &self.securities {
186            if info.stock_id == 0 {
187                zero_id_rows += 1;
188            } else {
189                positive_ids.insert(info.stock_id);
190            }
191        }
192        positive_ids.len() + zero_id_rows
193    }
194
195    /// Clear the stock-list-backed security view and all derived indexes.
196    ///
197    /// Ref: C++ `NNBiz_Qot_SecList.cpp:729-737` clears the security cache
198    /// after a successful stock-list update. Rust uses this at the first
199    /// committed page of a version-zero full refresh before publishing that
200    /// page, so SQLite and the runtime view advance together.
201    pub fn clear_stock_list_security_info(&self) -> usize {
202        let _identity_write = match self.zero_id_repair.lock() {
203            Ok(guard) => guard,
204            Err(_) => return 0,
205        };
206        let removed = self.security_info_count();
207        self.securities.clear();
208        self.securities_by_stock_id.clear();
209        self.id_to_key.clear();
210        self.security_request_aliases.clear();
211        self.public_stock_ids_by_key.clear();
212        self.future_main_link_aliases.clear();
213        self.option_contracts.clear();
214        self.crypto_pairs.clear();
215        self.crypto_pairs_by_stock_id.clear();
216        self.stale_mkt_ids.clear();
217        if let Ok(mut owners) = self.owner_to_warrants.write() {
218            owners.clear();
219        }
220        removed
221    }
222
223    pub fn stock_list_readiness(&self) -> StaticDataReadiness {
224        self.stock_list_sync_status()
225            .readiness_for_security_count(self.security_info_count())
226    }
227
228    /// v1.4.89 P2-A: 取 cache info 同时机会性 mark stale (若 mkt_id=0).
229    ///
230    /// 返 Some(info) 如果 cache hit (无论是否 stale). 返 None 如果 miss.
231    ///
232    /// 调 `info.needs_mkt_id_refresh()` 判 stale → mark `stale_mkt_ids`,
233    /// bump `mkt_id_refresh_marked_total` counter. 用 DashMap::insert 幂等
234    /// (同 key 重入不 double mark 但会 bump counter — 可接受).
235    ///
236    /// 替代 `get_security_info` 的推荐路径; 老 method 保留作 lookup-only 接口.
237    pub fn get_security_info_trigger_refresh(&self, key: &str) -> Option<CachedSecurityInfo> {
238        let info = self.get_security_info(key)?;
239        if info.needs_mkt_id_refresh() {
240            self.mark_stale_mkt_id(key);
241        }
242        Some(info)
243    }
244
245    /// v1.4.89 P2-A: 显式 mark key 需要 mkt_id refresh.
246    ///
247    /// 幂等: 同 key 可重入. Counter `mkt_id_refresh_marked_total` 每次都 bump
248    /// (用作 metrics 观察 heuristic fallback 触发频率).
249    pub fn mark_stale_mkt_id(&self, key: &str) {
250        self.stale_mkt_ids.insert(key.to_string(), ());
251        self.mkt_id_refresh_marked_total
252            .fetch_add(1, Ordering::Relaxed);
253    }
254
255    /// v1.4.89 P2-A: drain 所有 stale keys, 清空集合, 返 Vec (给 backend worker
256    /// 批量 CMD 20106 refresh).
257    ///
258    /// 背景 worker 用法 (伪码):
259    /// ```text
260    /// loop {
261    ///     sleep(Duration::from_secs(60)).await;
262    ///     let stale = cache.drain_stale_mkt_ids();
263    ///     if stale.is_empty() { continue; }
264    ///     for chunk in stale.chunks(50) {
265    ///         // CMD 20106 SecuritiesReq for chunk
266    ///         // on success: cache.update_mkt_id(key, new_mkt_id)
267    ///         //              + cache.record_mkt_id_refresh_done()
268    ///         // on failure: cache.record_mkt_id_refresh_failed()
269    ///     }
270    /// }
271    /// ```
272    pub fn drain_stale_mkt_ids(&self) -> Vec<String> {
273        let keys: Vec<String> = self.stale_mkt_ids.iter().map(|e| e.key().clone()).collect();
274        for k in &keys {
275            self.stale_mkt_ids.remove(k);
276        }
277        keys
278    }
279
280    /// v1.4.89 P2-A: 更新已 cache row 的 mkt_id (refresh success 时调).
281    ///
282    /// 只改 mkt_id 字段, 其他字段保留 (info 可能有 SQLite 里更精准的 lot_size /
283    /// list_time 等). 若 key 不在 cache (已被 evict), no-op.
284    pub fn update_mkt_id(&self, key: &str, new_mkt_id: u32) -> bool {
285        let _identity_write = match self.zero_id_repair.lock() {
286            Ok(guard) => guard,
287            Err(_) => return false,
288        };
289        self.update_mkt_id_locked(key, new_mkt_id)
290    }
291
292    fn update_mkt_id_locked(&self, key: &str, new_mkt_id: u32) -> bool {
293        let Some(public) = self
294            .securities
295            .get(key)
296            .map(|entry| Arc::clone(entry.value()))
297        else {
298            return false;
299        };
300
301        if public.stock_id == 0 {
302            if let Some(mut entry) = self.securities.get_mut(key) {
303                Arc::make_mut(&mut entry).mkt_id = new_mkt_id;
304            } else {
305                return false;
306            }
307        } else {
308            let Some(mut exact) = self.securities_by_stock_id.get_mut(&public.stock_id) else {
309                return false;
310            };
311            Arc::make_mut(&mut exact).mkt_id = new_mkt_id;
312            drop(exact);
313            self.refresh_public_keys_for_stock_id_locked(public.stock_id);
314        }
315
316        self.mkt_id_refresh_done_total
317            .fetch_add(1, Ordering::Relaxed);
318        true
319    }
320
321    /// v1.4.89 P2-A: 记录 refresh failure (不改 cache row, 让下次 drain 重试).
322    pub fn record_mkt_id_refresh_failed(&self) {
323        self.mkt_id_refresh_failed_total
324            .fetch_add(1, Ordering::Relaxed);
325    }
326
327    /// v1.4.89 P2-A: 当前 stale keys 数 (给 observability / debug).
328    #[must_use]
329    pub fn stale_mkt_ids_count(&self) -> usize {
330        self.stale_mkt_ids.len()
331    }
332
333    fn add_owner_relation(&self, info: &CachedSecurityInfo) {
334        if info.warrnt_stock_owner == 0 {
335            return;
336        }
337        if let Ok(mut map) = self.owner_to_warrants.write() {
338            map.entry(info.warrnt_stock_owner)
339                .or_default()
340                .insert(info.stock_id);
341        }
342    }
343
344    fn remove_owner_relation(&self, info: &CachedSecurityInfo) {
345        let owner = info.warrnt_stock_owner;
346        if owner == 0 {
347            return;
348        }
349        if info.stock_id == 0
350            && self
351                .securities
352                .iter()
353                .any(|candidate| candidate.stock_id == 0 && candidate.warrnt_stock_owner == owner)
354        {
355            return;
356        }
357        if let Ok(mut map) = self.owner_to_warrants.write()
358            && let Some(set) = map.get_mut(&owner)
359        {
360            set.remove(&info.stock_id);
361            if set.is_empty() {
362                map.remove(&owner);
363            }
364        }
365    }
366
367    fn remove_future_main_link_aliases_if_unreferenced(
368        &self,
369        key: &str,
370        removed: &CachedSecurityInfo,
371    ) {
372        for target in Self::future_main_link_target_ids(removed) {
373            let still_referenced = self.id_to_key.iter().any(|mapped| {
374                mapped.value() == key
375                    && self
376                        .securities_by_stock_id
377                        .get(mapped.key())
378                        .is_some_and(|info| {
379                            Self::future_main_link_target_ids(&info).contains(&target)
380                        })
381            });
382            if still_referenced {
383                continue;
384            }
385            if let Some(mut aliases) = self.future_main_link_aliases.get_mut(&target) {
386                aliases.remove(key);
387                let empty = aliases.is_empty();
388                drop(aliases);
389                if empty {
390                    self.future_main_link_aliases.remove(&target);
391                }
392            }
393        }
394    }
395
396    fn future_main_link_target_ids(info: &CachedSecurityInfo) -> Vec<u64> {
397        let mut ids = Vec::with_capacity(2);
398        for target in [info.future_origin_id, info.zhuli_id] {
399            if target != 0 && target != info.stock_id && !ids.contains(&target) {
400                ids.push(target);
401            }
402        }
403        ids
404    }
405
406    fn add_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
407        for target in Self::future_main_link_target_ids(info) {
408            self.future_main_link_aliases
409                .entry(target)
410                .or_default()
411                .insert(key.to_string());
412        }
413    }
414
415    fn remove_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
416        for target in Self::future_main_link_target_ids(info) {
417            if let Some(mut aliases) = self.future_main_link_aliases.get_mut(&target) {
418                aliases.remove(key);
419                let empty = aliases.is_empty();
420                drop(aliases);
421                if empty {
422                    self.future_main_link_aliases.remove(&target);
423                }
424            }
425        }
426    }
427
428    /// 查询某个 backend push stock_id 对应的主连/连续合约 sec_key 别名。
429    ///
430    /// 只返回 stock-list 明确下发 `origin_id` / `zhuli_id` 关系的 key;不做
431    /// `HSImain` 等字符串启发式。调用方通常先按 `id_to_key` 处理真实合约,
432    /// 再把这里返回的 main-link key 一并投递。
433    #[must_use]
434    pub fn get_future_main_link_alias_keys(&self, stock_id: u64) -> Vec<String> {
435        let Some(aliases) = self.future_main_link_aliases.get(&stock_id) else {
436            return Vec::new();
437        };
438        let mut keys: Vec<String> = aliases.iter().cloned().collect();
439        keys.sort();
440        keys
441    }
442
443    /// 查询 backend push stock_id 的所有 quote 投递目标。
444    ///
445    /// 顺序保持为:真实 stock_id 对应 key(如有)优先,然后是 stock-list
446    /// `origin_id` / `zhuli_id` 下发的主连/连续合约别名 key。调用方不再直接
447    /// 读取 `id_to_key` 和 `future_main_link_aliases` 两个索引,避免 alias 逻辑
448    /// 分散在 push parser 里。
449    #[must_use]
450    pub fn quote_push_targets_for_stock_id(
451        &self,
452        stock_id: u64,
453    ) -> Vec<(String, Arc<CachedSecurityInfo>)> {
454        let mut targets = Vec::new();
455
456        if let Some(sec_key_ref) = self.id_to_key.get(&stock_id) {
457            let sec_key = sec_key_ref.clone();
458            drop(sec_key_ref);
459            if let Some(info) = self
460                .securities_by_stock_id
461                .get(&stock_id)
462                .map(|entry| Arc::clone(entry.value()))
463            {
464                targets.push((sec_key, info));
465            }
466        }
467
468        for alias_key in self.get_future_main_link_alias_keys(stock_id) {
469            if targets.iter().any(|(key, _)| key == &alias_key) {
470                continue;
471            }
472            if let Some(info) = self.get_security_info_arc(&alias_key) {
473                targets.push((alias_key, info));
474            }
475        }
476
477        targets
478    }
479
480    /// **v1.4.110 Phase 2 Slice 5**: broker-aware 推送投递目标查询.
481    ///
482    /// 对应 push parser 从 `SecurityQuote.broker_id` 重建 broker-aware
483    /// `QotStockKey` 的路径 (对齐 C++ `NNBiz_Qot_PushQot.cpp:220-269`).
484    ///
485    /// 语义:
486    /// - `broker_id = None` (C++ `m_hasBroker=false`): 只查 no-broker key,
487    ///   返 `QotSecurityKey::no_broker(public_sec_key, stock_id)` 与
488    ///   `quote_push_targets_for_stock_id` 等价
489    /// - `broker_id = Some(N)` (C++ `m_hasBroker=true`): 沿 stock_id 反向
490    ///   找到 public_sec_key, 再用 `QotSecurityKey::from_broker_id(...)`
491    ///   构造 broker-aware key. 该 broker 下 cache 写入独立桶
492    ///   `"market_code@b{N}"`, 不污染同 stock_id 其他 broker 的 cache.
493    ///
494    /// **Phase 2 默认**: backend 当前对普通股 push 仍不下发 broker_id (=None),
495    /// 与升级前行为完全等价. crypto multi-broker push 会带 broker_id,
496    /// Phase 3 reader caller (handler `GetBasicQot` 等) 替换走 `_broker`
497    /// 版本后, broker-aware cache 才被消费.
498    #[must_use]
499    pub fn quote_push_targets_for_stock_key(
500        &self,
501        stock_id: u64,
502        broker_id: Option<std::num::NonZeroU32>,
503    ) -> Vec<(
504        futu_core::qot_stock_key::QotSecurityKey,
505        Arc<CachedSecurityInfo>,
506    )> {
507        // 先用 no-broker 路径查 stock_id → (public_sec_key, info) 列表
508        // (id_to_key + future_main_link_aliases). broker_id 注入到返 key
509        // 不改变 lookup 逻辑.
510        let bare = self.quote_push_targets_for_stock_id(stock_id);
511        bare.into_iter()
512            .map(|(public_sec_key, info)| {
513                let key = match broker_id {
514                    Some(nz) => futu_core::qot_stock_key::QotSecurityKey::from_broker_id(
515                        public_sec_key,
516                        stock_id,
517                        nz.get(),
518                    ),
519                    None => futu_core::qot_stock_key::QotSecurityKey::no_broker(
520                        public_sec_key,
521                        stock_id,
522                    ),
523                };
524                (key, info)
525            })
526            .collect()
527    }
528
529    /// **deprecated**: 改用 `upsert_full_security_info` /
530    /// `upsert_basic_security_info` 显式表达数据完整度。
531    ///
532    /// v1.4.111 codex legacy deep-dive follow-up: 保留此 public wrapper 兼容
533    /// 既有 tests/bench/下游辅助代码,但不再直接写 `securities` 造成半索引行。
534    /// `source.is_complete()` 走 full upsert, 否则走 basic upsert, 始终维护
535    /// `id_to_key` / `owner_to_warrants` 与主表一致。
536    ///
537    /// Removal trigger: repo 内 tests/benches 全部迁移到显式 upsert/delete API,
538    /// 且一个 minor release 内没有下游兼容反馈后删除。
539    #[deprecated(
540        since = "1.4.106",
541        note = "use upsert_full_security_info / upsert_basic_security_info / delete_security_info"
542    )]
543    pub fn set_security_info(&self, key: &str, info: CachedSecurityInfo) {
544        if info.source.is_complete() {
545            self.upsert_full_security_info(key, info);
546        } else {
547            self.upsert_basic_security_info(key, info);
548        }
549    }
550
551    pub fn get_security_info(&self, key: &str) -> Option<CachedSecurityInfo> {
552        self.get_security_info_arc(key)
553            .map(|info| info.as_ref().clone())
554    }
555
556    pub fn get_security_info_arc(&self, key: &str) -> Option<Arc<CachedSecurityInfo>> {
557        self.securities.get(key).map(|v| Arc::clone(v.value()))
558    }
559
560    pub fn security_id_for_key(&self, key: &str) -> Option<u64> {
561        self.get_security_info(key)
562            .map(|info| info.stock_id)
563            .filter(|stock_id| *stock_id > 0)
564    }
565
566    pub fn security_info_snapshot_matching(
567        &self,
568        mut predicate: impl FnMut(&CachedSecurityInfo) -> bool,
569    ) -> Vec<CachedSecurityInfo> {
570        let mut seen_positive_ids = HashSet::new();
571        self.securities
572            .iter()
573            .filter_map(|entry| {
574                let info = entry.value();
575                if !predicate(info.as_ref())
576                    || (info.stock_id > 0 && !seen_positive_ids.insert(info.stock_id))
577                {
578                    return None;
579                }
580                Some(info.as_ref().clone())
581            })
582            .collect()
583    }
584
585    pub fn security_key_by_stock_id(&self, stock_id: u64) -> Option<String> {
586        self.id_to_key.get(&stock_id).map(|key| key.value().clone())
587    }
588
589    /// 通过 stock_id 查找股票信息 (使用 id_to_key 反向映射)
590    pub fn get_security_info_by_stock_id(&self, stock_id: u64) -> Option<CachedSecurityInfo> {
591        self.securities_by_stock_id
592            .get(&stock_id)
593            .map(|info| info.as_ref().clone())
594    }
595
596    pub fn get_security_info_by_stock_id_trigger_refresh(
597        &self,
598        stock_id: u64,
599    ) -> Option<CachedSecurityInfo> {
600        let info = self.get_security_info_by_stock_id(stock_id)?;
601        if info.needs_mkt_id_refresh()
602            && let Some(key) = self.security_key_by_stock_id(stock_id)
603        {
604            self.mark_stale_mkt_id(&key);
605        }
606        Some(info)
607    }
608
609    /// 添加窝轮→正股的映射关系
610    ///
611    /// **v1.4.106 codex 1148 F6**: HashSet 自动去重, 重复 add 同 (warrant, owner)
612    /// 是 idempotent — SQLite reload + stock-list sync 不会重复入。
613    pub fn add_warrant_owner(&self, warrant_stock_id: u64, owner_stock_id: u64) {
614        if owner_stock_id == 0 {
615            return;
616        }
617        if let Ok(mut map) = self.owner_to_warrants.write() {
618            map.entry(owner_stock_id)
619                .or_default()
620                .insert(warrant_stock_id);
621        }
622    }
623
624    /// 通过正股 ID 搜索该正股的所有窝轮
625    ///
626    /// **v1.4.106 codex 1148 F6**: 内部 HashSet, 返 Vec (call site backward
627    /// compatible)。返序非确定 (HashSet 不保留 insertion order); call site 若
628    /// 需要稳定序应自己 sort。
629    ///
630    /// v1.4.111 P2-1 Tier 3 audit comment: warrant lookup helper — empty Vec =
631    /// "no warrants for this owner_stock_id" (legit "正股无窝轮"), 跟 C++
632    /// `SearchWarrantsByOwner` empty 行为对齐. caller decide 怎么用 (display 0
633    /// warrants 是合理). 非 silent-success risk (audit verified, essentials/
634    /// 2026-05-27).
635    #[must_use]
636    pub fn search_warrants_by_owner(&self, owner_stock_id: u64) -> Vec<u64> {
637        match self.owner_to_warrants.read() {
638            Ok(map) => map
639                .get(&owner_stock_id)
640                .map(|set| set.iter().copied().collect())
641                .unwrap_or_default(),
642            _ => Vec::new(),
643        }
644    }
645}
646
647impl Default for StaticDataCache {
648    fn default() -> Self {
649        Self::new()
650    }
651}
652
653#[cfg(test)]
654mod tests;