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;