Skip to main content

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;