Skip to main content

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;