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 (同事 jackie 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 (eli 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`(eli 报告用的字段名)
50/// - `max_count` → `max_ack_kl_num`(Python SDK 参数名)
51pub(crate) fn apply_known_field_aliases(value: &mut Value) {
52 // v1.4.82 A1 B1 配套: subscribe SDK 友好别名(双 tester v1.4.81 NEW-c22f-012 发现
53 // 用户传 stocks/symbols/sub_types/is_sub 非 proto 字段被 serde 静默 drop
54 // → SubHandler 空 list → silent-success). 本 alias + SubHandler 入口 loud
55 // validation 协同(CLAUDE.md 坑 #45)。
56 //
57 // **注意 symbols → security_list 的结构不匹配**:
58 // - alias 目标 security_list 要求 `[{market: N, code: "..."}, ...]`
59 // - 用户传的 `symbols: ["US.AAPL", ...]` 是扁平字符串
60 // - 本 alias 只做 key rename;结构转换(string array → Security object
61 // array)在 `expand_symbol_shorthand` → `expand_symbols_array_to_security_list`
62 // 完成(v1.4.82 B1 + v1.4.88 mixed-array 扩展)
63 const ALIASES: &[(&str, &str)] = &[
64 ("req_count", "max_ack_kl_num"),
65 ("max_count", "max_ack_kl_num"),
66 // v1.4.82 A1: subscribe 字段名 SDK 友好 alias
67 ("symbols", "security_list"),
68 ("stocks", "security_list"),
69 ("sub_types", "sub_type_list"),
70 ("is_sub", "is_sub_or_un_sub"),
71 // v1.4.90 P0-D: history-kline 用户常用 `begin` / `end` 简称
72 // (类 Python SDK 风格), proto 字段是 `begin_time` / `end_time`.
73 // 漏 alias → serde 静默 drop → handler 用空字符串默认 → backend 返
74 // 245 行全量 K 线 + ret_type=0 (silent-success 反模式 #45).
75 ("begin", "begin_time"),
76 ("end", "end_time"),
77 // v1.4.104 eli OBS-P3-002 (P3) fix: option-chain / option-expiration-date
78 // / warrant 等 endpoint 也常用 `start` 当 begin_time alias (类 Python
79 // SDK + Bloomberg-style). 之前只有 `begin` alias, `start` 被 strict
80 // validator silent drop. 加 alias 与 `begin` 并行 (proto 字段不变).
81 ("start", "begin_time"),
82 ];
83 match value {
84 Value::Object(map) => {
85 for (alias, canonical) in ALIASES {
86 if map.contains_key(*canonical) {
87 // canonical 已存在 → 不覆盖,user 显式指定优先
88 map.remove(*alias);
89 } else if let Some(v) = map.remove(*alias) {
90 map.insert((*canonical).to_string(), v);
91 }
92 }
93 for v in map.values_mut() {
94 apply_known_field_aliases(v);
95 }
96 }
97 Value::Array(arr) => {
98 for item in arr {
99 apply_known_field_aliases(item);
100 }
101 }
102 _ => {}
103 }
104}
105
106/// v1.4.73 BUG-005 fix: auto-wrap "flat" body 到 `{c2s: ...}` 嵌套结构。
107///
108/// eli v1.4.71 AI tester 报告:`POST /api/history-kline -d
109/// '{"symbol":"HK.00700","kl_type":"day","max_count":5}'` 返
110/// `ret_type=-1 "invalid kl_type"`(误导错,实际 proto Request struct 要求
111/// 顶层 `c2s` wrapper,用户传的 flat body 让 serde 报 "missing c2s" 被转成
112/// 看起来像字段值错的文案)。
113///
114/// 已在 adapter 入口(`proto_request_with_idempotency`)做 `normalize_json_keys_snake_case`
115/// 和 `apply_known_field_aliases` 之后、`serde_json::from_value` 之前调。
116///
117/// 判断规则:
118/// 1. 必须是 object(非 object 不动)
119/// 2. 已有 `c2s` key → 不动(nested form 已正确)
120/// 3. 有 `s2c` / `ret_type` / `ret_msg` / `err_code` key → 不动(看起来是
121/// response 结构误传 body,不 auto-wrap 免得加深错误)
122/// 4. 其他情况 → 把整个 object 包一层 `{c2s: body}`
123///
124/// 不尝试 validate c2s 字段 shape(让 serde_json::from_value 报更精确错误)。
125pub(crate) fn maybe_wrap_flat_body_as_c2s(value: &mut Value) {
126 let Value::Object(map) = value else {
127 return;
128 };
129 // 已嵌套 c2s 不动
130 if map.contains_key("c2s") {
131 return;
132 }
133 // response 结构误传 不动(这种情况交给 serde error message 告诉用户)
134 for response_key in ["s2c", "ret_type", "ret_msg", "err_code"] {
135 if map.contains_key(response_key) {
136 return;
137 }
138 }
139 // empty body → 不需要 wrap(serde_json::from_value(json!({})) 后 Request::default())
140 if map.is_empty() {
141 return;
142 }
143 // 把整个 object 包装进 c2s
144 let inner = std::mem::take(map);
145 map.insert("c2s".to_string(), Value::Object(inner));
146}
147
148/// v1.4.90 P2-D: 把 c2s 顶层的 trade-header 字段(`acc_id` / `trd_env`
149/// / `trd_market` / `jp_acc_type`) 自动 expand 到 `c2s.header.{...}` 嵌套.
150///
151/// **背景**: MCP tool 用 flat schema `{acc_id, trd_market, trd_env}`,
152/// REST 11+ trade endpoint 的 proto 是 `{c2s: {header: {trd_env, acc_id,
153/// trd_market}, ...}}` 嵌套. tester 常踩坑: 拿 MCP schema 直接 curl REST →
154/// header 字段全 silent drop → backend 拿 acc_id=0 + trd_env=0 + trd_market=0
155/// 直接报 "acc_id mismatch" 或更糟的 silent-success.
156///
157/// **触发规则**:
158/// 1. 必须存在 `c2s` object
159/// 2. `c2s` 已含 `header` 对象 → 不动(用户已显式)
160/// 3. `c2s` 顶层含至少一个 trade-header 字段 → 把这些字段 move 进
161/// `c2s.header`, 不影响其它字段(如 order_id / price / qty 等)
162/// 4. 顶层无 trade-header 字段 → 不动(非 trade endpoint)
163///
164/// 已在 `maybe_wrap_flat_body_as_c2s` 之后调用, 所以即使用户传纯 flat body
165/// (如 `{acc_id, market, code}`) 也已先包成 `{c2s: {acc_id, market, code}}`,
166/// 这里再把 `acc_id` 提进 `header`. 无 c2s / 已有 header 时为 no-op.
167///
168/// proto 字段映射: `Trd_Common.TrdHeader { trd_env, acc_id, trd_market,
169/// jp_acc_type }`. 见 `proto/Trd_Common.proto:315-322`.
170pub(crate) fn maybe_expand_flat_trd_header(value: &mut Value) {
171 let Value::Object(top) = value else {
172 return;
173 };
174 let Some(Value::Object(c2s)) = top.get_mut("c2s") else {
175 return;
176 };
177 // 已有 header object → 不动
178 if matches!(c2s.get("header"), Some(Value::Object(_))) {
179 return;
180 }
181 // proto Trd_Common.TrdHeader 字段名(snake_case 已 normalize 过)
182 const HEADER_FIELDS: &[&str] = &["trd_env", "acc_id", "trd_market", "jp_acc_type"];
183 // 收集 c2s 顶层中存在的 header 字段
184 let mut header_map = serde_json::Map::new();
185 for field in HEADER_FIELDS {
186 if let Some(v) = c2s.remove(*field) {
187 header_map.insert((*field).to_string(), v);
188 }
189 }
190 // 任一 header 字段都没传 → 非 trade endpoint, 不动
191 if header_map.is_empty() {
192 return;
193 }
194 c2s.insert("header".to_string(), Value::Object(header_map));
195}
196
197/// v1.4.73 BUG-005 fix: 处理 `symbol: "HK.00700"` shorthand → `security: {market, code}`。
198///
199/// Python SDK / 文档里常用 `symbol` 字符串表示 market + code 合并形式。proto
200/// 要求嵌套 `security: {market: 1, code: "00700"}`。Adapter 检测 c2s 里有
201/// `symbol` 字段但缺 `security` 时自动 parse + 替换。
202///
203/// Market prefix 覆盖(与 CLAUDE.md 坑 #36 "code-first" 原则一致):
204/// `HK.xxx / US.xxx / SH.xxx / SZ.xxx / HK_CC.xxx / SG.xxx / JP.xxx / AU.xxx
205/// / CA.xxx / HK_FUTURE.xxx / US_FUTURE.xxx`
206///
207/// Unknown prefix → 保留 symbol 字段不处理(交给下游 handler / serde 报错)。
208///
209/// **v1.4.90 P0-C**: 数组 expand 路径加 `MAX_SYMBOLS_PER_REQUEST` 检查, 超
210/// 限返 `Err(msg)`, 由调用方转 400. 空 string 单 symbol shorthand 路径
211/// 不受影响(单 symbol 没 DoS 风险).
212pub(crate) fn expand_symbol_shorthand(value: &mut Value) -> Result<(), String> {
213 let Value::Object(top) = value else {
214 return Ok(());
215 };
216 // 递归进 c2s 处理(新包装的 flat body 已进入 c2s)
217 let Some(Value::Object(inner)) = top.get_mut("c2s") else {
218 return Ok(());
219 };
220
221 // v1.4.82 B1: **数组 shorthand 先处理**(c22f 双 tester v1.4.81 §6 13
222 // REST endpoint silent empty 的主要修法之一)。
223 //
224 // 用户传 `code_list: ["US.AAPL", "HK.00700"]` 或 `symbols: ["US.TSLA"]`
225 // 字符串数组,proto 期望 `security_list: [{market, code}, ...]` 对象数组。
226 // 不做转换 → serde 反序列化 String[] 到 Security[] 失败 → 400 或 drop →
227 // handler 收空 list → silent-success ret_type=0 空数据。
228 //
229 // 这里在 `security_list` 不存在时,把 `code_list` / `symbols` / `stocks`
230 // / `symbol_list` 的字符串数组展开为 Security 对象数组。
231 //
232 // v1.4.90 P0-C: 数组长度先 cap, 超 MAX_SYMBOLS_PER_REQUEST 直接 400.
233 expand_symbols_array_to_security_list(inner)?;
234
235 // v1.4.83 §6 Phase 1.4 extend:
236 // tester 报 capital-flow / option-chain / option-expiration-date / warrant
237 // 用户传 `{"code": "US.AAPL"}` 或 `{"owner": "HK.00700"}` 单字符串 ret=-1.
238 // 这些 proto 期望 `security: {market, code}` 或 `owner: {market, code}` 对象.
239 //
240 // 扩展 single-security shorthand 支持 4 种输入 key: `symbol` / `code`
241 // / `owner` / `security_string`, 生成 **两个字段** (`security` +
242 // `owner`), proto struct 各取自己的字段名, 另一个被 serde silent drop.
243 //
244 // 这样:
245 // - capital-flow 用 security → security field hit
246 // - option-chain / option-expiration-date / warrant 用 owner → owner field hit
247 // - 不破坏 v1.4.73 原 `symbol` → `security` 单 field 行为
248 // (因为大部分 endpoint 只有一个字段, `owner` 被 drop 无害)
249 expand_single_symbol_shorthand_to_security_and_owner(inner);
250 Ok(())
251}