Skip to main content

futu_trd/
currency.rs

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