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::command_runtime::execute_qot_plaintext;
36use crate::conn::BackendConn;
37use crate::proto_internal::stock_list_sync_svr;
38use bytes::Bytes;
39use futu_core::error::{FutuError, Result};
40use futu_domain_static_data::ensure_static_info_query_backend_success;
41use prost::Message;
42use std::sync::Arc;
43
44/// CMD 6747: NN_ProtoCmd_Qot_Pull_StaticInfo
45///
46/// 拉一组 stock_id 的最新静态信息 (CSStockItem), 用于期货主连合约 ReqMainLinkContract
47/// 路径返当前主力合约真实 stock_id + code.
48pub use futu_command_spec::CMD_QOT_PULL_STATIC_INFO;
49
50/// v1.4.106 codex F5: 期货主连合约 → 真实月份合约 stock_id 解析结果.
51#[derive(Debug, Clone)]
52pub struct MainLinkContractInfo {
53 /// 真实月份合约 stock_id (C++ OMEvent `p3 = stock_id`).
54 ///
55 /// 主连 symbol (HSImain) 的 origin_id 是当前主力月份合约 stock_id.
56 pub real_stock_id: u64,
57 /// 真实月份合约 code (e.g. "HSI2604" / "NQ2606"), 从响应中 C++ 会作为
58 /// `p3` 的那一项 `code` 字段读. 如果 backend 未带 code, 则为空 string ——
59 /// caller 需要从本地 stock_list cache `id_to_key` 反查.
60 pub real_code: String,
61}
62
63/// v1.4.106 codex F5: 发 CMD 6747 拉指定 future_origin_id 的主力合约真实信息.
64///
65/// **入参**: `origin_stock_id` 是主连合约 row 的 origin_id 字段
66/// (`Ndt_Qot_SecInfo::nFutureOriginID`), 必须 > 0. 0 表示不是主连合约 — caller
67/// 应该在调本 fn 前用 `info.future_origin_id != 0` 提前过滤.
68///
69/// **响应解析** (`StockQueryRsp.arry_items`):
70/// 1. 找 stock_id == 入参 origin_stock_id 的 row。
71/// 2. 读该 row 的 `zhuli_id`; C++ `OnReply_MainLinkContract` 把它放进
72/// OMEvent `p2` 并只用作失败判断 (`p2 == 0` => UnknownStock)。
73/// 3. 如果 zhuli_id == 0, 表示 backend 没识别该 stock_id 是主力链路, 返
74/// `Err(FutuError::Codec("..."))` —— caller 应该 fallback 用原 symbol 下单
75/// (loud failure, 不 silent).
76/// 4. C++ 随后把同一 row 的 `stock_id` 放进 OMEvent `p3`, 并在
77/// `APIServer_Trd_PlaceOrder.cpp:815-817` 用 `GetAPIStock(p3)` 反查 code。
78/// 因此 Rust 的 `real_stock_id` 必须取 row.stock_id, 不是 zhuli_id。
79///
80/// **超时**: 走 `BackendConn::request` 默认 10s timeout. 如果 backend 慢或
81/// 不响应, PlaceOrder 整个流程会挂 10s 然后失败 —— 这是**故意选择**: 与 C++
82/// 行为一致 (C++ 用 OMEvent push 模型, request 在 backend 反正会响应; Rust
83/// sync await 简化但保留同样语义).
84///
85/// **错误**: 任何 backend 错误 / decode 失败 / future_origin_id 不是主力链路 →
86/// `Err(...)`.
87/// caller (PlaceOrder handler) 必须 propagate 让用户看到 loud error, 不 silent
88/// 用原 symbol (违反主连合约判定就是 silent-success 反模式, CLAUDE.md 坑 #45).
89pub async fn query_main_link_contract(
90 backend: Arc<BackendConn>,
91 origin_stock_id: u64,
92) -> Result<MainLinkContractInfo> {
93 if origin_stock_id == 0 {
94 return Err(FutuError::Codec(
95 "query_main_link_contract: origin_stock_id 必须 > 0 (主连合约 row \
96 的 origin_id 必非 0)"
97 .to_string(),
98 ));
99 }
100
101 let req = stock_list_sync_svr::StockQueryReq {
102 stock_id: vec![origin_stock_id],
103 };
104 let resp = execute_qot_plaintext(
105 backend.as_ref(),
106 CMD_QOT_PULL_STATIC_INFO,
107 Bytes::from(req.encode_to_vec()),
108 [0; 10],
109 )
110 .await?;
111
112 let parsed: stock_list_sync_svr::StockQueryRsp =
113 Message::decode(resp.body.as_ref()).map_err(|e| {
114 FutuError::Codec(format!(
115 "CMD 6747 (Qot_Pull_StaticInfo) StockQueryRsp decode 失败: {e}"
116 ))
117 })?;
118
119 if let Err(err) = ensure_static_info_query_backend_success(parsed.result) {
120 return Err(FutuError::Codec(format!(
121 "CMD 6747 backend error result={} for origin_stock_id={}",
122 match err {
123 futu_domain_static_data::StaticInfoQueryBackendError::Result(result) => result,
124 },
125 origin_stock_id
126 )));
127 }
128
129 // 找 queried row (stock_id == 入参). C++ `ReqMainLinkContract` 一次也只问
130 // 这个 stock_id; `OnReply_MainLinkContract` 从 arry_items(0) 同时取
131 // zhuli_id 和 stock_id.
132 let origin_row = match parsed
133 .arry_items
134 .iter()
135 .find(|item| item.stock_id == origin_stock_id)
136 {
137 Some(item) => item,
138 None => {
139 // backend 响应里没有这个 stock_id row — 不应发生 (我们只问了一个),
140 // 但 backend 行为不能完全控制, 防御性处理
141 return Err(FutuError::Codec(format!(
142 "CMD 6747 响应未包含 origin_stock_id={} 对应 row \
143 (arry_items.len={})",
144 origin_stock_id,
145 parsed.arry_items.len()
146 )));
147 }
148 };
149 let zhuli_id = origin_row.zhuli_id.unwrap_or(0);
150
151 if zhuli_id == 0 {
152 return Err(FutuError::Codec(format!(
153 "CMD 6747 origin_stock_id={} 对应 zhuli_id=0 \
154 (backend 未识别为主连合约 / 该期货品种当前无主力月份)",
155 origin_stock_id
156 )));
157 }
158
159 // C++ OMEvent: p2 = zhuli_id (failure gate), p3 = stock_id (used by
160 // APIServer_Trd_PlaceOrder.cpp:815 `GetAPIStock(p3)`). Keep that split.
161 let real_stock_id = origin_row.stock_id;
162 let real_code = origin_row.code.clone();
163
164 Ok(MainLinkContractInfo {
165 real_stock_id,
166 real_code,
167 })
168}
169
170#[cfg(test)]
171mod tests;