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;