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
11#[derive(serde::Serialize)]
12pub struct ApiResponse<T: serde::Serialize> {
13    pub ret_type: i32,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub ret_msg: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub data: Option<T>,
18}
19
20/// v1.4.45 (同事 external tester v1.4.43 反馈 "acc_id=0" 根因修): 把 JSON body 里所有
21/// camelCase key 转 snake_case。FTAPI proto 文档里字段名是 camelCase
22/// (`accID` / `trdEnv` / `filterConditions` / `beginTime`) — py-futu-api 和
23/// C++ OpenD 的文档都这样写。但 Rust REST 的 serde 默认按 struct field 名
24/// snake_case 匹配 + `#[serde(default)]` 静默吞未知字段 → 用户从官方文档复制
25/// 的 curl body 在 Rust daemon 就变成 acc_id=0 默认值。
26///
27/// **修法**:在 JSON body 进 serde 之前预处理,把 camelCase key 递归转
28/// snake_case。snake_case 原本正确的不受影响。
29///
30/// 边界情况:
31/// - 嵌套 object 递归转
32/// - Array 元素如果是 object 也递归转
33/// - 非字符串 key / 非对象 Value 保持不动
34/// - 字段值里的大小写不动(只处理 **key**)
35///
36/// **CLAUDE.md 核心原则对齐**:"任何实现上的细节缺失,对用户来说都是功能缺失"
37/// —— 和 FTAPI 官方 camelCase 不兼容 = 功能缺失。
38/// v1.4.68 Bug fix (external reviewer v1.4.57 #6): 常见 SDK 字段别名 → proto 字段
39///
40/// Python SDK 用 `max_count` 作为 K 线查询参数名,FTAPI proto 字段是
41/// `maxAckKLNum` / normalize 后 `max_ack_kl_num`。用户直接调 REST 若用
42/// Python SDK 习惯的 `max_count` / `req_count` → serde drop → silent fail
43/// (handler 用 0 不 truncate → 返 1000+ 条)。
44///
45/// 解法:在 normalize 之后加通用 field alias 表,同义字段自动 rename 到
46/// canonical proto 字段名。Alias 优先级:不覆盖已存在的 canonical 字段。
47///
48/// 未来加新 alias 时列到本表:
49/// - `req_count` → `max_ack_kl_num`(external reviewer 报告用的字段名)
50/// - `max_count` → `max_ack_kl_num`(Python SDK 参数名)
51#[cfg(test)]
52pub(crate) fn apply_known_field_aliases(value: &mut Value) {
53    apply_known_field_aliases_for_proto_id(value, None);
54}
55
56pub(crate) fn apply_known_field_aliases_for_proto_id(value: &mut Value, proto_id: Option<u32>) {
57    // v1.4.82 A1 B1 配套: subscribe SDK 友好别名(双 tester v1.4.81 NEW-c22f-012 发现
58    // 用户传 stocks/symbols/sub_types/is_sub 非 proto 字段被 serde 静默 drop
59    // → SubHandler 空 list → silent-success). 本 alias + SubHandler 入口 loud
60    // validation 协同(CLAUDE.md 坑 #45)。
61    //
62    // **注意 symbols → security_list 的结构不匹配**:
63    //   - alias 目标 security_list 要求 `[{market: N, code: "..."}, ...]`
64    //   - 用户传的 `symbols: ["US.AAPL", ...]` 是扁平字符串
65    //   - 本 alias 只做 key rename;结构转换(string array → Security object
66    //     array)在 `expand_symbol_shorthand` → `expand_symbols_array_to_security_list`
67    //     完成(v1.4.82 B1 + v1.4.88 mixed-array 扩展)
68    const COMMON_ALIASES: &[(&str, &str)] = &[
69        ("req_count", "max_ack_kl_num"),
70        ("max_count", "max_ack_kl_num"),
71        // v1.4.82 A1: subscribe 字段名 SDK 友好 alias
72        ("symbols", "security_list"),
73        ("stocks", "security_list"),
74        ("sub_types", "sub_type_list"),
75        ("is_sub", "is_sub_or_un_sub"),
76    ];
77    // These shorthand aliases are only valid for endpoints whose proto really
78    // uses begin_time/end_time. StockFilter/GetWarrant have a real `begin`
79    // pagination field; applying this globally turns valid requests into
80    // "missing begin" before serde sees them.
81    const TIME_ALIASES: &[(&str, &str)] = &[
82        // v1.4.90 P0-D: history-kline 用户常用 `begin` / `end` 简称
83        // (类 Python SDK 风格), proto 字段是 `begin_time` / `end_time`.
84        // 漏 alias → serde 静默 drop → handler 用空字符串默认 → backend 返
85        // 245 行全量 K 线 + ret_type=0 (silent-success 反模式 #45).
86        ("begin", "begin_time"),
87        ("end", "end_time"),
88        // v1.4.104 external reviewer OBS-P3-002 (P3) fix: option-chain / option-expiration-date
89        // / warrant 等 endpoint 也常用 `start` 当 begin_time alias (类 Python
90        // SDK + Bloomberg-style). 之前只有 `begin` alias, `start` 被 strict
91        // validator silent drop. 加 alias 与 `begin` 并行 (proto 字段不变).
92        ("start", "begin_time"),
93    ];
94
95    let apply_time_aliases = proto_id.is_none_or(supports_begin_time_aliases);
96    match value {
97        Value::Object(map) => {
98            apply_aliases_to_map(map, COMMON_ALIASES);
99            if apply_time_aliases {
100                apply_aliases_to_map(map, TIME_ALIASES);
101            }
102            for v in map.values_mut() {
103                apply_known_field_aliases_for_proto_id(v, proto_id);
104            }
105        }
106        Value::Array(arr) => {
107            for item in arr {
108                apply_known_field_aliases_for_proto_id(item, proto_id);
109            }
110        }
111        _ => {}
112    }
113}
114
115fn apply_aliases_to_map(map: &mut serde_json::Map<String, Value>, aliases: &[(&str, &str)]) {
116    for (alias, canonical) in aliases {
117        if map.contains_key(*canonical) {
118            // canonical 已存在 → 不覆盖,user 显式指定优先
119            map.remove(*alias);
120        } else if let Some(v) = map.remove(*alias) {
121            map.insert((*canonical).to_string(), v);
122        }
123    }
124}
125
126fn supports_begin_time_aliases(proto_id: u32) -> bool {
127    matches!(
128        proto_id,
129        futu_core::proto_id::QOT_GET_HISTORY_KL
130            | futu_core::proto_id::QOT_REQUEST_HISTORY_KL
131            | futu_core::proto_id::QOT_GET_OPTION_CHAIN
132            | futu_core::proto_id::QOT_GET_CAPITAL_FLOW
133            | futu_core::proto_id::QOT_GET_SUSPEND
134            | futu_core::proto_id::QOT_GET_HOLDING_CHANGE_LIST
135            | futu_core::proto_id::QOT_GET_TRADE_DATE
136            | futu_core::proto_id::QOT_REQUEST_TRADE_DATE
137    )
138}
139
140/// v1.4.73 BUG-005 fix: auto-wrap "flat" body 到 `{c2s: ...}` 嵌套结构。
141///
142/// external reviewer v1.4.71 AI tester 报告:`POST /api/history-kline -d
143/// '{"symbol":"HK.00700","kl_type":"day","max_count":5}'` 返
144/// `ret_type=-1 "invalid kl_type"`(误导错,实际 proto Request struct 要求
145/// 顶层 `c2s` wrapper,用户传的 flat body 让 serde 报 "missing c2s" 被转成
146/// 看起来像字段值错的文案)。
147///
148/// 已在 adapter 入口(`proto_request_with_idempotency`)做 `normalize_json_keys_snake_case`
149/// 和 proto-aware field aliases 之后、`serde_json::from_value` 之前调。
150///
151/// 判断规则:
152/// 1. 必须是 object(非 object 不动)
153/// 2. 已有 `c2s` key → 不动(nested form 已正确)
154/// 3. 有 `s2c` / `ret_type` / `ret_msg` / `err_code` key → 不动(看起来是
155///    response 结构误传 body,不 auto-wrap 免得加深错误)
156/// 4. 其他情况 → 把整个 object 包一层 `{c2s: body}`
157///
158/// 不尝试 validate c2s 字段 shape(让 serde_json::from_value 报更精确错误)。
159pub(crate) fn maybe_wrap_flat_body_as_c2s(value: &mut Value) {
160    let Value::Object(map) = value else {
161        return;
162    };
163    // 已嵌套 c2s 不动
164    if map.contains_key("c2s") {
165        return;
166    }
167    // response 结构误传 不动(这种情况交给 serde error message 告诉用户)
168    for response_key in ["s2c", "ret_type", "ret_msg", "err_code"] {
169        if map.contains_key(response_key) {
170            return;
171        }
172    }
173    // empty body → 不需要 wrap(serde_json::from_value(json!({})) 后 Request::default())
174    if map.is_empty() {
175        return;
176    }
177    // 把整个 object 包装进 c2s
178    let inner = std::mem::take(map);
179    map.insert("c2s".to_string(), Value::Object(inner));
180}
181
182/// v1.4.90 P2-D: 把 c2s 顶层的 trade-header 字段(`acc_id` / `trd_env`
183/// / `trd_market` / `jp_acc_type`) 自动 expand 到 `c2s.header.{...}` 嵌套.
184///
185/// **背景**: MCP tool 用 flat schema `{acc_id, trd_market, trd_env}`,
186/// REST 11+ trade endpoint 的 proto 是 `{c2s: {header: {trd_env, acc_id,
187/// trd_market}, ...}}` 嵌套. tester 常踩坑: 拿 MCP schema 直接 curl REST →
188/// header 字段全 silent drop → backend 拿 acc_id=0 + trd_env=0 + trd_market=0
189/// 直接报 "acc_id mismatch" 或更糟的 silent-success.
190///
191/// **触发规则**:
192/// 1. 必须存在 `c2s` object
193/// 2. `c2s` 已含 `header` 对象 → 不动(用户已显式)
194/// 3. `c2s` 顶层含至少一个 trade-header 字段 → 把这些字段 move 进
195///    `c2s.header`, 不影响其它字段(如 order_id / price / qty 等)
196/// 4. 顶层无 trade-header 字段 → 不动(非 trade endpoint)
197///
198/// 已在 `maybe_wrap_flat_body_as_c2s` 之后调用, 所以即使用户传纯 flat body
199/// (如 `{acc_id, market, code}`) 也已先包成 `{c2s: {acc_id, market, code}}`,
200/// 这里再把 `acc_id` 提进 `header`. 无 c2s / 已有 header 时为 no-op.
201///
202/// proto 字段映射: `Trd_Common.TrdHeader { trd_env, acc_id, trd_market,
203/// jp_acc_type }`. 见 `proto/Trd_Common.proto:315-322`.
204pub(crate) fn maybe_expand_flat_trd_header(value: &mut Value) {
205    let Value::Object(top) = value else {
206        return;
207    };
208    let Some(Value::Object(c2s)) = top.get_mut("c2s") else {
209        return;
210    };
211    // 已有 header object → 不动
212    if matches!(c2s.get("header"), Some(Value::Object(_))) {
213        return;
214    }
215    // proto Trd_Common.TrdHeader 字段名(snake_case 已 normalize 过)
216    const HEADER_FIELDS: &[&str] = &["trd_env", "acc_id", "trd_market", "jp_acc_type"];
217    // 收集 c2s 顶层中存在的 header 字段
218    let mut header_map = serde_json::Map::new();
219    for field in HEADER_FIELDS {
220        if let Some(v) = c2s.remove(*field) {
221            header_map.insert((*field).to_string(), v);
222        }
223    }
224    // 任一 header 字段都没传 → 非 trade endpoint, 不动
225    if header_map.is_empty() {
226        return;
227    }
228    c2s.insert("header".to_string(), Value::Object(header_map));
229}
230
231/// v1.4.73 BUG-005 fix: 处理 `symbol: "HK.00700"` shorthand → `security: {market, code}`。
232///
233/// Python SDK / 文档里常用 `symbol` 字符串表示 market + code 合并形式。proto
234/// 要求嵌套 `security: {market: 1, code: "00700"}`。Adapter 检测 c2s 里有
235/// `symbol` 字段但缺 `security` 时自动 parse + 替换。
236///
237/// Market prefix 覆盖(与 CLAUDE.md 坑 #36 "code-first" 原则一致):
238/// `HK.xxx / US.xxx / SH.xxx / SZ.xxx / HK_CC.xxx / SG.xxx / JP.xxx / AU.xxx
239/// / CA.xxx / HK_FUTURE.xxx / US_FUTURE.xxx`
240///
241/// Unknown prefix → 保留 symbol 字段不处理(交给下游 handler / serde 报错)。
242///
243/// **v1.4.90 P0-C**: 数组 expand 路径加 `MAX_SYMBOLS_PER_REQUEST` 检查, 超
244/// 限返 `Err(msg)`, 由调用方转 400. 空 string 单 symbol shorthand 路径
245/// 不受影响(单 symbol 没 DoS 风险).
246pub(crate) fn expand_symbol_shorthand(value: &mut Value) -> Result<(), String> {
247    let Value::Object(top) = value else {
248        return Ok(());
249    };
250    // 递归进 c2s 处理(新包装的 flat body 已进入 c2s)
251    let Some(Value::Object(inner)) = top.get_mut("c2s") else {
252        return Ok(());
253    };
254
255    // v1.4.82 B1: **数组 shorthand 先处理**(c22f 双 tester v1.4.81 §6 13
256    // REST endpoint silent empty 的主要修法之一)。
257    //
258    // 用户传 `code_list: ["US.AAPL", "HK.00700"]` 或 `symbols: ["US.TSLA"]`
259    // 字符串数组,proto 期望 `security_list: [{market, code}, ...]` 对象数组。
260    // 不做转换 → serde 反序列化 String[] 到 Security[] 失败 → 400 或 drop →
261    // handler 收空 list → silent-success ret_type=0 空数据。
262    //
263    // 这里在 `security_list` 不存在时,把 `code_list` / `symbols` / `stocks`
264    // / `symbol_list` 的字符串数组展开为 Security 对象数组。
265    //
266    // v1.4.90 P0-C: 数组长度先 cap, 超 MAX_SYMBOLS_PER_REQUEST 直接 400.
267    expand_symbols_array_to_security_list(inner)?;
268
269    // v1.4.83 §6 Phase 1.4 extend:
270    // tester 报 capital-flow / option-chain / option-expiration-date / warrant
271    // 用户传 `{"code": "US.AAPL"}` 或 `{"owner": "HK.00700"}` 单字符串 ret=-1.
272    // 这些 proto 期望 `security: {market, code}` 或 `owner: {market, code}` 对象.
273    //
274    // 扩展 single-security shorthand 支持 4 种输入 key: `symbol` / `code`
275    // / `owner` / `security_string`, 生成 **两个字段** (`security` +
276    // `owner`), proto struct 各取自己的字段名, 另一个被 serde silent drop.
277    //
278    // 这样:
279    // - capital-flow 用 security → security field hit
280    // - option-chain / option-expiration-date / warrant 用 owner → owner field hit
281    // - 不破坏 v1.4.73 原 `symbol` → `security` 单 field 行为
282    //   (因为大部分 endpoint 只有一个字段, `owner` 被 drop 无害)
283    expand_single_symbol_shorthand_to_security_and_owner(inner)?;
284    Ok(())
285}