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::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;