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;