Skip to main content

futu_cache/
security_resolver.rs

1//! v1.4.106 codex 1148 F5 (P2): 统一 stock_id ↔ Security 双向解析入口。
2//!
3//! C++ 对照:
4//! - `APIServer_Inner_API.cpp:604` `GetStockID(Security, &stock_id)` — Security → stock_id
5//! - `APIServer_Inner_API.cpp:669` `GetAPIStock(stock_id, &Security)` — stock_id → Security
6//! - 两者底层都查 `INNBiz_Qot_SecList::SearchSecBy{Code,ID}`, 失败语义清晰
7//!   (`enReturnRet != Ok` 即 fail), 不 silent drop。
8//!
9//! Rust 既有实装把 `id_to_key` / `securities` 直接 `pub` 出去, push parser /
10//! stock-filter / warrant / reference 各自查 + 各自处理 miss, **静默 drop**
11//! (filter_map / continue / 空 list 返客户端) → 后端返合法 stock_id 时无法
12//! 区分 "本地 cache miss" vs "后端真返空", 难调试 + 用户感知差。
13//!
14//! 本 module 提供:
15//! 1. **bidir API** — `resolve_stock_id_by_security` / `resolve_security_by_stock_id`
16//! 2. **loud miss** — miss 时 bump counter (`resolver_miss_total`), 加 stale
17//!    mark (复用 mkt_id refresh 路径)
18//! 3. **明确返 enum 区分 hit / miss / unsupported** — caller 必处理 miss
19//!    (要么 reject, 要么 partial marker, 要么 trigger refresh)
20//!
21//! Caller 重构 (后续 PR):
22//! - `bridge/push_parser.rs` quote push → resolver miss 时 counter + 不 silent drop
23//! - `handlers/qot/stock_filter.rs` → resolver miss 时返 partial 而非空 list
24//! - `handlers/qot/warrant.rs` → reverse-lookup miss 时 reject (与 owner forward
25//!   miss 一致)
26//! - `handlers/qot/misc.rs` → reverse-lookup miss 时 reject
27
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31use crate::static_data::{CachedSecurityInfo, StaticDataCache};
32
33/// stock_id ↔ Security 解析失败的原因。
34///
35/// 调用方据此选择 `Reject` (loud error) / `Refresh` (重 trigger) /
36/// `PartialResponse` (返 partial marker)。
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ResolverErr {
39    /// `stock_id` 不在反向索引 (`id_to_key`)。可能是:
40    /// - stock-list 还没 sync 到 (启动早期 / SQLite 丢失)
41    /// - stock_id 是后端新枚举值, 本地 cache 没收到 push
42    StockIdNotInCache(u64),
43    /// `Security` (market+code) 不在正向索引 (`securities`)。
44    /// - Symbol 还没 subscribe (on-demand fetch 没触发)
45    /// - Code 拼写错 (`HK.00700` vs `1_00700`)
46    SecurityNotInCache { market: i32, code: String },
47    /// `Security.market` 不是 daemon 支持的 enum 值 (e.g. 0 / 99)。
48    UnsupportedMarket(i32),
49    /// `stock_id == 0` — backend 返空 ID, 这是 silent-success bug 信号。
50    StockIdZero,
51}
52
53impl std::fmt::Display for ResolverErr {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            ResolverErr::StockIdNotInCache(id) => {
57                write!(f, "stock_id={id} not in cache (id_to_key miss)")
58            }
59            ResolverErr::SecurityNotInCache { market, code } => {
60                write!(f, "Security {market}.{code} not in cache (securities miss)")
61            }
62            ResolverErr::UnsupportedMarket(m) => write!(f, "unsupported market={m}"),
63            ResolverErr::StockIdZero => write!(f, "stock_id=0 (backend returned empty id)"),
64        }
65    }
66}
67
68impl std::error::Error for ResolverErr {}
69
70/// v1.4.106 codex 1148 F5 (P2): 统一 stock_id ↔ Security 双向解析。
71///
72/// 使用 `Arc<StaticDataCache>` 作为底层存储, 不重复持有 cache state — 与现
73/// 有 cache 完全 inter-op。可在 handler 构造时 `Arc::new(SecurityResolver::new(
74/// cache.clone()))` 持有, handler 全程调 resolver method 而不直接 touch
75/// cache 字段。
76pub struct SecurityResolver {
77    cache: Arc<StaticDataCache>,
78    /// 累计 resolver miss 次数 (forward + reverse 合计)。给 metrics observability。
79    resolver_miss_total: AtomicU64,
80    /// 累计 resolver hit 次数。
81    resolver_hit_total: AtomicU64,
82}
83
84impl SecurityResolver {
85    pub fn new(cache: Arc<StaticDataCache>) -> Self {
86        Self {
87            cache,
88            resolver_miss_total: AtomicU64::new(0),
89            resolver_hit_total: AtomicU64::new(0),
90        }
91    }
92
93    /// 借用底层 cache (caller 仍能直接 query 其他 method, 比如 trade_dates)。
94    pub fn cache(&self) -> &Arc<StaticDataCache> {
95        &self.cache
96    }
97
98    /// 累计 miss 数。
99    pub fn miss_total(&self) -> u64 {
100        self.resolver_miss_total.load(Ordering::Relaxed)
101    }
102
103    /// 累计 hit 数。
104    pub fn hit_total(&self) -> u64 {
105        self.resolver_hit_total.load(Ordering::Relaxed)
106    }
107
108    /// **C++ GetStockID 等价**: `Security` → `stock_id`。
109    ///
110    /// Cache hit + `info.stock_id > 0` → `Ok(stock_id)`;
111    /// 其他情况返 `ResolverErr` (caller 必处理)。
112    ///
113    /// 同时**触发 mkt_id refresh mark** (若 hit 但 mkt_id stale)。
114    pub fn resolve_stock_id_by_security(
115        &self,
116        security: &SecurityRef<'_>,
117    ) -> Result<u64, ResolverErr> {
118        if security.market <= 0 {
119            self.bump_miss();
120            return Err(ResolverErr::UnsupportedMarket(security.market));
121        }
122        let key = format!("{}_{}", security.market, security.code);
123        match self.cache.get_security_info_trigger_refresh(&key) {
124            Some(info) if info.stock_id > 0 => {
125                self.bump_hit();
126                Ok(info.stock_id)
127            }
128            Some(_) => {
129                // info exists but stock_id=0 — 半索引行 / on-demand basic 写入失败
130                self.bump_miss();
131                Err(ResolverErr::StockIdZero)
132            }
133            None => {
134                self.bump_miss();
135                Err(ResolverErr::SecurityNotInCache {
136                    market: security.market,
137                    code: security.code.to_string(),
138                })
139            }
140        }
141    }
142
143    /// **C++ GetAPIStock 等价**: `stock_id` → `Security`。
144    ///
145    /// Cache hit (id_to_key 含 stock_id 且 securities 含对应 row) → `Ok((market, code))`;
146    /// 其他情况返 `ResolverErr`。
147    ///
148    /// 同时返 `info` 引用让 caller 复用 row 字段 (无需第二次 lookup)。
149    pub fn resolve_security_by_stock_id(
150        &self,
151        stock_id: u64,
152    ) -> Result<CachedSecurityInfo, ResolverErr> {
153        if stock_id == 0 {
154            self.bump_miss();
155            return Err(ResolverErr::StockIdZero);
156        }
157        match self.cache.get_security_info_by_stock_id(stock_id) {
158            Some(info) => {
159                // F4: market=0 不该出现在公开 API resolver 路径 (bridge 已 reject)
160                // 但 SQLite 历史 row 可能含, 这里防御
161                if info.market <= 0 {
162                    self.bump_miss();
163                    return Err(ResolverErr::UnsupportedMarket(info.market));
164                }
165                self.bump_hit();
166                Ok(info)
167            }
168            None => {
169                self.bump_miss();
170                Err(ResolverErr::StockIdNotInCache(stock_id))
171            }
172        }
173    }
174
175    fn bump_hit(&self) {
176        self.resolver_hit_total.fetch_add(1, Ordering::Relaxed);
177    }
178
179    fn bump_miss(&self) {
180        self.resolver_miss_total.fetch_add(1, Ordering::Relaxed);
181    }
182}
183
184/// 轻量 Security 引用 (避免 caller 必须用 prost-generated 类型)。
185#[derive(Debug, Clone, Copy)]
186pub struct SecurityRef<'a> {
187    pub market: i32,
188    pub code: &'a str,
189}
190
191impl<'a> SecurityRef<'a> {
192    pub fn new(market: i32, code: &'a str) -> Self {
193        Self { market, code }
194    }
195}
196
197#[cfg(test)]
198mod tests;