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 ids;
43
44pub use ids::{
45    broker_id, currency_id, legacy_backend_fund_market_id, security_firm_id,
46    security_firm_to_broker_id, trd_market_id,
47};
48
49/// 账户类型分类 (用于查 supported currencies 表)
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AccountKind {
52    /// 期货账户 (`trd_market == NN_TrdMarket_Futures = 5`)
53    Futures,
54    /// 全能账户 / Universal (`trd_market == NN_TrdMarket_SG = 6`)
55    /// 注意: SG=6 在 C++ 是 "全能账户" enum 的复用, 不是新加坡市场专属
56    /// (见 `Trd_Common.proto:32` 注释 "期货市场"误注 + 实际 C++ 行为 path)
57    Universal,
58    /// 单币种账户 (其他所有 trd_market)
59    SingleCurrency,
60}
61
62/// 账户类型识别 — 对齐 C++ `INNData_Trd_CommonCurrency::GetAccountValidCurrency`
63/// (cpp:92-126) 的 3 分支结构:
64///
65/// ```cpp
66/// if (enTrdMkt == NN_TrdMarket_Futures)        → futures set per broker
67/// else if (enTrdMkt == NN_TrdMarket_SG /*6*/)  → universal set per broker
68/// else                                          → single currency from
69///                                                 GetTrdMarketCurrency(enTrdMkt)
70///                                                 (HK→HKD / US→USD / CN/HKCC→CNH /
71///                                                  default→Unknown+warn)
72/// ```
73///
74/// **本 fn 是 Rust cache normalization defense, 不是 C++ direct copy**.
75/// (Per v1.4.106 Finding C from codex source audit 2026-05-01).
76///
77/// C++ 严格按 `accItem.enTrdMkt == NN_TrdMarket_SG` 判 Universal, 没有任何
78/// fallback. Rust 加 `uni_card_num + security_firm` fallback 是因为 Rust
79/// cache (`CachedTrdAcc.trd_market`) 直接存 backend raw `Account.market`,
80/// raw 值跟 OpenAPI `TrdMarket` 不一定 1:1 对齐 (e.g. backend raw=13=HK_Fund
81/// vs OpenAPI 113=HK_Fund). 本 fn 防止 cache drift 把真 Universal 账户错认为
82/// SingleCurrency 静默放宽 currency 校验.
83///
84/// **risk of fallback**: 万一 fallback 路径误把 single-market HK/US 账户认成
85/// Universal, 会让 `validate_currency_for_account` 走 universal supported set,
86/// 可能 reject C++ 接受的 currency. 本 fn 的 fallback 仅当 trd_market 不在
87/// official 表里时启用 — 见 Step 3 实现注释.
88///
89/// **现实背景** (用户 2026-04-30 强调): "不论 hk/us/sg 等, 各个券商实体开给
90/// 客户的都是 Universal account; single account 是**老的账户形态**, 已经被
91/// 禁用, 只能浏览了". 所以现代活跃账户**99% 是 Universal** (`trd_market=6`),
92/// SingleCurrency 路径仅服务遗留浏览-only 账户.
93///
94/// 输入信号 (按优先级 + 与 C++ 对齐 + cache drift 防御):
95///
96/// 1. **`trd_market = FUTURES (5)`**: Futures 账户 (C++ 第 1 分支, 不受
97///    fallback 影响)
98/// 2. **`trd_market = SG (6) = AccountMarket::UNIVERSAL`**: Universal (C++
99///    第 2 分支, canonical)
100/// 3. **`uni_card_num` 非空 + `security_firm` 已识别**: 仍按 Universal 处理
101///    (cache drift 防御 — 真 Universal 账户必有 uni_card_num + security_firm).
102///    **必须排在第 4 步前**, 防 cache 把 trd_market 错存成 raw NN_TrdMarket
103///    值 (CA=112 / AU=8 / JP=15 / MY=111) 落进 SingleCurrency 静默放行.
104/// 4. **`trd_market` ∈ legacy single 表** (HK=1 / US=2 / CN=3 / HKCC=4 +
105///    backend-raw fund 13/22/23/24 + OpenAPI fund 113/123/124/125/126):
106///    legacy SingleCurrency (浏览-only 老账户, C++ 第 3 分支的具体派生路径)
107/// 5. 其他: SingleCurrency (supported 返 None → Unknown, 让 backend 决定)
108///
109/// 历史触发 (codex round 1 F1, v1.4.105 review):
110/// 之前只看 `trd_market == SG (6)`, 真实 cache 若存 raw NN_TrdMarket 值
111/// (CA=112 / AU=8 / JP=15 / MY=111) 则错落进 SingleCurrency, Layer A 静默放
112/// 行 → HKD/SGD silent fallback regression 可能复活.
113///
114/// 用户 2026-04-30 进一步纠偏: legacy single-market HK/US/HKCC 账户在现实里
115/// 都是浏览-only, **不应**早 return 抑制 Universal fallback — 真 Universal 账
116/// 户即使 cache 漂移到 1/2/4, fallback 仍要识别正确.
117pub fn classify_account(
118    trd_market: Option<i32>,
119    security_firm: Option<i32>,
120    uni_card_num: Option<&str>,
121) -> AccountKind {
122    // Step 1: Futures canonical match (C++ 第 1 分支). Futures 账户没有
123    // uni_card_num 概念, 不受 Universal fallback 影响.
124    if trd_market == Some(trd_market_id::FUTURES) {
125        return AccountKind::Futures;
126    }
127
128    // Step 2: Universal canonical match (C++ 第 2 分支, AccountMarket::UNIVERSAL=6).
129    // 现代活跃账户 99% 走这条.
130    if trd_market == Some(trd_market_id::SG) {
131        return AccountKind::Universal;
132    }
133
134    // Step 3: Universal cache-drift fallback. **必须**在 Step 4 (legacy single
135    // 早 return) 之前. 真 Universal 账户 (Moomoo CA/AU/JP/MY/SG/US) 必有
136    // uni_card_num + security_firm. 即使 cache 把 trd_market 错存成 raw
137    // NN_TrdMarket (CA=112 / AU=8 / JP=15 / MY=111) 或意外的 1/2/4 (HK/US/HKCC,
138    // 但 fallback signal 都齐), 这层 fallback 仍能识别 Universal.
139    //
140    // Per 用户 2026-04-30: 现代 HK/US 等账户实质都是 Universal, single 是
141    // legacy 浏览-only. 若一个有 uni_card_num 的账户被 cache 错存成 trd_market=1,
142    // 我们仍按 Universal 处理 (fallback 优先于 legacy 单币种早 return).
143    //
144    // codex round 2 F2 (P2): defensive check — `uni_card_num=Some("")` 不应
145    // trigger Universal fallback. backend 偶发下发空字符串, ingestion 层
146    // (`account_to_cached`) 已 trim+filter, 这里再加一道防御 (与 ingestion
147    // 一致).
148    let uni_card_present = uni_card_num.is_some_and(|s| !s.trim().is_empty());
149    if uni_card_present && security_firm.is_some() {
150        return AccountKind::Universal;
151    }
152
153    // Step 4: Legacy single-market accounts (C++ 第 3 分支具体派生路径)
154    // 仅服务**老账户** (HK Sec / US Sec / HKCC + 各国 Fund 子市场), 现代账户
155    // 不会进这里. 没有 fallback signal 说明既无 uni_card_num 也无 broker —
156    // legacy / browse-only 账户.
157    //
158    // **v1.4.106 Finding D 收紧**: 区分 backend raw `Account.market` (13=HK_Fund
159    // / 22/23=US_Fund / 24=SG_Fund) 与 OpenAPI canonical `TrdMarket`
160    // (113=HK_Fund / 123=US_Fund / 124=SG_Fund / 125=MY_Fund / 126=JP_Fund).
161    // `CachedTrdAcc.trd_market` 直接存 backend raw `acc.market` (见
162    // `bridge/account.rs::account_to_cached:202`), 所以两套都要识别.
163    // App `FTTradeEnableMarket` 数值 (HK_FUND=13/US_FUND=23/SG_FUND=24)
164    // 在数值上和 backend raw 巧合 alias, 但**绝不是同一概念** — 不能
165    // 把 App enum 当 OpenAPI TrdMarket 解释 (per Finding D).
166    match trd_market {
167        // Legacy single-market (real)
168        Some(trd_market_id::HK)
169        | Some(trd_market_id::US)
170        | Some(trd_market_id::CN)
171        | Some(trd_market_id::HKCC)
172        // Fund 子市场 — backend raw `Account.market` 值
173        | Some(legacy_backend_fund_market_id::HK_FUND)
174        | Some(legacy_backend_fund_market_id::US_FUND_OLD)
175        | Some(legacy_backend_fund_market_id::US_FUND)
176        | Some(legacy_backend_fund_market_id::SG_FUND)
177        // Fund 子市场 — OpenAPI canonical `NN_TrdMarket` 值
178        | Some(trd_market_id::HK_FUND)
179        | Some(trd_market_id::US_FUND)
180        | Some(trd_market_id::SG_FUND)
181        | Some(trd_market_id::MY_FUND)
182        | Some(trd_market_id::JP_FUND) => AccountKind::SingleCurrency,
183        // Step 5: 其他 (None / unknown) → SingleCurrency,
184        // single_currency_for_market 会返 None → supported_currencies 返 None →
185        // Layer A 进 Unknown 分支, 让 backend 决定.
186        _ => AccountKind::SingleCurrency,
187    }
188}
189
190/// 从公开 `TrdMarket` / cache auth-list market 推导资金视图币种桶。
191///
192/// 这不是账户主币种推断,而是用于识别“同一个账户暴露了多个币种/市场资金视图”
193/// 的结构信号。Dega 实测里部分现代 FutuHK 综合账户没有可靠 `uni_card_num`,
194/// 但 `trd_market_auth_list` 同时带 US + HKFUND/USFUND;这类账户若按
195/// SingleCurrency 处理,就会忽略用户显式 `currency` 参数。
196///
197/// Hardcoded / Assumption Ledger:
198/// - 这些映射来自 `Trd_Common.proto::TrdMarket` 与本文件 `trd_market_id`
199///   常量;不是按具体账号硬编码。
200/// - 仅作为 `classify_account_with_auth_list` 的 fallback 信号;canonical
201///   `trd_market=5/6` 与 `uni_card_num + broker` 仍优先。
202fn market_currency_bucket(market: i32) -> Option<i32> {
203    match market {
204        trd_market_id::HK
205        | trd_market_id::HKCC
206        | trd_market_id::FUTURES
207        | trd_market_id::HK_FUND
208        | legacy_backend_fund_market_id::HK_FUND => Some(currency_id::HKD),
209        trd_market_id::US
210        | trd_market_id::US_FUND
211        | legacy_backend_fund_market_id::US_FUND_OLD
212        | legacy_backend_fund_market_id::US_FUND => Some(currency_id::USD),
213        trd_market_id::CN => Some(currency_id::CNH),
214        trd_market_id::SG | trd_market_id::SG_FUND | legacy_backend_fund_market_id::SG_FUND => {
215            Some(currency_id::SGD)
216        }
217        trd_market_id::AU => Some(currency_id::AUD),
218        trd_market_id::JP | trd_market_id::JP_FUND => Some(currency_id::JPY),
219        trd_market_id::MY | trd_market_id::MY_FUND => Some(currency_id::MYR),
220        trd_market_id::CA => Some(currency_id::CAD),
221        _ => None,
222    }
223}
224
225fn auth_list_has_cross_currency_view(trd_market_auth_list: &[i32]) -> bool {
226    let mut buckets: Vec<i32> = Vec::new();
227    for market in trd_market_auth_list {
228        let Some(bucket) = market_currency_bucket(*market) else {
229            continue;
230        };
231        if !buckets.contains(&bucket) {
232            buckets.push(bucket);
233        }
234        if buckets.len() > 1 {
235            return true;
236        }
237    }
238    false
239}
240
241/// `classify_account` 的 cache-auth-list 增强版。
242///
243/// C++ 入口主要靠 `accItem.enTrdMkt == SG(6)` 识别综合账户,但 Rust cache
244/// 的字段来自 backend `FTUsrTrdAcc`,有些现代综合账户可能缺 `uni_card_num`
245/// 或 `trd_market=6` 信号。用户显式传 `currency` 时,auth-list 的跨币种
246/// 市场组合是更接近用户感知的“综合资金视图”信号。
247pub fn classify_account_with_auth_list(
248    trd_market: Option<i32>,
249    security_firm: Option<i32>,
250    uni_card_num: Option<&str>,
251    trd_market_auth_list: &[i32],
252) -> AccountKind {
253    let base = classify_account(trd_market, security_firm, uni_card_num);
254    if !matches!(base, AccountKind::SingleCurrency) {
255        return base;
256    }
257    if auth_list_has_cross_currency_view(trd_market_auth_list) {
258        return AccountKind::Universal;
259    }
260    base
261}
262
263/// 单币种账户的默认 view currency (对齐 C++
264/// `INNData_Trd_CommonCurrency.cpp::GetTrdMarketCurrency` line 63-87)
265///
266/// 单币种账户**只支持**这一个 currency, 用户传别的 → reject.
267pub fn single_currency_for_market(trd_market: Option<i32>) -> Option<i32> {
268    match trd_market? {
269        // C++ GetTrdMarketCurrency: HK_Fund / Sim_HK_Option 也归 HKD
270        trd_market_id::HK | trd_market_id::HKCC => Some(currency_id::HKD),
271        trd_market_id::US => Some(currency_id::USD),
272        trd_market_id::CN => Some(currency_id::CNH),
273        // 注: AU/JP/MY/CA 单市场账户在 C++ 也会进 `default OMWarn` 分支返
274        // Unknown — C++ 真实情况是 AU/JP/MY/CA 账户一定通过 trd_market=SG=6
275        // 走 Universal 路径 (security_firm=4/7/6/5 区分 broker), 不会单独
276        // 用 trd_market=8/15/111/112. 这里写 None 是 conservative.
277        _ => None,
278    }
279}
280
281/// 交易读响应的 market → currency 投影。
282///
283/// 这不是 `GetFunds` 单币种入参校验规则。C++ 在 positions / orders 等响应
284/// 组包时调用 `_APIServer_Trd_Comm.cpp::GetCurrencyByTrdMarket`, 覆盖 SG/AU/JP
285/// 和各类 sim market;而 `single_currency_for_market` 是用户传 `currency`
286/// 时的单币种账户校验,故两者必须分开,避免再次把资金查询语义污染到订单/持仓
287/// 响应投影。
288///
289/// Ref:
290/// - `FutuOpenD/Src/APIServer/Business/Trade/_APIServer_Trd_Comm.cpp:3148-3200`
291pub fn trade_read_currency_for_market(trd_market: Option<i32>) -> Option<i32> {
292    match trd_market? {
293        trd_market_id::HK | trd_market_id::SIM_HK_OPTION | trd_market_id::FUTURES_SIMULATE_HK => {
294            Some(currency_id::HKD)
295        }
296        trd_market_id::US
297        | trd_market_id::SIM_US_OPTION
298        | trd_market_id::SIM_US_MARGIN
299        | trd_market_id::FUTURES_SIMULATE_US => Some(currency_id::USD),
300        trd_market_id::CN | trd_market_id::HKCC => Some(currency_id::CNH),
301        trd_market_id::SG | trd_market_id::FUTURES_SIMULATE_SG => Some(currency_id::SGD),
302        trd_market_id::AU => Some(currency_id::AUD),
303        trd_market_id::JP | trd_market_id::FUTURES_SIMULATE_JP => Some(currency_id::JPY),
304        trd_market_id::MY => Some(currency_id::MYR),
305        trd_market_id::CA => Some(currency_id::CAD),
306        _ => None,
307    }
308}
309
310/// 期货账户 broker → supported currencies (对齐 C++
311/// `INNData_Trd_CommonCurrency.cpp:4-6`)
312fn futures_supported_currencies(security_firm: i32) -> Option<&'static [i32]> {
313    match security_firm {
314        // gs_setHKFuture
315        security_firm_id::FUTU_HK => Some(&[
316            currency_id::HKD,
317            currency_id::USD,
318            currency_id::CNH,
319            currency_id::JPY,
320        ]),
321        // gs_setSGFuture
322        security_firm_id::FUTU_SG => Some(&[
323            currency_id::HKD,
324            currency_id::USD,
325            currency_id::CNH,
326            currency_id::JPY,
327            currency_id::SGD,
328        ]),
329        // gs_setMYFuture
330        security_firm_id::FUTU_MY => Some(&[
331            currency_id::MYR,
332            currency_id::CNH,
333            currency_id::JPY,
334            currency_id::SGD,
335            currency_id::HKD,
336        ]),
337        _ => None,
338    }
339}
340
341/// 全能账户 broker → supported currencies (对齐 C++
342/// `INNData_Trd_CommonCurrency.cpp:8-14`)
343fn universal_supported_currencies(security_firm: i32) -> Option<&'static [i32]> {
344    match security_firm {
345        // gs_setHKUniversal
346        security_firm_id::FUTU_HK => Some(&[
347            currency_id::HKD,
348            currency_id::USD,
349            currency_id::CNH,
350            currency_id::JPY,
351        ]),
352        // gs_setUSUniversal
353        security_firm_id::FUTU_US => Some(&[
354            currency_id::HKD,
355            currency_id::USD,
356            currency_id::CNH,
357            currency_id::JPY,
358            currency_id::SGD,
359        ]),
360        // gs_setSGUniversal
361        security_firm_id::FUTU_SG => Some(&[
362            currency_id::HKD,
363            currency_id::USD,
364            currency_id::CNH,
365            currency_id::JPY,
366            currency_id::SGD,
367        ]),
368        // gs_setAUUniversal
369        security_firm_id::FUTU_AU => Some(&[
370            currency_id::HKD,
371            currency_id::USD,
372            currency_id::CNH,
373            currency_id::JPY,
374            currency_id::SGD,
375            currency_id::AUD,
376        ]),
377        // gs_setCAUniversal — Moomoo CA 测试账户
378        security_firm_id::FUTU_CA => Some(&[currency_id::USD, currency_id::CAD]),
379        // gs_setMYUniversal
380        security_firm_id::FUTU_MY => Some(&[
381            currency_id::MYR,
382            currency_id::CNH,
383            currency_id::USD,
384            currency_id::SGD,
385            currency_id::HKD,
386        ]),
387        // gs_setJPUniversal
388        security_firm_id::FUTU_JP => Some(&[currency_id::JPY, currency_id::USD]),
389        _ => None,
390    }
391}
392
393/// 取账户 supported currencies 完整列表 (对齐 C++
394/// `INNData_Trd_CommonCurrency::GetAccountValidCurrency` line 90-146)
395///
396/// - 期货账户: 按 broker 取 futures set
397/// - 全能账户: 按 broker 取 universal set
398/// - 单币种账户: 仅一个 currency (TrdMarket → Currency 派生)
399/// - broker 未识别: None (无法判断, daemon 不该 hard reject — 让 backend 决定)
400pub fn supported_currencies(
401    security_firm: Option<i32>,
402    trd_market: Option<i32>,
403    uni_card_num: Option<&str>,
404) -> Option<Vec<i32>> {
405    match classify_account(trd_market, security_firm, uni_card_num) {
406        AccountKind::Futures => security_firm
407            .and_then(futures_supported_currencies)
408            .map(|s| s.to_vec()),
409        AccountKind::Universal => security_firm
410            .and_then(universal_supported_currencies)
411            .map(|s| s.to_vec()),
412        AccountKind::SingleCurrency => single_currency_for_market(trd_market).map(|c| vec![c]),
413    }
414}
415
416/// 真实持仓刷新 CMD3020 使用的默认查询币种。
417///
418/// 对齐 C++:
419/// - `APIServer_Trd_GetPositionList.cpp:197,210` 调
420///   `INNProto_Trd_Acc::QueryPositionListNoLimit(...)`
421/// - `NNProto_Trd_Acc.cpp:787-801` 内部调用
422///   `QueryAssetInner(false, INNData_Trd_CommonCurrency::GetAccountFirstValidCurrency(accItem), ...)`
423/// - `INNData_Trd_CommonCurrency.cpp:148-192` 对 futures/universal 账户取
424///   supported currency set 的 `begin()`,single-currency 账户走
425///   `GetTrdMarketCurrency`.
426///
427/// 注意这不是用户侧 `GetFunds` 默认币种策略。`GetFunds` 为 UX 会按券商本地
428/// 币种补齐未传 currency;`GetPositionList` 没有 currency 字段,只是在
429/// C++ 内部用 first-valid currency 拉一次 AccountInfoReq 来刷新持仓 cache。
430///
431/// Hardcoded / Assumption Ledger:
432/// - supported currency set 来自本文件上方 C++ 对齐表,不按具体账号硬编码。
433/// - C++ 用 `std::set<NN_TrdCurrency>::begin()`,Rust 用数值最小 currency
434///   等价表达;若 C++ 改为保持插入顺序,这里必须同步调整。
435pub fn first_valid_currency_for_account(
436    security_firm: Option<i32>,
437    trd_market: Option<i32>,
438    uni_card_num: Option<&str>,
439    trd_market_auth_list: &[i32],
440) -> Option<i32> {
441    let kind = classify_account_with_auth_list(
442        trd_market,
443        security_firm,
444        uni_card_num,
445        trd_market_auth_list,
446    );
447    let mut supported = supported_currencies_for_kind(kind, security_firm, trd_market)?;
448    supported.sort_unstable();
449    supported.into_iter().next()
450}
451
452fn supported_currencies_for_kind(
453    kind: AccountKind,
454    security_firm: Option<i32>,
455    trd_market: Option<i32>,
456) -> Option<Vec<i32>> {
457    match kind {
458        AccountKind::Futures => security_firm
459            .and_then(futures_supported_currencies)
460            .map(|s| s.to_vec()),
461        AccountKind::Universal => security_firm
462            .and_then(universal_supported_currencies)
463            .map(|s| s.to_vec()),
464        AccountKind::SingleCurrency => single_currency_for_market(trd_market).map(|c| vec![c]),
465    }
466}
467
468/// Layer A 校验结果 (用 enum 让 caller 区分四种状态)
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub enum CurrencyValidation {
471    /// requested currency 在 broker supported set 内 → OK 发 backend.
472    /// SingleCurrency 缺 currency 也归 Ok (跟 C++ legacy 分支一致).
473    Ok,
474    /// **v1.4.106 Finding F1**: Futures / Universal 账户**必传** currency,
475    /// 未传 → loud reject (对齐 C++ `CheckReqParams_GetFunds`:
476    /// `if (!c2s.has_currency()) return false;`).
477    /// SingleCurrency 缺 currency 不进此分支, 仍归 Ok.
478    Missing {
479        broker_label: &'static str,
480        supported_label_list: Vec<&'static str>,
481    },
482    /// requested currency 不在 set 内 → 立即 reject (不发 backend)
483    /// 含 broker 标签 + supported list 用于 error message
484    Unsupported {
485        broker_label: &'static str,
486        supported_label_list: Vec<&'static str>,
487    },
488    /// 无法判断 (security_firm=None / cache miss / unknown broker) — 不 hard
489    /// reject, 让 backend 决定. 仍记录 hint 用于日志.
490    Unknown,
491}
492
493/// security_firm enum int → 对齐 broker 标签 (用于 error message)
494pub fn broker_label(security_firm: Option<i32>) -> &'static str {
495    match security_firm {
496        Some(security_firm_id::FUTU_HK) => "Futu HK",
497        Some(security_firm_id::FUTU_US) => "Moomoo US",
498        Some(security_firm_id::FUTU_SG) => "Moomoo SG",
499        Some(security_firm_id::FUTU_AU) => "Moomoo AU",
500        Some(security_firm_id::FUTU_CA) => "Moomoo CA",
501        Some(security_firm_id::FUTU_MY) => "Moomoo MY",
502        Some(security_firm_id::FUTU_JP) => "Moomoo JP",
503        _ => "unknown broker",
504    }
505}
506
507/// Broker 默认资金视图币种。
508///
509/// 这是 surface UX 层用于“用户未显式传 currency”时补齐 `Trd_GetFunds`
510/// 请求币种的规则,不是 C++ `CheckReqParams_GetFunds` 的参数校验规则。
511/// C++ 对 Futures / Universal 缺 `currency` 会直接 missing-parameter;Rust
512/// CLI/REST/MCP 为了让用户可直接用 App 可见 card-num 查资金,在 gateway
513/// 统一派生一个用户可预期的默认币种后再发 backend。
514///
515/// Hardcoded / Assumption Ledger:
516/// - broker enum 来自 `Trd_Common.proto::SecurityFirm` 与本文件
517///   `security_firm_id` 常量,不按具体账号硬编码。
518/// - 默认币种按券商本地记账币种选择:FutuHK=HKD, FutuInc=USD,
519///   FutuSG=SGD, FutuAU=AUD, FutuCA=CAD, FutuMY=MYR, FutuJP=JPY。
520/// - 若后续 backend 下发显式 base currency,应优先替换这张静态 broker 表。
521pub fn default_currency_by_security_firm(security_firm: Option<i32>) -> Option<i32> {
522    match security_firm? {
523        security_firm_id::FUTU_HK => Some(currency_id::HKD),
524        security_firm_id::FUTU_US => Some(currency_id::USD),
525        security_firm_id::FUTU_SG => Some(currency_id::SGD),
526        security_firm_id::FUTU_AU => Some(currency_id::AUD),
527        security_firm_id::FUTU_CA => Some(currency_id::CAD),
528        security_firm_id::FUTU_MY => Some(currency_id::MYR),
529        security_firm_id::FUTU_JP => Some(currency_id::JPY),
530        _ => None,
531    }
532}
533
534/// `Trd_GetFunds` 用户侧 effective currency。
535///
536/// - 用户显式传 `currency`:原样使用,后续 validator 负责校验 supported set。
537/// - Futures / Universal 未传:按 broker 默认币种补齐,避免 CLI/REST/MCP 每个
538///   surface 自己猜,也避免用户必须先知道内部 acc_id/currency 规则。
539/// - SingleCurrency 未传:保持 `None`,对齐 C++ legacy 分支“currency 被忽略”
540///   的语义。
541pub fn effective_get_funds_currency_for_account(
542    requested_currency: Option<i32>,
543    security_firm: Option<i32>,
544    trd_market: Option<i32>,
545    uni_card_num: Option<&str>,
546    trd_market_auth_list: &[i32],
547) -> Option<i32> {
548    if requested_currency.is_some() {
549        return requested_currency;
550    }
551
552    let kind = classify_account_with_auth_list(
553        trd_market,
554        security_firm,
555        uni_card_num,
556        trd_market_auth_list,
557    );
558    match kind {
559        AccountKind::Futures | AccountKind::Universal => {
560            default_currency_by_security_firm(security_firm)
561        }
562        AccountKind::SingleCurrency => None,
563    }
564}
565
566/// currency enum int → 对齐 currency 标签 (用于 error message)
567pub fn currency_label(c: i32) -> &'static str {
568    match c {
569        currency_id::HKD => "HKD",
570        currency_id::USD => "USD",
571        currency_id::CNH => "CNH",
572        currency_id::JPY => "JPY",
573        currency_id::SGD => "SGD",
574        currency_id::AUD => "AUD",
575        currency_id::CAD => "CAD",
576        currency_id::MYR => "MYR",
577        9 => "USDT", // daemon-only extension, not in C++
578        _ => "UNKNOWN",
579    }
580}
581
582/// Parse a user-facing currency code into `Trd_Common.Currency` enum int.
583///
584/// This is the shared contract for CLI / MCP / REST-like adapters that accept
585/// textual currency input. Unknown values must be rejected loudly by the caller
586/// instead of silently falling back to "no currency".
587pub fn parse_currency_label(s: &str) -> anyhow::Result<i32> {
588    match s.trim().to_ascii_uppercase().as_str() {
589        "HKD" => Ok(currency_id::HKD),
590        "USD" => Ok(currency_id::USD),
591        "CNH" | "CNY" | "RMB" => Ok(currency_id::CNH),
592        "JPY" => Ok(currency_id::JPY),
593        "SGD" => Ok(currency_id::SGD),
594        "AUD" => Ok(currency_id::AUD),
595        "CAD" => Ok(currency_id::CAD),
596        "MYR" => Ok(currency_id::MYR),
597        "USDT" => Ok(9),
598        _ => anyhow::bail!("invalid currency {s:?}: expected HKD|USD|CNH|JPY|SGD|AUD|CAD|MYR|USDT"),
599    }
600}
601
602/// Display label for known currency IDs.
603///
604/// `currency_label` deliberately returns `UNKNOWN` for diagnostics. Table/JSON
605/// presentation often needs a tri-state instead: absent / unknown should stay
606/// absent so the surface can render `-`, omit a field, or choose its own
607/// fallback text.
608#[must_use]
609pub fn known_currency_label(c: Option<i32>) -> Option<&'static str> {
610    match c? {
611        currency_id::HKD => Some("HKD"),
612        currency_id::USD => Some("USD"),
613        currency_id::CNH => Some("CNH"),
614        currency_id::JPY => Some("JPY"),
615        currency_id::SGD => Some("SGD"),
616        currency_id::AUD => Some("AUD"),
617        currency_id::CAD => Some("CAD"),
618        currency_id::MYR => Some("MYR"),
619        9 => Some("USDT"),
620        _ => None,
621    }
622}
623
624/// **Layer A pre-check** — 严格对齐 C++ `CheckReqParams_GetFunds` /
625/// `CheckCurrencyValid` (`APIServer_Trd_GetFunds.cpp:457-491`):
626///
627/// ```cpp
628/// // 期货综合账户或全能账户需要传货币参数
629/// if (accItem.enTrdMkt == NN_TrdMarket_Futures || accItem.enTrdMkt == NN_TrdMarket_SG)
630/// {
631///     if (!c2s.has_currency()) return false;     // missing → reject
632///     if (!CheckCurrencyValid(...)) return false; // out-of-set → reject
633/// }
634/// return true;
635/// ```
636///
637/// **C++ 只对 `Futures` (trd_market=5) + `SG/Universal` (trd_market=6)
638/// 验证 currency**. 其他账户 (legacy HK Sec / US Sec / HKCC / Crypto / Forex
639/// / HK_Fund / US_Fund / sim) **完全不验证** — backend 在 `FillFunds` else
640/// branch 用 `nnFunds.enCurrency` 返 native currency, 静默忽略 client 传的
641/// `currency` 参数.
642///
643/// **v1.4.106 修法 (P0 + Finding F1, 真机 vs C++ OpenD 4/4 不一致 catalog 触发)**:
644/// 之前 v1.4.105 对**所有**账户 strict reject, 违反 pitfall #51 "对齐
645/// C++ = 减法". legacy 单市场账户 + USD/CAD/SGD daemon reject 但 C++ 接受.
646/// 现在严格只 validate Futures + Universal, SingleCurrency 直接 pass-through.
647///
648/// **v1.4.106 Finding F1 收紧**: 之前 `requested_currency=None` 全部账户都
649/// pass-through (太宽松). C++ `CheckReqParams_GetFunds` 对 Futures + SG/Universal
650/// 强制要求 `c2s.has_currency()`, 缺则返 missing-parameter. 现在分两层:
651///   - SingleCurrency 缺 currency → `Ok` (legacy pass-through 不变)
652///   - Futures / Universal 缺 currency → `Missing` (loud reject)
653///
654/// SGD silent-trust regression 防御仍由 `Universal` 分支锁住:
655/// Moomoo CA Universal (security_firm=5 + uni_card_num + AccountMarket=6)
656/// 进 `AccountKind::Universal` 分支, supported set 不含 SGD → reject.
657///
658/// Return 分类:
659/// - `Ok`:
660///   1. SingleCurrency kind (legacy 单市场 / Crypto / Forex / Fund / sim) —
661///      pass-through, 无论 requested 是否 = None.
662///   2. Futures/Universal + supported list 已知 + requested ∈ set
663/// - `Missing` (v1.4.106 新加):
664///   - Futures / Universal kind + requested = None + supported list 已知
665/// - `Unsupported`:
666///   - Futures / Universal kind + supported list 已知 + requested ∉ set
667/// - `Unknown`:
668///   - Futures / Universal kind 但 broker 未识别 (security_firm=None / cache
669///     miss) → 让 backend 决定 (无法构造 supported list, 也无 Missing
670///     loud-reject 上下文)
671pub fn validate_currency_for_account(
672    requested_currency: Option<i32>,
673    security_firm: Option<i32>,
674    trd_market: Option<i32>,
675    uni_card_num: Option<&str>,
676) -> CurrencyValidation {
677    // **v1.4.106 Finding F1**: classify FIRST, 之前 missing-currency 在
678    // classify 前 early-return Ok 让 Futures/Universal 缺 currency 静默放行,
679    // 与 C++ 不一致.
680    let kind = classify_account(trd_market, security_firm, uni_card_num);
681
682    // **v1.4.106 P0 减法**: SingleCurrency kind 直接 Pass-through (无论 requested
683    // 是否 = None), 跟 C++ legacy 分支一致 (只对 Futures + SG validate).
684    // 此 kind 涵盖 legacy 单市场 / Crypto / Forex / HK_Fund / US_Fund / sim 账户.
685    if matches!(kind, AccountKind::SingleCurrency) {
686        return CurrencyValidation::Ok;
687    }
688
689    // 到这里: kind ∈ {Futures, Universal}. 跟 C++ 同样 strict validate.
690    let Some(supported) = supported_currencies(security_firm, trd_market, uni_card_num) else {
691        // broker 未知 (security_firm=None) → 让 backend 决定. 不 hard reject.
692        return CurrencyValidation::Unknown;
693    };
694
695    // **v1.4.106 Finding F1**: missing currency on Futures/Universal → loud reject.
696    // 对齐 C++ `CheckReqParams_GetFunds:475-485`:
697    //   `if (!c2s.has_currency()) return false;`
698    let Some(req) = requested_currency else {
699        return CurrencyValidation::Missing {
700            broker_label: broker_label(security_firm),
701            supported_label_list: supported.iter().map(|&c| currency_label(c)).collect(),
702        };
703    };
704
705    if supported.contains(&req) {
706        return CurrencyValidation::Ok;
707    }
708
709    // requested ∉ supported → Layer A reject (历史 SGD silent-trust 防御).
710    // 跟 C++ backend `CheckCurrencyValid` 行为一致.
711    CurrencyValidation::Unsupported {
712        broker_label: broker_label(security_firm),
713        supported_label_list: supported.iter().map(|&c| currency_label(c)).collect(),
714    }
715}
716
717/// User-facing `Trd_GetFunds` currency validation.
718///
719/// 用户感知语义(2026-05-05 真机反馈):
720/// - 未显式传 `currency`:使用账户/backend 默认口径,不因为现代综合账户缺参数而
721///   拒绝;不能自行硬贴 HKD/USD 标签。
722/// - 显式传 `currency`:必须落在账户支持集合内,并由 backend 返回同币种的
723///   `union_fund_info`,否则 gateway 后置校验会 loud reject。
724/// - Legacy SingleCurrency 账户沿用 C++ legacy 分支 pass-through:不在本层按
725///   单市场默认币种拒绝用户显式 currency;后续 refresh/cache key 会保留该参数。
726///
727/// 这与 `validate_currency_for_account` 的 C++ strict-missing 行为不同,后者仍保留
728/// 给需要完全模拟 C++ 参数检查的路径。
729pub fn validate_get_funds_currency_for_account(
730    requested_currency: Option<i32>,
731    security_firm: Option<i32>,
732    trd_market: Option<i32>,
733    uni_card_num: Option<&str>,
734    trd_market_auth_list: &[i32],
735) -> CurrencyValidation {
736    let kind = classify_account_with_auth_list(
737        trd_market,
738        security_firm,
739        uni_card_num,
740        trd_market_auth_list,
741    );
742    if matches!(kind, AccountKind::SingleCurrency) {
743        return CurrencyValidation::Ok;
744    }
745
746    let Some(req) = requested_currency else {
747        return CurrencyValidation::Ok;
748    };
749
750    let Some(supported) = supported_currencies_for_kind(kind, security_firm, trd_market) else {
751        return CurrencyValidation::Unknown;
752    };
753
754    if supported.contains(&req) {
755        return CurrencyValidation::Ok;
756    }
757
758    CurrencyValidation::Unsupported {
759        broker_label: broker_label(security_firm),
760        supported_label_list: supported.iter().map(|&c| currency_label(c)).collect(),
761    }
762}
763
764/// 把 Unsupported 变 user-friendly error message (PII-free, 不暴露内部
765/// 实现细节 / 私仓源码路径 / 发版引用)
766///
767/// codex round 1 F3 (P2) v1.4.105: 之前 message 含 `APIServer_Trd_GetFunds.cpp
768/// ::CheckCurrencyValid` C++ 私仓引用 + `see CHANGELOG v1.4.105` release-process
769/// hint, 这俩不应进 user-facing public surface (`/api/funds` / MCP / CLI). 改成
770/// 只保留 broker / requested currency / supported list — 用户能看懂 + ops 能
771/// debug 即可. C++ 对齐证据保留在本 helper 上方代码注释.
772pub fn unsupported_error_message(
773    requested: i32,
774    broker_label: &str,
775    supported_labels: &[&'static str],
776) -> String {
777    format!(
778        "InvalidCurrency: account at broker {broker} does not support currency {req}. \
779         supported currencies for this account: {sup}.",
780        broker = broker_label,
781        req = currency_label(requested),
782        sup = supported_labels.join(", "),
783    )
784}
785
786/// **v1.4.106 Finding F1**: missing-currency 错误消息 (Futures / Universal 必传).
787/// 对齐 C++ `CheckReqParams_GetFunds:475-485` "missing necessary parameter
788/// Currency" 语义.
789pub fn missing_currency_error_message(
790    broker_label: &str,
791    supported_labels: &[&'static str],
792) -> String {
793    format!(
794        "MissingCurrency: futures/universal account at broker {broker} requires \
795         the `currency` parameter. supported currencies for this account: {sup}.",
796        broker = broker_label,
797        sup = supported_labels.join(", "),
798    )
799}
800
801#[cfg(test)]
802mod tests;