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