futu_trd/currency/account.rs
1use super::{currency_id, legacy_backend_fund_market_id, trd_market_id};
2
3/// 账户类型分类 (用于查 supported currencies 表)
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum AccountKind {
6 /// 期货账户 (`trd_market == NN_TrdMarket_Futures = 5`)
7 Futures,
8 /// 全能账户 / Universal (`trd_market == NN_TrdMarket_SG = 6`)
9 /// 注意: SG=6 在 C++ 是 "全能账户" enum 的复用, 不是新加坡市场专属
10 /// (见 `Trd_Common.proto:32` 注释 "期货市场"误注 + 实际 C++ 行为 path)
11 Universal,
12 /// 单币种账户 (其他所有 trd_market)
13 SingleCurrency,
14}
15
16/// 账户类型识别 — 对齐 C++ `INNData_Trd_CommonCurrency::GetAccountValidCurrency`
17/// (cpp:92-126) 的 3 分支结构:
18///
19/// ```cpp
20/// if (enTrdMkt == NN_TrdMarket_Futures) → futures set per broker
21/// else if (enTrdMkt == NN_TrdMarket_SG /*6*/) → universal set per broker
22/// else → single currency from
23/// GetTrdMarketCurrency(enTrdMkt)
24/// (HK→HKD / US→USD / CN/HKCC→CNH /
25/// default→Unknown+warn)
26/// ```
27///
28/// **本 fn 是 Rust cache normalization defense, 不是 C++ direct copy**.
29/// (Per v1.4.106 Finding C from codex source audit 2026-05-01).
30///
31/// C++ 严格按 `accItem.enTrdMkt == NN_TrdMarket_SG` 判 Universal, 没有任何
32/// fallback. Rust 加 `uni_card_num + security_firm` fallback 是因为 Rust
33/// cache (`CachedTrdAcc.trd_market`) 直接存 backend raw `Account.market`,
34/// raw 值跟 OpenAPI `TrdMarket` 不一定 1:1 对齐 (e.g. backend raw=13=HK_Fund
35/// vs OpenAPI 113=HK_Fund). 本 fn 防止 cache drift 把真 Universal 账户错认为
36/// SingleCurrency 静默放宽 currency 校验.
37///
38/// **risk of fallback**: 万一 fallback 路径误把 single-market HK/US 账户认成
39/// Universal, 会让 `validate_currency_for_account` 走 universal supported set,
40/// 可能 reject C++ 接受的 currency. 本 fn 的 fallback 仅当 trd_market 不在
41/// official 表里时启用 — 见 Step 3 实现注释.
42///
43/// **现实背景** (用户 2026-04-30 强调): "不论 hk/us/sg 等, 各个券商实体开给
44/// 客户的都是 Universal account; single account 是**老的账户形态**, 已经被
45/// 禁用, 只能浏览了". 所以现代活跃账户**99% 是 Universal** (`trd_market=6`),
46/// SingleCurrency 路径仅服务遗留浏览-only 账户.
47///
48/// 输入信号 (按优先级 + 与 C++ 对齐 + cache drift 防御):
49///
50/// 1. **`trd_market = FUTURES (5)`**: Futures 账户 (C++ 第 1 分支, 不受
51/// fallback 影响)
52/// 2. **`trd_market = SG (6) = AccountMarket::UNIVERSAL`**: Universal (C++
53/// 第 2 分支, canonical)
54/// 3. **`uni_card_num` 非空 + `security_firm` 已识别**: 仍按 Universal 处理
55/// (cache drift 防御 — 真 Universal 账户必有 uni_card_num + security_firm).
56/// **必须排在第 4 步前**, 防 cache 把 trd_market 错存成 raw NN_TrdMarket
57/// 值 (CA=112 / AU=8 / JP=15 / MY=111) 落进 SingleCurrency 静默放行.
58/// 4. **`trd_market` ∈ legacy single 表** (HK=1 / US=2 / CN=3 / HKCC=4 +
59/// backend-raw fund 13/22/23/24 + OpenAPI fund 113/123/124/125/126):
60/// legacy SingleCurrency (浏览-only 老账户, C++ 第 3 分支的具体派生路径)
61/// 5. 其他: SingleCurrency (supported 返 None → Unknown, 让 backend 决定)
62///
63/// 历史触发 (codex round 1 F1, v1.4.105 review):
64/// 之前只看 `trd_market == SG (6)`, 真实 cache 若存 raw NN_TrdMarket 值
65/// (CA=112 / AU=8 / JP=15 / MY=111) 则错落进 SingleCurrency, Layer A 静默放
66/// 行 → HKD/SGD silent fallback regression 可能复活.
67///
68/// 用户 2026-04-30 进一步纠偏: legacy single-market HK/US/HKCC 账户在现实里
69/// 都是浏览-only, **不应**早 return 抑制 Universal fallback — 真 Universal 账
70/// 户即使 cache 漂移到 1/2/4, fallback 仍要识别正确.
71pub fn classify_account(
72 trd_market: Option<i32>,
73 security_firm: Option<i32>,
74 uni_card_num: Option<&str>,
75) -> AccountKind {
76 // Step 1: Futures canonical match (C++ 第 1 分支). Futures 账户没有
77 // uni_card_num 概念, 不受 Universal fallback 影响.
78 if trd_market == Some(trd_market_id::FUTURES) {
79 return AccountKind::Futures;
80 }
81
82 // Step 2: Universal canonical match (C++ 第 2 分支, AccountMarket::UNIVERSAL=6).
83 // 现代活跃账户 99% 走这条.
84 if trd_market == Some(trd_market_id::SG) {
85 return AccountKind::Universal;
86 }
87
88 // Step 3: Universal cache-drift fallback. **必须**在 Step 4 (legacy single
89 // 早 return) 之前. 真 Universal 账户 (Moomoo CA/AU/JP/MY/SG/US) 必有
90 // uni_card_num + security_firm. 即使 cache 把 trd_market 错存成 raw
91 // NN_TrdMarket (CA=112 / AU=8 / JP=15 / MY=111) 或意外的 1/2/4 (HK/US/HKCC,
92 // 但 fallback signal 都齐), 这层 fallback 仍能识别 Universal.
93 //
94 // Per 用户 2026-04-30: 现代 HK/US 等账户实质都是 Universal, single 是
95 // legacy 浏览-only. 若一个有 uni_card_num 的账户被 cache 错存成 trd_market=1,
96 // 我们仍按 Universal 处理 (fallback 优先于 legacy 单币种早 return).
97 //
98 // codex round 2 F2 (P2): defensive check — `uni_card_num=Some("")` 不应
99 // trigger Universal fallback. backend 偶发下发空字符串, ingestion 层
100 // (`account_to_cached`) 已 trim+filter, 这里再加一道防御 (与 ingestion
101 // 一致).
102 let uni_card_present = uni_card_num.is_some_and(|s| !s.trim().is_empty());
103 if uni_card_present && security_firm.is_some() {
104 return AccountKind::Universal;
105 }
106
107 // Step 4: Legacy single-market accounts (C++ 第 3 分支具体派生路径)
108 // 仅服务**老账户** (HK Sec / US Sec / HKCC + 各国 Fund 子市场), 现代账户
109 // 不会进这里. 没有 fallback signal 说明既无 uni_card_num 也无 broker —
110 // legacy / browse-only 账户.
111 //
112 // **v1.4.106 Finding D 收紧**: 区分 backend raw `Account.market` (13=HK_Fund
113 // / 22/23=US_Fund / 24=SG_Fund) 与 OpenAPI canonical `TrdMarket`
114 // (113=HK_Fund / 123=US_Fund / 124=SG_Fund / 125=MY_Fund / 126=JP_Fund).
115 // `CachedTrdAcc.trd_market` 直接存 backend raw `acc.market` (见
116 // `bridge/account.rs::account_to_cached:202`), 所以两套都要识别.
117 // App `FTTradeEnableMarket` 数值 (HK_FUND=13/US_FUND=23/SG_FUND=24)
118 // 在数值上和 backend raw 巧合 alias, 但**绝不是同一概念** — 不能
119 // 把 App enum 当 OpenAPI TrdMarket 解释 (per Finding D).
120 match trd_market {
121 // Legacy single-market (real)
122 Some(trd_market_id::HK)
123 | Some(trd_market_id::US)
124 | Some(trd_market_id::CN)
125 | Some(trd_market_id::HKCC)
126 // Fund 子市场 — backend raw `Account.market` 值
127 | Some(legacy_backend_fund_market_id::HK_FUND)
128 | Some(legacy_backend_fund_market_id::US_FUND_OLD)
129 | Some(legacy_backend_fund_market_id::US_FUND)
130 | Some(legacy_backend_fund_market_id::SG_FUND)
131 // Fund 子市场 — OpenAPI canonical `NN_TrdMarket` 值
132 | Some(trd_market_id::HK_FUND)
133 | Some(trd_market_id::US_FUND)
134 | Some(trd_market_id::SG_FUND)
135 | Some(trd_market_id::MY_FUND)
136 | Some(trd_market_id::JP_FUND) => AccountKind::SingleCurrency,
137 // Step 5: 其他 (None / unknown) → SingleCurrency,
138 // single_currency_for_market 会返 None → supported_currencies 返 None →
139 // Layer A 进 Unknown 分支, 让 backend 决定.
140 _ => AccountKind::SingleCurrency,
141 }
142}
143
144/// 从公开 `TrdMarket` / cache auth-list market 推导资金视图币种桶。
145///
146/// 这不是账户主币种推断,而是用于识别“同一个账户暴露了多个币种/市场资金视图”
147/// 的结构信号。Dega 实测里部分现代 FutuHK 综合账户没有可靠 `uni_card_num`,
148/// 但 `trd_market_auth_list` 同时带 US + HKFUND/USFUND;这类账户若按
149/// SingleCurrency 处理,就会忽略用户显式 `currency` 参数。
150///
151/// Hardcoded / Assumption Ledger:
152/// - 这些映射来自 `Trd_Common.proto::TrdMarket` 与本文件 `trd_market_id`
153/// 常量;不是按具体账号硬编码。
154/// - 仅作为 `classify_account_with_auth_list` 的 fallback 信号;canonical
155/// `trd_market=5/6` 与 `uni_card_num + broker` 仍优先。
156fn market_currency_bucket(market: i32) -> Option<i32> {
157 match market {
158 trd_market_id::HK
159 | trd_market_id::HKCC
160 | trd_market_id::FUTURES
161 | trd_market_id::HK_FUND
162 | legacy_backend_fund_market_id::HK_FUND => Some(currency_id::HKD),
163 trd_market_id::US
164 | trd_market_id::US_FUND
165 | legacy_backend_fund_market_id::US_FUND_OLD
166 | legacy_backend_fund_market_id::US_FUND => Some(currency_id::USD),
167 trd_market_id::CN => Some(currency_id::CNH),
168 trd_market_id::SG | trd_market_id::SG_FUND | legacy_backend_fund_market_id::SG_FUND => {
169 Some(currency_id::SGD)
170 }
171 trd_market_id::AU => Some(currency_id::AUD),
172 trd_market_id::JP | trd_market_id::JP_FUND => Some(currency_id::JPY),
173 trd_market_id::MY | trd_market_id::MY_FUND => Some(currency_id::MYR),
174 trd_market_id::CA => Some(currency_id::CAD),
175 _ => None,
176 }
177}
178
179fn auth_list_has_cross_currency_view(trd_market_auth_list: &[i32]) -> bool {
180 let mut buckets: Vec<i32> = Vec::new();
181 for market in trd_market_auth_list {
182 let Some(bucket) = market_currency_bucket(*market) else {
183 continue;
184 };
185 if !buckets.contains(&bucket) {
186 buckets.push(bucket);
187 }
188 if buckets.len() > 1 {
189 return true;
190 }
191 }
192 false
193}
194
195/// `classify_account` 的 cache-auth-list 增强版。
196///
197/// C++ 入口主要靠 `accItem.enTrdMkt == SG(6)` 识别综合账户,但 Rust cache
198/// 的字段来自 backend `FTUsrTrdAcc`,有些现代综合账户可能缺 `uni_card_num`
199/// 或 `trd_market=6` 信号。用户显式传 `currency` 时,auth-list 的跨币种
200/// 市场组合是更接近用户感知的“综合资金视图”信号。
201pub fn classify_account_with_auth_list(
202 trd_market: Option<i32>,
203 security_firm: Option<i32>,
204 uni_card_num: Option<&str>,
205 trd_market_auth_list: &[i32],
206) -> AccountKind {
207 let base = classify_account(trd_market, security_firm, uni_card_num);
208 if !matches!(base, AccountKind::SingleCurrency) {
209 return base;
210 }
211 if auth_list_has_cross_currency_view(trd_market_auth_list) {
212 return AccountKind::Universal;
213 }
214 base
215}