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
26/// POST /api/order — 下单
27///
28/// v1.2:在 dispatch 之前先解析 JSON 提取 CheckCtx,跑 `check_full_skip_rate`
29/// 做 market/symbol/value/side/daily 细粒度检查(auth 层已做 rate/hours 闸门)。
30/// `Extension<Arc<KeyRecord>>` 来自 bearer_auth middleware;scope 模式下必有,
31/// legacy 模式下没有该 extension → 跳过 handler 层检查(保持旧行为)。
32pub async fn place_order(
33    State(state): State<RestState>,
34    rec: Option<Extension<Arc<KeyRecord>>>,
35    headers: HeaderMap,
36    Json(mut body): Json<Value>,
37) -> ApiResult {
38    // v1.4.45: normalize camelCase → snake_case(兼容 FTAPI 官方文档字段名)
39    crate::adapter::normalize_json_keys_snake_case(&mut body);
40    // v1.4.105 D12 (Phase 2): 提取 card_num 字段 (top-level / c2s.header) →
41    // resolve via trd_cache → 写进 c2s.header.acc_id. user 可用 4 位末尾或
42    // 16 位完整卡号代替 acc_id (App 显示的便利)。**必须在 trd_market /
43    // trd_env validate 之前**, 因为 promote_flat / acc_id 写入要在 validate
44    // 之前完成 — 但实际上 placeholder header 字段在 validate 后, 我们这里
45    // 把 card_num strip + acc_id 填回不会影响 trd_market validation 顺序.
46    //
47    // v1.4.105 contract-hardening 补丁: 传 rec 让 helper 同步做 string-level
48    // allowed_card_nums whitelist 校验 (UX 清晰).
49    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
50    extract_and_resolve_card_num_into_acc_id(
51        &state,
52        rec_ref_for_card_num,
53        &mut body,
54        "/api/order",
55    )?;
56    // v1.4.93 P0-4 (NEW-C-01): trd_market enum 白名单(在 normalize 后做,保证看到 snake_case)
57    // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
58    validate_header_trd_market_write(&body, "/api/order")?;
59    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
60    validate_header_trd_env_present(&body, "/api/order")?;
61    if let Some(Extension(rec)) = rec {
62        // 解析 JSON → trd_place_order::Request 提取 CheckCtx
63        match serde_json::from_value::<trd_place_order::Request>(body.clone()) {
64            Ok(parsed) => rest_handler_limit_check(&state, &rec, &parsed)?,
65            Err(_) => {
66                // 反序列化失败时不挡,让下游 dispatch 报真正的 400/格式错误
67                // (limits 不该挡格式问题)
68            }
69        }
70    }
71    // v1.4.38 Phase 4: 提取 `Idempotency-Key` header(客户端显式 opt-in)。
72    // 无 header → 透传不加幂等保护(backward-compat)。
73    let idem_key = headers
74        .get("idempotency-key")
75        .and_then(|v| v.to_str().ok())
76        .map(|s| s.to_string());
77    adapter::proto_request_with_idempotency::<trd_place_order::Request, trd_place_order::Response>(
78        &state,
79        proto_id::TRD_PLACE_ORDER,
80        Some(body),
81        idem_key,
82    )
83    .await
84}
85
86/// POST /api/combo-order — 组合期权下单
87pub async fn place_combo_order(
88    State(state): State<RestState>,
89    rec: Option<Extension<Arc<KeyRecord>>>,
90    headers: HeaderMap,
91    Json(mut body): Json<Value>,
92) -> ApiResult {
93    crate::adapter::normalize_json_keys_snake_case(&mut body);
94    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
95    extract_and_resolve_card_num_into_acc_id(
96        &state,
97        rec_ref_for_card_num,
98        &mut body,
99        "/api/combo-order",
100    )?;
101    validate_header_trd_market_write(&body, "/api/combo-order")?;
102    validate_header_trd_env_present(&body, "/api/combo-order")?;
103    if let Some(Extension(rec)) = rec
104        && let Ok(parsed) = serde_json::from_value::<trd_place_combo_order::Request>(body.clone())
105    {
106        let market = trd_market_str(parsed.c2s.header.trd_market);
107        let symbol = parsed
108            .c2s
109            .combo_legs
110            .first()
111            .map(|leg| leg.security.code.as_str())
112            .filter(|code| !market.is_empty() && !code.is_empty())
113            .map(|code| format!("{market}.{code}"))
114            .unwrap_or_default();
115        let ctx = CheckCtx {
116            market: market.to_string(),
117            symbol,
118            order_value: parsed.c2s.price.map(|price| price * parsed.c2s.qty),
119            trd_side: None,
120            acc_id: Some(parsed.c2s.header.acc_id),
121            mutation_no_exposure: false,
122            currency: futu_auth::market_to_currency(market).map(String::from),
123        };
124        let now = chrono::Utc::now();
125        let outcome = state
126            .counters
127            .check_full_skip_rate(&rec.id, &rec.limits(), &ctx, now);
128        if let Some(reason) = outcome.reason() {
129            futu_auth::audit::reject(
130                "rest",
131                "/api/combo-order",
132                &rec.id,
133                &format!("limit: {reason}"),
134            );
135            let status = StatusCode::from_u16(outcome.http_status_code())
136                .unwrap_or(StatusCode::TOO_MANY_REQUESTS);
137            return Err((
138                status,
139                Json(serde_json::json!({
140                    "error": format!("limit check failed: {reason}")
141                })),
142            ));
143        }
144    }
145    let idem_key = headers
146        .get("idempotency-key")
147        .and_then(|v| v.to_str().ok())
148        .map(|s| s.to_string());
149    adapter::proto_request_with_idempotency::<
150        trd_place_combo_order::Request,
151        trd_place_combo_order::Response,
152    >(
153        &state,
154        proto_id::TRD_PLACE_COMBO_ORDER,
155        Some(body),
156        idem_key,
157    )
158    .await
159}
160
161/// POST /api/modify-order — 改单/撤单
162///
163/// v1.2:和 `place_order` 类似但 ModifyOrder protobuf 给不出 symbol/qty/value
164/// (只有 order_id),只能做 market 白名单 + hours 检查;symbol/value/side
165/// 留空让 `check_full_skip_rate` 自动跳过。
166///
167/// **响应语义**(v1.4.111 P2-5 doc 沉淀,对齐 C++ APIServer_Trd_ModifyOrder by-design):
168/// `ret_type=0` 仅表示 backend 接受了 modify 操作 (operation ACK), **不**保证 `/api/orders`
169/// 立即可见新 price/qty/status. 客户端**不应**调完 modify-order 就立即 query `/api/orders`
170/// 期望看到改动 — race window 通常 < 2s (backend push 异步同步 daemon orders cache).
171///
172/// **正确 client pattern** (ack-based):
173/// 1. modify-order 返 `ret_type=0` → assume backend 接受
174/// 2. wait push event (REST `/ws` 订阅 trade notify) 或 sleep 1-2s 后 retry query
175/// 3. 若 2s+ 仍看不到更新 → v1.4.109 stub TTL guard fallback 会主动 refresh orders
176///    (post_ack.rs:50-80, 默认 14.5s TTL 兜底)
177///
178/// **不修原因**: C++ OpenD 同条件也只返 operation ACK (modify_order.rs:809 注释),
179/// Rust 同 C++ 行为 = by-design 对齐 (pitfall #51 "对齐 C++ = 减法, 不超越").
180pub async fn modify_order(
181    State(state): State<RestState>,
182    rec: Option<Extension<Arc<KeyRecord>>>,
183    headers: HeaderMap,
184    Json(mut body): Json<Value>,
185) -> ApiResult {
186    // v1.4.45: normalize camelCase → snake_case
187    crate::adapter::normalize_json_keys_snake_case(&mut body);
188    // v1.4.105 D12 (Phase 2): card_num → acc_id 解析 (与 place_order 一致).
189    // v1.4.105 D12 contract-hardening 补丁: 同 place_order, 加 rec 做 string-level
190    // allowed_card_nums whitelist 校验.
191    let rec_ref_for_card_num = rec.as_ref().map(|Extension(r)| r.as_ref());
192    extract_and_resolve_card_num_into_acc_id(
193        &state,
194        rec_ref_for_card_num,
195        &mut body,
196        "/api/modify-order",
197    )?;
198    // v1.4.96 BUG #003 hotfix (external reviewer double-tester report 2026-04-26):
199    // 之前漏 validate, trd_market=999 silent accept. 加 validate 让 modify-order
200    // 与 place-order 校验对齐.
201    // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
202    validate_header_trd_market_write(&body, "/api/modify-order")?;
203    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
204    validate_header_trd_env_present(&body, "/api/modify-order")?;
205    if let Some(Extension(rec)) = rec
206        && let Ok(parsed) = serde_json::from_value::<trd_modify_order::Request>(body.clone())
207    {
208        let market = trd_market_str(parsed.c2s.header.trd_market);
209        // v1.4.106 codex 0538 F2 (P2): ModifyOrder Normal (op=1) 改价/改量 →
210        // 新 exposure delta, 算 qty*price 进 daily counter; Cancel/Disable/
211        // Enable/Delete 标 mutation_no_exposure=true 跳 daily counter.
212        const MODIFY_OP_NORMAL: i32 = 1;
213        let (order_value, mutation_no_exposure) = if parsed.c2s.modify_order_op == MODIFY_OP_NORMAL
214        {
215            let v = match (parsed.c2s.qty, parsed.c2s.price) {
216                (Some(q), Some(pr)) => Some(q * pr),
217                _ => None, // MARKET 单 / proto 不全 → 跳金额检查
218            };
219            (v, false)
220        } else {
221            (None, true)
222        };
223        let ctx = CheckCtx {
224            market: market.to_string(),
225            symbol: String::new(),
226            order_value,
227            trd_side: None,
228            acc_id: Some(parsed.c2s.header.acc_id), // v1.4.35
229            mutation_no_exposure,
230            // v1.4.106 F4 (P3): 派生 per-market currency
231            currency: futu_auth::market_to_currency(market).map(String::from),
232        };
233        let now = chrono::Utc::now();
234        // v1.4.36 Bug #1:Whitelist → 403
235        let outcome = state
236            .counters
237            .check_full_skip_rate(&rec.id, &rec.limits(), &ctx, now);
238        if let Some(reason) = outcome.reason() {
239            futu_auth::audit::reject(
240                "rest",
241                "/api/modify-order",
242                &rec.id,
243                &format!("limit: {reason}"),
244            );
245            let status = StatusCode::from_u16(outcome.http_status_code())
246                .unwrap_or(StatusCode::TOO_MANY_REQUESTS);
247            return Err((
248                status,
249                Json(serde_json::json!({
250                    "error": format!("limit check failed: {reason}")
251                })),
252            ));
253        }
254    }
255    let idem_key = headers
256        .get("idempotency-key")
257        .and_then(|v| v.to_str().ok())
258        .map(|s| s.to_string());
259    adapter::proto_request_with_idempotency::<trd_modify_order::Request, trd_modify_order::Response>(
260        &state,
261        proto_id::TRD_MODIFY_ORDER,
262        Some(body),
263        idem_key,
264    )
265    .await
266}
267
268/// v1.4.40 #4 fix (external reviewer exhaustive report): 暴露 `/api/reconfirm-order` endpoint。
269///
270/// daemon 内部一直注册了 `TRD_RECONFIRM_ORDER` (proto_id 2237) 的 handler,但 REST
271/// 层没路由过来,用户无法通过 REST 重新确认订单(比如美股下 day-trade 订单需要
272/// 用户显式确认才会真正提交)。v1.4.40 补上此 endpoint。
273///
274/// POST /api/reconfirm-order — 重新确认订单(美股 PDT 规则下的二次确认路径)
275pub async fn reconfirm_order(
276    State(state): State<RestState>,
277    rec: Option<Extension<Arc<KeyRecord>>>,
278    Json(mut body): Json<Value>,
279) -> ApiResult {
280    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/reconfirm-order")?;
281    read_handler_acc_id_check(
282        &state,
283        rec.as_deref().map(|r| r.as_ref()),
284        &body,
285        "/api/reconfirm-order",
286    )?;
287    // v1.4.96 BUG #003 hotfix
288    // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
289    validate_header_trd_market_write(&body, "/api/reconfirm-order")?;
290    // v1.4.102 BUG-005: 缺 trd_env 直接 400 (避免 "Nonexisting acc_id" 误导)
291    validate_header_trd_env_present(&body, "/api/reconfirm-order")?;
292    adapter::proto_request::<trd_reconfirm_order::Request, trd_reconfirm_order::Response>(
293        &state,
294        proto_id::TRD_RECONFIRM_ORDER,
295        Some(body),
296    )
297    .await
298}