futu_backend/main_link_contract.rs
1// v1.4.106 codex F5: 期货主连合约真实合约解析 (CMD 6747).
2//
3// 对齐 C++ `APIServer_Trd_PlaceOrder.cpp:688-704` 的
4// `INNBiz_Qot_SecList::ReqMainLinkContract(secInfo.nFutureOriginID)` —— 当
5// PlaceOrder 收到主连合约 symbol (如 `HSImain` / `NQmain`) 时, 异步发 CMD 6747
6// 拉真实月份合约的 stock_id, 用真实合约 code 替换 c2s.code 后下发 backend.
7//
8// **C++ 原始流程** (`NNBiz_Qot_SecList.cpp:216`):
9// ```cpp
10// ReqKey ReqMainLinkContract(u64_t nStockID) {
11// stock_list_sync_svr::StockQueryReq pbReq;
12// pbReq.add_stock_id(nStockID);
13// ReqKey nReqKey = SendTCPProto_ProtoBuf(NN_TCPProtoCategory_Quote,
14// NN_ProtoCmd_Qot_Pull_StaticInfo, pbReq);
15// m_setMainLinkReq.insert(nReqKey);
16// return nReqKey;
17// }
18// ```
19//
20// C++ 用 async event-driven 模型 (`OnOMEvent_Reply_PullMainContract`,
21// `APIServer_Trd_PlaceOrder.cpp:785-820`): `p2=zhuli_id` 只用于失败判断,
22// `p3=stock_id` 才传给 `GetAPIStock(p3)` 反查 code. Rust 改 sync `async/await`:
23// caller 直接 `query_main_link_contract(broker, future_origin_id).await` 拿
24// `stock_id` 后用 cache.id_to_key 反查 code.
25//
26// **proto 定义在 proto-internal/stock_list_sync.proto** (StockQueryReq +
27// StockQueryRsp, 字段对齐 backend `stock_list_sync_svr` package).
28//
29// **CMD 6747 = NN_ProtoCmd_Qot_Pull_StaticInfo**, see C++
30// `FutuOpenD/Src/NNBase/NNBase_Define_ProtoCmd.h:85`.
31//
32// **限制**: backend StockQueryReq 注释说"最多请求 50 只股票", 但 PlaceOrder
33// 场景每次只查 1 个 future_origin_id, 远低于此限.
34
35use crate::conn::BackendConn;
36use crate::proto_internal::stock_list_sync_svr;
37use futu_core::error::{FutuError, Result};
38use prost::Message;
39use std::sync::Arc;
40
41/// CMD 6747: NN_ProtoCmd_Qot_Pull_StaticInfo
42///
43/// 拉一组 stock_id 的最新静态信息 (CSStockItem), 用于期货主连合约 ReqMainLinkContract
44/// 路径返当前主力合约真实 stock_id + code.
45pub const CMD_QOT_PULL_STATIC_INFO: u16 = 6747;
46
47/// v1.4.106 codex F5: 期货主连合约 → 真实月份合约 stock_id 解析结果.
48#[derive(Debug, Clone)]
49pub struct MainLinkContractInfo {
50 /// 真实月份合约 stock_id (C++ OMEvent `p3 = stock_id`).
51 ///
52 /// 主连 symbol (HSImain) 的 origin_id 是当前主力月份合约 stock_id.
53 pub real_stock_id: u64,
54 /// 真实月份合约 code (e.g. "HSI2604" / "NQ2606"), 从响应中 C++ 会作为
55 /// `p3` 的那一项 `code` 字段读. 如果 backend 未带 code, 则为空 string ——
56 /// caller 需要从本地 stock_list cache `id_to_key` 反查.
57 pub real_code: String,
58}
59
60/// v1.4.106 codex F5: 发 CMD 6747 拉指定 future_origin_id 的主力合约真实信息.
61///
62/// **入参**: `origin_stock_id` 是主连合约 row 的 origin_id 字段
63/// (`Ndt_Qot_SecInfo::nFutureOriginID`), 必须 > 0. 0 表示不是主连合约 — caller
64/// 应该在调本 fn 前用 `info.future_origin_id != 0` 提前过滤.
65///
66/// **响应解析** (`StockQueryRsp.arry_items`):
67/// 1. 找 stock_id == 入参 origin_stock_id 的 row。
68/// 2. 读该 row 的 `zhuli_id`; C++ `OnReply_MainLinkContract` 把它放进
69/// OMEvent `p2` 并只用作失败判断 (`p2 == 0` => UnknownStock)。
70/// 3. 如果 zhuli_id == 0, 表示 backend 没识别该 stock_id 是主力链路, 返
71/// `Err(FutuError::Codec("..."))` —— caller 应该 fallback 用原 symbol 下单
72/// (loud failure, 不 silent).
73/// 4. C++ 随后把同一 row 的 `stock_id` 放进 OMEvent `p3`, 并在
74/// `APIServer_Trd_PlaceOrder.cpp:815-817` 用 `GetAPIStock(p3)` 反查 code。
75/// 因此 Rust 的 `real_stock_id` 必须取 row.stock_id, 不是 zhuli_id。
76///
77/// **超时**: 走 `BackendConn::request` 默认 10s timeout. 如果 backend 慢或
78/// 不响应, PlaceOrder 整个流程会挂 10s 然后失败 —— 这是**故意选择**: 与 C++
79/// 行为一致 (C++ 用 OMEvent push 模型, request 在 backend 反正会响应; Rust
80/// sync await 简化但保留同样语义).
81///
82/// **错误**: 任何 backend 错误 / decode 失败 / future_origin_id 不是主力链路 →
83/// `Err(...)`.
84/// caller (PlaceOrder handler) 必须 propagate 让用户看到 loud error, 不 silent
85/// 用原 symbol (违反主连合约判定就是 silent-success 反模式, CLAUDE.md 坑 #45).
86pub async fn query_main_link_contract(
87 backend: Arc<BackendConn>,
88 origin_stock_id: u64,
89) -> Result<MainLinkContractInfo> {
90 if origin_stock_id == 0 {
91 return Err(FutuError::Codec(
92 "query_main_link_contract: origin_stock_id 必须 > 0 (主连合约 row \
93 的 origin_id 必非 0)"
94 .to_string(),
95 ));
96 }
97
98 let req = stock_list_sync_svr::StockQueryReq {
99 stock_id: vec![origin_stock_id],
100 };
101 let resp = backend
102 .request(CMD_QOT_PULL_STATIC_INFO, req.encode_to_vec())
103 .await?;
104
105 let parsed: stock_list_sync_svr::StockQueryRsp =
106 Message::decode(resp.body.as_ref()).map_err(|e| {
107 FutuError::Codec(format!(
108 "CMD 6747 (Qot_Pull_StaticInfo) StockQueryRsp decode 失败: {e}"
109 ))
110 })?;
111
112 if parsed.result != 0 {
113 return Err(FutuError::Codec(format!(
114 "CMD 6747 backend error result={} for origin_stock_id={}",
115 parsed.result, origin_stock_id
116 )));
117 }
118
119 // 找 queried row (stock_id == 入参). C++ `ReqMainLinkContract` 一次也只问
120 // 这个 stock_id; `OnReply_MainLinkContract` 从 arry_items(0) 同时取
121 // zhuli_id 和 stock_id.
122 let origin_row = match parsed
123 .arry_items
124 .iter()
125 .find(|item| item.stock_id == origin_stock_id)
126 {
127 Some(item) => item,
128 None => {
129 // backend 响应里没有这个 stock_id row — 不应发生 (我们只问了一个),
130 // 但 backend 行为不能完全控制, 防御性处理
131 return Err(FutuError::Codec(format!(
132 "CMD 6747 响应未包含 origin_stock_id={} 对应 row \
133 (arry_items.len={})",
134 origin_stock_id,
135 parsed.arry_items.len()
136 )));
137 }
138 };
139 let zhuli_id = origin_row.zhuli_id.unwrap_or(0);
140
141 if zhuli_id == 0 {
142 return Err(FutuError::Codec(format!(
143 "CMD 6747 origin_stock_id={} 对应 zhuli_id=0 \
144 (backend 未识别为主连合约 / 该期货品种当前无主力月份)",
145 origin_stock_id
146 )));
147 }
148
149 // C++ OMEvent: p2 = zhuli_id (failure gate), p3 = stock_id (used by
150 // APIServer_Trd_PlaceOrder.cpp:815 `GetAPIStock(p3)`). Keep that split.
151 let real_stock_id = origin_row.stock_id;
152 let real_code = origin_row.code.clone();
153
154 Ok(MainLinkContractInfo {
155 real_stock_id,
156 real_code,
157 })
158}
159
160#[cfg(test)]
161mod tests;