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}