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;