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