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;