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}