Skip to main content

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;