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