futu_gateway_core/bridge/utils.rs
1//! bridge 工具函数(v1.4.60 Phase E1 从 bridge/mod.rs 抽出)
2//!
3//! 纯 utility 函数:无状态 / 无 `GatewayBridge` 依赖 / 无 async / 无 I/O。
4//! 统一 `pub(crate)` 让 bridge 子模块 + GatewayBridge 自己都能用。
5//!
6//! 包含:
7//! - `split_host_port` — "IP:port" 拆解
8//! - `listing_date_to_str` — stock-list 上市日时间戳 → HK/China 日期
9//! - `timestamp_to_datetime_str` — Unix 时间戳 → 市场本地日期时间
10//! - `qot_market_to_tz` — FTAPI QotMarket → IANA 时区(v1.4.68)
11//! - `market_code_to_qot_market` — backend NN_QuoteMktID → FTAPI QotMarket
12//! - `market_code_to_exch_type` — backend NN_QuoteMktID → FTAPI ExchType
13//! - `*_with_security` — market_code 未知时,用 stock_list 证券类型/code 做受控兜底
14//! - `security_firm_to_broker_id` — SecurityFirm → broker_id 反查
15
16/// 把 "IP:port" 字符串拆成 (IP, port)。失败时返回默认 port 9595。
17/// 用于 TCP 登录时上报 `ReqEncryptData.host_ip` / `host_port`。
18pub(crate) fn split_host_port(addr: &str) -> (String, u32) {
19 match addr.rsplit_once(':') {
20 Some((host, port_text)) if !host.is_empty() => {
21 let port = port_text.parse::<u32>().unwrap_or(9595);
22 (host.to_string(), port)
23 }
24 _ => (addr.to_string(), 9595),
25 }
26}
27
28// v1.4.69 A1: `qot_market_to_tz` 已迁移到 `futu_core::market::qot_market_to_tz`
29// (跨 crate 共享,bridge + qot handler 都 `use futu_core::market::qot_market_to_tz`)
30// 本 inline 实装删除,依 futu-core 单一 source of truth。
31pub(crate) use futu_core::market::qot_market_to_tz;
32use futu_core::market::{BackendMktId, QotMarketId};
33
34/// listing_date (Unix timestamp 秒) → "YYYY-MM-DD" HK/China 日期
35///
36/// C++ `GetStockStaticInfo` / `StockSnapshot` explicitly render stock-list
37/// `nListingDate` with `QotMarket_HK_Security`:
38/// “后台返回的上市时间都是中国时区转出来的”. Do not use the quote market timezone
39/// here; US static `list_time` would otherwise move one day earlier.
40pub fn listing_date_to_str(ts: u32, _market: i32) -> String {
41 // C++ 对 listing_date=0 也正常格式化为 "1970-01-01"(不跳过)
42 use chrono::DateTime;
43 let tz = qot_market_to_tz(1);
44 match DateTime::from_timestamp(ts as i64, 0) {
45 Some(dt) => dt.with_timezone(&tz).format("%Y-%m-%d").to_string(),
46 None => String::new(),
47 }
48}
49
50/// Unix 时间戳(秒) → "YYYY-MM-DD HH:MM:SS" 市场本地时区
51///
52/// v1.4.68 Bug fix:原实装 hardcode `+8*3600` HKT → push 行情 update_time
53/// 美股显示差 12-13h。对齐 C++ `APITimeStampToTimeStr_Qot`。
54pub fn timestamp_to_datetime_str(ts: f64, market: i32) -> String {
55 if ts <= 0.0 {
56 return String::new();
57 }
58 use chrono::DateTime;
59 let tz = qot_market_to_tz(market);
60 match DateTime::from_timestamp(ts as i64, 0) {
61 Some(dt) => dt
62 .with_timezone(&tz)
63 .format("%Y-%m-%d %H:%M:%S")
64 .to_string(),
65 None => String::new(),
66 }
67}
68
69// v1.4.68: days_to_ymd helper 已删除(bridge/utils.rs 里曾被 listing_date_to_str
70// + timestamp_to_datetime_str 用,v1.4.68 改用 chrono-tz 后不再需要 manual
71// Rata Die 算法。trd/max_qty.rs 仍有独立 copy 作 free cash flow 日期计算)。
72
73/// 后端 market_code (NN_QuoteMktID) → FTAPI QotMarket 映射
74/// 对齐 C++: NN_QuoteMktType_From_NN_QuoteMktID() → Market_NNToAPI()
75pub(crate) fn market_code_to_qot_market(market_code: u32) -> i32 {
76 market_code_to_qot_market_id(BackendMktId::new(market_code)).raw_i32()
77}
78
79/// Typed backend `NN_QuoteMktID` → public FTAPI `QotMarket`.
80pub(crate) fn market_code_to_qot_market_id(market_code: BackendMktId) -> QotMarketId {
81 QotMarketId::new(match market_code.raw_u32() {
82 // HK market: mainboard, gemboard, nasdaq, extend → QotMarket_HK_Security
83 1..=4 => 1,
84 // HK futures (old/new) → QotMarket_HK_Security (QotMarket_HK_Future=2 已废弃)
85 5 | 6 => 1,
86 // HK options (stock option, index option) → QotMarket_HK_Security
87 7 | 8 => 1,
88 // US market: NYSE, NASDAQ, AMEX, PINK, etc.
89 10..=29 => 11, // QotMarket_US_Security
90 // CN A-shares
91 30 | 32 | 33 | 34 | 36..=40 => 21, // SH, KCB → QotMarket_CNSH_Security
92 31 | 35 => 22, // SZ, CYB → QotMarket_CNSZ_Security
93 // US options
94 41..=49 => 11, // QotMarket_US_Security
95 // US futures: NYMEX, COMEX, CBOT, CME, CBOE
96 60..=109 => 11, // QotMarket_US_Security
97 // HK futures (new range)
98 110..=119 => 1, // QotMarket_HK_Security
99 // Forex
100 120..=123 => 81, // QotMarket_FX_Security
101 // SG futures
102 160..=179 => 31, // QotMarket_SG_Security
103 // SG cash
104 180..=184 => 31, // QotMarket_SG_Security
105 // JP futures
106 185..=194 => 41, // QotMarket_JP_Security
107 // Crypto / digital currency.
108 //
109 // Ref:
110 // - FutuOpenD/Src/NNBase/NNBase_Define_Enum.h:
111 // NN_QuoteMktID_Digital_CCY_Min=360,
112 // NN_QuoteMktID_Digital_CCY_Max=459.
113 // - FutuOpenD/Src/NNBase/NNBase_Define_Inline.h:
114 // NN_QuoteMktType_From_NN_QuoteMktID maps this range to
115 // NN_QuoteMktType_CRYPTO.
116 360..=459 => 91, // QotMarket_CC_Security
117 // HK index option
118 570 => 1, // QotMarket_HK_Security
119 // JP cash/index
120 800..=849 => 41, // QotMarket_JP_Security
121 // HK HSI index
122 1000..=1049 => 1, // QotMarket_HK_Security
123 // US new market codes
124 1200..=1249 => 11, // QotMarket_US_Security
125 // MY cash
126 1350..=1399 => 61, // QotMarket_MY_Security
127 _ => 0, // Unknown
128 })
129}
130
131const STOCK_LIST_SEC_TYPE_FUTURE: u32 = 10;
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum FuturesRegion {
135 Hk,
136 Us,
137 Sg,
138 Jp,
139}
140
141/// backend stock_list 里未知 market_code 的受控 fallback。
142///
143/// 正常路径仍以 `market_code_to_qot_market()` 为准;只有当旧 C++ enum / Rust
144/// 静态表不认识 market_code,且 `instrument_type=10(Future)` 时,才看 code。
145/// C++ 10.7 source defines `NN_QuoteMktID_MY_Futures_Min/Max = 1400..1449`,
146/// but does not map that range through `NN_QuoteMktType_From_NN_QuoteMktID`
147/// or `ExchType_NNToAPI`. Keep it fail-closed instead of resurrecting the
148/// older private-HK-future fallback.
149pub fn market_code_to_qot_market_with_security(
150 market_code: u32,
151 code: &str,
152 instrument_type: u32,
153) -> i32 {
154 let mapped = market_code_to_qot_market(market_code);
155 if mapped != 0 {
156 return mapped;
157 }
158
159 if instrument_type != STOCK_LIST_SEC_TYPE_FUTURE {
160 return 0;
161 }
162
163 if (1400..=1449).contains(&market_code) {
164 return 0;
165 }
166
167 match infer_futures_region_from_code(code) {
168 Some(FuturesRegion::Hk) => 1,
169 Some(FuturesRegion::Us) => 11,
170 Some(FuturesRegion::Sg) => 31,
171 Some(FuturesRegion::Jp) => 41,
172 None => 0,
173 }
174}
175
176/// 后端 market_code → FTAPI ExchType 映射
177/// 对齐 C++ `APIServer_Inner_API.cpp::ExchType_NNToAPI`.
178///
179/// v1.4.93 P1-3 (BUG-5318-003 / BUG-60b0-004 30+ 版未修): 补 US 期货子交易所
180/// (NYMEX/COMEX/CBOT/CME/CBOE) 映射. mkt_id range 对齐
181/// `handlers/trd/security_type::us_futures_sub_exchange_from_mkt_id`:
182/// - 60..=69 → NYMEX (ExchType_US_NYMEX=9)
183/// - 70..=79 → COMEX (ExchType_US_COMEX=10)
184/// - 80..=89 → CBOT (ExchType_US_CBOT=11)
185/// - 90..=99 → CME (ExchType_US_CME=12)
186/// - 100..=109 → CBOE (ExchType_US_CBOE=13)
187///
188/// 之前 fallthrough `_ => 0` (Unknown) 导致 snapshot.exch_type 永远 None
189/// (CLAUDE.md 坑 #45 silent-success 变种: cache miss 走 default 0 silent
190/// drop). cross 30+ 版未修 (v1.4.57 BUG-60b0-004 → v1.4.90 BUG-5318-003).
191pub(crate) fn market_code_to_exch_type(market_code: u32) -> i32 {
192 match market_code {
193 1 => 1, // HK MainBoard → ExchType_HK_MainBoard
194 2 => 2, // HK GEM → ExchType_HK_GEMBoard
195 3..=9 => 3, // HK other → ExchType_HK_HKEX
196 10 => 4, // US NYSE → ExchType_US_NYSE
197 11 => 5, // US Nasdaq → ExchType_US_Nasdaq
198 12 => 7, // US AMEX → ExchType_US_AMEX
199 13 => 6, // US OTC → ExchType_US_Pink
200 30 => 14, // CN SH → ExchType_CN_SH
201 31 | 35 => 15, // CN SZ / ChiNext → ExchType_CN_SZ
202 32 => 16, // CN KCB → ExchType_CN_STIB
203 41 => 8, // US Option → ExchType_US_Option
204 // v1.4.93 P1-3: US 期货子交易所 (BUG-5318-003)
205 60..=69 => 9, // US Futures NYMEX → ExchType_US_NYMEX
206 70..=79 => 10, // US Futures COMEX → ExchType_US_COMEX
207 80..=89 => 11, // US Futures CBOT → ExchType_US_CBOT
208 90..=99 => 12, // US Futures CME → ExchType_US_CME
209 100..=109 => 13, // US Futures CBOE → ExchType_US_CBOE
210 110..=119 => 3, // HK Futures → ExchType_HK_HKEX
211 1200 => 13, // US .VIX series → ExchType_US_CBOE
212 160..=184 => 17, // SG Futures/Cash → ExchType_SG_SGX
213 185..=194 => 18, // JP Futures → ExchType_JP_OSE
214 360..=459 => 19, // Digital CCY → ExchType_CC_CRYPTO
215 800..=829 => 22, // JP Nikkei → ExchType_JP_Nikkei
216 830..=849 => 21, // JP TSE → ExchType_JP_TSE
217 1350..=1399 => 20, // MY Securities → ExchType_MY_MYX
218 _ => 0, // Unknown
219 }
220}
221
222/// `market_code_to_exch_type()` 的证券上下文版本。
223///
224/// 用于 stock_list sync / SQLite reload 这类已有 `code + instrument_type` 的路径;
225/// 普通 handler 不应只拿 code 猜 exchange,应优先走 cache-first。
226pub fn market_code_to_exch_type_with_security(
227 market_code: u32,
228 code: &str,
229 instrument_type: u32,
230) -> i32 {
231 let mapped = market_code_to_exch_type(market_code);
232 if mapped != 0 {
233 return mapped;
234 }
235
236 if instrument_type != STOCK_LIST_SEC_TYPE_FUTURE {
237 return 0;
238 }
239
240 if (1400..=1449).contains(&market_code) {
241 return 0;
242 }
243
244 match infer_futures_region_from_code(code) {
245 Some(FuturesRegion::Hk) => 3,
246 Some(FuturesRegion::Sg) => 17,
247 Some(FuturesRegion::Jp) => 18,
248 // US futures 的子交易所需要 NYMEX/COMEX/CBOT/CME/CBOE 精细判断;
249 // 这里没有 backend market_code range 时不猜具体 ExchType,避免把
250 // `NQmain` 以外的合约误标。QotMarket 已可归 US,exch_type 保持 Unknown。
251 Some(FuturesRegion::Us) | None => 0,
252 }
253}
254
255fn infer_futures_region_from_code(code: &str) -> Option<FuturesRegion> {
256 let trimmed = code.trim();
257 if trimmed.is_empty() {
258 return None;
259 }
260
261 if let Some((prefix, _)) = trimmed.split_once('.') {
262 match prefix.to_ascii_uppercase().as_str() {
263 "HK" => return Some(FuturesRegion::Hk),
264 "US" => return Some(FuturesRegion::Us),
265 "SG" => return Some(FuturesRegion::Sg),
266 "JP" => return Some(FuturesRegion::Jp),
267 _ => {}
268 }
269 }
270
271 let ticker = extract_futures_ticker_for_region(trimmed);
272 match ticker.as_str() {
273 // HKFE 常见指数/波指/商品/连续合约代码。`FUCOmain/current/next`
274 // 是 external tester 2026-05-05 真机日志里的触发样本。
275 "HSI" | "HHI" | "HTI" | "MHI" | "MCH" | "VHSI" | "CSI300" | "CSI500" | "CSI800"
276 | "FUCO" => Some(FuturesRegion::Hk),
277 // CME Group / US 期货常见 ticker。这里只用于 QotMarket 粗分,exch_type
278 // 不在未知 market_code 下进一步猜。
279 "NQ" | "MNQ" | "ES" | "MES" | "RTY" | "M2K" | "NKD" | "YM" | "MYM" | "6E" | "6J" | "6B"
280 | "6A" | "6C" | "6S" | "6M" | "6N" | "GE" | "SR3" | "BTC" | "MBT" | "ETH" | "MET"
281 | "CL" | "MCL" | "NG" | "MNG" | "HO" | "RB" | "BZ" | "WBS" | "GC" | "MGC" | "SI"
282 | "SIL" | "HG" | "MHG" | "PL" | "PA" | "ZS" | "ZC" | "ZW" | "ZO" | "ZR" | "ZT" | "ZF"
283 | "ZN" | "ZB" | "UB" | "TN" | "VX" | "VXM" => Some(FuturesRegion::Us),
284 "NK" | "N225" | "JGB" => Some(FuturesRegion::Jp),
285 "CN" | "STW" | "SIR" | "FEF" => Some(FuturesRegion::Sg),
286 _ => None,
287 }
288}
289
290fn extract_futures_ticker_for_region(code: &str) -> String {
291 let mut s = code.trim();
292 if let Some((_, rest)) = s.split_once('.') {
293 s = rest;
294 }
295
296 let mut upper = s.to_ascii_uppercase();
297 for suffix in ["CURRENT", "NEXT", "MAIN"] {
298 if upper.ends_with(suffix) && upper.len() > suffix.len() {
299 upper.truncate(upper.len() - suffix.len());
300 return upper;
301 }
302 }
303
304 if upper.len() > 4 && upper[upper.len() - 4..].chars().all(|c| c.is_ascii_digit()) {
305 upper.truncate(upper.len() - 4);
306 }
307 upper
308}
309
310/// v1.4.93 P1-3 (BUG-5318-003): exch_type 派生 fallback for proto field.
311///
312/// 优先用 cached `exch_type` (cache hit + populated → 直通); cache miss / 历史
313/// SQLite 行 / backend 默认 → 用 `mkt_id` 转换. 二者都 0 → 返 None (符合 proto
314/// optional 语义).
315///
316/// 这是 CLAUDE.md 坑 #35 的"cache-first, mkt_id-derive fallback, None as last
317/// resort"模式. 之前 `make_static_info` hardcode `exch_type != 0 ? Some : None`
318/// 没考虑 mkt_id 派生路径 → US 期货 snapshot/static_info 永远 None.
319pub fn derive_exch_type_with_fallback(cached_exch_type: i32, mkt_id: u32) -> Option<i32> {
320 if cached_exch_type != 0 {
321 return Some(cached_exch_type);
322 }
323 let derived = market_code_to_exch_type(mkt_id);
324 if derived != 0 { Some(derived) } else { None }
325}
326
327/// SecurityFirm 枚举 → broker_id 反查。
328///
329/// 正向映射在 `GatewayBridge::broker_to_security_firm`,这里是反向版本:
330/// CachedTrdAcc 里存的是 `security_firm`,`dispatch_trade_data_queries` 需要
331/// 按账户拿到对应的 broker_id 进而路由到 broker 通道。
332pub(crate) fn security_firm_to_broker_id(sf: i32) -> Option<u32> {
333 futu_trd::currency::security_firm_to_broker_id(sf)
334}
335
336#[cfg(test)]
337mod tests;