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