futu_backend/crypto_trade.rs
1//! Crypto trade backend wire helpers.
2//!
3//! Crypto trade support added by C++ OpenD 10.5.6508 uses several different
4//! server-side request shapes, but they all start from the same account facts:
5//! public long account id, optional native intra account id, broker id, and
6//! trade cipher. Keep that extraction here so handlers do not re-invent it.
7
8#[cfg(test)]
9mod tests;
10
11use futu_cache::trd_cache::{CachedTrdAcc, TrdCache};
12use futu_core::error::{FutuError, Result};
13
14use crate::{
15 msg_header,
16 proto_internal::{odr_sys_cmn, trade_cmn},
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CryptoAccountContext {
21 pub acc_id: u64,
22 pub intra_acc_id: Option<u64>,
23 pub broker_id: Option<u32>,
24 pub customer_id: Option<u64>,
25 pub cipher: Vec<u8>,
26}
27
28impl CryptoAccountContext {
29 /// Build the `odr_sys_cmn::MsgHeader` used by crypto asset reads.
30 ///
31 /// C++ `NNProto_Trd_AccCrypto.cpp:197-215` sends
32 /// `asset_pl::AccountInfoReq` through `M_SendProto_SetReqMsgHeader`;
33 /// `NNProtoCenter_Inner_Macro_Send.h:16-24` always sets `cipher` bytes
34 /// even when the cipher length is zero.
35 ///
36 /// v1.4.110 P0-1: delegate to [`crate::msg_header::build_real`].
37 /// `_op` 参数保留作 caller-side 语义标注 (test fixture / log key 可能用),
38 /// 当前实现忽略 (与 v1.4.110 之前 `self.req_id(op)` 的 `_op` 行为一致).
39 pub fn build_asset_msg_header(&self, _op: &str) -> odr_sys_cmn::MsgHeader {
40 msg_header::build_real(self.acc_id, Some(self.cipher.clone()), None, None)
41 }
42
43 /// Build the `trade_cmn::CryptoMsgHeader` used by crypto order paths.
44 ///
45 /// C++ `NNProto_Trd_OrderOpCrypto.cpp:40-48,94-102` sets `req_id` and
46 /// `account_id`, but only writes `cipher` when `GetAccCipher` returned a
47 /// non-empty buffer. Keep that distinction because backend crypto order
48 /// services use this lighter header rather than `odr_sys_cmn::MsgHeader`.
49 ///
50 /// v1.4.110 P0-1: delegate to [`crate::msg_header::build_crypto`].
51 pub fn build_crypto_msg_header(&self, _op: &str) -> trade_cmn::CryptoMsgHeader {
52 msg_header::build_crypto(self.acc_id, self.cipher.clone())
53 }
54
55 pub fn require_intra_acc_id(&self, op: &str) -> Result<u64> {
56 self.intra_acc_id.ok_or_else(|| {
57 crypto_context_error(format!(
58 "Crypto {op}: account {} missing intra_acc_id",
59 self.acc_id
60 ))
61 })
62 }
63
64 pub fn require_broker_id(&self, op: &str) -> Result<u32> {
65 self.broker_id.ok_or_else(|| {
66 crypto_context_error(format!(
67 "Crypto {op}: account {} missing broker_id",
68 self.acc_id
69 ))
70 })
71 }
72
73 pub fn require_customer_id(&self, op: &str) -> Result<u64> {
74 self.customer_id.ok_or_else(|| {
75 crypto_context_error(format!(
76 "Crypto {op}: account {} missing customer_id",
77 self.acc_id
78 ))
79 })
80 }
81}
82
83pub fn lookup_crypto_account_context(
84 cache: &TrdCache,
85 acc_id: u64,
86) -> Result<CryptoAccountContext> {
87 let acc = cache.lookup_account(acc_id).ok_or_else(|| {
88 crypto_context_error(format!("Crypto account {acc_id} not found in trade cache"))
89 })?;
90 crypto_account_context_from_acc(cache, &acc)
91}
92
93pub fn crypto_account_context_from_acc(
94 cache: &TrdCache,
95 acc: &CachedTrdAcc,
96) -> Result<CryptoAccountContext> {
97 if !acc.is_crypto_account() {
98 return Err(crypto_context_error(format!(
99 "Account {} is not a crypto account",
100 acc.acc_id
101 )));
102 }
103 // v1.4.111 P2-1 follow-through: cipher missing → fail-closed Err (mirror P2-1
104 // trade handler check_cipher_or_short_circuit pattern). 之前 unwrap_or_default
105 // 让 cipher empty Vec 进 CryptoMsgHeader.cipher → 发 backend → crypto trade
106 // auth fail → 浪费 backend round-trip + 错误信息泛化. 改为早 reject 让 caller
107 // 通过 `?` propagate Err 转 ret_type=-1 + 清晰 hint "Crypto cipher 未解锁".
108 // 对应 v1.4.111-prep merge 后 audit 11 D 桶 verify 后剩 1 真 D 桶, pitfall #45
109 // silent-success polish.
110 let cipher = cache.get_cipher(acc.acc_id).ok_or_else(|| {
111 crypto_context_error(format!(
112 "Crypto account {} cipher 未解锁 — 先调 /api/unlock-trade",
113 acc.acc_id
114 ))
115 })?;
116 Ok(CryptoAccountContext {
117 acc_id: acc.acc_id,
118 intra_acc_id: acc.intra_acc_id,
119 broker_id: broker_id_from_account(acc),
120 // C++ `NNProto_Trd_MaxQtyCrypto.cpp:49-54` writes `m_nUserID`
121 // into `crypto_risk_comm::Account.cid`. Rust account projection keeps
122 // the same user id in owner_uid/opr_uid.
123 customer_id: acc
124 .owner_uid
125 .filter(|uid| *uid != 0)
126 .or_else(|| acc.opr_uid.filter(|uid| *uid != 0)),
127 cipher,
128 })
129}
130
131/// Crypto native account requests need broker ids such as 1001/1007.
132///
133/// Prefer the C++ sort key `(BrokerID << 48) | (TrdMkt << 32) | IntraAccID`
134/// stored by `bridge/account/real_projection.rs:323-326`. If a historical
135/// cache entry lacks that key, fall back to `Trd_Common.SecurityFirm` mapping
136/// used by C++ `NetCallback::BrokerIDToTcpCategory`.
137pub fn broker_id_from_account(acc: &CachedTrdAcc) -> Option<u32> {
138 let from_sort_key = (acc.sort_key >> 48) as u32;
139 if from_sort_key != 0 {
140 return Some(from_sort_key);
141 }
142 acc.security_firm.and_then(security_firm_to_broker_id)
143}
144
145pub fn security_firm_to_broker_id(sf: i32) -> Option<u32> {
146 futu_trd::currency::security_firm_to_broker_id(sf)
147}
148
149/// v1.4.110 codex audit P1 #3: 用户唯一已开户 crypto account 的 broker_id.
150///
151/// 对齐 C++ `INNData_Trd_MainBrokerage::GetCryptoSupportedDefaultMainBroker`
152/// (line 70-123) 优先级 1: 如果只开了一个 crypto account, 直接取该 account 的 broker.
153///
154/// 返:
155/// - `Some(broker_id)`: trd_cache 恰好 1 个 `is_crypto_account()`, 取其 broker
156/// - `None`: 0 个或 ≥ 2 个 crypto account, caller 走 9419 crypto_brokers / fallback 路径
157///
158/// 此值作 `resolve_qot_broker_for_request` / `resolve_or_reject_broker` 第 5
159/// 参数注入, QOT handler `securityFirm=Unknown(0)` 时决定 default broker.
160pub fn single_crypto_account_broker(trd_cache: &futu_cache::trd_cache::TrdCache) -> Option<u32> {
161 let crypto_brokers: std::collections::HashSet<u32> = trd_cache
162 .accounts
163 .iter()
164 .filter(|r| r.value().is_crypto_account())
165 .filter_map(|r| broker_id_from_account(r.value()))
166 .collect();
167 if crypto_brokers.len() == 1 {
168 crypto_brokers.into_iter().next()
169 } else {
170 None
171 }
172}
173
174fn crypto_context_error(msg: String) -> FutuError {
175 FutuError::ServerError { ret_type: -1, msg }
176}