Skip to main content

futu_rest/adapter/
response.rs

1//! Split from adapter.rs: response.
2//!
3//! pub items: ApiResponse.
4
5use serde_json::Value;
6
7use super::symbol_normalize::{
8    expand_single_symbol_shorthand_to_security_and_owner, expand_symbols_array_to_security_list,
9};
10
11pub(crate) type EndpointRequestNormalizer = fn(&mut Value) -> Result<(), String>;
12
13#[derive(serde::Serialize)]
14pub struct ApiResponse<T: serde::Serialize> {
15    pub ret_type: i32,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub ret_msg: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub data: Option<T>,
20}
21
22/// v1.4.45 (同事 external tester v1.4.43 反馈 "acc_id=0" 根因修): 把 JSON body 里所有
23/// camelCase key 转 snake_case。FTAPI proto 文档里字段名是 camelCase
24/// (`accID` / `trdEnv` / `filterConditions` / `beginTime`) — py-futu-api 和
25/// C++ OpenD 的文档都这样写。但 Rust REST 的 serde 默认按 struct field 名
26/// snake_case 匹配 + `#[serde(default)]` 静默吞未知字段 → 用户从官方文档复制
27/// 的 curl body 在 Rust daemon 就变成 acc_id=0 默认值。
28///
29/// **修法**:在 JSON body 进 serde 之前预处理,把 camelCase key 递归转
30/// snake_case。snake_case 原本正确的不受影响。
31///
32/// 边界情况:
33/// - 嵌套 object 递归转
34/// - Array 元素如果是 object 也递归转
35/// - 非字符串 key / 非对象 Value 保持不动
36/// - 字段值里的大小写不动(只处理 **key**)
37///
38/// **CLAUDE.md 核心原则对齐**:"任何实现上的细节缺失,对用户来说都是功能缺失"
39/// —— 和 FTAPI 官方 camelCase 不兼容 = 功能缺失。
40/// v1.4.68 Bug fix (external reviewer v1.4.57 #6): 常见 SDK 字段别名 → proto 字段
41///
42/// Python SDK 用 `max_count` 作为 K 线查询参数名,FTAPI proto 字段是
43/// `maxAckKLNum` / normalize 后 `max_ack_kl_num`。用户直接调 REST 若用
44/// Python SDK 习惯的 `max_count` / `req_count` → serde drop → silent fail
45/// (handler 用 0 不 truncate → 返 1000+ 条)。
46///
47/// 解法:在 normalize 之后加通用 field alias 表,同义字段自动 rename 到
48/// canonical proto 字段名。Alias 优先级:不覆盖已存在的 canonical 字段。
49///
50/// 未来加新 alias 时列到本表:
51/// - `req_count` → `max_ack_kl_num`(external reviewer 报告用的字段名)
52/// - `max_count` → `max_ack_kl_num`(Python SDK 参数名)
53#[cfg(test)]
54pub(crate) fn apply_known_field_aliases(value: &mut Value) {
55    apply_known_field_aliases_for_proto_id(value, None);
56}
57
58pub(crate) fn apply_known_field_aliases_for_proto_id(value: &mut Value, proto_id: Option<u32>) {
59    // v1.4.82 A1 B1 配套: subscribe SDK 友好别名(双 tester v1.4.81 NEW-c22f-012 发现
60    // 用户传 stocks/symbols/sub_types/is_sub 非 proto 字段被 serde 静默 drop
61    // → SubHandler 空 list → silent-success). 本 alias + SubHandler 入口 loud
62    // validation 协同(CLAUDE.md 坑 #45)。
63    //
64    // **注意 symbols → security_list 的结构不匹配**:
65    //   - alias 目标 security_list 要求 `[{market: N, code: "..."}, ...]`
66    //   - 用户传的 `symbols: ["US.AAPL", ...]` 是扁平字符串
67    //   - 本 alias 只做 key rename;结构转换(string array → Security object
68    //     array)在 `expand_symbol_shorthand` → `expand_symbols_array_to_security_list`
69    //     完成(v1.4.82 B1 + v1.4.88 mixed-array 扩展)
70    const COMMON_ALIASES: &[(&str, &str)] = &[
71        ("req_count", "max_ack_kl_num"),
72        ("max_count", "max_ack_kl_num"),
73        // v1.4.82 A1: subscribe 字段名 SDK 友好 alias
74        ("symbols", "security_list"),
75        ("stocks", "security_list"),
76        ("sub_types", "sub_type_list"),
77        ("is_sub", "is_sub_or_un_sub"),
78    ];
79    // These shorthand aliases are only valid for endpoints whose proto really
80    // uses begin_time/end_time. StockFilter/GetWarrant have a real `begin`
81    // pagination field; applying this globally turns valid requests into
82    // "missing begin" before serde sees them.
83    const TIME_ALIASES: &[(&str, &str)] = &[
84        // v1.4.90 P0-D: history-kline 用户常用 `begin` / `end` 简称
85        // (类 Python SDK 风格), proto 字段是 `begin_time` / `end_time`.
86        // 漏 alias → serde 静默 drop → handler 用空字符串默认 → backend 返
87        // 245 行全量 K 线 + ret_type=0 (silent-success 反模式 #45).
88        ("begin", "begin_time"),
89        ("end", "end_time"),
90        // v1.4.104 external reviewer OBS-P3-002 (P3) fix: option-chain / option-expiration-date
91        // / warrant 等 endpoint 也常用 `start` 当 begin_time alias (类 Python
92        // SDK + Bloomberg-style). 之前只有 `begin` alias, `start` 被 strict
93        // validator silent drop. 加 alias 与 `begin` 并行 (proto 字段不变).
94        ("start", "begin_time"),
95    ];
96
97    let apply_time_aliases = proto_id.is_none_or(supports_begin_time_aliases);
98    match value {
99        Value::Object(map) => {
100            apply_aliases_to_map(map, COMMON_ALIASES);
101            if apply_time_aliases {
102                apply_aliases_to_map(map, TIME_ALIASES);
103            }
104            for v in map.values_mut() {
105                apply_known_field_aliases_for_proto_id(v, proto_id);
106            }
107        }
108        Value::Array(arr) => {
109            for item in arr {
110                apply_known_field_aliases_for_proto_id(item, proto_id);
111            }
112        }
113        _ => {}
114    }
115}
116
117/// Normalize high-level REST `/api/cancel-order` aliases into the underlying
118/// `Trd_ModifyOrder(Cancel)` C2S shape.
119///
120/// This is intentionally endpoint-local. `ModifyOrder` remains the low-level
121/// proto surface, while `CancelOrder` accepts the CLI/MCP-style flat shape:
122/// `env`, `market`, `op`, `order_id_ex`, and string-form numeric `order_id`.
123/// Strict-field validation, route-level checks, and the adapter decoder all
124/// call this same function so the three boundaries cannot drift.
125pub(crate) fn normalize_cancel_order_request_aliases(value: &mut Value) -> Result<(), String> {
126    let Some(map) = value.as_object_mut() else {
127        return Ok(());
128    };
129    if let Some(c2s) = map.get_mut("c2s").and_then(Value::as_object_mut) {
130        normalize_cancel_order_c2s_aliases(c2s)?;
131    } else {
132        normalize_cancel_order_c2s_aliases(map)?;
133    }
134    Ok(())
135}
136
137/// Normalize REST history-order aliases into the underlying
138/// `Trd_GetHistoryOrderList` / `Trd_GetHistoryOrderFillList` C2S shape.
139///
140/// CLI and MCP expose `market` as both the trade header market and the
141/// APIServer-side `filter_conditions.filter_market`. Raw REST previously only
142/// had the proto JSON shape, so SDK-style flat calls could accidentally query a
143/// wider account market set than CLI/MCP.
144pub(crate) fn normalize_history_order_request_aliases(value: &mut Value) -> Result<(), String> {
145    let Some(map) = value.as_object_mut() else {
146        return Ok(());
147    };
148    if let Some(c2s) = map.get_mut("c2s").and_then(Value::as_object_mut) {
149        normalize_history_order_c2s_aliases(c2s)?;
150    } else {
151        normalize_history_order_c2s_aliases(map)?;
152    }
153    Ok(())
154}
155
156pub(crate) fn endpoint_local_request_normalizer_for_spec(
157    spec: &futu_surface_spec::EndpointSpec,
158) -> Option<EndpointRequestNormalizer> {
159    super::endpoint_policy::endpoint_adapter_policy_for_spec(spec)
160        .and_then(|policy| policy.request_normalizer)
161}
162
163pub(crate) fn normalize_endpoint_local_request_aliases_for_spec(
164    spec: &futu_surface_spec::EndpointSpec,
165    value: &mut Value,
166) -> Result<(), String> {
167    if let Some(normalizer) = endpoint_local_request_normalizer_for_spec(spec) {
168        normalizer(value)
169    } else {
170        Ok(())
171    }
172}
173
174pub(crate) fn normalize_endpoint_local_request_aliases_for_rest_path(
175    rest_path: &str,
176    value: &mut Value,
177) -> Result<(), String> {
178    if let Some(policy) = super::endpoint_policy::endpoint_adapter_policy_for_rest_path(rest_path)
179        && let Some(normalizer) = policy.request_normalizer
180    {
181        return normalizer(value);
182    }
183    let Some(spec) = futu_surface_spec::lookup_endpoint_by_rest_path(rest_path) else {
184        return Ok(());
185    };
186    normalize_endpoint_local_request_aliases_for_spec(spec, value)
187}
188
189fn normalize_cancel_order_c2s_aliases(
190    c2s: &mut serde_json::Map<String, Value>,
191) -> Result<(), String> {
192    normalize_cancel_order_header_aliases(c2s)?;
193    normalize_cancel_order_id_aliases(c2s)?;
194    let move_trd_env = c2s
195        .get("header")
196        .and_then(Value::as_object)
197        .is_some_and(|header| !header.contains_key("trd_env"))
198        .then(|| c2s.remove("trd_env"))
199        .flatten();
200    let move_trd_market = c2s
201        .get("header")
202        .and_then(Value::as_object)
203        .is_some_and(|header| !header.contains_key("trd_market"))
204        .then(|| c2s.remove("trd_market"))
205        .flatten();
206    if let Some(header) = c2s.get_mut("header").and_then(Value::as_object_mut) {
207        normalize_cancel_order_header_aliases(header)?;
208        if let Some(value) = move_trd_env {
209            header.insert("trd_env".to_string(), value);
210        }
211        if let Some(value) = move_trd_market {
212            header.insert("trd_market".to_string(), value);
213        }
214    }
215    normalize_cancel_order_op(c2s)
216}
217
218fn normalize_history_order_c2s_aliases(
219    c2s: &mut serde_json::Map<String, Value>,
220) -> Result<(), String> {
221    let header_market = normalize_history_order_header_aliases(c2s)?;
222    normalize_history_order_filter_aliases(c2s, header_market)
223}
224
225fn normalize_history_order_header_aliases(
226    c2s: &mut serde_json::Map<String, Value>,
227) -> Result<Option<i32>, String> {
228    let mut header = c2s
229        .remove("header")
230        .map(|v| match v {
231            Value::Object(map) => map,
232            other => {
233                let mut map = serde_json::Map::new();
234                map.insert("invalid_header".to_string(), other);
235                map
236            }
237        })
238        .unwrap_or_default();
239
240    if !header.contains_key("acc_id")
241        && let Some(value) = c2s.remove("acc_id")
242    {
243        header.insert("acc_id".to_string(), value);
244    }
245    if !header.contains_key("trd_env")
246        && let Some(value) = remove_first(c2s, &["trd_env", "env"])
247    {
248        let trd_env = parse_rest_trd_env(&value)?;
249        header.insert("trd_env".to_string(), Value::Number(trd_env.into()));
250    }
251
252    let mut header_market = header
253        .get("trd_market")
254        .map(parse_rest_read_trd_market)
255        .transpose()?;
256    if !header.contains_key("trd_market")
257        && let Some(value) = remove_first(c2s, &["trd_market", "market", "order_market"])
258    {
259        let trd_market = parse_rest_read_trd_market(&value)?;
260        header.insert("trd_market".to_string(), Value::Number(trd_market.into()));
261        header_market = Some(trd_market);
262    } else if header_market.is_some() {
263        c2s.remove("trd_market");
264        c2s.remove("market");
265    }
266
267    if !header.is_empty() {
268        c2s.insert("header".to_string(), Value::Object(header));
269    }
270    Ok(header_market)
271}
272
273fn normalize_history_order_filter_aliases(
274    c2s: &mut serde_json::Map<String, Value>,
275    header_market: Option<i32>,
276) -> Result<(), String> {
277    let mut filter = c2s
278        .remove("filter_conditions")
279        .map(|v| match v {
280            Value::Object(map) => map,
281            other => {
282                let mut map = serde_json::Map::new();
283                map.insert("invalid_filter_conditions".to_string(), other);
284                map
285            }
286        })
287        .unwrap_or_default();
288
289    move_alias_if_missing(
290        c2s,
291        &mut filter,
292        "begin_time",
293        &["begin_time", "begin", "start"],
294    );
295    move_alias_if_missing(c2s, &mut filter, "end_time", &["end_time", "end"]);
296    move_alias_if_missing(c2s, &mut filter, "code_list", &["code_list", "codes"]);
297    move_alias_if_missing(c2s, &mut filter, "id_list", &["id_list"]);
298    move_alias_if_missing(c2s, &mut filter, "order_id_ex_list", &["order_id_ex_list"]);
299
300    if !filter.contains_key("filter_market") {
301        if let Some(value) = remove_first(c2s, &["filter_market", "order_market"]) {
302            let filter_market = parse_rest_read_trd_market(&value)?;
303            filter.insert(
304                "filter_market".to_string(),
305                Value::Number(filter_market.into()),
306            );
307        } else if let Some(market) = header_market {
308            filter.insert("filter_market".to_string(), Value::Number(market.into()));
309        }
310    }
311
312    if !filter.is_empty() {
313        c2s.insert("filter_conditions".to_string(), Value::Object(filter));
314    }
315    Ok(())
316}
317
318fn move_alias_if_missing(
319    from: &mut serde_json::Map<String, Value>,
320    to: &mut serde_json::Map<String, Value>,
321    canonical: &str,
322    aliases: &[&str],
323) {
324    if to.contains_key(canonical) {
325        for alias in aliases {
326            if *alias != canonical {
327                from.remove(*alias);
328            }
329        }
330        return;
331    }
332    if let Some(value) = remove_first(from, aliases) {
333        to.insert(canonical.to_string(), value);
334    }
335}
336
337fn remove_first(map: &mut serde_json::Map<String, Value>, aliases: &[&str]) -> Option<Value> {
338    for alias in aliases {
339        if let Some(value) = map.remove(*alias) {
340            return Some(value);
341        }
342    }
343    None
344}
345
346fn normalize_cancel_order_id_aliases(
347    c2s: &mut serde_json::Map<String, Value>,
348) -> Result<(), String> {
349    let Some(value) = c2s.get_mut("order_id") else {
350        return Ok(());
351    };
352    let Some(raw) = value.as_str() else {
353        return Ok(());
354    };
355    let raw = raw.trim();
356    if raw.is_empty() {
357        return Ok(());
358    }
359    let parsed = raw
360        .parse::<u64>()
361        .map_err(|_| "invalid order_id; expected numeric order_id or order_id_ex".to_string())?;
362    *value = Value::Number(parsed.into());
363    Ok(())
364}
365
366fn normalize_cancel_order_header_aliases(
367    map: &mut serde_json::Map<String, Value>,
368) -> Result<(), String> {
369    if !map.contains_key("trd_env")
370        && let Some(value) = map.remove("env")
371    {
372        let trd_env = parse_rest_trd_env(&value)?;
373        map.insert("trd_env".to_string(), Value::Number(trd_env.into()));
374    } else {
375        map.remove("env");
376    }
377    if !map.contains_key("trd_market")
378        && let Some(value) = map.remove("market")
379    {
380        let trd_market = parse_rest_write_trd_market(&value)?;
381        map.insert("trd_market".to_string(), Value::Number(trd_market.into()));
382    } else {
383        map.remove("market");
384    }
385    Ok(())
386}
387
388fn normalize_cancel_order_op(c2s: &mut serde_json::Map<String, Value>) -> Result<(), String> {
389    let canonical = c2s.remove("modify_order_op");
390    let alias = c2s.remove("op");
391    for value in canonical.iter().chain(alias.iter()) {
392        let op = parse_rest_modify_order_op(value)?;
393        if op != futu_trd::types::ModifyOrderOp::Cancel as i32 {
394            return Err(format!(
395                "/api/cancel-order only accepts cancel operation (2/CANCEL), got {op}"
396            ));
397        }
398    }
399    c2s.insert(
400        "modify_order_op".to_string(),
401        Value::Number((futu_trd::types::ModifyOrderOp::Cancel as i32).into()),
402    );
403    Ok(())
404}
405
406fn parse_rest_trd_env(value: &Value) -> Result<i32, String> {
407    if let Some(n) = value.as_i64() {
408        return match n {
409            0 | 1 => Ok(n as i32),
410            _ => Err(format!(
411                "invalid env {n}; expected real|simulate|sim or 1|0"
412            )),
413        };
414    }
415    let Some(raw) = value.as_str() else {
416        return Err("invalid env; expected real|simulate|sim or 1|0".to_string());
417    };
418    if let Ok(n) = raw.trim().parse::<i64>() {
419        return match n {
420            0 | 1 => Ok(n as i32),
421            _ => Err(format!(
422                "invalid env {n}; expected real|simulate|sim or 1|0"
423            )),
424        };
425    }
426    futu_trd::parsing::parse_trd_env(raw)
427        .map(|env| env as i32)
428        .ok_or_else(|| {
429            format!(
430                "invalid env {raw:?}; expected {}",
431                futu_trd::parsing::TRD_ENV_PARSE_CHOICES
432            )
433        })
434}
435
436fn parse_rest_write_trd_market(value: &Value) -> Result<i32, String> {
437    if let Some(n) = value.as_i64() {
438        let n = i32::try_from(n).map_err(|_| {
439            format!(
440                "invalid market {n}; expected {}",
441                futu_trd::market::TRD_MARKET_NON_FUND_PARSE_CHOICES
442            )
443        })?;
444        return futu_trd::market::trd_market_from_i32(n)
445            .filter(|market| futu_trd::market::canonical_fund_trd_market_label(*market).is_none())
446            .map(|market| market as i32)
447            .ok_or_else(|| {
448                format!(
449                    "invalid market {n}; expected {}",
450                    futu_trd::market::TRD_MARKET_NON_FUND_PARSE_CHOICES
451                )
452            });
453    }
454    let Some(raw) = value.as_str() else {
455        return Err(format!(
456            "invalid market; expected {}",
457            futu_trd::market::TRD_MARKET_NON_FUND_PARSE_CHOICES
458        ));
459    };
460    futu_trd::market::parse_non_fund_trd_market(raw)
461        .map(|market| market as i32)
462        .ok_or_else(|| {
463            format!(
464                "invalid market {raw:?}; expected {}",
465                futu_trd::market::TRD_MARKET_NON_FUND_PARSE_CHOICES
466            )
467        })
468}
469
470fn parse_rest_read_trd_market(value: &Value) -> Result<i32, String> {
471    if let Some(n) = value.as_i64() {
472        let n = i32::try_from(n).map_err(|_| {
473            format!(
474                "invalid market {n}; expected {}",
475                futu_trd::market::TRD_MARKET_PARSE_CHOICES
476            )
477        })?;
478        return futu_trd::market::trd_market_from_i32(n)
479            .map(|market| market as i32)
480            .ok_or_else(|| {
481                format!(
482                    "invalid market {n}; expected {}",
483                    futu_trd::market::TRD_MARKET_PARSE_CHOICES
484                )
485            });
486    }
487    let Some(raw) = value.as_str() else {
488        return Err(format!(
489            "invalid market; expected {}",
490            futu_trd::market::TRD_MARKET_PARSE_CHOICES
491        ));
492    };
493    futu_trd::market::parse_trd_market(raw)
494        .map(|market| market as i32)
495        .ok_or_else(|| {
496            format!(
497                "invalid market {raw:?}; expected {}",
498                futu_trd::market::TRD_MARKET_PARSE_CHOICES
499            )
500        })
501}
502
503fn parse_rest_modify_order_op(value: &Value) -> Result<i32, String> {
504    if let Some(n) = value.as_i64() {
505        return i32::try_from(n).map_err(|_| {
506            format!(
507                "invalid op {n}; expected {}",
508                futu_trd::parsing::MODIFY_OP_PARSE_CHOICES
509            )
510        });
511    }
512    let Some(raw) = value.as_str() else {
513        return Err(format!(
514            "invalid op; expected {}",
515            futu_trd::parsing::MODIFY_OP_PARSE_CHOICES
516        ));
517    };
518    if let Ok(n) = raw.trim().parse::<i32>() {
519        return Ok(n);
520    }
521    futu_trd::parsing::parse_modify_op(raw)
522        .map(|op| op as i32)
523        .ok_or_else(|| {
524            format!(
525                "invalid op {raw:?}; expected {}",
526                futu_trd::parsing::MODIFY_OP_PARSE_CHOICES
527            )
528        })
529}
530
531fn apply_aliases_to_map(map: &mut serde_json::Map<String, Value>, aliases: &[(&str, &str)]) {
532    for (alias, canonical) in aliases {
533        if map.contains_key(*canonical) {
534            // canonical 已存在 → 不覆盖,user 显式指定优先
535            map.remove(*alias);
536        } else if let Some(v) = map.remove(*alias) {
537            map.insert((*canonical).to_string(), v);
538        }
539    }
540}
541
542fn supports_begin_time_aliases(proto_id: u32) -> bool {
543    matches!(
544        proto_id,
545        futu_core::proto_id::QOT_GET_HISTORY_KL
546            | futu_core::proto_id::QOT_REQUEST_HISTORY_KL
547            | futu_core::proto_id::QOT_GET_OPTION_CHAIN
548            | futu_core::proto_id::QOT_GET_CAPITAL_FLOW
549            | futu_core::proto_id::QOT_GET_SUSPEND
550            | futu_core::proto_id::QOT_GET_HOLDING_CHANGE_LIST
551            | futu_core::proto_id::QOT_GET_TRADE_DATE
552            | futu_core::proto_id::QOT_REQUEST_TRADE_DATE
553    )
554}
555
556/// v1.4.73 BUG-005 fix: auto-wrap "flat" body 到 `{c2s: ...}` 嵌套结构。
557///
558/// external reviewer v1.4.71 AI tester 报告:`POST /api/history-kline -d
559/// '{"symbol":"HK.00700","kl_type":"day","max_count":5}'` 返
560/// `ret_type=-1 "invalid kl_type"`(误导错,实际 proto Request struct 要求
561/// 顶层 `c2s` wrapper,用户传的 flat body 让 serde 报 "missing c2s" 被转成
562/// 看起来像字段值错的文案)。
563///
564/// 已在 adapter 入口(`proto_request_with_idempotency`)做 `normalize_json_keys_snake_case`
565/// 和 proto-aware field aliases 之后、`serde_json::from_value` 之前调。
566///
567/// 判断规则:
568/// 1. 必须是 object(非 object 不动)
569/// 2. 已有 `c2s` key → 不动(nested form 已正确)
570/// 3. 有 `s2c` / `ret_type` / `ret_msg` / `err_code` key → 不动(看起来是
571///    response 结构误传 body,不 auto-wrap 免得加深错误)
572/// 4. 其他情况 → 把整个 object 包一层 `{c2s: body}`
573///
574/// 不尝试 validate c2s 字段 shape(让 serde_json::from_value 报更精确错误)。
575pub(crate) fn maybe_wrap_flat_body_as_c2s(value: &mut Value) {
576    let Value::Object(map) = value else {
577        return;
578    };
579    // 已嵌套 c2s 不动
580    if map.contains_key("c2s") {
581        return;
582    }
583    // response 结构误传 不动(这种情况交给 serde error message 告诉用户)
584    for response_key in ["s2c", "ret_type", "ret_msg", "err_code"] {
585        if map.contains_key(response_key) {
586            return;
587        }
588    }
589    // empty body → 不需要 wrap(serde_json::from_value(json!({})) 后 Request::default())
590    if map.is_empty() {
591        return;
592    }
593    // 把整个 object 包装进 c2s
594    let inner = std::mem::take(map);
595    map.insert("c2s".to_string(), Value::Object(inner));
596}
597
598/// v1.4.90 P2-D: 把 c2s 顶层的 trade-header 字段(`acc_id` / `trd_env`
599/// / `trd_market` / `jp_acc_type`) 自动 expand 到 `c2s.header.{...}` 嵌套.
600///
601/// **背景**: MCP tool 用 flat schema `{acc_id, trd_market, trd_env}`,
602/// REST 11+ trade endpoint 的 proto 是 `{c2s: {header: {trd_env, acc_id,
603/// trd_market}, ...}}` 嵌套. tester 常踩坑: 拿 MCP schema 直接 curl REST →
604/// header 字段全 silent drop → backend 拿 acc_id=0 + trd_env=0 + trd_market=0
605/// 直接报 "acc_id mismatch" 或更糟的 silent-success.
606///
607/// **触发规则**:
608/// 1. 必须存在 `c2s` object
609/// 2. `c2s` 已含 `header` 对象 → 不动(用户已显式)
610/// 3. `c2s` 顶层含至少一个 trade-header 字段 → 把这些字段 move 进
611///    `c2s.header`, 不影响其它字段(如 order_id / price / qty 等)
612/// 4. 顶层无 trade-header 字段 → 不动(非 trade endpoint)
613///
614/// 已在 `maybe_wrap_flat_body_as_c2s` 之后调用, 所以即使用户传纯 flat body
615/// (如 `{acc_id, market, code}`) 也已先包成 `{c2s: {acc_id, market, code}}`,
616/// 这里再把 `acc_id` 提进 `header`. 无 c2s / 已有 header 时为 no-op.
617///
618/// proto 字段映射: `Trd_Common.TrdHeader { trd_env, acc_id, trd_market,
619/// jp_acc_type }`. 见 `proto/Trd_Common.proto:315-322`.
620pub(crate) fn maybe_expand_flat_trd_header(value: &mut Value) {
621    let Value::Object(top) = value else {
622        return;
623    };
624    let Some(Value::Object(c2s)) = top.get_mut("c2s") else {
625        return;
626    };
627    // 已有 header object → 不动
628    if matches!(c2s.get("header"), Some(Value::Object(_))) {
629        return;
630    }
631    // proto Trd_Common.TrdHeader 字段名(snake_case 已 normalize 过)
632    const HEADER_FIELDS: &[&str] = &["trd_env", "acc_id", "trd_market", "jp_acc_type"];
633    // 收集 c2s 顶层中存在的 header 字段
634    let mut header_map = serde_json::Map::new();
635    for field in HEADER_FIELDS {
636        if let Some(v) = c2s.remove(*field) {
637            header_map.insert((*field).to_string(), v);
638        }
639    }
640    // 任一 header 字段都没传 → 非 trade endpoint, 不动
641    if header_map.is_empty() {
642        return;
643    }
644    c2s.insert("header".to_string(), Value::Object(header_map));
645}
646
647/// v1.4.73 BUG-005 fix: 处理 `symbol: "HK.00700"` shorthand → `security: {market, code}`。
648///
649/// Python SDK / 文档里常用 `symbol` 字符串表示 market + code 合并形式。proto
650/// 要求嵌套 `security: {market: 1, code: "00700"}`。Adapter 检测 c2s 里有
651/// `symbol` 字段但缺 `security` 时自动 parse + 替换。
652///
653/// Market prefix 覆盖(与 CLAUDE.md 坑 #36 "code-first" 原则一致):
654/// `HK.xxx / US.xxx / SH.xxx / SZ.xxx / HK_CC.xxx / SG.xxx / JP.xxx / AU.xxx
655/// / CA.xxx / HK_FUTURE.xxx / US_FUTURE.xxx`
656///
657/// Unknown prefix → 保留 symbol 字段不处理(交给下游 handler / serde 报错)。
658///
659/// **v1.4.90 P0-C**: 数组 expand 路径加 `MAX_SYMBOLS_PER_REQUEST` 检查, 超
660/// 限返 `Err(msg)`, 由调用方转 400. 空 string 单 symbol shorthand 路径
661/// 不受影响(单 symbol 没 DoS 风险).
662pub(crate) fn expand_symbol_shorthand(value: &mut Value) -> Result<(), String> {
663    let Value::Object(top) = value else {
664        return Ok(());
665    };
666    // 递归进 c2s 处理(新包装的 flat body 已进入 c2s)
667    let Some(Value::Object(inner)) = top.get_mut("c2s") else {
668        return Ok(());
669    };
670
671    // v1.4.82 B1: **数组 shorthand 先处理**(c22f 双 tester v1.4.81 §6 13
672    // REST endpoint silent empty 的主要修法之一)。
673    //
674    // 用户传 `code_list: ["US.AAPL", "HK.00700"]` 或 `symbols: ["US.TSLA"]`
675    // 字符串数组,proto 期望 `security_list: [{market, code}, ...]` 对象数组。
676    // 不做转换 → serde 反序列化 String[] 到 Security[] 失败 → 400 或 drop →
677    // handler 收空 list → silent-success ret_type=0 空数据。
678    //
679    // 这里在 `security_list` 不存在时,把 `code_list` / `symbols` / `stocks`
680    // / `symbol_list` 的字符串数组展开为 Security 对象数组。
681    //
682    // v1.4.90 P0-C: 数组长度先 cap, 超 MAX_SYMBOLS_PER_REQUEST 直接 400.
683    expand_symbols_array_to_security_list(inner)?;
684
685    // v1.4.83 §6 Phase 1.4 extend:
686    // tester 报 capital-flow / option-chain / option-expiration-date / warrant
687    // 用户传 `{"code": "US.AAPL"}` 或 `{"owner": "HK.00700"}` 单字符串 ret=-1.
688    // 这些 proto 期望 `security: {market, code}` 或 `owner: {market, code}` 对象.
689    //
690    // 扩展 single-security shorthand 支持 4 种输入 key: `symbol` / `code`
691    // / `owner` / `security_string`, 生成 **两个字段** (`security` +
692    // `owner`), proto struct 各取自己的字段名, 另一个被 serde silent drop.
693    //
694    // 这样:
695    // - capital-flow 用 security → security field hit
696    // - option-chain / option-expiration-date / warrant 用 owner → owner field hit
697    // - 不破坏 v1.4.73 原 `symbol` → `security` 单 field 行为
698    //   (因为大部分 endpoint 只有一个字段, `owner` 被 drop 无害)
699    expand_single_symbol_shorthand_to_security_and_owner(inner)?;
700    Ok(())
701}