futu_cache/static_data/types.rs
1/// v1.4.106 codex 1148 F7 (P2): cache row "完整度 / 来源" 标记。
2///
3/// 此 enum 区分一个 `CachedSecurityInfo` 是从 stock-list 完整 sync 来的
4/// (字段全), 还是 subscribe on-demand CMD 20106 临时补的 (大量字段为空)。
5/// `GetStaticInfo` / `GetSecuritySnapshot` / `make_static_info` 等需要完整
6/// 静态字段的路径在收到 `OnDemandBasic` 行时**应**触发完整 stock-list /
7/// 20106 completed refresh, 或明确返 partial / unavailable, 不假装完整数据。
8///
9/// 来源 audit: `codex/2026-05-01-1148-v1.4.106-codex-qot-static-data-review.md`
10/// Finding 7。
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum SecurityInfoSource {
13 /// 来自 backend stock-list full sync (含 list_time / warrnt_stock_owner
14 /// / exch_type / no_search 等所有字段, 是权威数据)。
15 StockListFull,
16 /// 来自 subscribe / GetStaticInfo / GetSecuritySnapshot 的 on-demand CMD 20106
17 /// 临时补行 — 仅含 stock_id / market / mkt_id / code / name / lot_size /
18 /// sec_type, 其余字段缺省 (list_time="", warrnt_stock_owner=0,
19 /// exch_type=0, no_search=false)。**不**应当作权威静态数据源。
20 ///
21 /// **作 Default**: 保守策略 — 历史 caller 漏标 source 时, 当 partial 处理
22 /// (is_complete()=false), 后续 stock_list_sync / Bootstrap 自动覆写为权威
23 /// 来源。这样 cache miss → on-demand fetch → 默认 source = OnDemandBasic
24 /// 不会被误当 StockListFull 权威数据消费。
25 #[default]
26 OnDemandBasic,
27 /// SQLite bootstrap reload (启动时从本地数据库恢复)。字段完整度同
28 /// `StockListFull` (因为 SQLite 是上次 full sync 的快照), 但**生命期更长**
29 /// (跨 daemon 重启)。一般 caller 当 `StockListFull` 处理。
30 Bootstrap,
31}
32
33impl SecurityInfoSource {
34 /// 是否含完整静态字段 (list_time / warrnt_stock_owner / exch_type /
35 /// no_search 等)。`OnDemandBasic` 缺这些, 其他 source 都齐。
36 #[must_use]
37 pub fn is_complete(self) -> bool {
38 matches!(self, Self::StockListFull | Self::Bootstrap)
39 }
40}
41
42/// 股票静态信息
43///
44/// **v1.4.106 codex F5**: 加 `Default` impl 让 caller 可用
45/// `CachedSecurityInfo { stock_id: ..., market: ..., ..Default::default() }`.
46/// 现有显式 callsite 不动, 新加 callsite 可省冗余字段.
47#[derive(Debug, Clone, Default)]
48pub struct CachedSecurityInfo {
49 pub stock_id: u64,
50 /// FTAPI QotMarket 值 (1=HK_Security, 11=US_Security, 21=CNSH, 22=CNSZ 等)
51 ///
52 /// 由 bridge.rs `market_code_to_qot_market()` 从 backend `market_code`
53 /// (NN_QuoteMktID) 映射而来; 用于构造 cache key `"{market}_{code}"`
54 pub market: i32,
55 /// Backend 原始 NN_QuoteMktID (market_code), **保留子交易所精度**
56 ///
57 /// 用于 cache-first 路由决策: 比如 CME Group 子交易所 (60=NYMEX, 70=COMEX,
58 /// 80=CBOT, 90=CME, 100=CBOE) 都被 `market_code_to_qot_market()` 统一压到
59 /// FTAPI QotMarket=11 (US_Security), 但 `mkt_id` 保留原始 range, 使
60 /// `derive_security_type` / `us_futures_sub_exchange` 等能做 O(1) 范围
61 /// 判别, 替代 CLAUDE.md 坑 #35 的 code-pattern 启发式. SQLite 历史行无此
62 /// 字段时读为 0 (默认 Unknown), 下次 upsert 自动修正.
63 pub mkt_id: u32,
64 pub code: String,
65 pub name: String,
66 pub lot_size: i32,
67 pub sec_type: i32,
68 /// Backend `CSStockItem.spread_table_code` (field 13).
69 ///
70 /// C++ `Ndt_Qot_SecInfo::nSpreadCode` feeds
71 /// `INNBiz_Qot_Spread::AdjustPrice` for snapshot `priceSpread` and trade
72 /// price-grid validation. `0` means unavailable in the local cache.
73 pub spread_table_code: u32,
74 /// Backend `CSStockItem.sub_instrument_type_v2` (field 69).
75 ///
76 /// C++ exposes this as `Ndt_Qot_SecInfo::enSubTypeV2`. v10.6
77 /// `GetCompanyProfile` uses it to decide whether Trust securities route to
78 /// F10 CompanyDetail or HK ETF profile.
79 pub sub_instrument_type_v2: u32,
80 pub list_time: String,
81 /// 窝轮所属正股 ID, 0 表示无
82 pub warrnt_stock_owner: u64,
83 /// C++ internal `NN_QotWarrantType` from stock-list `warrnt_type`.
84 ///
85 /// Public `Qot_Common.WarrantType` only exposes 1-5. C++ stock-list also
86 /// carries DLC Long/Short as 6/7, and `GetReference(WARRANT)` explicitly
87 /// filters those out before returning `SecurityStaticInfo`.
88 pub warrant_type: i32,
89 /// 是否已退市
90 pub delisting: bool,
91 /// 交易所类型 (ExchType)
92 pub exch_type: i32,
93 /// 不可搜索标记 (对齐 C++ no_search 字段)
94 pub no_search: bool,
95 /// v1.4.106 codex F5: 期货主连合约关联 stock_id.
96 ///
97 /// 主连合约 (e.g. `HSImain`, `NQmain`) 此字段非 0, 指向真实月份合约
98 /// stock_id (CMD 6747 拉真实 code 用); 普通合约此字段为 0.
99 ///
100 /// 来源: backend stock_list_sync `CSStockItem.origin_id` (proto field 41).
101 /// PlaceOrder F5 用 `info.future_origin_id != 0` 判 NeedPullFutureContractInfo
102 /// (对齐 C++ `Ndt_Qot_SecInfo::nFutureOriginID` + `APIServer_Trd_PlaceOrder
103 /// .cpp:36-42` `NeedPullFutureContractInfo`).
104 ///
105 /// SQLite 历史行无此列时默认 0, 下次 stock_list sync (CMD 6746) 重新写入.
106 pub future_origin_id: u64,
107 /// v1.4.106 codex F5: 期货主力合约 stock_id.
108 ///
109 /// 主连合约持有此字段, 指向当前主力合约 stock_id (流动性最大). PlaceOrder
110 /// F5 在收到 CMD 6747 响应后用此字段反查真实 code 再下单.
111 ///
112 /// 来源: backend `CSStockItem.zhuli_id` (proto field 40). 普通合约 0.
113 pub zhuli_id: u64,
114 /// v1.4.106 codex 1148 F7 (P2): 数据来源 / 完整度标记。详见
115 /// `SecurityInfoSource` doc。**默认 `OnDemandBasic`** 以保守对待历史 caller
116 /// 漏标场景 (能漏标的就是没填全字段, 当 partial 处理最安全)。
117 pub source: SecurityInfoSource,
118 /// v1.4.110 final E.5 P2#6: 交易所字符串 (proto `CSStockItem.exchange` field 57).
119 ///
120 /// 数据驱动 (pitfall #35 C++ 数据驱动 vs Rust 启发式): trade handler
121 /// `derive_exchange_str` 优先用此字段, 缺失时 fallback 到 code prefix /
122 /// pattern match heuristic. 取值例: "SEHK", "NASDAQ", "NYSE", "SSE", "SZSE", "HKEX".
123 pub exchange: String,
124 /// v1.4.110 final E.5 P2#6: 上市交易所 (proto `CSStockItem.listed_exchange` field 74).
125 ///
126 /// 通常等同 `exchange`, dual-listed ADR / 双重主要上市等场景下可能不同.
127 pub listed_exchange: String,
128}
129
130impl CachedSecurityInfo {
131 /// v1.4.89 P2-A: 判断 cache row 是否需要后台 mkt_id refresh.
132 ///
133 /// **场景**: SQLite 历史行 (v1.4.77 Phase D 之前 upsert) 没有 `mkt_id` 字段,
134 /// 读取时默认为 0. 这导致 cache-first 路由走 heuristic fallback (坑 #35).
135 ///
136 /// 返 `true` 即需要 refresh; 如果 `mkt_id == 0 && market != 0`, 是历史行
137 /// (有基本 market 但缺 sub-exchange 精度).
138 ///
139 /// **使用**: `StaticDataCache::mark_stale_mkt_id` 在 get 时 opportunistic
140 /// mark stale, 背景 worker 批量 CMD 20106 refresh.
141 #[must_use]
142 pub fn needs_mkt_id_refresh(&self) -> bool {
143 self.mkt_id == 0 && self.market != 0
144 }
145
146 /// v1.4.106 codex 1148 F7: 是否含完整静态字段 (基于 source). 调用方需要
147 /// `list_time` / `warrnt_stock_owner` / `exch_type` 等字段时, 应先 check
148 /// 此 flag, false 时触发 backend 完整 refresh 或返 partial marker。
149 #[must_use]
150 pub fn is_complete(&self) -> bool {
151 self.source.is_complete()
152 }
153}
154
155/// Crypto 交易币对元数据。
156///
157/// C++ `NNProto_Trd_MaxQtyCrypto.cpp` 通过 `INNBiz_Qot_SecList::SearchSecByID`
158/// 读取 `Ndt_Qot_SecInfo::szCCOrigin/szCCDestination`,并把它们分别写入
159/// backend `GetMaxBuySellReq.coin/currency`。Rust 从 stock-list field 60/61
160/// 接入同一份数据,避免在交易路径按 symbol 字符串拆币种。
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
162pub struct CryptoPairInfo {
163 pub cc_origin: String,
164 pub cc_destination: String,
165}
166
167/// Option contract static metadata from CMD20106 `OptionResultInfo`.
168///
169/// C++ stores these fields on `Ndt_Qot_SecInfo` (`nSecID_OptionOwner`,
170/// `nOptionStrikeTime`, `nOptionStrikePrice`, `enOptionType`) and uses them when
171/// projecting snapshot option extra data. Rust keeps the metadata separate from
172/// `CachedSecurityInfo` because stock-list sync does not carry the full option
173/// tuple; CMD20106 on-demand refresh is the authoritative source.
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
175pub struct OptionContractInfo {
176 pub underlying_stock_id: u64,
177 pub strike_date: u64,
178 /// C++ `Ndt_Qot_SecInfo::nOptionLastTradeTime`, sourced from
179 /// `stock_information.OptionResultInfo.real_expiration_time` field 36.
180 ///
181 /// `0` means backend did not return it; callers should fall back to
182 /// `strike_date`, matching C++ `APIServer_Qot_OptionQuote.cpp`.
183 pub real_expiration_time: u64,
184 pub strike_price: u64,
185 pub option_type: u32,
186 /// Backend `stock_information.OptionResultInfo.index_option_type` field 50.
187 ///
188 /// Only HSI/GQI index options use this as a backend query discriminator;
189 /// `0` means absent or not applicable.
190 pub index_option_type: i32,
191}
192
193/// Crypto 交易配置缓存。
194///
195/// C++ `INNData_Trd_CryptoTradeConfig` 以 `(broker_id, symbol, exchange)` 作为
196/// lookup key,MaxBuySellQty 投影用 `minimum_qty` 和 `base_tick_size` 做数量
197/// 步进对齐。这里保存解析后的 nano 整数,避免每个 surface 自己解析 decimal。
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct CryptoTradeConfig {
200 pub symbol: String,
201 pub exchange: String,
202 pub pi_only: bool,
203 pub tick_size_nano: i64,
204 pub min_trade_qty_nano: i64,
205 pub max_trade_qty_nano: i64,
206 pub qty_increment_nano: i64,
207}
208
209/// 交易日
210#[derive(Debug, Clone)]
211pub struct CachedTradeDate {
212 pub time: String,
213 pub timestamp: f64,
214}
215
216/// 板块信息
217#[derive(Debug, Clone)]
218pub struct CachedPlateInfo {
219 pub market: i32,
220 pub code: String,
221 pub name: String,
222 pub plate_type: i32,
223}