Skip to main content

futu_rest/adapter/
symbol_normalize.rs

1//! Split from adapter.rs: symbol_normalize.
2//!
3//! pub items: normalize_json_keys_snake_case,expand_single_symbol_shorthand_to_security_and_owner,expand_symbols_array_to_security_list,parse_symbol_prefix,try_parse_mixed_array_to_securities,to_snake_case.
4
5use serde_json::Value;
6
7use super::*;
8
9/// 只是现在同时也加 `owner` 方便 option-chain 等 endpoint。
10pub(super) fn expand_single_symbol_shorthand_to_security_and_owner(
11    inner: &mut serde_json::Map<String, Value>,
12) -> Result<(), String> {
13    // 若用户已显式传 security **对象** 或 owner **对象** → 不动
14    // (但如 owner 是 **string**, 属 shorthand input, 继续处理)
15    let has_security_obj = matches!(inner.get("security"), Some(Value::Object(_)));
16    let has_owner_obj = matches!(inner.get("owner"), Some(Value::Object(_)));
17    if has_security_obj || has_owner_obj {
18        return Ok(());
19    }
20    // v1.4.104 external reviewer P1-001 (P1) fix: 若 array path (expand_symbols_array_to_security_list)
21    // 已经生成 `security_list` (说明输入用 list-style endpoint shorthand
22    // `symbol`/`code` singular → list), 单字符串 shorthand 路径**也跳过**.
23    // 否则 single path 会从 `c2s.symbol` 再生成 `c2s.security` + `c2s.owner`
24    // orphan objects, strict_fields validator 见 list-style proto (e.g.
25    // qot_get_basic_qot.Request 仅有 security_list) 会把 security/owner
26    // 当 unknown field → 400 reject.
27    //
28    // 注意 single path 仍要 remove `symbol`/`code` 这些 source key (不让它们
29    // 留下被 strict_fields 当 unknown field). 所以条件是 "security_list 已
30    // 存在 → 只 remove source 不再 insert security/owner".
31    let _already_has_security_list = matches!(inner.get("security_list"), Some(Value::Array(_)));
32
33    // 找 shorthand source key (按优先级: symbol > code > owner-string > security_string)
34    const CANDIDATES: &[&str] = &["symbol", "code", "owner", "security_string"];
35    let mut source_key_opt: Option<&'static str> = None;
36    for key in CANDIDATES {
37        if let Some(v) = inner.get(*key)
38            && v.is_string()
39        {
40            source_key_opt = Some(*key);
41            break;
42        }
43    }
44    let Some(source_key) = source_key_opt else {
45        return Ok(());
46    };
47    let Some(raw) = inner.remove(source_key) else {
48        return Ok(());
49    };
50    let Value::String(sym) = raw else {
51        inner.insert(source_key.to_string(), raw);
52        return Ok(());
53    };
54    if let Some((market, code)) = parse_symbol_prefix(&sym)? {
55        // v1.4.104 external reviewer P1-001 (P1) follow-up: 保留原行为 (生成 security + owner 双
56        // 对象), 即使 array path 已生成 security_list. 因为大量已有测试期望
57        // single path 生成 security/owner. 改修在 strict_fields 把 c2s.security
58        // / c2s.owner 加进 ignore_paths for list-style endpoint (quote / snapshot
59        // / subscribe). 此变量 _already_has_security_list 仅作 debug 提示, 不再
60        // 改变行为.
61        let mut sec = serde_json::Map::new();
62        sec.insert("market".to_string(), Value::Number(market.into()));
63        sec.insert("code".to_string(), Value::String(code));
64        // 生成 security + owner 双对象 (各 proto 按自己字段名取)
65        inner.insert("security".to_string(), Value::Object(sec.clone()));
66        inner.insert("owner".to_string(), Value::Object(sec));
67    } else {
68        // prefix 不认识 → 放回 source key, 让 serde 报精确错误
69        inner.insert(source_key.to_string(), Value::String(sym));
70    }
71    Ok(())
72}
73
74/// v1.4.82 B1: `"HK.00700"` / `"US.AAPL"` 等 prefix-style symbol 字符串 →
75/// `(market: i32, code: String)`。
76///
77/// 返 None 表示无 `.` 分隔 或 prefix 未知。抽成独立 fn 是因为 `security_list`
78/// (v1.4.82 B1) 和 `security` (v1.4.73 BUG-005) 两条 shorthand 路径都需要。
79fn parse_symbol_prefix(sym: &str) -> Result<Option<(i32, String)>, String> {
80    let Some((prefix, code)) = sym.split_once('.') else {
81        return Ok(None);
82    };
83    // Historical REST shorthand accepted this non-public proto value. Keep the
84    // compatibility local instead of teaching the shared QOT parser an enum
85    // value that is absent from proto/Qot_Common.proto.
86    if prefix.eq_ignore_ascii_case("US_FUTURE") && !code.is_empty() {
87        futu_qot::symbol::validate_full_symbol_len(sym).map_err(|err| err.to_string())?;
88        futu_qot::symbol::validate_symbol_code_len(code).map_err(|err| err.to_string())?;
89        return Ok(Some((12, code.to_string())));
90    }
91    match futu_qot::symbol::parse_symbol_parts(sym) {
92        Ok(parsed) => Ok(Some((parsed.market_i32(), parsed.code))),
93        Err(err) => {
94            let msg = err.to_string();
95            if msg.contains("symbol length") || msg.contains("code length") {
96                Err(msg)
97            } else {
98                Ok(None)
99            }
100        }
101    }
102}
103
104/// v1.4.82 B1: 把 `code_list` / `symbols` / `stocks` / `symbol_list` 的字符串
105/// 数组展开为 `security_list: [{market, code}, ...]` proto 期望格式。
106///
107/// **两类来源**:
108/// 1. `security_list` 本身是 `[String]`(A1 alias 已把 `stocks`/`symbols`
109///    rename 成 `security_list` 但保留原 string array 值)— 就地 transform
110/// 2. `code_list` / `symbol_list` 等独立 shorthand key 存在 — take + transform
111///
112/// 用户已传 `security_list: [{market, code}, ...]`(正确 proto 格式)时不动。
113/// 处理三种形式的 transform:
114///   - 纯 `[String]` 数组(v1.4.82 B1 原实装)
115///   - 纯 `[{market, code}]` 对象数组(proto 正确格式,不动)
116///   - **混合** `[{market, code}, "US.AAPL", ...]`(v1.4.88 扩展;string 元素
117///     被就地展开成 object,object 元素透传)
118///
119/// 若任一 string 元素 prefix 未知或非 String/Object → 整体回退放回,让 serde
120/// 报精确错误,不 silent transform。
121///
122/// **v1.4.90 P0-C**: 任一路径数组长度超 `MAX_SYMBOLS_PER_REQUEST` 立即返
123/// `Err(msg)`, 上抛由 `proto_request_with_idempotency` 转 400. v1.4.82 B1
124/// 漏的 input validation, 攻击者 / buggy SDK 一次传 150k symbols → quota
125/// 爆 + RSS 40× 放大. 检查覆盖三种来源:
126///   - `security_list` 已含 string 元素(混合或纯 string)
127///   - `security_list` 已是纯 object array(用户绕过 shorthand 直传)
128///   - 独立 shorthand key (`code_list` / `symbols` / `stocks` / `symbol_list`)
129pub(super) fn expand_symbols_array_to_security_list(
130    inner: &mut serde_json::Map<String, Value>,
131) -> Result<(), String> {
132    // 路径 1: security_list 存在(A1 alias rename 后的形态 / 用户直传)
133    if let Some(existing) = inner.get("security_list") {
134        // v1.4.90 P0-C: 不论 string / object array 都先 cap 长度
135        if let Value::Array(items) = existing
136            && items.len() > MAX_SYMBOLS_PER_REQUEST
137        {
138            return Err(format!(
139                "security_list length {} exceeds MAX_SYMBOLS_PER_REQUEST={}; \
140                 split into multiple requests",
141                items.len(),
142                MAX_SYMBOLS_PER_REQUEST
143            ));
144        }
145        // 判断 security_list 当前值是否需要 transform(含 string 元素即需要)
146        if let Value::Array(items) = existing
147            && items.iter().any(|it| it.is_string())
148        {
149            // 从 shorthand string/mixed array → Security objects(v1.4.88
150            // 扩展:支持 string + object 混合,object 元素直接透传)
151            // 取出后重新插入(避免 borrow 冲突)
152            let Some(raw) = inner.remove("security_list") else {
153                return Ok(());
154            };
155            let Value::Array(items) = raw else {
156                inner.insert("security_list".to_string(), raw);
157                return Ok(());
158            };
159            if let Some(expanded) = try_parse_mixed_array_to_securities(&items)? {
160                inner.insert("security_list".to_string(), Value::Array(expanded));
161            } else {
162                // parse 失败 → 放回让 serde 报错
163                inner.insert("security_list".to_string(), Value::Array(items));
164            }
165            // 已处理 path 1,不再 fall through 到 path 2 (security_list 已
166            // 就地 transformed,避免再次触发)
167            return Ok(());
168        }
169        // security_list 是纯 object array / 空数组 / 非 array — 用户显式传,不动
170        return Ok(());
171    }
172    // 路径 2: 独立 shorthand key(code_list / symbols / stocks / symbol_list)
173    //
174    // v1.4.104 external reviewer P1-001 (P1) extension: 也接受单字符串 `symbol` / `code`
175    // 作 1-element list shorthand. CLI/MCP 都接 `symbol: "US.AAPL"`, REST 之
176    // 前要求 array (`symbols: ["US.AAPL"]`), 不一致让 agent 困惑. 现在两边
177    // 都接, single-string 路径生成 `security_list: [{market, code}]`.
178    //
179    // 注: `symbol` / `code` 也是 `expand_single_symbol_shorthand_to_security_and_owner`
180    // (path 后续) 的输入. 两个 path 用同 source key 时, 这里 (array path)
181    // 优先 — security_list 是更通用形态, 单 security/owner endpoint 仍能用
182    // security_list[0] 的 fallback (因 proto 字段名不同 serde 会 silent drop).
183    // 但 single-symbol endpoint 通常显式传 `security` 对象, shorthand 走
184    // single path 才需要; 此处只在 list 类 endpoint 配合.
185    //
186    // **去重策略**: 我们不 remove `symbol`/`code` (留给后续 single path 处理),
187    // 只 clone 其值生成 `security_list`. 这样两个 path 各取所需, 互不打架.
188    const CANDIDATES: &[&str] = &["code_list", "symbols", "stocks", "symbol_list"];
189    const SINGULAR_CANDIDATES: &[&str] = &["symbol", "code"];
190    let mut source_key_opt: Option<&'static str> = None;
191    for key in CANDIDATES {
192        if inner.contains_key(*key) {
193            source_key_opt = Some(*key);
194            break;
195        }
196    }
197    if source_key_opt.is_none() {
198        // v1.4.104 external reviewer P1-001 (P1) fix: 尝试 singular shorthand → 1-element list.
199        // **重要**: 不 remove `symbol`/`code` 让 single path 后续也用 (生成
200        // security/owner objects). single path 会 remove. 但 strict_fields
201        // validator 在 expand_symbol_shorthand 后立即跑 deny_unknown_fields,
202        // 此时 single path 已经 remove 了, 不会冲突.
203        for key in SINGULAR_CANDIDATES {
204            if let Some(Value::String(s)) = inner.get(*key)
205                && let Some((market, code)) = parse_symbol_prefix(s)?
206            {
207                let mut sec = serde_json::Map::new();
208                sec.insert("market".to_string(), Value::Number(market.into()));
209                sec.insert("code".to_string(), Value::String(code));
210                inner.insert(
211                    "security_list".to_string(),
212                    Value::Array(vec![Value::Object(sec)]),
213                );
214                // 不 return — 让外层 expand_symbol_shorthand 继续走 single path
215                // (会 remove `symbol`/`code`, 防 strict_fields unknown-field reject)
216                return Ok(());
217            }
218        }
219        return Ok(());
220    }
221    let Some(source_key) = source_key_opt else {
222        return Ok(());
223    };
224    let Some(raw) = inner.remove(source_key) else {
225        return Ok(());
226    };
227    let Value::Array(items) = raw else {
228        // 不是 array → 放回让 serde 报错
229        inner.insert(source_key.to_string(), raw);
230        return Ok(());
231    };
232    // v1.4.90 P0-C: shorthand-key 路径同样 cap 长度
233    if items.len() > MAX_SYMBOLS_PER_REQUEST {
234        return Err(format!(
235            "{} length {} exceeds MAX_SYMBOLS_PER_REQUEST={}; \
236             split into multiple requests",
237            source_key,
238            items.len(),
239            MAX_SYMBOLS_PER_REQUEST
240        ));
241    }
242    if let Some(expanded) = try_parse_mixed_array_to_securities(&items)? {
243        inner.insert("security_list".to_string(), Value::Array(expanded));
244    } else {
245        // parse 失败 → 回退原 key + 原 array
246        inner.insert(source_key.to_string(), Value::Array(items));
247    }
248    Ok(())
249}
250
251/// v1.4.88: 把 `[String | Security{market, code}]` 混合数组解析为
252/// `[Security{market, code}]`(pure object / pure string / mixed 三形态)。
253///
254/// 每个元素的处理:
255///   - `Value::String(sym)` → parse_symbol_prefix → `{market, code}` object
256///   - `Value::Object` 含 `market` + `code` 字段 → 直接透传(不重 parse)
257///   - 其他(Object 缺字段 / Number / Bool / null / Array) → 整体返 None
258///
259/// 只要一个 string 元素 prefix 未知 → 返 None,调用方回退原数组让 serde 报精确错。
260/// 这保证 "要么全部合法转换,要么原样回退" 的 all-or-nothing 语义,避免半转换
261/// 留下混合状态 confuse 下游 handler。
262fn try_parse_mixed_array_to_securities(items: &[Value]) -> Result<Option<Vec<Value>>, String> {
263    let mut out: Vec<Value> = Vec::with_capacity(items.len());
264    for item in items {
265        match item {
266            Value::String(sym) => {
267                let Some((market, code)) = parse_symbol_prefix(sym)? else {
268                    return Ok(None);
269                };
270                let mut sec = serde_json::Map::new();
271                sec.insert("market".to_string(), Value::Number(market.into()));
272                sec.insert("code".to_string(), Value::String(code));
273                out.push(Value::Object(sec));
274            }
275            // 已是 Security object — 校验含 market + code 字段(防用户误传
276            // 其他 schema 也 silently 透传 junk 下去)
277            Value::Object(obj) if obj.contains_key("market") && obj.contains_key("code") => {
278                out.push(Value::Object(obj.clone()));
279            }
280            _ => return Ok(None),
281        }
282    }
283    Ok(Some(out))
284}
285
286pub fn normalize_json_keys_snake_case(value: &mut Value) {
287    match value {
288        Value::Object(map) => {
289            // 取出所有 entries,重建 map(遇到同 key snake_case 已存在,后到的覆盖)
290            let mut new_map = serde_json::Map::with_capacity(map.len());
291            // 用 std::mem::take 避免 clone
292            let old = std::mem::take(map);
293            for (k, mut v) in old {
294                normalize_json_keys_snake_case(&mut v);
295                let sk = to_snake_case(&k);
296                new_map.insert(sk, v);
297            }
298            *map = new_map;
299        }
300        Value::Array(arr) => {
301            for item in arr {
302                normalize_json_keys_snake_case(item);
303            }
304        }
305        _ => {}
306    }
307}
308
309/// camelCase / PascalCase → snake_case 转换。
310/// 已经是 snake_case / lowercase 的保持不变。
311/// 规则:每个 uppercase letter 前加 `_`(除非在开头),然后全小写。
312///
313/// 例:
314/// - `accID` → `acc_id` (ID 两个大写合并成一个 _id)
315/// - `trdEnv` → `trd_env`
316/// - `filterConditions` → `filter_conditions`
317/// - `acc_id` → `acc_id` (已经 snake_case, 不动)
318/// - `id` → `id`
319/// - `ID` → `id`
320pub(super) fn to_snake_case(s: &str) -> String {
321    let mut out = String::with_capacity(s.len() + 4);
322    let chars: Vec<char> = s.chars().collect();
323    for (i, &c) in chars.iter().enumerate() {
324        if c.is_ascii_uppercase() {
325            // v1.4.47 P1.3 修(external reviewer 验收报告 §14.5):之前 `pwdMD5` → `pwd_m_d5`(错),
326            // `TRD_ENV` → `tr_d_env`(错)。根因:之前"连续大写 abbreviation"判据用
327            // `next is uppercase or end`,不考虑 next 是**数字或下划线**的情况 ——
328            // 这时应该**继续** treat as abbreviation(MD5 整体 / TRD 整体)不 break。
329            //
330            // 新规则:boundary 触发 = 从 non-upper 进 upper (正常 camelCase),
331            // OR 从 upper 进 upper 再接 **lowercase** (acronym 结束,如 XMLParser
332            // 在 P 前 break: `xml_parser`)。数字 / 下划线 / 结尾都算 "abbreviation
333            // 继续",不触发 boundary.
334            //
335            // 覆盖新场景:
336            //  - `pwdMD5` → `pwd_md5` ✓(MD5 作为 abbreviation)
337            //  - `TRD_ENV` → `trd_env` ✓(TRD 下划线前不 break 内部)
338            //  - `accID` → `acc_id` ✓(原行为保持)
339            //  - `XMLParser` → `xml_parser` ✓(acronym 遇 lowercase break)
340            //
341            // 未覆盖:`trdenv` / `TRDENV` 这种无 boundary marker 的全连写,无法拆(需字典)。
342            let prev = if i > 0 { Some(chars[i - 1]) } else { None };
343            let next = chars.get(i + 1).copied();
344            let prev_upper = prev.map(|p| p.is_ascii_uppercase()).unwrap_or(false);
345            // v1.4.47 P1.3: 判"abbreviation 继续"时考虑数字和下划线
346            let next_is_not_lower = next.map(|n| !n.is_ascii_lowercase()).unwrap_or(true);
347            let at_boundary = i > 0 && prev != Some('_') && !(prev_upper && next_is_not_lower);
348            if at_boundary {
349                out.push('_');
350            }
351            out.push(c.to_ascii_lowercase());
352        } else {
353            out.push(c);
354        }
355    }
356    out
357}