Skip to main content

futu_backend/
stock_list.rs

1// 股票列表同步
2//
3// 对应 C++ SecListDBUpdater
4// CMD 6746: 增量拉取股票列表 (响应 zlib 压缩)
5// CMD 6822: 注册市场事件推送 (需要 header reserved 设置市场类型)
6// CMD 6823: 拉取市场状态
7
8use std::io::Read as IoRead;
9
10use flate2::read::GzDecoder;
11use futu_core::error::{FutuError, Result};
12
13use crate::conn::BackendConn;
14use crate::proto_internal::{ft_cmd6822, ft_cmd6823, stock_list_sync_svr};
15
16/// 股票列表同步命令 ID
17pub const CMD_UPDATE_SEC_LIST: u16 = 6746;
18/// 注册市场事件推送
19pub const CMD_EVENT_NOTICE_SUB: u16 = 6822;
20/// 拉取市场状态
21pub const CMD_PULL_EVENT_NOTICE: u16 = 6823;
22
23/// 行情市场类型 (C++ NN_QuoteMktType + MarketID 分组)
24///
25/// 作 CMD 6822/6823 TCP header reserved[0] 时必须先经
26/// `quote_mkt_to_nn_mkt_type` 转成 C++ `NN_QuoteMktType`。
27///
28/// 其中 `DigitalCcy` 虽然是 v1.4.49 扩展进来的 market-id 分组,但线上
29/// C++ 10.5.6508 已有 `NN_QuoteMktType_CRYPTO = 17`,不能再当成
30/// "只用于过滤、不发 backend" 的本地分组。
31#[repr(u8)]
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum QuoteMktType {
35    Unknown = 0,
36    HK = 1,
37    US = 2,
38    SH = 3,
39    SZ = 4,
40    HKFuture = 5,
41    HKFuture2 = 6,
42    USOption = 7,
43    USFuture = 8,
44    HKOption = 9,
45    SHKC = 10,
46    Forex = 11,
47    SGFuture = 12,
48    JPFuture = 13,
49    // v1.4.49 新增:MarketID 范围分组
50    Bond = 14,          // 130-159 债券
51    GlobalIndex = 15,   // 260-359 全球指数
52    SGSecurity = 16,    // 180-184 新加坡股票
53    StockConnect = 17,  // 50-51 港股通/A股通
54    DigitalCcy = 18,    // 360-459 数字货币
55    TreasuryYield = 19, // 460-559 国债收益率
56    // v1.4.50 新增:仅用于 MarketID 范围过滤,不作 TCP header
57    Fund = 20, // 560-569 基金市场 (C++ NN_QuoteMktID_FUND_*)
58    // v1.4.111: C++ 10.6 GetGlobalState exposes JP/MY stock market state.
59    JPSecurity = 21, // 830-849 日本东证市场
60    MYSecurity = 22, // 1350-1399 马来西亚股票
61}
62
63/// v1.4.68 修正(external reviewer v1.4.57 #5 P2 修):CMD 6822/6823 reserved[0] 实际接受
64/// `NN_QuoteMktType` 值(C++ enum),**不是 `QotCommon.QotMarket` 值**。
65///
66/// C++ 实锤对照 `NNBiz_Qot_EventNotice.cpp::SubEventNotice(enMktType)` L178-188:
67/// CMD 6822 订阅请求 reserved[0] = NN_QuoteMktType 枚举值:
68/// - NN_QuoteMktType_HK = 1
69/// - NN_QuoteMktType_US = 2
70/// - NN_QuoteMktType_SH = 3
71/// - NN_QuoteMktType_SZ = 4
72/// - NN_QuoteMktType_FUT_HK = 5     ← 港期老
73/// - NN_QuoteMktType_FUT_HK_NEW = 6 ← 港期新(主要)
74/// - NN_QuoteMktType_US_OPTIONS = 7
75/// - NN_QuoteMktType_US_FUT = 8     ← 美期
76/// - NN_QuoteMktType_HK_OPTIONS = 9
77/// - NN_QuoteMktType_SH_KCB = 10
78/// - NN_QuoteMktType_Forex = 11
79/// - NN_QuoteMktType_SG_FUTURE = 13 ← 新加坡期货(跳 12)
80/// - NN_QuoteMktType_SG_SECURITY = 15 ← 新加坡股票
81/// - NN_QuoteMktType_JP_FUTURE = 16 ← 日本期货
82/// - NN_QuoteMktType_CRYPTO = 17 ← 加密货币
83/// - NN_QuoteMktType_JP_SECURITY = 25 ← 日本东证市场
84/// - NN_QuoteMktType_MY_SECURITY = 27 ← 马来西亚股票
85///
86/// v1.4.47 P1.2 曾改用 QotMarket 值(HK=1, US=11, ...),以为"QuoteMktType
87/// 不等于 QotMarket 就不对"—— **反了方向**。CMD 6822 backend 接受的是
88/// NN_QuoteMktType。HK/US/SH/SZ 的 QotMarket 值(1/11/21/22)对 backend 来说
89/// 部分 work 是因为:1 ↔ NN_QuoteMktType_HK=1 巧合对齐;其他(US=11 / SH=21
90/// / SZ=22)不在 NN_QuoteMktType 枚举范围内 backend 可能 fallback 全市场
91/// 返回。但 HKFuture / USFuture / SGFuture / JPFuture 都返 0(因为 v1.4.47
92/// 把它们改 reserved[0]=0 导致 backend 不订阅 HK 期货 → external reviewer 报告 #5)。
93///
94/// 本次修法:**按 C++ SubEventNotice 实装**。期货按 FUT_HK_NEW=6 / US_FUT=8
95/// / SG_FUTURE=13 / JP_FUTURE=16 订阅 → backend CMD 6823 响应能返 HK 期货
96/// market_id(5-6 / 110-119)→ pick_market_state 能拿到 HK future state。
97fn quote_mkt_to_nn_mkt_type(m: QuoteMktType) -> u8 {
98    match m {
99        QuoteMktType::HK => 1,          // NN_QuoteMktType_HK
100        QuoteMktType::US => 2,          // NN_QuoteMktType_US
101        QuoteMktType::SH => 3,          // NN_QuoteMktType_SH
102        QuoteMktType::SZ => 4,          // NN_QuoteMktType_SZ
103        QuoteMktType::HKFuture => 5,    // NN_QuoteMktType_FUT_HK(老港期)
104        QuoteMktType::HKFuture2 => 6,   // NN_QuoteMktType_FUT_HK_NEW(主要港期)
105        QuoteMktType::USOption => 7,    // NN_QuoteMktType_US_OPTIONS
106        QuoteMktType::USFuture => 8,    // NN_QuoteMktType_US_FUT
107        QuoteMktType::HKOption => 9,    // NN_QuoteMktType_HK_OPTIONS
108        QuoteMktType::SHKC => 10,       // NN_QuoteMktType_SH_KCB
109        QuoteMktType::Forex => 11,      // NN_QuoteMktType_Forex
110        QuoteMktType::SGFuture => 13,   // NN_QuoteMktType_SG_FUTURE(C++ 跳 12)
111        QuoteMktType::SGSecurity => 15, // NN_QuoteMktType_SG_SECURITY
112        QuoteMktType::JPFuture => 16,   // NN_QuoteMktType_JP_FUTURE(C++ 跳 14-15)
113        QuoteMktType::DigitalCcy => 17, // C++ NN_QuoteMktType_CRYPTO
114        QuoteMktType::JPSecurity => 25, // NN_QuoteMktType_JP_SECURITY
115        QuoteMktType::MYSecurity => 27, // NN_QuoteMktType_MY_SECURITY
116        // v1.4.49 新增 variant 多数不在 NN_QuoteMktType,仅用于 pick_market_state
117        // 按 MarketID 过滤。返 0 → backend 忽略(CMD 6822 订阅 reserved[0]=0 可能
118        // 表示"通用",不影响其他订阅的市场)。
119        QuoteMktType::Bond
120        | QuoteMktType::GlobalIndex
121        | QuoteMktType::StockConnect
122        | QuoteMktType::TreasuryYield
123        | QuoteMktType::Fund
124        | QuoteMktType::Unknown => 0,
125    }
126}
127
128/// 构造行情命令的 header reserved 字段。
129///
130/// v1.4.47 P1.2 修:`reserved[0]` 用 Futu QotMarket 值(不是内部 enum 值)。
131pub fn make_quote_reserved(mkt: QuoteMktType, ex_type: u8) -> [u8; 10] {
132    let mut reserved = [0u8; 10];
133    reserved[0] = quote_mkt_to_nn_mkt_type(mkt);
134    reserved[1] = ex_type;
135    reserved
136}
137
138/// 缓存的证券信息 (从 CSStockItem 解析)
139#[derive(Debug, Clone)]
140pub struct StockInfo {
141    pub stock_id: u64,
142    pub sequence: u64,
143    pub code: String,
144    pub name_sc: String,
145    pub name_tc: String,
146    pub name_en: String,
147    pub market_code: u32,
148    pub instrument_type: u32,
149    /// C++ `Ndt_Qot_SecInfo::nSpreadCode` data source.
150    ///
151    /// Source: stock_list_sync `CSStockItem.spread_table_code` field 13.
152    pub spread_table_code: u32,
153    pub sub_instrument_type: u32,
154    /// C++ `Ndt_Qot_SecInfo::enSubTypeV2` data source.
155    ///
156    /// Source: stock_list_sync `CSStockItem.sub_instrument_type_v2` field 69.
157    /// v10.6 `GetCompanyProfile` uses this to split Trust/ETF routing exactly like
158    /// C++ `APIServer_Qot_CompanyProfile.cpp:55-63`.
159    pub sub_instrument_type_v2: u32,
160    pub lot_size: u32,
161    pub currency_code: u32,
162    pub listing_date: u32,
163    pub delisting: bool,
164    pub delete_flag: bool,
165    /// 窝轮所属正股 ID (warrnt_stock_owner), 0 表示无
166    pub warrnt_stock_owner: u64,
167    /// C++ internal `NN_QotWarrantType` from stock-list `warrnt_type`.
168    ///
169    /// Values 1-5 match public `Qot_Common.WarrantType`; values 6/7 are C++
170    /// internal DLC Long/Short and are filtered out by `GetReference(WARRANT)`.
171    pub warrant_type: u32,
172    /// 不可搜索标记 (no_search), true 表示不可搜索
173    pub no_search: bool,
174    /// v1.4.106 codex F5: 期货主连合约关联 stock_id (proto field 41 origin_id).
175    ///
176    /// 含义 (对齐 C++ `Ndt_Qot_SecInfo::nFutureOriginID`): 当前 row 是**主连合约**
177    /// (e.g. `HSImain`, `NQmain`) 时, `origin_id` 指向真实月份合约的 stock_id;
178    /// 普通合约 (`HSI2604` / `NQ2606`) 此字段为 0.
179    ///
180    /// **F5 用途**: PlaceOrder 在 `IsFuturesTrdMarket && origin_id != 0` 时
181    /// 异步发 CMD 6747 拉真实合约 code, 用真实 code 下单 (对齐 C++
182    /// `APIServer_Trd_PlaceOrder.cpp:632-650` `ReqMainLinkContract` 流程).
183    pub future_origin_id: u64,
184    /// v1.4.106 codex F5: 期货主力合约 stock_id (proto field 40 zhuli_id).
185    ///
186    /// 含义: 主连合约持有此字段, 指向当前**主力合约** stock_id (流动性最大).
187    /// CMD 6747 响应解析此字段后用 `id_to_key` 反查 code → 替换 PlaceOrder
188    /// `c2s.code` 后下发. 普通合约此字段为 0.
189    pub zhuli_id: u64,
190    /// Crypto 货币对左侧货币 (C++ `CSStockItem.cc_origin`, proto field 60).
191    pub cc_origin: String,
192    /// Crypto 货币对右侧货币 (C++ `CSStockItem.cc_destination`, proto field 61).
193    pub cc_destination: String,
194    /// v1.4.110 final E.5 P2#6: 交易所 (proto field 57, e.g. "SEHK", "NASDAQ").
195    ///
196    /// 数据驱动 fallback (pitfall #35 C++ 数据驱动 vs Rust 启发式): trade
197    /// handler 的 `derive_exchange_str` 应优先用此字段, 缺失时再 fallback 到
198    /// pattern match heuristic. backend 不下发此字段时为空串.
199    pub exchange: String,
200    /// v1.4.110 final E.5 P2#6: 上市交易所 (proto field 74).
201    ///
202    /// 通常与 `exchange` 相同, dual-listed ADR / 双重主要上市等场景下不同
203    /// (e.g. 港股通的 H 股可能 exchange=HKEX, listed_exchange=SEHK).
204    pub listed_exchange: String,
205}
206
207/// 同步结果
208pub struct SyncResult {
209    pub total_stocks: usize,
210    pub next_interval_secs: u32,
211}
212
213/// 同步股票列表 (CMD 6746)
214///
215/// 从后端增量拉取股票列表。响应经 zlib 压缩,需解压后解析。
216/// 返回同步的股票总数和下次同步间隔。
217pub async fn sync_stock_list<F>(
218    backend: &BackendConn,
219    version: &std::sync::atomic::AtomicU64,
220    on_stock: &mut F,
221) -> Result<SyncResult>
222where
223    F: FnMut(StockInfo),
224{
225    use prost::Message;
226
227    let mut total = 0usize;
228    let mut next_interval = 150u32;
229    const MAX_PAGES: usize = 500; // 防止无限循环
230
231    for _page in 0..MAX_PAGES {
232        let cur_version = version.load(std::sync::atomic::Ordering::Relaxed);
233
234        let req = stock_list_sync_svr::StockListReq {
235            stock_list_version: cur_version,
236            if_req_all: 0, // delta sync
237            special_version: Some(1),
238        };
239
240        let resp = backend
241            .request(CMD_UPDATE_SEC_LIST, req.encode_to_vec())
242            .await?;
243
244        // 响应是 zlib (gzip) 压缩的,需要解压
245        let decompressed = decompress_gzip(&resp.body)?;
246
247        let parsed: stock_list_sync_svr::StockListRsp = Message::decode(decompressed.as_slice())
248            .map_err(|e| FutuError::Codec(format!("CMD6746 decode failed: {e}")))?;
249
250        if parsed.result != 0 {
251            return Err(FutuError::Codec(format!(
252                "CMD6746 error: result={}",
253                parsed.result
254            )));
255        }
256
257        // 处理每个 CSStockItem
258        let batch_count = parsed.arry_items.len();
259        for item in &parsed.arry_items {
260            let info = parse_stock_item(item);
261            if info.delete_flag {
262                // 删除的股票也通知上层处理
263            }
264            on_stock(info);
265            total += 1;
266        }
267
268        // 更新版本号
269        if let Some(max_ver) = parsed.array_max_version {
270            version.store(max_ver, std::sync::atomic::Ordering::Relaxed);
271        }
272
273        // 更新下次请求间隔
274        if let Some(interval) = parsed.next_request_interval {
275            next_interval = interval;
276        }
277
278        let all_count = parsed.all_count.unwrap_or(0);
279        tracing::debug!(
280            batch = batch_count,
281            total,
282            all_count,
283            version = version.load(std::sync::atomic::Ordering::Relaxed),
284            "stock list sync batch"
285        );
286
287        // if_all_rsp != 0 表示拉取完成
288        if parsed.if_all_rsp.unwrap_or(1) != 0 {
289            // 校验 checksum (debug-log only, 不 enforce mismatch)
290            // v1.4.110 final E.5 P3#8: id_check_sum / seq_check_sum 已标 deprecated
291            // (新客户端走 id_check_sum_v2 / seq_check_sum_v2, 64+64 bit 拆分避免溢出).
292            // server 仍下发老字段一段时间, Rust 保留读取兼容. allow(deprecated) 显式表态.
293            #[allow(deprecated)]
294            if let (Some(server_id_sum), Some(server_seq_sum)) =
295                (parsed.id_check_sum, parsed.seq_check_sum)
296                && (server_id_sum > 0 || server_seq_sum > 0)
297            {
298                tracing::debug!(server_id_sum, server_seq_sum, "server checksums received");
299            }
300            // v2 校验和 (新 server 下发, debug-log only, 同样不 enforce).
301            if let (Some(id_v2), Some(seq_v2)) = (&parsed.id_check_sum_v2, &parsed.seq_check_sum_v2)
302            {
303                tracing::debug!(
304                    id_low = id_v2.low,
305                    id_high = id_v2.high,
306                    seq_low = seq_v2.low,
307                    seq_high = seq_v2.high,
308                    "server v2 checksums received"
309                );
310            }
311            break;
312        }
313    }
314
315    Ok(SyncResult {
316        total_stocks: total,
317        next_interval_secs: next_interval,
318    })
319}
320
321/// 注册市场事件推送 (CMD 6822)
322///
323/// 需要对每个市场单独发送注册请求。
324/// header reserved[0] = market_type, reserved[1] = 0 (SECURITY)
325pub async fn register_markets(backend: &BackendConn) -> Result<()> {
326    // v1.4.57 P2: 加 HKFuture / USFuture / SGFuture / JPFuture 订阅,让 CMD
327    // 6823 全市场 snapshot 能返期货市场状态(之前 HK 期货一直 0)。
328    // 对齐 C++ `NNBiz_Qot_EventNotice.cpp::SubEventNotice()`:
329    // - 港期必须同时订阅 FUT_HK=5 和 FUT_HK_NEW=6。
330    // - 10.5.6508 新增 crypto,需订阅 NN_QuoteMktType_CRYPTO=17。
331    // - 10.6 GetGlobalState 新增 SG/MY/JP 股票市场状态,需订阅对应
332    //   NN_QuoteMktType_SG_SECURITY/JP_SECURITY/MY_SECURITY。
333    let markets = [
334        (QuoteMktType::HK, "HK"),
335        (QuoteMktType::HKFuture, "HKFuture"),
336        (QuoteMktType::HKFuture2, "HKFuture2"),
337        (QuoteMktType::HKOption, "HKOption"),
338        (QuoteMktType::US, "US"),
339        (QuoteMktType::USOption, "USOption"),
340        (QuoteMktType::SH, "SH"),
341        (QuoteMktType::SHKC, "SHKC"),
342        (QuoteMktType::SZ, "SZ"),
343        (QuoteMktType::USFuture, "USFuture"),
344        (QuoteMktType::SGFuture, "SGFuture"),
345        (QuoteMktType::JPFuture, "JPFuture"),
346        (QuoteMktType::JPSecurity, "JPSecurity"),
347        (QuoteMktType::SGSecurity, "SGSecurity"),
348        (QuoteMktType::MYSecurity, "MYSecurity"),
349        // Existing Rust extension: crypto status is pulled by C++ PullEventNotice
350        // and v1.4.68 wired CMD6822 too; keep it to avoid regressing crypto state.
351        (QuoteMktType::DigitalCcy, "DigitalCcy"),
352    ];
353
354    for (mkt, name) in &markets {
355        let req = ft_cmd6822::RegisterReq { reserved: 0 };
356        let reserved = make_quote_reserved(*mkt, 0); // ex_type = SECURITY
357
358        match backend
359            .request_with_reserved(
360                CMD_EVENT_NOTICE_SUB,
361                prost::Message::encode_to_vec(&req),
362                reserved,
363            )
364            .await
365        {
366            Ok(resp) => {
367                let parsed: ft_cmd6822::RegisterRes = prost::Message::decode(resp.body.as_ref())
368                    .unwrap_or(ft_cmd6822::RegisterRes { res: -1 });
369                if parsed.res != 0 {
370                    tracing::warn!(
371                        market = name,
372                        res = parsed.res,
373                        "CMD6822 registration failed"
374                    );
375                } else {
376                    tracing::debug!(market = name, "CMD6822 registered");
377                }
378            }
379            Err(e) => {
380                tracing::warn!(market = name, error = %e, "CMD6822 request failed");
381            }
382        }
383    }
384
385    Ok(())
386}
387
388/// 拉取市场状态 (CMD 6823)
389pub async fn pull_market_status(backend: &BackendConn) -> Result<Vec<MarketStatus>> {
390    let markets = [
391        (QuoteMktType::HK, "HK"),
392        (QuoteMktType::US, "US"),
393        (QuoteMktType::SH, "SH"),
394    ];
395
396    let mut all_status = Vec::new();
397
398    for (mkt, name) in &markets {
399        let req = ft_cmd6823::MarketStatusReq { reserved: 0 };
400        let reserved = make_quote_reserved(*mkt, 0);
401
402        match backend
403            .request_with_reserved(
404                CMD_PULL_EVENT_NOTICE,
405                prost::Message::encode_to_vec(&req),
406                reserved,
407            )
408            .await
409        {
410            Ok(resp) => {
411                let parsed = decode_market_status_rsp(resp.body.as_ref())?;
412                for s in &parsed.res_status {
413                    all_status.push(MarketStatus {
414                        market_id: s.id,
415                        status: s.status.unwrap_or(0),
416                        status_text: s.status_text_sc.clone().unwrap_or_default(),
417                    });
418                }
419                tracing::debug!(
420                    market = name,
421                    count = parsed.res_status.len(),
422                    "CMD6823 status"
423                );
424            }
425            Err(e) => {
426                tracing::warn!(market = name, error = %e, "CMD6823 request failed");
427            }
428        }
429    }
430
431    Ok(all_status)
432}
433
434fn decode_market_status_rsp(body: &[u8]) -> Result<ft_cmd6823::MarketStatusRsp> {
435    prost::Message::decode(body).map_err(FutuError::Proto)
436}
437
438/// 市场状态
439#[derive(Debug, Clone)]
440pub struct MarketStatus {
441    pub market_id: u32,
442    pub status: u32,
443    pub status_text: String,
444}
445
446/// v1.4.48 修:CMD 6823 返回**全市场**子交易所列表(~100+ 条),一次查询即可拿所有市场
447/// 状态。旧版 (v1.4.47) 8 次查询 + 盲取 `statuses[0]` 导致误读市场状态。
448///
449/// 参考 C++ `market_tradingDay.proto::MarketID` enum:
450/// - 1-4: HK 股票 (主板/创业板/纳斯达克/扩展板)
451/// - 5-6: HK Future (old / new)
452/// - 7-8: HK Option
453/// - 10-29: US 股票 (NYSE, NASDAQ, AMEX, ...)
454/// - 30-40: CN A 股 (SH / SZ)
455/// - 41-45: US Option
456/// - 60-109: US Future (NYMEX=60, COMEX=70, CBOT 80-84, CME 90-99, CBOE 100-109)
457/// - 110-119: HK Future 扩展
458/// - 120-123: Forex
459/// - 130-159: Bonds
460/// - 50-51: Stock Connect (港股通 / A股通) — v1.4.49 加
461/// - 60-109: US Future (NYMEX, COMEX, CBOT, CME, CBOE)
462/// - 110-119: HK Future 扩展
463/// - 120-123: Forex
464/// - 130-159: Bonds — v1.4.49 加
465/// - 160-179: SGX Future
466/// - 180-184: SGX Market (新加坡股票) — v1.4.49 加
467/// - 185-194: JPX Future
468/// - 260-359: Global Index — v1.4.49 加
469/// - 360-459: Digital Currency — v1.4.49 加
470/// - 460-559: Treasury Yield — v1.4.49 加
471/// - 560-569: Fund — v1.4.50 加(`NN_QuoteMktID_FUND_*`)
472/// - 570-579: HK Index Option 扩展 — v1.4.50 加入 HKOption
473/// - 830-849: JP TSE 股票 — v1.4.111 加(C++ `NN_QuoteMktID_JP_TSE_*`)
474/// - 1000-1049: HK HSI Index — v1.4.50 加入 HK(C++ 源码确认归 HK 市场)
475/// - 1200-1249: US New VIX — v1.4.50 加入 US(C++ 源码确认归 US 市场)
476/// - 1350-1399: MY 股票 — v1.4.111 加(C++ `NN_QuoteMktID_MY_Security_*`)
477pub fn market_id_matches(mkt: QuoteMktType, id: u32) -> bool {
478    match mkt {
479        // v1.4.50: HK 加 1000-1049(HSI Index 扩展,C++ 映射归 HK 市场)
480        QuoteMktType::HK => (1..=4).contains(&id) || (1000..=1049).contains(&id),
481        // C++ `NN_QuoteMktType_From_NN_QuoteMktID` keeps old/new HK futures
482        // distinct: 5 -> FUT_HK, 6 and 110..=119 -> FUT_HK_NEW.
483        QuoteMktType::HKFuture => id == 5,
484        QuoteMktType::HKFuture2 => id == 6 || (110..=119).contains(&id),
485        // v1.4.50: HKOption 加 570-579(HK Index Option 扩展)
486        QuoteMktType::HKOption => (7..=8).contains(&id) || (570..=579).contains(&id),
487        // v1.4.50: US 加 1200-1249(US New VIX,C++ 映射归 US 市场)
488        QuoteMktType::US => (10..=29).contains(&id) || (1200..=1249).contains(&id),
489        QuoteMktType::SH | QuoteMktType::SZ | QuoteMktType::SHKC => (30..=40).contains(&id),
490        QuoteMktType::USOption => (41..=45).contains(&id),
491        QuoteMktType::USFuture => (60..=109).contains(&id),
492        QuoteMktType::SGFuture => (160..=179).contains(&id),
493        QuoteMktType::JPFuture => (185..=194).contains(&id),
494        QuoteMktType::Forex => (120..=123).contains(&id),
495        // v1.4.49 新增 6 个 variant(对齐 C++ market_tradingDay.proto MarketID)
496        QuoteMktType::StockConnect => (50..=51).contains(&id),
497        QuoteMktType::Bond => (130..=159).contains(&id),
498        QuoteMktType::SGSecurity => (180..=184).contains(&id),
499        QuoteMktType::GlobalIndex => (260..=359).contains(&id),
500        QuoteMktType::DigitalCcy => (360..=459).contains(&id),
501        QuoteMktType::TreasuryYield => (460..=559).contains(&id),
502        // v1.4.50 新增
503        QuoteMktType::Fund => (560..=569).contains(&id),
504        QuoteMktType::JPSecurity => (830..=849).contains(&id),
505        QuoteMktType::MYSecurity => (1350..=1399).contains(&id),
506        QuoteMktType::Unknown => false,
507    }
508}
509
510/// v1.4.48 新:一次 CMD 6823 query 拿全市场状态 snapshot,避免重复 8 次查询。
511///
512/// **v1.4.55 修正**(同事反馈 `market_hkfuture: NONE` 即使夜盘交易中):
513/// backend 对 CMD 6823 在 reserved=HK 时返的 snapshot **不含期货市场**
514/// (HK 主板 / 港股通 / US / CN / Bond 等都在,但 HK/US/SG/JP 期货 market_id 缺席)。
515/// 真机实测 v1.4.48 一次 snapshot 里 market_hk/us/sh/sz 都能填,但
516/// market_hk_future / market_us_future / market_sg_future / market_jp_future 全是
517/// `NONE`。
518///
519/// **修法**:并发发 5 个 CMD 6823(HK / HKFuture / USFuture / SGFuture /
520/// JPFuture),合并结果按 `market_id` 去重。backend 对 `reserved=HKFuture` 等
521/// 期货 market type 会下发对应 market_id ∈ {5, 6, 110-119} (HK) / 60-109 (US) /
522/// 160-179 (SG) 的状态条目,合并后 `pick_market_state(HKFuture)` 就能命中。
523///
524/// 代价:5 个并发请求而非 1 个(~5x backend load),但 CMD 6823 是轻量 snapshot,
525/// 实测每次 ~10-20ms,合计 <100ms 仍在可接受范围(global_state 不是高频 API)。
526pub async fn pull_all_market_status(backend: &BackendConn) -> Result<Vec<MarketStatus>> {
527    use futures::future::join_all;
528
529    let req = ft_cmd6823::MarketStatusReq { reserved: 0 };
530    let req_bytes = prost::Message::encode_to_vec(&req);
531
532    // 按 C++ `PullEventNotice` 的 market type 维度主动拉取状态。Crypto 在
533    // C++ 10.5.6508 中走 NN_QuoteMktType_CRYPTO=17。
534    let market_types = [
535        QuoteMktType::HK,        // 非期货市场全量(HK 主板 / 港股通 / US / CN / Bond / etc)
536        QuoteMktType::HKFuture,  // 老港期(NN_QuoteMktType_FUT_HK=5,MktID 5)
537        QuoteMktType::HKFuture2, // 主要港期(NN_QuoteMktType_FUT_HK_NEW=6,MktID 6 / 110-119)
538        QuoteMktType::HKOption,  // HK 期权(NN_QuoteMktType_HK_OPTIONS=9)
539        QuoteMktType::US,        // US 股票(NN_QuoteMktType_US=2)
540        QuoteMktType::USOption,  // US 期权(NN_QuoteMktType_US_OPTIONS=7)
541        QuoteMktType::SH,        // SH A 股(NN_QuoteMktType_SH=3)
542        QuoteMktType::SHKC,      // 科创板(NN_QuoteMktType_SH_KCB=10)
543        QuoteMktType::SZ,        // SZ A 股(NN_QuoteMktType_SZ=4)
544        QuoteMktType::USFuture,  // US 期货(NN_QuoteMktType_US_FUT=8,MktID 60-109)
545        QuoteMktType::SGFuture,  // SG 期货(NN_QuoteMktType_SG_FUTURE=13,MktID 160-179)
546        QuoteMktType::JPFuture,  // JP 期货(NN_QuoteMktType_JP_FUTURE=16,MktID 185-194)
547        QuoteMktType::JPSecurity, // JP 股票(NN_QuoteMktType_JP_SECURITY=25,MktID 830-849)
548        QuoteMktType::DigitalCcy, // 数字货币(NN_QuoteMktType_CRYPTO=17,MktID 360-459)
549        QuoteMktType::SGSecurity, // SG 股票(NN_QuoteMktType_SG_SECURITY=15,MktID 180-184)
550        QuoteMktType::MYSecurity, // MY 股票(NN_QuoteMktType_MY_SECURITY=27,MktID 1350-1399)
551    ];
552
553    let pulls = market_types.iter().map(|mkt| {
554        let reserved = make_quote_reserved(*mkt, 0);
555        let body = req_bytes.clone();
556        async move {
557            backend
558                .request_with_reserved(CMD_PULL_EVENT_NOTICE, body, reserved)
559                .await
560        }
561    });
562    let results = join_all(pulls).await;
563
564    let mut seen_ids = std::collections::HashSet::new();
565    let mut statuses: Vec<MarketStatus> = Vec::new();
566    for (mkt, resp_result) in market_types.iter().zip(results) {
567        match resp_result {
568            Ok(resp) => {
569                let parsed = decode_market_status_rsp(resp.body.as_ref())?;
570                for s in &parsed.res_status {
571                    if seen_ids.insert(s.id) {
572                        statuses.push(MarketStatus {
573                            market_id: s.id,
574                            status: s.status.unwrap_or(0),
575                            status_text: s.status_text_sc.clone().unwrap_or_default(),
576                        });
577                    }
578                }
579            }
580            Err(e) => {
581                // 单个 market 拉失败不 block 整体,warn 继续
582                tracing::warn!(
583                    market_type = ?mkt,
584                    error = %e,
585                    "v1.4.55 CMD6823 one market pull failed, continuing with others"
586                );
587            }
588        }
589    }
590
591    tracing::debug!(
592        total_entries = statuses.len(),
593        market_types_pulled = market_types.len(),
594        "v1.4.55 CMD6823 multi-market snapshot fetched"
595    );
596    for s in &statuses {
597        tracing::trace!(
598            market_id = s.market_id,
599            status = s.status,
600            status_text = %s.status_text,
601            "CMD6823 entry"
602        );
603    }
604
605    // v1.4.57 P2 (外部报告 #4): 额外打 DEBUG 汇总所有 market_id 值(逗号分隔),
606    // 方便在字段级别 diagnostic "HK 期货 market_id 在哪个范围 backend 返" 这类问题。
607    // 发版日志开 RUST_LOG=debug 时可看见,正常级别不出。
608    if !statuses.is_empty() && tracing::enabled!(tracing::Level::DEBUG) {
609        let ids: Vec<String> = statuses.iter().map(|s| s.market_id.to_string()).collect();
610        tracing::debug!(
611            count = statuses.len(),
612            market_ids = %ids.join(","),
613            "v1.4.57 CMD6823 all received market_ids (for HK futures / etc. diagnostic)"
614        );
615    }
616
617    Ok(statuses)
618}
619
620/// v1.4.48 helper: 从全市场 snapshot 里按 `QuoteMktType` 过滤 + 取状态。
621///
622/// **v1.4.71 D5 fix**(external reviewer v1.4.69 `market_hk_future=0` 报告,代码级定位到本函数):
623/// 早期 `.find` 取第一个匹配时,公共 HK futures 查询容易先碰到 `FUT_HK=5`
624/// (老港期,已 deprecated 但 backend 仍下发,status 常为 0=NOT_TRADING),
625/// 从而遮蔽 `FUT_HK_NEW=6`(主要港期,open 时 status=15=FUTURE_DAY_OPEN)。
626/// 现在 `HKFuture` / `HKFuture2` 的 market_id 归属已经按 C++
627/// `NN_QuoteMktType_From_NN_QuoteMktID` 拆开,主港期全局状态取 `HKFuture2`。
628///
629/// **v1.4.73 B1 D5 升级**(external reviewer v1.4.71 AI tester 报告 "Rust 对 HK/JP/SG 期货全
630/// 返 15,C++ SG 返 18=FUTURE_DAY_WAIT_OPEN"):同一个 `QuoteMktType` 范围
631/// 内 backend 可能下发多条 non-zero status(如 SGFuture 多个子交易所),当
632/// 同时有"开盘"类(15 / 23 / 3 / 5 / 13)和"等待开盘 / 休市"类(2 / 4 / 6 /
633/// 11 / 14 / 16 / 17 / 18 / 9 / 1)时,**优先返后者**(更精确描述市场实际情况:
634/// 不能交易的状态永远比"能交易"更安全,防止客户端误判"等待开盘"为"已开盘"下
635/// 单被拒)。
636///
637/// 对齐 C++ `APIServer_GlobalState.cpp:87-94` 的语义 —— C++ 仅取
638/// `pMktStateInfo[0]`,但是 backend 层 `INNData_Qot_EventNotice::GetMarketStateInfo`
639/// 按 enum 精确 dispatch,不会混多个 market_id。Rust 的 `market_id_matches`
640/// 按 range 匹配,会得到多条,所以要在 selection 上做 "prefer restrictive"
641/// 来对齐 C++ 实际观测行为。
642///
643/// 无匹配 → None。
644pub fn pick_market_state(all: &[MarketStatus], mkt: QuoteMktType) -> Option<u32> {
645    let mut best: Option<u32> = None;
646    let mut best_priority: u8 = 0;
647
648    for s in all.iter().filter(|s| market_id_matches(mkt, s.market_id)) {
649        let pri = market_state_priority(s.status);
650        if pri > best_priority {
651            best = Some(s.status);
652            best_priority = pri;
653        } else if best.is_none() {
654            best = Some(s.status);
655        }
656    }
657    best
658}
659
660/// v1.4.73 B1 D5 升级:按"描述市场实际情况"优先级对 `QotMarketState` 打分。
661///
662/// | Priority | Meaning | Values |
663/// |---|---|---|
664/// | 0 | None / zero | 0 |
665/// | 1 | Open / 可交易 | 3 (Morning), 5 (Afternoon), 13 (NightOpen), 15 (FutureDayOpen), 23 (FutureOpen) |
666/// | 2 | Restrictive / 不可交易 或 精确状态 | 1 (Auction), 2 (WaitingOpen), 4 (Rest), 6 (Closed), 8 (PreMarketBegin), 9 (PreMarketEnd), 10 (AfterHoursBegin), 11 (AfterHoursEnd), 14 (NightEnd), 16 (FutureDayBreak), 17 (FutureDayClose), 18 (FutureDayWaitForOpen), 19 (HkCas) |
667///
668/// 同一 market 多个 market_id 返 non-zero 时,按 priority 选最高。priority
669/// 相同时选第一个(保持 v1.4.71 first-non-zero 行为)。
670///
671/// 对齐 proto `Qot_Common.QotMarketState` 值(0-19 全覆盖;未来新增 variant
672/// 默认归到 priority 1,保守 fallback)。
673fn market_state_priority(status: u32) -> u8 {
674    match status {
675        0 => 0,
676        // 可交易 / 开盘状态
677        3 | 5 | 13 | 15 | 23 => 1,
678        // 不可交易 / 等待 / 休市 / 精确 pre/after-market 状态
679        1 | 2 | 4 | 6 | 8..=11 | 14 | 16..=19 | 12 => 2,
680        // 未知新增 variant → 保守 fallback
681        _ => 1,
682    }
683}
684
685/// 拉取单个市场的状态 (CMD 6823) — v1.4.48 legacy API,保持兼容
686///
687/// 建议用 `pull_all_market_status` + `pick_market_state` 替代(一次查询拿所有市场)。
688pub async fn pull_single_market_status(
689    backend: &BackendConn,
690    mkt: QuoteMktType,
691) -> Result<Vec<MarketStatus>> {
692    let all = pull_all_market_status(backend).await?;
693    Ok(all
694        .into_iter()
695        .filter(|s| market_id_matches(mkt, s.market_id))
696        .collect())
697}
698
699/// FTAPI QotMarket → 后端 QuoteMktType
700pub fn qot_market_to_backend(qot_market: i32) -> Option<QuoteMktType> {
701    match qot_market {
702        1 => Some(QuoteMktType::HK),
703        2 => Some(QuoteMktType::HKFuture2),
704        11 => Some(QuoteMktType::US),
705        21 => Some(QuoteMktType::SH),
706        22 => Some(QuoteMktType::SZ),
707        31 => Some(QuoteMktType::SGSecurity),
708        41 => Some(QuoteMktType::JPSecurity),
709        61 => Some(QuoteMktType::MYSecurity),
710        91 => Some(QuoteMktType::DigitalCcy),
711        _ => None,
712    }
713}
714
715/// v1.4.47 P1.2 修(external reviewer 验收报告 §3 P2 / §14.3 根因):
716/// FTAPI **TrdMarket** → 后端 `QuoteMktType`。
717///
718/// 注意:TrdMarket 和 QotMarket **是两个不同的 enum**,数值不相同:
719/// - TrdMarket: HK=1, US=2, CN=3, HKCC=4, Futures=5, SG=6, AU=8, JP=15, MY=111, CA=112
720/// - QotMarket: HK_Security=1, HK_Future=2, US_Security=11, CNSH=21, CNSZ=22, SG=31, JP=41, AU=51, MY=61, CA=71, FX=81
721///
722/// v1.4.93 BUG-001 fix (S level ship-blocker): 之前注释里 TrdMarket JP=7 / AU=8 /
723/// CA=9 是错的 (那是 SecurityFirm 编号). 真实 `Trd_Common.proto::TrdMarket` 是
724/// JP=15 / AU=8 / CA=112 / MY=111. 注释纠正以防日后 reader 又踩坑.
725///
726/// 旧 bug:`maybe_annotate_market_closed` 把 TrdMarket=2(US)直接传给
727/// `qot_market_to_backend`(期望 QotMarket),被识别为 QotMarket=2(HK_Future)
728/// → backend 返 HK_Future 状态而不是 US 状态 → hint 永远说"US 非交易时段"
729/// (因为 HK_Future 在 HKT 23:00 确实 Closed)。修:独立 TrdMarket 映射函数。
730///
731/// 对齐 CLAUDE.md 核心原则"与 C++ OpenD 零差异":C++ `Trd_Common.TrdMarket` 和
732/// `Qot_Common.QotMarket` 一直是独立 enum,我们之前混用是 bug。
733pub fn trd_market_to_backend(trd_market: i32) -> Option<QuoteMktType> {
734    match trd_market {
735        1 | 4 => Some(QuoteMktType::HK), // HK / HKCC → HK_Security
736        2 => Some(QuoteMktType::US),     // US stock
737        3 => Some(QuoteMktType::SH),     // CN A 股(默认 SH;SZ 由 code 前缀区分,
738        // 但 market-state 查询粒度到不了 symbol 层,先用 SH)
739        5 => Some(QuoteMktType::HKFuture), // Futures
740        6 | 124 => Some(QuoteMktType::SGSecurity),
741        7 => Some(QuoteMktType::DigitalCcy),
742        15 | 126 => Some(QuoteMktType::JPSecurity),
743        111 | 125 => Some(QuoteMktType::MYSecurity),
744        // AU=8 / CA=112: C++ NN_QuoteMktType 当前没有 AU/CA 股票枚举,保持 None。
745        _ => None,
746    }
747}
748
749// ===== 内部函数 =====
750
751/// 解压 gzip/zlib 压缩数据
752fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>> {
753    if data.is_empty() {
754        return Ok(vec![]);
755    }
756
757    let mut decoder = GzDecoder::new(data);
758    let mut decompressed = Vec::new();
759    match decoder.read_to_end(&mut decompressed) {
760        Ok(_) => Ok(decompressed),
761        Err(_) => {
762            // 可能不是 gzip 压缩,尝试 zlib
763            let mut decoder = flate2::read::ZlibDecoder::new(data);
764            let mut decompressed = Vec::new();
765            match decoder.read_to_end(&mut decompressed) {
766                Ok(_) => Ok(decompressed),
767                Err(_) => {
768                    // 可能本身就没压缩,直接返回原始数据
769                    tracing::debug!(len = data.len(), "data not compressed, using raw");
770                    Ok(data.to_vec())
771                }
772            }
773        }
774    }
775}
776
777/// 后端 InstrumentTypeV2 → FTAPI NN_QuoteSecurityType (V1) 映射
778/// 对齐 C++ QuoteSecurityTypeV2ToV1()
779fn instrument_type_v2_to_v1(type_v2: u32) -> u32 {
780    match type_v2 {
781        1 | 13 => 1,          // BOND, OTC_BOND → Bond
782        3 => 3,               // EQUITY → Eqty
783        2 | 4 | 12 | 19 => 4, // WEALTH_MANAGE, FUND, TRUST, OLD_OTC_STRUCT_NOTES → Trust
784        5 => 5,               // WARRANT → Warrant
785        6 => 6,               // INDEX → Index
786        7 => 7,               // PLATE → Plate
787        8 => 8,               // OPTION → Drvt
788        15 => 9,              // PLATE_SET → PlateSet
789        9 => 10,              // FUTURE → Future
790        10 => 11,             // FOREX → Forex
791        _ => 0,               // Unknown
792    }
793}
794
795/// 从 CSStockItem 解析 StockInfo
796fn parse_stock_item(item: &stock_list_sync_svr::CsStockItem) -> StockInfo {
797    // C++ 只使用 instrument_type_v2 并通过 QuoteSecurityTypeV2ToV1 转换
798    // V1 instrument_Type 字段完全不使用(对齐 C++ SecListDBUpdater)
799    let instrument_type = if let Some(v2) = item.instrument_type_v2 {
800        instrument_type_v2_to_v1(v2)
801    } else {
802        0 // 无 V2 时默认 Unknown,与 C++ 保持一致
803    };
804
805    StockInfo {
806        stock_id: item.stock_id,
807        sequence: item.sequence,
808        code: item.code.clone(),
809        name_sc: item.sc_name.clone().unwrap_or_default(),
810        name_tc: item.tc_name.clone().unwrap_or_default(),
811        name_en: item.eng_name.clone().unwrap_or_default(),
812        market_code: item.market_code.unwrap_or(0),
813        instrument_type,
814        spread_table_code: item.spread_table_code.unwrap_or(0),
815        sub_instrument_type: item.sub_instrument_type.unwrap_or(0),
816        sub_instrument_type_v2: item.sub_instrument_type_v2.unwrap_or(0),
817        lot_size: item.lot_size.unwrap_or(0),
818        currency_code: item.currency_code.unwrap_or(0),
819        listing_date: item.listing_date.unwrap_or(0),
820        delisting: item.delisting_flag.unwrap_or(0) != 0,
821        delete_flag: item.delete_flag.unwrap_or(0) != 0,
822        warrnt_stock_owner: item.warrnt_stock_owner.unwrap_or(0),
823        warrant_type: item.warrnt_type.unwrap_or(0),
824        no_search: item.no_search.unwrap_or(0) != 0,
825        // v1.4.106 codex F5: 期货主连合约字段 (proto field 40/41)
826        future_origin_id: item.origin_id.unwrap_or(0),
827        zhuli_id: item.zhuli_id.unwrap_or(0),
828        cc_origin: item.cc_origin.clone().unwrap_or_default(),
829        cc_destination: item.cc_destination.clone().unwrap_or_default(),
830        // v1.4.110 final E.5 P2#6: 补 exchange + listed_exchange (proto field 57/74)
831        // C++ Quote/stock_list_sync.proto:215,221. 数据驱动来源, 优先于 pattern fallback.
832        exchange: item.exchange.clone().unwrap_or_default(),
833        listed_exchange: item.listed_exchange.clone().unwrap_or_default(),
834    }
835}
836
837#[cfg(test)]
838mod tests;