futu_trd/currency.rs
1//! Broker → supported currencies 表 + currency 校验 helper
2//!
3//! v1.4.105 (external reviewer funds-currency-display-suggestion 2026-04-29 P0):
4//!
5//! **触发**: 外部 reviewer 实测 Moomoo CA 账户 `/api/funds`:
6//! - 请求 `currency=USD/HKD/SGD` 都返同一份 CAD 口径
7//! - HKD/SGD 不该支持却 silent 不报错
8//!
9//! **C++ 对齐**: `APIServer_Trd_GetFunds.cpp:496-511` `CheckCurrencyValid` 调
10//! `INNData_Trd_CommonCurrency::GetAccountValidCurrency(accItem)` 拿 broker
11//! supported currency set, 不在内 → 返 `NNData_StaticText_InvalidCurrency`
12//! "This account does not support converting to this currency".
13//!
14//! **C++ 静态表**: `INNData_Trd_CommonCurrency.cpp:4-14` 8 个静态 set:
15//! ```text
16//! HK Future (Futu HK) HKD/USD/CNH/JPY
17//! SG Future (FutuSG) HKD/USD/CNH/JPY/SGD
18//! MY Future (FutuMY) MYR/CNH/JPY/SGD/HKD
19//! HK Universal (Futu HK) HKD/USD/CNH/JPY
20//! US Universal (FutuInc) HKD/USD/CNH/JPY/SGD
21//! SG Universal (FutuSG) HKD/USD/CNH/JPY/SGD
22//! AU Universal (FutuAU) HKD/USD/CNH/JPY/SGD/AUD
23//! CA Universal (FutuCA) USD/CAD ← Moomoo CA 测试账户
24//! MY Universal (FutuMY) MYR/CNH/USD/SGD/HKD
25//! JP Universal (FutuJP) JPY/USD
26//! ```
27//! 单币种账户 (其他 trd_market): 由 TrdMarket → currency 派生 (HK→HKD /
28//! US→USD / CN/HKCC→CNH).
29//!
30//! **历史教训** (用户 2026-04-29 强调):
31//! > "上一次修复就是因为 external reviewer 提到了 SGD, 结果就把返回 SGD 当作了正确结果."
32//!
33//! 即: 不能只 trust backend 返的 currency. 必须 broker → supported 表先验,
34//! Moomoo CA 账户 (security_firm=5) 不支持 SGD, 即使 backend 真返了 SGD 也
35//! 是 stale cache / 错误 routing. 本 helper 在 daemon-side 做 pre-check, 不
36//! 发 backend, 直接返结构化 error (Layer A 防御).
37//!
38//! **相关**: pitfall #36 (SDK metadata 不可信, code/broker 才是真相), pitfall
39//! #45 (silent-success — fallback 返 CAD 而无 loud reject), pitfall #51
40//! (对齐 C++ 减法 — 抄 C++ 表, 不发明).
41
42mod account;
43mod ids;
44mod labels;
45
46pub use account::{AccountKind, classify_account, classify_account_with_auth_list};
47pub use ids::{
48 broker_id, currency_id, legacy_backend_fund_market_id, security_firm_id,
49 security_firm_to_broker_id, trd_market_id,
50};
51
52/// Compatibility facade for the internal simulated-account
53/// `NN_TrdMarket -> Currency` projection.
54///
55/// The argument is an internal sim market, not public `TrdMarket`; real and
56/// Crypto responses must use their backend currency facts directly.
57#[must_use]
58pub fn trade_read_currency_for_market(trd_market: Option<i32>) -> Option<i32> {
59 futu_core::trade_currency::trade_read_currency_for_sim_nn_market(trd_market)
60}
61pub use labels::{
62 broker_label, currency_label, default_currency_by_security_firm, known_currency_label,
63 missing_currency_error_message, parse_currency_label, unsupported_error_message,
64};
65
66/// 单币种账户的默认 view currency (对齐 C++
67/// `INNData_Trd_CommonCurrency.cpp::GetTrdMarketCurrency` line 63-87)
68///
69/// 单币种账户**只支持**这一个 currency, 用户传别的 → reject.
70pub fn single_currency_for_market(trd_market: Option<i32>) -> Option<i32> {
71 match trd_market? {
72 // C++ GetTrdMarketCurrency: HK_Fund / Sim_HK_Option 也归 HKD
73 trd_market_id::HK | trd_market_id::HKCC => Some(currency_id::HKD),
74 trd_market_id::US => Some(currency_id::USD),
75 trd_market_id::CN => Some(currency_id::CNH),
76 // 注: AU/JP/MY/CA 单市场账户在 C++ 也会进 `default OMWarn` 分支返
77 // Unknown — C++ 真实情况是 AU/JP/MY/CA 账户一定通过 trd_market=SG=6
78 // 走 Universal 路径 (security_firm=4/7/6/5 区分 broker), 不会单独
79 // 用 trd_market=8/15/111/112. 这里写 None 是 conservative.
80 _ => None,
81 }
82}
83
84/// 期货账户 broker → supported currencies (对齐 C++
85/// `INNData_Trd_CommonCurrency.cpp:4-6`)
86fn futures_supported_currencies(security_firm: i32) -> Option<&'static [i32]> {
87 match security_firm {
88 // gs_setHKFuture
89 security_firm_id::FUTU_HK => Some(&[
90 currency_id::HKD,
91 currency_id::USD,
92 currency_id::CNH,
93 currency_id::JPY,
94 ]),
95 // gs_setSGFuture
96 security_firm_id::FUTU_SG => Some(&[
97 currency_id::HKD,
98 currency_id::USD,
99 currency_id::CNH,
100 currency_id::JPY,
101 currency_id::SGD,
102 ]),
103 // gs_setMYFuture
104 security_firm_id::FUTU_MY => Some(&[
105 currency_id::MYR,
106 currency_id::CNH,
107 currency_id::JPY,
108 currency_id::SGD,
109 currency_id::HKD,
110 ]),
111 _ => None,
112 }
113}
114
115/// 全能账户 broker → supported currencies (对齐 C++
116/// `INNData_Trd_CommonCurrency.cpp:8-14`)
117fn universal_supported_currencies(security_firm: i32) -> Option<&'static [i32]> {
118 match security_firm {
119 // gs_setHKUniversal
120 security_firm_id::FUTU_HK => Some(&[
121 currency_id::HKD,
122 currency_id::USD,
123 currency_id::CNH,
124 currency_id::JPY,
125 ]),
126 // gs_setUSUniversal
127 security_firm_id::FUTU_US => Some(&[
128 currency_id::HKD,
129 currency_id::USD,
130 currency_id::CNH,
131 currency_id::JPY,
132 currency_id::SGD,
133 ]),
134 // gs_setSGUniversal
135 security_firm_id::FUTU_SG => Some(&[
136 currency_id::HKD,
137 currency_id::USD,
138 currency_id::CNH,
139 currency_id::JPY,
140 currency_id::SGD,
141 ]),
142 // gs_setAUUniversal
143 security_firm_id::FUTU_AU => Some(&[
144 currency_id::HKD,
145 currency_id::USD,
146 currency_id::CNH,
147 currency_id::JPY,
148 currency_id::SGD,
149 currency_id::AUD,
150 ]),
151 // gs_setCAUniversal — Moomoo CA 测试账户
152 security_firm_id::FUTU_CA => Some(&[currency_id::USD, currency_id::CAD]),
153 // gs_setMYUniversal
154 security_firm_id::FUTU_MY => Some(&[
155 currency_id::MYR,
156 currency_id::CNH,
157 currency_id::USD,
158 currency_id::SGD,
159 currency_id::HKD,
160 ]),
161 // gs_setJPUniversal
162 security_firm_id::FUTU_JP => Some(&[currency_id::JPY, currency_id::USD]),
163 _ => None,
164 }
165}
166
167/// 取账户 supported currencies 完整列表 (对齐 C++
168/// `INNData_Trd_CommonCurrency::GetAccountValidCurrency` line 90-146)
169///
170/// - 期货账户: 按 broker 取 futures set
171/// - 全能账户: 按 broker 取 universal set
172/// - 单币种账户: 仅一个 currency (TrdMarket → Currency 派生)
173/// - broker 未识别: None (无法判断, daemon 不该 hard reject — 让 backend 决定)
174pub fn supported_currencies(
175 security_firm: Option<i32>,
176 trd_market: Option<i32>,
177 uni_card_num: Option<&str>,
178) -> Option<Vec<i32>> {
179 match classify_account(trd_market, security_firm, uni_card_num) {
180 AccountKind::Futures => security_firm
181 .and_then(futures_supported_currencies)
182 .map(|s| s.to_vec()),
183 AccountKind::Universal => security_firm
184 .and_then(universal_supported_currencies)
185 .map(|s| s.to_vec()),
186 AccountKind::SingleCurrency => single_currency_for_market(trd_market).map(|c| vec![c]),
187 }
188}
189
190/// 真实持仓刷新 CMD3020 使用的默认查询币种。
191///
192/// 对齐 C++:
193/// - `APIServer_Trd_GetPositionList.cpp:197,210` 调
194/// `INNProto_Trd_Acc::QueryPositionListNoLimit(...)`
195/// - `NNProto_Trd_Acc.cpp:787-801` 内部调用
196/// `QueryAssetInner(false, INNData_Trd_CommonCurrency::GetAccountFirstValidCurrency(accItem), ...)`
197/// - `INNData_Trd_CommonCurrency.cpp:148-192` 对 futures/universal 账户取
198/// supported currency set 的 `begin()`,single-currency 账户走
199/// `GetTrdMarketCurrency`.
200///
201/// 注意这不是用户侧 `GetFunds` 默认币种策略。`GetFunds` 为 UX 会按券商本地
202/// 币种补齐未传 currency;`GetPositionList` 没有 currency 字段,只是在
203/// C++ 内部用 first-valid currency 拉一次 AccountInfoReq 来刷新持仓 cache。
204///
205/// Hardcoded / Assumption Ledger:
206/// - supported currency set 来自本文件上方 C++ 对齐表,不按具体账号硬编码。
207/// - C++ 用 `std::set<NN_TrdCurrency>::begin()`,Rust 用数值最小 currency
208/// 等价表达;若 C++ 改为保持插入顺序,这里必须同步调整。
209pub fn first_valid_currency_for_account(
210 security_firm: Option<i32>,
211 trd_market: Option<i32>,
212 uni_card_num: Option<&str>,
213 trd_market_auth_list: &[i32],
214) -> Option<i32> {
215 let kind = classify_account_with_auth_list(
216 trd_market,
217 security_firm,
218 uni_card_num,
219 trd_market_auth_list,
220 );
221 let mut supported = supported_currencies_for_kind(kind, security_firm, trd_market)?;
222 supported.sort_unstable();
223 supported.into_iter().next()
224}
225
226fn supported_currencies_for_kind(
227 kind: AccountKind,
228 security_firm: Option<i32>,
229 trd_market: Option<i32>,
230) -> Option<Vec<i32>> {
231 match kind {
232 AccountKind::Futures => security_firm
233 .and_then(futures_supported_currencies)
234 .map(|s| s.to_vec()),
235 AccountKind::Universal => security_firm
236 .and_then(universal_supported_currencies)
237 .map(|s| s.to_vec()),
238 AccountKind::SingleCurrency => single_currency_for_market(trd_market).map(|c| vec![c]),
239 }
240}
241
242/// Layer A 校验结果 (用 enum 让 caller 区分四种状态)
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum CurrencyValidation {
245 /// requested currency 在 broker supported set 内 → OK 发 backend.
246 /// SingleCurrency 缺 currency 也归 Ok (跟 C++ legacy 分支一致).
247 Ok,
248 /// **v1.4.106 Finding F1**: Futures / Universal 账户**必传** currency,
249 /// 未传 → loud reject (对齐 C++ `CheckReqParams_GetFunds`:
250 /// `if (!c2s.has_currency()) return false;`).
251 /// SingleCurrency 缺 currency 不进此分支, 仍归 Ok.
252 Missing {
253 broker_label: &'static str,
254 supported_label_list: Vec<&'static str>,
255 },
256 /// requested currency 不在 set 内 → 立即 reject (不发 backend)
257 /// 含 broker 标签 + supported list 用于 error message
258 Unsupported {
259 broker_label: &'static str,
260 supported_label_list: Vec<&'static str>,
261 },
262 /// 无法判断 (security_firm=None / cache miss / unknown broker) — 不 hard
263 /// reject, 让 backend 决定. 仍记录 hint 用于日志.
264 Unknown,
265}
266
267/// `Trd_GetFunds` 用户侧 effective currency。
268///
269/// - 用户显式传 `currency`:原样使用,后续 validator 负责校验 supported set。
270/// - Futures / Universal 未传:按 broker 默认币种补齐,避免 CLI/REST/MCP 每个
271/// surface 自己猜,也避免用户必须先知道内部 acc_id/currency 规则。
272/// - SingleCurrency 未传:保持 `None`,对齐 C++ legacy 分支“currency 被忽略”
273/// 的语义。
274pub fn effective_get_funds_currency_for_account(
275 requested_currency: Option<i32>,
276 security_firm: Option<i32>,
277 trd_market: Option<i32>,
278 uni_card_num: Option<&str>,
279 trd_market_auth_list: &[i32],
280) -> Option<i32> {
281 if requested_currency.is_some() {
282 return requested_currency;
283 }
284
285 let kind = classify_account_with_auth_list(
286 trd_market,
287 security_firm,
288 uni_card_num,
289 trd_market_auth_list,
290 );
291 match kind {
292 AccountKind::Futures | AccountKind::Universal => {
293 default_currency_by_security_firm(security_firm)
294 }
295 AccountKind::SingleCurrency => None,
296 }
297}
298
299/// **Layer A pre-check** — 严格对齐 C++ `CheckReqParams_GetFunds` /
300/// `CheckCurrencyValid` (`APIServer_Trd_GetFunds.cpp:457-491`):
301///
302/// ```cpp
303/// // 期货综合账户或全能账户需要传货币参数
304/// if (accItem.enTrdMkt == NN_TrdMarket_Futures || accItem.enTrdMkt == NN_TrdMarket_SG)
305/// {
306/// if (!c2s.has_currency()) return false; // missing → reject
307/// if (!CheckCurrencyValid(...)) return false; // out-of-set → reject
308/// }
309/// return true;
310/// ```
311///
312/// **C++ 只对 `Futures` (trd_market=5) + `SG/Universal` (trd_market=6)
313/// 验证 currency**. 其他账户 (legacy HK Sec / US Sec / HKCC / Crypto / Forex
314/// / HK_Fund / US_Fund / sim) **完全不验证** — backend 在 `FillFunds` else
315/// branch 用 `nnFunds.enCurrency` 返 native currency, 静默忽略 client 传的
316/// `currency` 参数.
317///
318/// **v1.4.106 修法 (P0 + Finding F1, 真机 vs C++ OpenD 4/4 不一致 catalog 触发)**:
319/// 之前 v1.4.105 对**所有**账户 strict reject, 违反 pitfall #51 "对齐
320/// C++ = 减法". legacy 单市场账户 + USD/CAD/SGD daemon reject 但 C++ 接受.
321/// 现在严格只 validate Futures + Universal, SingleCurrency 直接 pass-through.
322///
323/// **v1.4.106 Finding F1 收紧**: 之前 `requested_currency=None` 全部账户都
324/// pass-through (太宽松). C++ `CheckReqParams_GetFunds` 对 Futures + SG/Universal
325/// 强制要求 `c2s.has_currency()`, 缺则返 missing-parameter. 现在分两层:
326/// - SingleCurrency 缺 currency → `Ok` (legacy pass-through 不变)
327/// - Futures / Universal 缺 currency → `Missing` (loud reject)
328///
329/// SGD silent-trust regression 防御仍由 `Universal` 分支锁住:
330/// Moomoo CA Universal (security_firm=5 + uni_card_num + AccountMarket=6)
331/// 进 `AccountKind::Universal` 分支, supported set 不含 SGD → reject.
332///
333/// Return 分类:
334/// - `Ok`:
335/// 1. SingleCurrency kind (legacy 单市场 / Crypto / Forex / Fund / sim) —
336/// pass-through, 无论 requested 是否 = None.
337/// 2. Futures/Universal + supported list 已知 + requested ∈ set
338/// - `Missing` (v1.4.106 新加):
339/// - Futures / Universal kind + requested = None + supported list 已知
340/// - `Unsupported`:
341/// - Futures / Universal kind + supported list 已知 + requested ∉ set
342/// - `Unknown`:
343/// - Futures / Universal kind 但 broker 未识别 (security_firm=None / cache
344/// miss) → 让 backend 决定 (无法构造 supported list, 也无 Missing
345/// loud-reject 上下文)
346pub fn validate_currency_for_account(
347 requested_currency: Option<i32>,
348 security_firm: Option<i32>,
349 trd_market: Option<i32>,
350 uni_card_num: Option<&str>,
351) -> CurrencyValidation {
352 // **v1.4.106 Finding F1**: classify FIRST, 之前 missing-currency 在
353 // classify 前 early-return Ok 让 Futures/Universal 缺 currency 静默放行,
354 // 与 C++ 不一致.
355 let kind = classify_account(trd_market, security_firm, uni_card_num);
356
357 // **v1.4.106 P0 减法**: SingleCurrency kind 直接 Pass-through (无论 requested
358 // 是否 = None), 跟 C++ legacy 分支一致 (只对 Futures + SG validate).
359 // 此 kind 涵盖 legacy 单市场 / Crypto / Forex / HK_Fund / US_Fund / sim 账户.
360 if matches!(kind, AccountKind::SingleCurrency) {
361 return CurrencyValidation::Ok;
362 }
363
364 // 到这里: kind ∈ {Futures, Universal}. 跟 C++ 同样 strict validate.
365 let Some(supported) = supported_currencies(security_firm, trd_market, uni_card_num) else {
366 // broker 未知 (security_firm=None) → 让 backend 决定. 不 hard reject.
367 return CurrencyValidation::Unknown;
368 };
369
370 // **v1.4.106 Finding F1**: missing currency on Futures/Universal → loud reject.
371 // 对齐 C++ `CheckReqParams_GetFunds:475-485`:
372 // `if (!c2s.has_currency()) return false;`
373 let Some(req) = requested_currency else {
374 return CurrencyValidation::Missing {
375 broker_label: broker_label(security_firm),
376 supported_label_list: supported.iter().map(|&c| currency_label(c)).collect(),
377 };
378 };
379
380 if supported.contains(&req) {
381 return CurrencyValidation::Ok;
382 }
383
384 // requested ∉ supported → Layer A reject (历史 SGD silent-trust 防御).
385 // 跟 C++ backend `CheckCurrencyValid` 行为一致.
386 CurrencyValidation::Unsupported {
387 broker_label: broker_label(security_firm),
388 supported_label_list: supported.iter().map(|&c| currency_label(c)).collect(),
389 }
390}
391
392/// User-facing `Trd_GetFunds` currency validation.
393///
394/// 用户感知语义(2026-05-05 真机反馈):
395/// - 未显式传 `currency`:使用账户/backend 默认口径,不因为现代综合账户缺参数而
396/// 拒绝;不能自行硬贴 HKD/USD 标签。
397/// - 显式传 `currency`:必须落在账户支持集合内,并由 backend 返回同币种的
398/// `union_fund_info`,否则 gateway 后置校验会 loud reject。
399/// - Legacy SingleCurrency 账户沿用 C++ legacy 分支 pass-through:不在本层按
400/// 单市场默认币种拒绝用户显式 currency;后续 refresh/cache key 会保留该参数。
401///
402/// 这与 `validate_currency_for_account` 的 C++ strict-missing 行为不同,后者仍保留
403/// 给需要完全模拟 C++ 参数检查的路径。
404pub fn validate_get_funds_currency_for_account(
405 requested_currency: Option<i32>,
406 security_firm: Option<i32>,
407 trd_market: Option<i32>,
408 uni_card_num: Option<&str>,
409 trd_market_auth_list: &[i32],
410) -> CurrencyValidation {
411 let kind = classify_account_with_auth_list(
412 trd_market,
413 security_firm,
414 uni_card_num,
415 trd_market_auth_list,
416 );
417 if matches!(kind, AccountKind::SingleCurrency) {
418 return CurrencyValidation::Ok;
419 }
420
421 let Some(req) = requested_currency else {
422 return CurrencyValidation::Ok;
423 };
424
425 let Some(supported) = supported_currencies_for_kind(kind, security_firm, trd_market) else {
426 return CurrencyValidation::Unknown;
427 };
428
429 if supported.contains(&req) {
430 return CurrencyValidation::Ok;
431 }
432
433 CurrencyValidation::Unsupported {
434 broker_label: broker_label(security_firm),
435 supported_label_list: supported.iter().map(|&c| currency_label(c)).collect(),
436 }
437}
438
439#[cfg(test)]
440mod tests;