Skip to main content

futu_rest/routes/trd/
write.rs

1//! REST trade write routes.
2
3use std::sync::Arc;
4
5use axum::extract::{Extension, Json, State};
6use axum::http::{HeaderMap, StatusCode};
7use serde_json::Value;
8
9use futu_auth::{CheckCtx, KeyRecord};
10use futu_core::proto_id;
11use futu_proto::trd_modify_order;
12use futu_proto::trd_place_combo_order;
13use futu_proto::trd_place_order;
14use futu_proto::trd_reconfirm_order;
15
16use super::ApiResult;
17use super::card_num::{
18    extract_and_resolve_card_num_into_acc_id, normalize_and_resolve_card_num_for_route,
19};
20use super::validation::{
21    read_handler_acc_id_check, rest_handler_limit_check, trd_market_str,
22    validate_header_trd_env_present, validate_header_trd_market_write,
23};
24use crate::adapter::{self, RestState};
25
26use super::write_pipeline::{
27    idempotency_key_from_headers, limit_reject_response, surface_spec_or_internal_error,
28};
29
30/// POST /api/order — 下单
31///
32/// v1.2:在 dispatch 之前先解析 JSON 提取 CheckCtx,跑 `check_full_skip_rate`
33/// 做 market/symbol/value/side/daily 细粒度检查(auth 层已做 rate/hours 闸门)。
34/// `Extension<Arc<KeyRecord>>` 来自 bearer_auth middleware;scope 模式下必有,
35/// legacy 模式下没有该 extension → 跳过 handler 层检查(保持旧行为)。
36pub async fn place_order(
37    State(state): State<RestState>,
38    rec: Option<Extension<Arc<KeyRecord>>>,
39    headers: HeaderMap,
40    Json(mut body): Json<Value>,
41) -> ApiResult {
42    // v1.4.45: normalize camelCase → snake_case(兼容 FTAPI 官方文档字段名)
43    crate::adapter::normalize_json_keys_snake_case(&mut body);
44    // v1.4.105 D12 (Phase 2): 提取 card_num 字段 (top-level / c2s.header) →
45    // resolve via trd_cache → 写进 c2s.header.acc_id. user 可用 4 位末尾或
46    // 16 位完整卡号代替 acc_id (App 显示的便利)。**必须在 trd_market /
47    // trd_env validate 之前**, 因为 promote_flat / acc_id 写入要在 validate
48    // 之前完成 — 但实际上 placeholder header 字段在 validate 后, 我们这里
49    // 把 card_num strip + acc_id 填回不会影响 trd_market validation 顺序.
50    //
51    // v1.4.105 contract-hardening 补丁: 传 rec 让 helper 同步做 string-level
52    // allowed_card_nums whitelist 校验 (UX 清晰).
53    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
54    extract_and_resolve_card_num_into_acc_id(
55        &state,
56        rec_ref_for_card_num,
57        &mut body,
58        "/api/order",
59    )?;
60    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单(在 normalize 后做,保证看到 snake_case)
61    // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
62    validate_header_trd_market_write(&body, "/api/order")?;
63    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
64    validate_header_trd_env_present(&body, "/api/order")?;
65    if let Some(Extension(rec)) = rec {
66        // 解析 JSON → trd_place_order::Request 提取 CheckCtx
67        match serde_json::from_value::<trd_place_order::Request>(body.clone()) {
68            Ok(parsed) => rest_handler_limit_check(&state, &rec, &parsed)?,
69            Err(_) => {
70                // 反序列化失败时不挡,让下游 dispatch 报真正的 400/格式错误
71                // (limits 不该挡格式问题)
72            }
73        }
74    }
75    // v1.4.38 Phase 4: 提取 `Idempotency-Key` header(客户端显式 opt-in)。
76    // 无 header → 透传不加幂等保护(backward-compat)。
77    let idem_key = idempotency_key_from_headers(&headers);
78    adapter::proto_request_with_idempotency::<trd_place_order::Request, trd_place_order::Response>(
79        &state,
80        proto_id::TRD_PLACE_ORDER,
81        Some(body),
82        idem_key,
83    )
84    .await
85}
86
87/// POST /api/combo-order — 组合期权下单
88pub async fn place_combo_order(
89    State(state): State<RestState>,
90    rec: Option<Extension<Arc<KeyRecord>>>,
91    headers: HeaderMap,
92    Json(mut body): Json<Value>,
93) -> ApiResult {
94    crate::adapter::normalize_json_keys_snake_case(&mut body);
95    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
96    extract_and_resolve_card_num_into_acc_id(
97        &state,
98        rec_ref_for_card_num,
99        &mut body,
100        "/api/combo-order",
101    )?;
102    validate_header_trd_market_write(&body, "/api/combo-order")?;
103    validate_header_trd_env_present(&body, "/api/combo-order")?;
104    if let Some(Extension(rec)) = rec
105        && let Ok(parsed) = serde_json::from_value::<trd_place_combo_order::Request>(body.clone())
106    {
107        let market = trd_market_str(parsed.c2s.header.trd_market);
108        let symbol = parsed
109            .c2s
110            .combo_legs
111            .first()
112            .map(|leg| leg.security.code.as_str())
113            .filter(|code| !market.is_empty() && !code.is_empty())
114            .map(|code| format!("{market}.{code}"))
115            .unwrap_or_default();
116        let ctx = CheckCtx {
117            market: market.to_string(),
118            symbol,
119            order_value: parsed.c2s.price.map(|price| price * parsed.c2s.qty),
120            trd_side: None,
121            acc_id: Some(parsed.c2s.header.acc_id),
122            mutation_no_exposure: false,
123            currency: futu_auth::market_to_currency(market).map(String::from),
124        };
125        let now = chrono::Utc::now();
126        let outcome = state
127            .counters
128            .check_full_skip_rate(&rec.id, &rec.limits(), &ctx, now);
129        if let Some(reason) = outcome.reason() {
130            return Err(limit_reject_response(
131                "/api/combo-order",
132                &rec,
133                &reason,
134                outcome.http_status_code(),
135            ));
136        }
137    }
138    let idem_key = idempotency_key_from_headers(&headers);
139    adapter::proto_request_with_idempotency::<
140        trd_place_combo_order::Request,
141        trd_place_combo_order::Response,
142    >(
143        &state,
144        proto_id::TRD_PLACE_COMBO_ORDER,
145        Some(body),
146        idem_key,
147    )
148    .await
149}
150
151/// POST /api/modify-order — 改单/撤单
152///
153/// v1.2:和 `place_order` 类似但 ModifyOrder protobuf 给不出 symbol/qty/value
154/// (只有 order_id),只能做 market 白名单 + hours 检查;symbol/value/side
155/// 留空让 `check_full_skip_rate` 自动跳过。
156///
157/// **响应语义**(v1.4.111 P2-5 doc 沉淀,对齐 C++ APIServer_Trd_ModifyOrder by-design):
158/// `ret_type=0` 仅表示 backend 接受了 modify 操作 (operation ACK), 不是最终成交 /
159/// 撤单状态证明;最终状态仍来自 UpdateOrder push 或后续查询。
160///
161/// **正确 client pattern** (ack-based):
162/// 1. modify-order 返 `ret_type=0` → assume backend 接受
163/// 2. wait push event (REST `/ws` 订阅 trade notify) 或 retry query
164/// 3. 单笔 CancelOrder 成功时,Rust 额外在返回 ACK 前做 bounded authoritative
165///    orders refresh;这不是本地 patch,也不改变 ACK 语义,只是缩短
166///    `/api/orders` 立即读到 stale cache 的窗口。`can_sell_qty` 仍以后续交易
167///    push 或显式 `refresh_cache=true` 持仓查询为准。
168pub async fn modify_order(
169    State(state): State<RestState>,
170    rec: Option<Extension<Arc<KeyRecord>>>,
171    headers: HeaderMap,
172    Json(mut body): Json<Value>,
173) -> ApiResult {
174    // v1.4.45: normalize camelCase → snake_case
175    crate::adapter::normalize_json_keys_snake_case(&mut body);
176    // v1.4.105 D12 (Phase 2): card_num → acc_id 解析 (与 place_order 一致).
177    // v1.4.105 D12 contract-hardening 补丁: 同 place_order, 加 rec 做 string-level
178    // allowed_card_nums whitelist 校验.
179    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
180    extract_and_resolve_card_num_into_acc_id(
181        &state,
182        rec_ref_for_card_num,
183        &mut body,
184        "/api/modify-order",
185    )?;
186    // v1.4.96 BUG #003 hotfix (external reviewer double-tester report 2026-04-26):
187    // 之前漏 validate, trd_market=999 silent accept. 加 validate 让 modify-order
188    // 与 place-order 校验对齐.
189    // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
190    validate_header_trd_market_write(&body, "/api/modify-order")?;
191    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
192    validate_header_trd_env_present(&body, "/api/modify-order")?;
193    if let Some(Extension(rec)) = rec
194        && let Ok(parsed) = serde_json::from_value::<trd_modify_order::Request>(body.clone())
195    {
196        let market = trd_market_str(parsed.c2s.header.trd_market);
197        // v1.4.106 codex 0538 F2 (P2): ModifyOrder Normal (op=1) 改价/改量 →
198        // 新 exposure delta, 算 qty*price 进 daily counter; Cancel/Disable/
199        // Enable/Delete 标 mutation_no_exposure=true 跳 daily counter.
200        const MODIFY_OP_NORMAL: i32 = 1;
201        let (order_value, mutation_no_exposure) = if parsed.c2s.modify_order_op == MODIFY_OP_NORMAL
202        {
203            let v = match (parsed.c2s.qty, parsed.c2s.price) {
204                (Some(q), Some(pr)) => Some(q * pr),
205                _ => None, // MARKET 单 / proto 不全 → 跳金额检查
206            };
207            (v, false)
208        } else {
209            (None, true)
210        };
211        let ctx = CheckCtx {
212            market: market.to_string(),
213            symbol: String::new(),
214            order_value,
215            trd_side: None,
216            acc_id: Some(parsed.c2s.header.acc_id), // v1.4.35
217            mutation_no_exposure,
218            // v1.4.106 F4 (P3): 派生 per-market currency
219            currency: futu_auth::market_to_currency(market).map(String::from),
220        };
221        let now = chrono::Utc::now();
222        // v1.4.36 Bug #1:Whitelist → 403
223        let outcome = state
224            .counters
225            .check_full_skip_rate(&rec.id, &rec.limits(), &ctx, now);
226        if let Some(reason) = outcome.reason() {
227            return Err(limit_reject_response(
228                "/api/modify-order",
229                &rec,
230                &reason,
231                outcome.http_status_code(),
232            ));
233        }
234    }
235    let idem_key = idempotency_key_from_headers(&headers);
236    adapter::proto_request_with_idempotency::<trd_modify_order::Request, trd_modify_order::Response>(
237        &state,
238        proto_id::TRD_MODIFY_ORDER,
239        Some(body),
240        idem_key,
241    )
242    .await
243}
244
245/// POST /api/cancel-order — 单笔撤单
246///
247/// 高层 REST 语义入口,底层仍对齐 C++/OpenAPI 的 `Trd_ModifyOrder` cancel
248/// 分支:强制 `modify_order_op=2`,允许 `order_id_ex` 单独作为撤单标识,
249/// 且不要求用户传低层 `packet_id`(dispatch 前统一自动补齐)。
250///
251/// `/api/modify-order` 保留低层 proto JSON 入口;新增本 route 是为了让 REST
252/// 和 CLI/MCP 的 `cancel-order` 语义一致。
253pub async fn cancel_order(
254    State(state): State<RestState>,
255    rec: Option<Extension<Arc<KeyRecord>>>,
256    headers: HeaderMap,
257    Json(mut body): Json<Value>,
258) -> ApiResult {
259    crate::adapter::normalize_json_keys_snake_case(&mut body);
260    adapter::normalize_endpoint_local_request_aliases_for_rest_path("/api/cancel-order", &mut body)
261        .map_err(cancel_order_alias_error)?;
262    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
263    extract_and_resolve_card_num_into_acc_id(
264        &state,
265        rec_ref_for_card_num,
266        &mut body,
267        "/api/cancel-order",
268    )?;
269    // Route-level validation and limit checks run before the generic adapter,
270    // so pre-normalize into the same C2S shape that dispatch will encode.
271    adapter::maybe_wrap_flat_body_as_c2s(&mut body);
272    adapter::normalize_endpoint_local_request_aliases_for_rest_path("/api/cancel-order", &mut body)
273        .map_err(cancel_order_alias_error)?;
274    adapter::maybe_expand_flat_trd_header(&mut body);
275    validate_header_trd_market_write(&body, "/api/cancel-order")?;
276    validate_header_trd_env_present(&body, "/api/cancel-order")?;
277    if let Some(Extension(rec)) = rec
278        && let Ok(parsed) = serde_json::from_value::<trd_modify_order::Request>(body.clone())
279    {
280        let market = trd_market_str(parsed.c2s.header.trd_market);
281        let ctx = CheckCtx {
282            market: market.to_string(),
283            symbol: String::new(),
284            order_value: None,
285            trd_side: None,
286            acc_id: Some(parsed.c2s.header.acc_id),
287            mutation_no_exposure: true,
288            currency: futu_auth::market_to_currency(market).map(String::from),
289        };
290        let now = chrono::Utc::now();
291        let outcome = state
292            .counters
293            .check_full_skip_rate(&rec.id, &rec.limits(), &ctx, now);
294        if let Some(reason) = outcome.reason() {
295            return Err(limit_reject_response(
296                "/api/cancel-order",
297                &rec,
298                &reason,
299                outcome.http_status_code(),
300            ));
301        }
302    }
303    let cancel_spec = surface_spec_or_internal_error("/api/cancel-order", "CancelOrder")?;
304    let idem_key = idempotency_key_from_headers(&headers);
305    adapter::proto_request_with_surface_spec_and_idempotency::<
306        trd_modify_order::Request,
307        trd_modify_order::Response,
308    >(
309        &state,
310        proto_id::TRD_MODIFY_ORDER,
311        Some(body),
312        idem_key,
313        cancel_spec,
314    )
315    .await
316}
317
318#[cfg(test)]
319mod tests;
320
321fn cancel_order_alias_error(message: String) -> (StatusCode, Json<Value>) {
322    (
323        StatusCode::BAD_REQUEST,
324        Json(serde_json::json!({
325            "ret_type": -1,
326            "ret_msg": message,
327            "error": message,
328        })),
329    )
330}
331
332/// v1.4.40 #4 fix (external reviewer exhaustive report): 暴露 `/api/reconfirm-order` endpoint。
333///
334/// daemon 内部一直注册了 `TRD_RECONFIRM_ORDER` (proto_id 2237) 的 handler,但 REST
335/// 层没路由过来,用户无法通过 REST 重新确认订单(比如美股下 day-trade 订单需要
336/// 用户显式确认才会真正提交)。v1.4.40 补上此 endpoint。
337///
338/// POST /api/reconfirm-order — 重新确认订单(美股 PDT 规则下的二次确认路径)
339pub async fn reconfirm_order(
340    State(state): State<RestState>,
341    rec: Option<Extension<Arc<KeyRecord>>>,
342    headers: HeaderMap,
343    Json(mut body): Json<Value>,
344) -> ApiResult {
345    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/reconfirm-order")?;
346    read_handler_acc_id_check(
347        &state,
348        rec.as_deref().map(|r| r.as_ref()),
349        &body,
350        "/api/reconfirm-order",
351    )?;
352    // v1.4.96 BUG #003 hotfix
353    // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
354    validate_header_trd_market_write(&body, "/api/reconfirm-order")?;
355    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
356    validate_header_trd_env_present(&body, "/api/reconfirm-order")?;
357    if let Some(Extension(rec)) = rec
358        && let Ok(parsed) = serde_json::from_value::<trd_reconfirm_order::Request>(body.clone())
359    {
360        let market = trd_market_str(parsed.c2s.header.trd_market);
361        let ctx = CheckCtx {
362            market: market.to_string(),
363            symbol: String::new(),
364            order_value: None,
365            trd_side: None,
366            acc_id: Some(parsed.c2s.header.acc_id),
367            mutation_no_exposure: true,
368            currency: futu_auth::market_to_currency(market).map(String::from),
369        };
370        let now = chrono::Utc::now();
371        let outcome = state
372            .counters
373            .check_full_skip_rate(&rec.id, &rec.limits(), &ctx, now);
374        if let Some(reason) = outcome.reason() {
375            return Err(limit_reject_response(
376                "/api/reconfirm-order",
377                &rec,
378                &reason,
379                outcome.http_status_code(),
380            ));
381        }
382    }
383    let reconfirm_spec = surface_spec_or_internal_error("/api/reconfirm-order", "ReconfirmOrder")?;
384    let idem_key = idempotency_key_from_headers(&headers);
385    adapter::proto_request_with_surface_spec_and_idempotency::<
386        trd_reconfirm_order::Request,
387        trd_reconfirm_order::Response,
388    >(
389        &state,
390        proto_id::TRD_RECONFIRM_ORDER,
391        Some(body),
392        idem_key,
393        reconfirm_spec,
394    )
395    .await
396}