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