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