futu_backend/main_broker_svr.rs
1//! CMD 9419 `kCmdFetchMainBroker` — 拉主推券商 + 数字货币主推券商.
2//!
3//! 对齐 C++ `FutuOpenD/Src/FTGateway/GTWCmdAndPushReply.cpp:928-930` +
4//! `NNProtoCenter/Trade/NNProto_Trd_MainBrokerage.cpp:34-73`:
5//!
6//! - C++ FTGateway 平台 TCP login 成功后调
7//! `INNProto_Trd_MainBrokerage::PullMainBrokerage()` 发 CMD9419.
8//! - response `MainBrokerageRsp.main_brokers` + `crypto_brokers` 写入
9//! `INNData_Trd_MainBrokerage::SetMainBrokers` / `SetCryptoMainBrokers`.
10//! - QOT `securityFirm=Unknown` 时调 `GetCryptoSupportedDefaultMainBroker()`
11//! 从 `crypto_brokers` / `main_brokers` 顺序选 default broker.
12//!
13//! v1.4.110 codex QOT C++ alignment Slice 2: Rust 之前**没有** 9419 caller,
14//! 所以 `securityFirm=Unknown` 无法对齐 C++ default broker 行为. 本模块补这条.
15//!
16//! ## Hardcoded / Assumption Ledger
17//!
18//! - CMD9419 `NN_ProtoCmd_Trd_BaseMainBroker = 9419` 来源:
19//! `FutuOpenD/Src/NNBase/NNBase_Define_ProtoCmd.h:83`.
20//! 该 cmd 是 trade-side broker discovery (encrypted by default), 不进
21//! `is_unencrypted_proto` 白名单.
22//! - QOT crypto-supported broker 候选硬编码集 {1001 FUTU_HK, 1007 FUTU_US,
23//! 1008 FUTU_SG}, 来源 `NNData_Trd_MainBrokerage.cpp:50-68`. 这是 C++ 自己
24//! 的硬编码 (协议常量), 不是服务端动态下发, 因此 Rust 复刻可接受.
25
26use futu_command_spec::BrokerDiscoveryOperation;
27pub use futu_command_spec::CMD_FETCH_MAIN_BROKER;
28use futu_core::{
29 broker_discovery::ensure_broker_discovery_backend_success,
30 error::{FutuError, Result},
31};
32use prost::Message;
33
34use crate::command_runtime::execute_broker_discovery;
35use crate::conn::BackendConn;
36use crate::proto_internal::main_broker_svr::{BrokerInfo, MainBrokerageReq, MainBrokerageRsp};
37
38/// 当前 QOT crypto 行情支持 broker_id 候选集 (C++ 协议常量, broker_market_svr.cpp:50-68).
39/// 与 `qot_security_firm_to_broker_id` 形成闭环: firm 1/2/3 → broker 1001/1007/1008.
40pub const CRYPTO_SUPPORTED_MAIN_BROKER_CANDIDATES: &[u32] = &[1001, 1007, 1008];
41
42/// 兜底 broker_id (C++ `NNData_Trd_MainBrokerage.cpp:118` 最后 fallback).
43/// 用户暂无 crypto account / main_brokers 缺时使用.
44pub const FALLBACK_DEFAULT_CRYPTO_BROKER: u32 = 1007; // moomoo US
45
46/// `MainBrokerageRsp` 解析后的 snapshot, 用于 `MainBrokerCache`.
47#[derive(Debug, Clone, Default)]
48pub struct MainBrokerSnapshot {
49 /// 主推券商 (按 backend 下发顺序)
50 pub main_brokers: Vec<u32>,
51 /// 主推 + 已开户 (建 broker channel 时用, 9419 主要应用)
52 ///
53 /// v1.4.111: **当前 Rust daemon 不用此字段决定 broker channel**。
54 /// 对齐 FTLogin 10.6 `logger.cpp:1425` / `1496-1575`,通道有效性权威源
55 /// 是 CMD20176 `FetchValidBrokerList`;`auth_code_list` 只提供 HTTP auth 票据,
56 /// CMD20176 失败时才作为 fallback。9419 `connect_brokers` 保留为主推券商
57 /// 语义数据,不把它提升为 channel creation authority。
58 pub connect_brokers: Vec<u32>,
59 /// 数字货币主推券商 (按 backend 下发顺序, QOT default broker 解析关键)
60 pub crypto_brokers: Vec<u32>,
61}
62
63impl MainBrokerSnapshot {
64 /// C++ `INNData_Trd_MainBrokerage::GetCryptoSupportedDefaultMainBroker()` 等价 (line 70-123).
65 ///
66 /// 选择顺序:
67 /// 1. 如果 caller 提供了已开户 crypto account 数 == 1, 直接用该 account 的 broker
68 /// (caller 责任注入, 本 fn 不查 trd_cache).
69 /// 2. crypto_brokers 第一个支持 crypto 的 main broker.
70 /// 3. main_brokers 第一个支持 crypto 的 main broker.
71 /// 4. 兜底 `FALLBACK_DEFAULT_CRYPTO_BROKER` (1007).
72 pub fn default_crypto_broker(&self, single_crypto_account_broker: Option<u32>) -> u32 {
73 if let Some(b) = single_crypto_account_broker {
74 return b;
75 }
76 for b in &self.crypto_brokers {
77 if CRYPTO_SUPPORTED_MAIN_BROKER_CANDIDATES.contains(b) {
78 return *b;
79 }
80 }
81 for b in &self.main_brokers {
82 if CRYPTO_SUPPORTED_MAIN_BROKER_CANDIDATES.contains(b) {
83 return *b;
84 }
85 }
86 FALLBACK_DEFAULT_CRYPTO_BROKER
87 }
88}
89
90/// 发 CMD9419, 解析 response 成 `MainBrokerSnapshot`.
91///
92/// 失败模式:
93/// - 网络错误 / decode 错 → Err (caller log + 继续, 不阻塞 daemon 启动)
94/// - `ret_code != 0` → Err with backend err_message
95///
96/// 行为对齐 C++ `NNProto_Trd_MainBrokerage::PullMainBrokerage()` 全 path.
97pub async fn fetch_main_brokers(backend: &BackendConn) -> Result<MainBrokerSnapshot> {
98 let req = MainBrokerageReq { reserved: None };
99 let body = req.encode_to_vec();
100 tracing::debug!(
101 body_len = body.len(),
102 "sending CMD9419 MainBrokerageReq (fetch main brokers + crypto brokers)"
103 );
104
105 let resp = execute_broker_discovery(
106 backend,
107 BrokerDiscoveryOperation::MainBrokerage,
108 body.into(),
109 )
110 .await?;
111
112 let rsp = MainBrokerageRsp::decode(resp.body.as_ref())
113 .map_err(|e| FutuError::Codec(format!("CMD9419 decode: {e}")))?;
114
115 if let Err(err) = ensure_broker_discovery_backend_success(rsp.ret_code) {
116 let ret_code = err.ret_code();
117 return Err(FutuError::ServerError {
118 ret_type: ret_code,
119 msg: format!(
120 "CMD9419 ret_code={ret_code} msg={:?}",
121 rsp.err_message.as_deref().unwrap_or("")
122 ),
123 });
124 }
125
126 let snapshot = MainBrokerSnapshot {
127 main_brokers: brokers_to_ids(&rsp.main_brokers),
128 connect_brokers: brokers_to_ids(&rsp.connect_brokers),
129 crypto_brokers: brokers_to_ids(&rsp.crypto_brokers),
130 };
131
132 tracing::info!(
133 main_brokers = ?snapshot.main_brokers,
134 crypto_brokers = ?snapshot.crypto_brokers,
135 connect_brokers = ?snapshot.connect_brokers,
136 "CMD9419 main brokers received (v1.4.110 audit QOT alignment Slice 2)"
137 );
138 Ok(snapshot)
139}
140
141fn brokers_to_ids(brokers: &[BrokerInfo]) -> Vec<u32> {
142 brokers
143 .iter()
144 .enumerate()
145 .filter_map(|(index, b)| match b.broker_id {
146 Some(id) if id > 0 => Some(id as u32),
147 Some(id) => {
148 tracing::warn!(
149 index,
150 broker_id = id,
151 "CMD9419 broker entry skipped: non-positive broker_id"
152 );
153 None
154 }
155 None => None,
156 })
157 .collect()
158}
159
160#[cfg(test)]
161mod tests;