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