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
16mod market;
17mod parse;
18
19use market::market_state_priority;
20#[cfg(test)]
21use market::quote_mkt_to_nn_mkt_type;
22pub use market::{
23    QuoteMktType, make_quote_reserved, market_id_matches, qot_market_to_backend,
24    trd_market_to_backend,
25};
26use parse::parse_stock_item;
27
28/// 股票列表同步命令 ID
29pub const CMD_UPDATE_SEC_LIST: u16 = 6746;
30/// 注册市场事件推送
31pub const CMD_EVENT_NOTICE_SUB: u16 = 6822;
32/// 拉取市场状态
33pub const CMD_PULL_EVENT_NOTICE: u16 = 6823;
34
35/// 缓存的证券信息 (从 CSStockItem 解析)
36#[derive(Debug, Clone)]
37pub struct StockInfo {
38    pub stock_id: u64,
39    pub sequence: u64,
40    pub code: String,
41    pub name_sc: String,
42    pub name_tc: String,
43    pub name_en: String,
44    /// C++ `Ndt_Qot_SecInfo::szNameJp`, from stock_list_sync `CSStockItem.ja_name` field 65.
45    pub name_jp: String,
46    pub market_code: u32,
47    pub instrument_type: u32,
48    /// C++ `Ndt_Qot_SecInfo::nSpreadCode` data source.
49    ///
50    /// Source: stock_list_sync `CSStockItem.spread_table_code` field 13.
51    pub spread_table_code: u32,
52    pub sub_instrument_type: u32,
53    /// C++ `Ndt_Qot_SecInfo::enSubTypeV2` data source.
54    ///
55    /// Source: stock_list_sync `CSStockItem.sub_instrument_type_v2` field 69.
56    /// v10.6 `GetCompanyProfile` uses this to split Trust/ETF routing exactly like
57    /// C++ `APIServer_Qot_CompanyProfile.cpp:55-63`.
58    pub sub_instrument_type_v2: u32,
59    pub lot_size: u32,
60    pub currency_code: u32,
61    pub listing_date: u32,
62    pub delisting: bool,
63    pub delete_flag: bool,
64    /// 窝轮所属正股 ID (warrnt_stock_owner), 0 表示无
65    pub warrnt_stock_owner: u64,
66    /// C++ internal `NN_QotWarrantType` from stock-list `warrnt_type`.
67    ///
68    /// Values 1-5 match public `Qot_Common.WarrantType`; values 6/7 are C++
69    /// internal DLC Long/Short and are filtered out by `GetReference(WARRANT)`.
70    pub warrant_type: u32,
71    /// 不可搜索标记 (no_search), true 表示不可搜索
72    pub no_search: bool,
73    /// v1.4.106 codex F5: 期货主连合约关联 stock_id (proto field 41 origin_id).
74    ///
75    /// 含义 (对齐 C++ `Ndt_Qot_SecInfo::nFutureOriginID`): 当前 row 是**主连合约**
76    /// (e.g. `HSImain`, `NQmain`) 时, `origin_id` 指向真实月份合约的 stock_id;
77    /// 普通合约 (`HSI2604` / `NQ2606`) 此字段为 0.
78    ///
79    /// **F5 用途**: PlaceOrder 在 `IsFuturesTrdMarket && origin_id != 0` 时
80    /// 异步发 CMD 6747 拉真实合约 code, 用真实 code 下单 (对齐 C++
81    /// `APIServer_Trd_PlaceOrder.cpp:632-650` `ReqMainLinkContract` 流程).
82    pub future_origin_id: u64,
83    /// v1.4.106 codex F5: 期货主力合约 stock_id (proto field 40 zhuli_id).
84    ///
85    /// 含义: 主连合约持有此字段, 指向当前**主力合约** stock_id (流动性最大).
86    /// CMD 6747 响应解析此字段后用 `id_to_key` 反查 code → 替换 PlaceOrder
87    /// `c2s.code` 后下发. 普通合约此字段为 0.
88    pub zhuli_id: u64,
89    /// Crypto 货币对左侧货币 (C++ `CSStockItem.cc_origin`, proto field 60).
90    pub cc_origin: String,
91    /// Crypto 货币对右侧货币 (C++ `CSStockItem.cc_destination`, proto field 61).
92    pub cc_destination: String,
93    /// v1.4.110 final E.5 P2#6: 交易所 (proto field 57, e.g. "SEHK", "NASDAQ").
94    ///
95    /// 数据驱动 fallback (pitfall #35 C++ 数据驱动 vs Rust 启发式): trade
96    /// handler 的 `derive_exchange_str` 应优先用此字段, 缺失时再 fallback 到
97    /// pattern match heuristic. backend 不下发此字段时为空串.
98    pub exchange: String,
99    /// v1.4.110 final E.5 P2#6: 上市交易所 (proto field 74).
100    ///
101    /// 通常与 `exchange` 相同, dual-listed ADR / 双重主要上市等场景下不同
102    /// (e.g. 港股通的 H 股可能 exchange=HKEX, listed_exchange=SEHK).
103    pub listed_exchange: String,
104}
105
106/// 同步结果
107pub struct SyncResult {
108    pub total_stocks: usize,
109    pub next_interval_secs: u32,
110    pub server_checksum: Option<StockListServerChecksum>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct ChecksumV2 {
115    pub low: i64,
116    pub high: i64,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct StockListServerChecksum {
121    pub stock_count: Option<i32>,
122    pub id_v2: Option<ChecksumV2>,
123    pub seq_v2: Option<ChecksumV2>,
124}
125
126fn checksum_v2_from_proto(value: &crate::proto_internal::ftcmd6741::CheckSum) -> ChecksumV2 {
127    ChecksumV2 {
128        low: value.low.unwrap_or(0),
129        high: value.high.unwrap_or(0),
130    }
131}
132
133/// 同步股票列表 (CMD 6746)
134///
135/// 从后端增量拉取股票列表。响应经 zlib 压缩,需解压后解析。
136/// 返回同步的股票总数和下次同步间隔。
137pub async fn sync_stock_list<F>(
138    backend: &BackendConn,
139    version: &std::sync::atomic::AtomicU64,
140    on_stock: &mut F,
141) -> Result<SyncResult>
142where
143    F: FnMut(StockInfo),
144{
145    use prost::Message;
146
147    let mut total = 0usize;
148    let mut next_interval = 150u32;
149    let mut server_checksum = None;
150    const MAX_PAGES: usize = 500; // 防止无限循环
151
152    for _page in 0..MAX_PAGES {
153        let cur_version = version.load(std::sync::atomic::Ordering::Relaxed);
154
155        let req = stock_list_sync_svr::StockListReq {
156            stock_list_version: cur_version,
157            if_req_all: 0, // delta sync
158            special_version: Some(1),
159        };
160
161        let resp = backend
162            .request(CMD_UPDATE_SEC_LIST, req.encode_to_vec())
163            .await?;
164
165        // 响应是 zlib (gzip) 压缩的,需要解压
166        let decompressed = decompress_gzip(&resp.body)?;
167
168        let parsed: stock_list_sync_svr::StockListRsp = Message::decode(decompressed.as_slice())
169            .map_err(|e| FutuError::Codec(format!("CMD6746 decode failed: {e}")))?;
170
171        if parsed.result != 0 {
172            return Err(FutuError::Codec(format!(
173                "CMD6746 error: result={}",
174                parsed.result
175            )));
176        }
177
178        // 处理每个 CSStockItem
179        let batch_count = parsed.arry_items.len();
180        for item in &parsed.arry_items {
181            let info = parse_stock_item(item);
182            if info.delete_flag {
183                // 删除的股票也通知上层处理
184            }
185            on_stock(info);
186            total += 1;
187        }
188
189        // 更新版本号
190        if let Some(max_ver) = parsed.array_max_version {
191            version.store(max_ver, std::sync::atomic::Ordering::Relaxed);
192        }
193
194        // 更新下次请求间隔
195        if let Some(interval) = parsed.next_request_interval {
196            next_interval = interval;
197        }
198
199        let all_count = parsed.all_count.unwrap_or(0);
200        tracing::debug!(
201            batch = batch_count,
202            total,
203            all_count,
204            version = version.load(std::sync::atomic::Ordering::Relaxed),
205            "stock list sync batch"
206        );
207
208        // if_all_rsp != 0 表示拉取完成
209        if parsed.if_all_rsp.unwrap_or(1) != 0 {
210            server_checksum = Some(StockListServerChecksum {
211                stock_count: parsed.stock_count,
212                id_v2: parsed.id_check_sum_v2.as_ref().map(checksum_v2_from_proto),
213                seq_v2: parsed.seq_check_sum_v2.as_ref().map(checksum_v2_from_proto),
214            });
215            // 校验 checksum (debug-log only, 不 enforce mismatch)
216            // v1.4.110 final E.5 P3#8: id_check_sum / seq_check_sum 已标 deprecated
217            // (新客户端走 id_check_sum_v2 / seq_check_sum_v2, 64+64 bit 拆分避免溢出).
218            // server 仍下发老字段一段时间, Rust 保留读取兼容. allow(deprecated) 显式表态.
219            // Removal trigger: 真机/线上日志连续一个 minor release 只出现 v2 checksum
220            // 且旧字段始终为 None/0 后,删除本兼容读取与 allow(deprecated)。
221            #[allow(deprecated)]
222            if let (Some(server_id_sum), Some(server_seq_sum)) =
223                (parsed.id_check_sum, parsed.seq_check_sum)
224                && (server_id_sum > 0 || server_seq_sum > 0)
225            {
226                tracing::debug!(server_id_sum, server_seq_sum, "server checksums received");
227            }
228            // v2 校验和 (新 server 下发, debug-log only, 同样不 enforce).
229            if let (Some(id_v2), Some(seq_v2)) = (&parsed.id_check_sum_v2, &parsed.seq_check_sum_v2)
230            {
231                tracing::debug!(
232                    id_low = id_v2.low,
233                    id_high = id_v2.high,
234                    seq_low = seq_v2.low,
235                    seq_high = seq_v2.high,
236                    "server v2 checksums received"
237                );
238            }
239            break;
240        }
241    }
242
243    Ok(SyncResult {
244        total_stocks: total,
245        next_interval_secs: next_interval,
246        server_checksum,
247    })
248}
249
250/// 注册市场事件推送 (CMD 6822)
251///
252/// 需要对每个市场单独发送注册请求。
253/// header reserved[0] = market_type, reserved[1] = 0 (SECURITY)
254pub async fn register_markets(backend: &BackendConn) -> Result<()> {
255    // v1.4.57 P2: 加 HKFuture / USFuture / SGFuture / JPFuture 订阅,让 CMD
256    // 6823 全市场 snapshot 能返期货市场状态(之前 HK 期货一直 0)。
257    // 对齐 C++ `NNBiz_Qot_EventNotice.cpp::SubEventNotice()`:
258    // - 港期必须同时订阅 FUT_HK=5 和 FUT_HK_NEW=6。
259    // - 10.5.6508 新增 crypto,需订阅 NN_QuoteMktType_CRYPTO=17。
260    // - 10.6 GetGlobalState 新增 SG/MY/JP 股票市场状态,需订阅对应
261    //   NN_QuoteMktType_SG_SECURITY/JP_SECURITY/MY_SECURITY。
262    let markets = [
263        (QuoteMktType::HK, "HK"),
264        (QuoteMktType::HKFuture, "HKFuture"),
265        (QuoteMktType::HKFuture2, "HKFuture2"),
266        (QuoteMktType::HKOption, "HKOption"),
267        (QuoteMktType::US, "US"),
268        (QuoteMktType::USOption, "USOption"),
269        (QuoteMktType::SH, "SH"),
270        (QuoteMktType::SHKC, "SHKC"),
271        (QuoteMktType::SZ, "SZ"),
272        (QuoteMktType::USFuture, "USFuture"),
273        (QuoteMktType::SGFuture, "SGFuture"),
274        (QuoteMktType::JPFuture, "JPFuture"),
275        (QuoteMktType::JPSecurity, "JPSecurity"),
276        (QuoteMktType::SGSecurity, "SGSecurity"),
277        (QuoteMktType::MYSecurity, "MYSecurity"),
278        // Existing Rust extension: crypto status is pulled by C++ PullEventNotice
279        // and v1.4.68 wired CMD6822 too; keep it to avoid regressing crypto state.
280        (QuoteMktType::DigitalCcy, "DigitalCcy"),
281    ];
282
283    for (mkt, name) in &markets {
284        let req = ft_cmd6822::RegisterReq { reserved: 0 };
285        let reserved = make_quote_reserved(*mkt, 0); // ex_type = SECURITY
286
287        match backend
288            .request_with_reserved(
289                CMD_EVENT_NOTICE_SUB,
290                prost::Message::encode_to_vec(&req),
291                reserved,
292            )
293            .await
294        {
295            Ok(resp) => {
296                let parsed: ft_cmd6822::RegisterRes = prost::Message::decode(resp.body.as_ref())
297                    .unwrap_or(ft_cmd6822::RegisterRes { res: -1 });
298                if parsed.res != 0 {
299                    tracing::warn!(
300                        market = name,
301                        res = parsed.res,
302                        "CMD6822 registration failed"
303                    );
304                } else {
305                    tracing::debug!(market = name, "CMD6822 registered");
306                }
307            }
308            Err(e) => {
309                tracing::warn!(market = name, error = %e, "CMD6822 request failed");
310            }
311        }
312    }
313
314    Ok(())
315}
316
317/// 拉取市场状态 (CMD 6823)
318pub async fn pull_market_status(backend: &BackendConn) -> Result<Vec<MarketStatus>> {
319    let markets = [
320        (QuoteMktType::HK, "HK"),
321        (QuoteMktType::US, "US"),
322        (QuoteMktType::SH, "SH"),
323    ];
324
325    let mut all_status = Vec::new();
326
327    for (mkt, name) in &markets {
328        let req = ft_cmd6823::MarketStatusReq { reserved: 0 };
329        let reserved = make_quote_reserved(*mkt, 0);
330
331        match backend
332            .request_with_reserved(
333                CMD_PULL_EVENT_NOTICE,
334                prost::Message::encode_to_vec(&req),
335                reserved,
336            )
337            .await
338        {
339            Ok(resp) => {
340                let parsed = decode_market_status_rsp(resp.body.as_ref())?;
341                for s in &parsed.res_status {
342                    all_status.push(MarketStatus {
343                        market_id: s.id,
344                        status: s.status.unwrap_or(0),
345                        status_text: s.status_text_sc.clone().unwrap_or_default(),
346                    });
347                }
348                tracing::debug!(
349                    market = name,
350                    count = parsed.res_status.len(),
351                    "CMD6823 status"
352                );
353            }
354            Err(e) => {
355                tracing::warn!(market = name, error = %e, "CMD6823 request failed");
356            }
357        }
358    }
359
360    Ok(all_status)
361}
362
363fn decode_market_status_rsp(body: &[u8]) -> Result<ft_cmd6823::MarketStatusRsp> {
364    prost::Message::decode(body).map_err(FutuError::Proto)
365}
366
367/// 市场状态
368#[derive(Debug, Clone)]
369pub struct MarketStatus {
370    pub market_id: u32,
371    pub status: u32,
372    pub status_text: String,
373}
374
375/// v1.4.48 修:CMD 6823 返回**全市场**子交易所列表(~100+ 条),一次查询即可拿所有市场
376/// 状态。旧版 (v1.4.47) 8 次查询 + 盲取 `statuses[0]` 导致误读市场状态。
377///
378/// 参考 C++ `market_tradingDay.proto::MarketID` enum:
379/// - 1-4: HK 股票 (主板/创业板/纳斯达克/扩展板)
380/// - 5-6: HK Future (old / new)
381/// - 7-8: HK Option
382/// - 10-29: US 股票 (NYSE, NASDAQ, AMEX, ...)
383/// - 30-40: CN A 股 (SH / SZ)
384/// - 41-45: US Option
385/// - 60-109: US Future (NYMEX=60, COMEX=70, CBOT 80-84, CME 90-99, CBOE 100-109)
386/// - 110-119: HK Future 扩展
387/// - 120-123: Forex
388/// - 130-159: Bonds
389/// - 50-51: Stock Connect (港股通 / A股通) — v1.4.49 加
390/// - 60-109: US Future (NYMEX, COMEX, CBOT, CME, CBOE)
391/// - 110-119: HK Future 扩展
392/// - 120-123: Forex
393/// - 130-159: Bonds — v1.4.49 加
394/// - 160-179: SGX Future
395/// - 180-184: SGX Market (新加坡股票) — v1.4.49 加
396/// - 185-194: JPX Future
397/// - 260-359: Global Index — v1.4.49 加
398/// - 360-459: Digital Currency — v1.4.49 加
399/// - 460-559: Treasury Yield — v1.4.49 加
400/// - 560-569: Fund — v1.4.50 加(`NN_QuoteMktID_FUND_*`)
401/// - 570-579: HK Index Option 扩展 — v1.4.50 加入 HKOption
402/// - 830-849: JP TSE 股票 — v1.4.111 加(C++ `NN_QuoteMktID_JP_TSE_*`)
403/// - 1000-1049: HK HSI Index — v1.4.50 加入 HK(C++ 源码确认归 HK 市场)
404/// - 1200-1249: US New VIX — v1.4.50 加入 US(C++ 源码确认归 US 市场)
405/// - 1350-1399: MY 股票 — v1.4.111 加(C++ `NN_QuoteMktID_MY_Security_*`)
406///
407/// v1.4.48 新:一次 CMD 6823 query 拿全市场状态 snapshot,避免重复 8 次查询。
408///
409/// **v1.4.55 修正**(同事反馈 `market_hkfuture: NONE` 即使夜盘交易中):
410/// backend 对 CMD 6823 在 reserved=HK 时返的 snapshot **不含期货市场**
411/// (HK 主板 / 港股通 / US / CN / Bond 等都在,但 HK/US/SG/JP 期货 market_id 缺席)。
412/// 真机实测 v1.4.48 一次 snapshot 里 market_hk/us/sh/sz 都能填,但
413/// market_hk_future / market_us_future / market_sg_future / market_jp_future 全是
414/// `NONE`。
415///
416/// **修法**:并发发 5 个 CMD 6823(HK / HKFuture / USFuture / SGFuture /
417/// JPFuture),合并结果按 `market_id` 去重。backend 对 `reserved=HKFuture` 等
418/// 期货 market type 会下发对应 market_id ∈ {5, 6, 110-119} (HK) / 60-109 (US) /
419/// 160-179 (SG) 的状态条目,合并后 `pick_market_state(HKFuture)` 就能命中。
420///
421/// 代价:5 个并发请求而非 1 个(~5x backend load),但 CMD 6823 是轻量 snapshot,
422/// 实测每次 ~10-20ms,合计 <100ms 仍在可接受范围(global_state 不是高频 API)。
423pub async fn pull_all_market_status(backend: &BackendConn) -> Result<Vec<MarketStatus>> {
424    use futures::future::join_all;
425
426    let req = ft_cmd6823::MarketStatusReq { reserved: 0 };
427    let req_bytes = prost::Message::encode_to_vec(&req);
428
429    // 按 C++ `PullEventNotice` 的 market type 维度主动拉取状态。Crypto 在
430    // C++ 10.5.6508 中走 NN_QuoteMktType_CRYPTO=17。
431    let market_types = [
432        QuoteMktType::HK,        // 非期货市场全量(HK 主板 / 港股通 / US / CN / Bond / etc)
433        QuoteMktType::HKFuture,  // 老港期(NN_QuoteMktType_FUT_HK=5,MktID 5)
434        QuoteMktType::HKFuture2, // 主要港期(NN_QuoteMktType_FUT_HK_NEW=6,MktID 6 / 110-119)
435        QuoteMktType::HKOption,  // HK 期权(NN_QuoteMktType_HK_OPTIONS=9)
436        QuoteMktType::US,        // US 股票(NN_QuoteMktType_US=2)
437        QuoteMktType::USOption,  // US 期权(NN_QuoteMktType_US_OPTIONS=7)
438        QuoteMktType::SH,        // SH A 股(NN_QuoteMktType_SH=3)
439        QuoteMktType::SHKC,      // 科创板(NN_QuoteMktType_SH_KCB=10)
440        QuoteMktType::SZ,        // SZ A 股(NN_QuoteMktType_SZ=4)
441        QuoteMktType::USFuture,  // US 期货(NN_QuoteMktType_US_FUT=8,MktID 60-109)
442        QuoteMktType::SGFuture,  // SG 期货(NN_QuoteMktType_SG_FUTURE=13,MktID 160-179)
443        QuoteMktType::JPFuture,  // JP 期货(NN_QuoteMktType_JP_FUTURE=16,MktID 185-194)
444        QuoteMktType::JPSecurity, // JP 股票(NN_QuoteMktType_JP_SECURITY=25,MktID 830-849)
445        QuoteMktType::DigitalCcy, // 数字货币(NN_QuoteMktType_CRYPTO=17,MktID 360-459)
446        QuoteMktType::SGSecurity, // SG 股票(NN_QuoteMktType_SG_SECURITY=15,MktID 180-184)
447        QuoteMktType::MYSecurity, // MY 股票(NN_QuoteMktType_MY_SECURITY=27,MktID 1350-1399)
448    ];
449
450    let pulls = market_types.iter().map(|mkt| {
451        let reserved = make_quote_reserved(*mkt, 0);
452        let body = req_bytes.clone();
453        async move {
454            backend
455                .request_with_reserved(CMD_PULL_EVENT_NOTICE, body, reserved)
456                .await
457        }
458    });
459    let results = join_all(pulls).await;
460
461    let mut seen_ids = std::collections::HashSet::new();
462    let mut statuses: Vec<MarketStatus> = Vec::new();
463    for (mkt, resp_result) in market_types.iter().zip(results) {
464        match resp_result {
465            Ok(resp) => {
466                let parsed = decode_market_status_rsp(resp.body.as_ref())?;
467                for s in &parsed.res_status {
468                    if seen_ids.insert(s.id) {
469                        statuses.push(MarketStatus {
470                            market_id: s.id,
471                            status: s.status.unwrap_or(0),
472                            status_text: s.status_text_sc.clone().unwrap_or_default(),
473                        });
474                    }
475                }
476            }
477            Err(e) => {
478                // 单个 market 拉失败不 block 整体,warn 继续
479                tracing::warn!(
480                    market_type = ?mkt,
481                    error = %e,
482                    "v1.4.55 CMD6823 one market pull failed, continuing with others"
483                );
484            }
485        }
486    }
487
488    tracing::debug!(
489        total_entries = statuses.len(),
490        market_types_pulled = market_types.len(),
491        "v1.4.55 CMD6823 multi-market snapshot fetched"
492    );
493    for s in &statuses {
494        tracing::trace!(
495            market_id = s.market_id,
496            status = s.status,
497            status_text = %s.status_text,
498            "CMD6823 entry"
499        );
500    }
501
502    // v1.4.57 P2 (外部报告 #4): 额外打 DEBUG 汇总所有 market_id 值(逗号分隔),
503    // 方便在字段级别 diagnostic "HK 期货 market_id 在哪个范围 backend 返" 这类问题。
504    // 发版日志开 RUST_LOG=debug 时可看见,正常级别不出。
505    if !statuses.is_empty() && tracing::enabled!(tracing::Level::DEBUG) {
506        let ids: Vec<String> = statuses.iter().map(|s| s.market_id.to_string()).collect();
507        tracing::debug!(
508            count = statuses.len(),
509            market_ids = %ids.join(","),
510            "v1.4.57 CMD6823 all received market_ids (for HK futures / etc. diagnostic)"
511        );
512    }
513
514    Ok(statuses)
515}
516
517/// v1.4.48 helper: 从全市场 snapshot 里按 `QuoteMktType` 过滤 + 取状态。
518///
519/// **v1.4.71 D5 fix**(external reviewer v1.4.69 `market_hk_future=0` 报告,代码级定位到本函数):
520/// 早期 `.find` 取第一个匹配时,公共 HK futures 查询容易先碰到 `FUT_HK=5`
521/// (老港期,已 deprecated 但 backend 仍下发,status 常为 0=NOT_TRADING),
522/// 从而遮蔽 `FUT_HK_NEW=6`(主要港期,open 时 status=15=FUTURE_DAY_OPEN)。
523/// 现在 `HKFuture` / `HKFuture2` 的 market_id 归属已经按 C++
524/// `NN_QuoteMktType_From_NN_QuoteMktID` 拆开,主港期全局状态取 `HKFuture2`。
525///
526/// **v1.4.73 B1 D5 升级**(external reviewer v1.4.71 AI tester 报告 "Rust 对 HK/JP/SG 期货全
527/// 返 15,C++ SG 返 18=FUTURE_DAY_WAIT_OPEN"):同一个 `QuoteMktType` 范围
528/// 内 backend 可能下发多条 non-zero status(如 SGFuture 多个子交易所),当
529/// 同时有"开盘"类(15 / 23 / 3 / 5 / 13)和"等待开盘 / 休市"类(2 / 4 / 6 /
530/// 11 / 14 / 16 / 17 / 18 / 9 / 1)时,**优先返后者**(更精确描述市场实际情况:
531/// 不能交易的状态永远比"能交易"更安全,防止客户端误判"等待开盘"为"已开盘"下
532/// 单被拒)。
533///
534/// 对齐 C++ `APIServer_GlobalState.cpp:87-94` 的语义 —— C++ 仅取
535/// `pMktStateInfo[0]`,但是 backend 层 `INNData_Qot_EventNotice::GetMarketStateInfo`
536/// 按 enum 精确 dispatch,不会混多个 market_id。Rust 的 `market_id_matches`
537/// 按 range 匹配,会得到多条,所以要在 selection 上做 "prefer restrictive"
538/// 来对齐 C++ 实际观测行为。
539///
540/// 无匹配 → None。
541pub fn pick_market_state(all: &[MarketStatus], mkt: QuoteMktType) -> Option<u32> {
542    let mut best: Option<u32> = None;
543    let mut best_priority: u8 = 0;
544
545    for s in all.iter().filter(|s| market_id_matches(mkt, s.market_id)) {
546        let pri = market_state_priority(s.status);
547        if pri > best_priority {
548            best = Some(s.status);
549            best_priority = pri;
550        } else if best.is_none() {
551            best = Some(s.status);
552        }
553    }
554    best
555}
556
557/// v1.4.73 B1 D5 升级:按"描述市场实际情况"优先级对 `QotMarketState` 打分。
558///
559/// | Priority | Meaning | Values |
560/// |---|---|---|
561/// | 0 | None / zero | 0 |
562/// | 1 | Open / 可交易 | 3 (Morning), 5 (Afternoon), 13 (NightOpen), 15 (FutureDayOpen), 23 (FutureOpen) |
563/// | 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) |
564///
565/// 同一 market 多个 market_id 返 non-zero 时,按 priority 选最高。priority
566/// 相同时选第一个(保持 v1.4.71 first-non-zero 行为)。
567///
568/// 对齐 proto `Qot_Common.QotMarketState` 值(0-19 全覆盖;未来新增 variant
569/// 默认归到 priority 1,保守 fallback)。
570/// 拉取单个市场的状态 (CMD 6823) — v1.4.48 legacy API,保持兼容
571///
572/// 建议用 `pull_all_market_status` + `pick_market_state` 替代(一次查询拿所有市场)。
573pub async fn pull_single_market_status(
574    backend: &BackendConn,
575    mkt: QuoteMktType,
576) -> Result<Vec<MarketStatus>> {
577    let all = pull_all_market_status(backend).await?;
578    Ok(all
579        .into_iter()
580        .filter(|s| market_id_matches(mkt, s.market_id))
581        .collect())
582}
583
584// FTAPI QotMarket -> backend QuoteMktType mapping lives in stock_list/market.rs.
585// ===== 内部函数 =====
586
587/// 解压 gzip/zlib 压缩数据
588fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>> {
589    if data.is_empty() {
590        return Ok(vec![]);
591    }
592
593    let mut decoder = GzDecoder::new(data);
594    let mut decompressed = Vec::new();
595    match decoder.read_to_end(&mut decompressed) {
596        Ok(_) => Ok(decompressed),
597        Err(_) => {
598            // 可能不是 gzip 压缩,尝试 zlib
599            let mut decoder = flate2::read::ZlibDecoder::new(data);
600            let mut decompressed = Vec::new();
601            match decoder.read_to_end(&mut decompressed) {
602                Ok(_) => Ok(decompressed),
603                Err(_) => {
604                    // 可能本身就没压缩,直接返回原始数据
605                    tracing::debug!(len = data.len(), "data not compressed, using raw");
606                    Ok(data.to_vec())
607                }
608            }
609        }
610    }
611}
612
613#[cfg(test)]
614mod tests;