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