Skip to main content

futu_mcp/tools/
trade_write.rs

1//! MCP trade-write tools (place/modify/cancel/reconfirm).
2
3use futu_auth::CheckCtx;
4use rmcp::{RoleServer, handler::server::wrapper::Parameters, service::RequestContext};
5
6use crate::guard;
7use crate::handlers;
8use crate::tool_args::*;
9use crate::tool_auth::{http_bearer_token, outcome_key_id_from_snapshot};
10
11use super::FutuServer;
12
13fn place_order_check_ctx(req: &PlaceOrderReq, resolved_acc_id: u64) -> CheckCtx {
14    let market = req.market.trim().to_ascii_uppercase();
15    let price_times_qty = req.price.map(|price| price * req.qty);
16    let order_value = if futu_core::trade_market::parse_trd_market_id(&market)
17        == Some(futu_core::trade_market::trd_market_id::PREDICTION)
18    {
19        req.amount.or(price_times_qty)
20    } else {
21        price_times_qty
22    };
23    CheckCtx {
24        symbol: format!("{}.{}", market, req.code.trim()),
25        market,
26        order_value,
27        trd_side: Some(req.side.trim().to_ascii_uppercase()),
28        acc_id: Some(resolved_acc_id),
29        mutation_no_exposure: false,
30        currency: None,
31    }
32}
33
34impl FutuServer {
35    // ------- 交易写入(需 --enable-trading) -------
36
37    fn require_place_order_limit(
38        &self,
39        req: &PlaceOrderReq,
40        resolved_acc_id: u64,
41        override_key: Option<&str>,
42    ) -> Option<String> {
43        self.require_trading(
44            "futu_place_order",
45            &req.env,
46            Some(place_order_check_ctx(req, resolved_acc_id)),
47            override_key,
48        )
49    }
50
51    async fn futu_place_order_impl(
52        &self,
53        Parameters(req): Parameters<PlaceOrderReq>,
54        req_ctx: RequestContext<RoleServer>,
55    ) -> std::result::Result<String, String> {
56        // per-call key 优先级:tool args `api_key` > HTTP Authorization Bearer > startup key
57        let header_token = http_bearer_token(&req_ctx);
58        let override_key = req.api_key.as_deref().or(header_token.as_deref());
59        let args_hash = guard::args_short_hash(&req);
60        tracing::warn!(
61            target: futu_auth::audit::TARGET,
62            iface = "mcp",
63            endpoint = "futu_place_order",
64            env = %req.env,
65            market = %req.market,
66            acc_id = req.acc_id.unwrap_or(0), // v1.4.105 T-D1: Option<u64> → u64 for audit log
67            card_num_provided = req.card_num.is_some(),
68            side = %req.side,
69            order_type = %req.order_type,
70            code = %req.code,
71            qty = req.qty,
72            // v1.4.90 P2-C: Option<f64> → f64 NaN sentinel; was `?req.price`
73            // 之前 record_debug 把 Some(400.0) 编为 string "Some(400.0)" 让下游 jq 数值聚合炸
74            price = crate::state::audit_fmt::opt_f64(req.price),
75            time_in_force = req.time_in_force.unwrap_or(-1),
76            fill_outside_rth = req.fill_outside_rth.unwrap_or(false),
77            session = req.session.unwrap_or(-1),
78            args_hash = %args_hash,
79            outcome = "request",
80            "place_order request received"
81        );
82        req.validate()?;
83        // codex round 1 F2 (P2) v1.4.105: caller key strict pre-check FIRST,
84        // **再** client_or_err + resolve. invalid Bearer 在此 fail-closed,
85        // 不再触发 daemon GetAccList + startup-key allowed_card_nums leak.
86        let caller_key_rec = match self.require_caller_key_strict("futu_place_order", override_key)
87        {
88            Ok(rec) => rec,
89            Err(reject_json) => return Err(reject_json),
90        };
91        // codex round 2 F1 (P2) v1.4.105: 早期 trade-scope 校验 — 在
92        // `client_or_err` + `resolve_acc_id_with_card_num` 之前. 防 valid
93        // 但**非-trade** key (e.g. qot:read only) 触发 daemon GetAccList +
94        // card_num 探测 not-found/ambiguous/existence timing.
95        if let Some(reject) =
96            self.require_trading_scope_only("futu_place_order", &req.env, caller_key_rec.as_ref())
97        {
98            return Err(reject);
99        }
100        // v1.4.105 D12 (Phase 2): resolve card_num → acc_id 在 scope check 前.
101        // require_trading 的 CheckCtx 需要 final acc_id 做 allowed_acc_ids 校验,
102        // 所以必须 client_or_err + resolve 提前. card_num resolve 多花 1 次
103        // GetAccList RPC (~10ms 本地 daemon).
104        let client = self.client_or_err().await?;
105        // v1.4.105 D12 contract-hardening 补丁: 同步传 allowed_card_nums 做 string-level
106        // whitelist 校验 (resolve 前). caller key 配置非空时, user 输 card_num
107        // 必须 ∈ 白名单 — UX clear.
108        // codex F2: 用 caller_key_rec (require_caller_key_strict 已验) 而非
109        // current_key_rec(override_key) 重新查 — 避免 silent fallback startup.
110        let allowed_card_nums = caller_key_rec
111            .as_ref()
112            .and_then(|r| r.allowed_card_nums.as_deref());
113        // v1.4.106 codex round 2 F1 case 2 (P1) fix: caller-snapshot acc_id
114        // 早过滤 — 受限 key 不再能 enumerate 其他用户的 acc_id (timing leak
115        // via 0-match/1-match/N-match 差异).
116        let caller_allowed_acc_ids = caller_key_rec
117            .as_ref()
118            .and_then(|r| r.allowed_acc_ids.as_ref());
119        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
120            &client,
121            req.acc_id.unwrap_or(0),
122            req.card_num.as_deref(),
123            allowed_card_nums,
124            caller_allowed_acc_ids,
125        )
126        .await
127        {
128            Ok(id) => id,
129            Err(msg) => return Self::tool_err(msg),
130        };
131        if let Some(rej) = self.require_place_order_limit(&req, resolved_acc_id, override_key) {
132            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
133            return Err(rej);
134        }
135        let result = Self::wrap_result(
136            handlers::trade_write::place_order(
137                &client,
138                handlers::trade_write::PlaceOrderInput {
139                    env: &req.env,
140                    acc_id: resolved_acc_id,
141                    market: &req.market,
142                    side: &req.side,
143                    order_type: &req.order_type,
144                    code: &req.code,
145                    qty: req.qty,
146                    price: req.price,
147                    amount: req.amount,
148                    pred_side: req.pred_side,
149                    time_in_force: req.time_in_force,
150                    fill_outside_rth: req.fill_outside_rth,
151                    session: req.session,
152                    expire_time: req.expire_time.as_deref(),
153                    jp_acc_type: req.jp_acc_type,
154                    idempotency_key: req.idempotency_key.clone(),
155                    // v1.4.53 F1 条件单
156                    stop_price: req.stop_price,
157                    trail_type: req.trail_type,
158                    trail_value: req.trail_value,
159                    trail_spread: req.trail_spread,
160                },
161            )
162            .await,
163        );
164        // codex round 1 F4 (P2) v1.4.106: emit_trade_outcome 用 caller_key_rec
165        // snapshot (require_caller_key_strict 已 lock 住), **不**调
166        // current_key_id(override_key) 重新 verify — 防 SIGHUP reload 在
167        // daemon dispatch 中途 revoke/narrow per-call key, audit 记录被误归
168        // 属到 startup key 或 fallback. snapshot reuse pattern 与 unlock_trade
169        // (codex round 1 F5) / list_accounts (codex round 1 F3) 一致.
170        let startup_key = self.state.authed_key();
171        let outcome_key_id =
172            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
173        guard::emit_trade_outcome(
174            "futu_place_order",
175            outcome_key_id,
176            &args_hash,
177            Self::result_as_str(&result),
178        );
179        result
180    }
181
182    async fn futu_modify_order_impl(
183        &self,
184        Parameters(req): Parameters<ModifyOrderReq>,
185        req_ctx: RequestContext<RoleServer>,
186    ) -> std::result::Result<String, String> {
187        let header_token = http_bearer_token(&req_ctx);
188        let override_key = req.api_key.as_deref().or(header_token.as_deref());
189        let args_hash = guard::args_short_hash(&req);
190        tracing::warn!(
191            target: futu_auth::audit::TARGET,
192            iface = "mcp",
193            endpoint = "futu_modify_order",
194            env = %req.env,
195            market = %req.market,
196            acc_id = req.acc_id.unwrap_or(0), // v1.4.105 T-D1: Option<u64> → u64 for audit log
197            card_num_provided = req.card_num.is_some(),
198            order_id = %req.order_id,
199            op = %req.op,
200            // v1.4.90 P2-C: Option<f64> → f64 NaN sentinel
201            qty = crate::state::audit_fmt::opt_f64(req.qty),
202            price = crate::state::audit_fmt::opt_f64(req.price),
203            args_hash = %args_hash,
204            outcome = "request",
205            "modify_order request received"
206        );
207        req.validate()?;
208        // codex round 1 F2 (P2) v1.4.105: caller key strict pre-check FIRST.
209        let caller_key_rec = match self.require_caller_key_strict("futu_modify_order", override_key)
210        {
211            Ok(rec) => rec,
212            Err(reject_json) => return Err(reject_json),
213        };
214        // codex round 2 F1 (P2) v1.4.105: 早期 trade-scope 校验 (同 place_order).
215        if let Some(reject) =
216            self.require_trading_scope_only("futu_modify_order", &req.env, caller_key_rec.as_ref())
217        {
218            return Err(reject);
219        }
220        // v1.4.105 D12: client_or_err + resolve card_num → acc_id 提前 (scope check 用)
221        let client = self.client_or_err().await?;
222        // v1.4.105 D12 contract-hardening 补丁: 同步传 allowed_card_nums 做 string-level
223        // whitelist 校验 (resolve 前). caller key 配置非空时, user 输 card_num
224        // 必须 ∈ 白名单 — UX clear.
225        // codex F2: 用 caller_key_rec (require_caller_key_strict 已验) 而非
226        // current_key_rec(override_key) 重新查.
227        let allowed_card_nums = caller_key_rec
228            .as_ref()
229            .and_then(|r| r.allowed_card_nums.as_deref());
230        // v1.4.106 codex round 2 F1 case 2 (P1) fix: caller-snapshot acc_id
231        // 早过滤 (同 place_order).
232        let caller_allowed_acc_ids = caller_key_rec
233            .as_ref()
234            .and_then(|r| r.allowed_acc_ids.as_ref());
235        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
236            &client,
237            req.acc_id.unwrap_or(0),
238            req.card_num.as_deref(),
239            allowed_card_nums,
240            caller_allowed_acc_ids,
241        )
242        .await
243        {
244            Ok(id) => id,
245            Err(msg) => return Self::tool_err(msg),
246        };
247        // modify 改单 API 只给 order_id,symbol/金额/side 都推不出来 → 填空 ctx
248        // 好让 market 白名单 / 时段 / 速率限制照样生效(symbol / 单笔 / 日累计 / side
249        // 这几项在 limits.rs 里都已 skip-empty-ctx)
250        let mutation_ctx = CheckCtx {
251            market: req.market.trim().to_ascii_uppercase(),
252            symbol: String::new(),
253            order_value: None,
254            trd_side: None,
255            acc_id: Some(resolved_acc_id), // v1.4.35; v1.4.105 D12: resolved
256            mutation_no_exposure: false,
257            currency: None,
258        };
259        if let Some(rej) = self.require_trading(
260            "futu_modify_order",
261            &req.env,
262            Some(mutation_ctx),
263            override_key,
264        ) {
265            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
266            return Err(rej);
267        }
268        let result = Self::wrap_result(
269            handlers::trade_write::modify_order(
270                &client,
271                handlers::trade_write::ModifyOrderInput {
272                    env: &req.env,
273                    acc_id: resolved_acc_id,
274                    market: &req.market,
275                    order_id: &req.order_id,
276                    op: &req.op,
277                    qty: req.qty,
278                    price: req.price,
279                    jp_acc_type: req.jp_acc_type,
280                    idempotency_key: req.idempotency_key.clone(),
281                },
282            )
283            .await,
284        );
285        // codex round 1 F4 (P2) v1.4.106: snapshot reuse — 见 place_order 同段注释.
286        let startup_key = self.state.authed_key();
287        let outcome_key_id =
288            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
289        guard::emit_trade_outcome(
290            "futu_modify_order",
291            outcome_key_id,
292            &args_hash,
293            Self::result_as_str(&result),
294        );
295        result
296    }
297
298    async fn futu_cancel_order_impl(
299        &self,
300        Parameters(req): Parameters<CancelOrderReq>,
301        req_ctx: RequestContext<RoleServer>,
302    ) -> std::result::Result<String, String> {
303        let header_token = http_bearer_token(&req_ctx);
304        let override_key = req.api_key.as_deref().or(header_token.as_deref());
305        let args_hash = guard::args_short_hash(&req);
306        tracing::warn!(
307            target: futu_auth::audit::TARGET,
308            iface = "mcp",
309            endpoint = "futu_cancel_order",
310            env = %req.env,
311            market = %req.market,
312            acc_id = req.acc_id.unwrap_or(0), // v1.4.105 T-D1: Option<u64> → u64 for audit log
313            card_num_provided = req.card_num.is_some(),
314            order_id = %req.order_id,
315            args_hash = %args_hash,
316            outcome = "request",
317            "cancel_order request received"
318        );
319        // codex round 1 F2 (P2) v1.4.105: caller key strict pre-check FIRST.
320        let caller_key_rec = match self.require_caller_key_strict("futu_cancel_order", override_key)
321        {
322            Ok(rec) => rec,
323            Err(reject_json) => return Err(reject_json),
324        };
325        // codex round 2 F1 (P2) v1.4.105: 早期 trade-scope 校验 (同 place_order).
326        if let Some(reject) =
327            self.require_trading_scope_only("futu_cancel_order", &req.env, caller_key_rec.as_ref())
328        {
329            return Err(reject);
330        }
331        // v1.4.105 D12: client_or_err + resolve card_num → acc_id 提前 (scope check 用)
332        let client = self.client_or_err().await?;
333        // v1.4.105 D12 contract-hardening 补丁: 同步传 allowed_card_nums 做 string-level
334        // whitelist 校验 (resolve 前). caller key 配置非空时, user 输 card_num
335        // 必须 ∈ 白名单 — UX clear.
336        // codex F2: 用 caller_key_rec (require_caller_key_strict 已验) 而非
337        // current_key_rec(override_key) 重新查.
338        let allowed_card_nums = caller_key_rec
339            .as_ref()
340            .and_then(|r| r.allowed_card_nums.as_deref());
341        // v1.4.106 codex round 2 F1 case 2 (P1) fix: caller-snapshot acc_id
342        // 早过滤 (同 place_order).
343        let caller_allowed_acc_ids = caller_key_rec
344            .as_ref()
345            .and_then(|r| r.allowed_acc_ids.as_ref());
346        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
347            &client,
348            req.acc_id.unwrap_or(0),
349            req.card_num.as_deref(),
350            allowed_card_nums,
351            caller_allowed_acc_ids,
352        )
353        .await
354        {
355            Ok(id) => id,
356            Err(msg) => return Self::tool_err(msg),
357        };
358        let mutation_ctx = CheckCtx {
359            market: req.market.trim().to_ascii_uppercase(),
360            symbol: String::new(),
361            order_value: None,
362            trd_side: None,
363            acc_id: Some(resolved_acc_id), // v1.4.35; v1.4.105 D12: resolved
364            mutation_no_exposure: false,
365            currency: None,
366        };
367        if let Some(rej) = self.require_trading(
368            "futu_cancel_order",
369            &req.env,
370            Some(mutation_ctx),
371            override_key,
372        ) {
373            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
374            return Err(rej);
375        }
376        let result = Self::wrap_result(
377            handlers::trade_write::cancel_order(
378                &client,
379                &req.env,
380                resolved_acc_id,
381                &req.market,
382                &req.order_id,
383                req.jp_acc_type,
384                req.idempotency_key.clone(),
385            )
386            .await,
387        );
388        // codex round 1 F4 (P2) v1.4.106: snapshot reuse — 见 place_order 同段注释.
389        let startup_key = self.state.authed_key();
390        let outcome_key_id =
391            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
392        guard::emit_trade_outcome(
393            "futu_cancel_order",
394            outcome_key_id,
395            &args_hash,
396            Self::result_as_str(&result),
397        );
398        result
399    }
400
401    async fn futu_reconfirm_order_impl(
402        &self,
403        Parameters(req): Parameters<ReconfirmOrderReq>,
404        req_ctx: RequestContext<RoleServer>,
405    ) -> std::result::Result<String, String> {
406        let header_token = http_bearer_token(&req_ctx);
407        let override_key = req.api_key.as_deref().or(header_token.as_deref());
408        let args_hash = guard::args_short_hash(&req);
409        tracing::warn!(
410            target: futu_auth::audit::TARGET,
411            iface = "mcp",
412            endpoint = "futu_reconfirm_order",
413            env = %req.env,
414            market = %req.market,
415            acc_id = req.acc_id.unwrap_or(0),
416            card_num_provided = req.card_num.is_some(),
417            order_id = %req.order_id,
418            reason = req.reason,
419            args_hash = %args_hash,
420            outcome = "request",
421            "reconfirm_order request received"
422        );
423        let caller_key_rec =
424            match self.require_caller_key_strict("futu_reconfirm_order", override_key) {
425                Ok(rec) => rec,
426                Err(reject_json) => return Err(reject_json),
427            };
428        if let Some(reject) = self.require_trading_scope_only(
429            "futu_reconfirm_order",
430            &req.env,
431            caller_key_rec.as_ref(),
432        ) {
433            return Err(reject);
434        }
435        let client = self.client_or_err().await?;
436        let allowed_card_nums = caller_key_rec
437            .as_ref()
438            .and_then(|r| r.allowed_card_nums.as_deref());
439        let caller_allowed_acc_ids = caller_key_rec
440            .as_ref()
441            .and_then(|r| r.allowed_acc_ids.as_ref());
442        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
443            &client,
444            req.acc_id.unwrap_or(0),
445            req.card_num.as_deref(),
446            allowed_card_nums,
447            caller_allowed_acc_ids,
448        )
449        .await
450        {
451            Ok(id) => id,
452            Err(msg) => return Self::tool_err(msg),
453        };
454        let mutation_ctx = CheckCtx {
455            market: req.market.trim().to_ascii_uppercase(),
456            symbol: String::new(),
457            order_value: None,
458            trd_side: None,
459            acc_id: Some(resolved_acc_id),
460            mutation_no_exposure: false,
461            currency: None,
462        };
463        if let Some(rej) = self.require_trading(
464            "futu_reconfirm_order",
465            &req.env,
466            Some(mutation_ctx),
467            override_key,
468        ) {
469            return Err(rej);
470        }
471        let result = Self::wrap_result(
472            handlers::trade_write::reconfirm_order(
473                &client,
474                handlers::trade_write::ReconfirmOrderInput {
475                    env: &req.env,
476                    acc_id: resolved_acc_id,
477                    market: &req.market,
478                    order_id: &req.order_id,
479                    reason: req.reason,
480                    jp_acc_type: req.jp_acc_type,
481                },
482            )
483            .await,
484        );
485        let startup_key = self.state.authed_key();
486        let outcome_key_id =
487            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
488        guard::emit_trade_outcome(
489            "futu_reconfirm_order",
490            outcome_key_id,
491            &args_hash,
492            Self::result_as_str(&result),
493        );
494        result
495    }
496
497    async fn futu_cancel_all_order_impl(
498        &self,
499        Parameters(req): Parameters<CancelAllOrderReq>,
500        req_ctx: RequestContext<RoleServer>,
501    ) -> std::result::Result<String, String> {
502        let header_token = http_bearer_token(&req_ctx);
503        let override_key = req.api_key.as_deref().or(header_token.as_deref());
504        let args_hash = guard::args_short_hash(&req);
505        tracing::warn!(
506            target: futu_auth::audit::TARGET,
507            iface = "mcp",
508            endpoint = "futu_cancel_all_order",
509            env = %req.env,
510            market = %req.market,
511            acc_id = req.acc_id,
512            args_hash = %args_hash,
513            outcome = "request",
514            "cancel_all_order request received"
515        );
516        // v1.4.34 MCP-3b 修:market 是必填,空字符串下发到后端会炸
517        // `unknown trd market ""` —— 这是个模糊的内部错误,LLM 客户端没法自修。
518        // 在 tool 层前置校验给清晰的必填提示 + 合法值列表。
519        // v1.4.84 §5 B4: 用集中的 validate() (其他 tool 也复用类似模式)
520        req.validate()?;
521        // codex round 1 F4 (P2) v1.4.106: lock caller key snapshot **早**
522        // (在 require_trading + daemon dispatch 之前), 用于 emit_trade_outcome
523        // 防 SIGHUP race. 与 place/modify/cancel_order 的 require_caller_key_strict
524        // 同语义 — invalid override 立即 fail-closed, scope mode 关闭则 Ok(None).
525        let caller_key_rec =
526            match self.require_caller_key_strict("futu_cancel_all_order", override_key) {
527                Ok(rec) => rec,
528                Err(reject_json) => return Err(reject_json),
529            };
530        let market_trimmed = req.market.trim();
531        let mutation_ctx = CheckCtx {
532            market: market_trimmed.to_ascii_uppercase(),
533            symbol: String::new(),
534            order_value: None,
535            trd_side: None,
536            acc_id: Some(req.acc_id), // v1.4.35
537            mutation_no_exposure: false,
538            currency: None,
539        };
540        if let Some(rej) = self.require_trading(
541            "futu_cancel_all_order",
542            &req.env,
543            Some(mutation_ctx),
544            override_key,
545        ) {
546            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
547            return Err(rej);
548        }
549        let client = self.client_or_err().await?;
550        let result = Self::wrap_result(
551            handlers::trade_write::cancel_all_order(&client, &req.env, req.acc_id, &req.market)
552                .await,
553        );
554        // codex round 1 F4 (P2) v1.4.106: snapshot reuse — 见 place_order 同段注释.
555        let startup_key = self.state.authed_key();
556        let outcome_key_id =
557            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
558        guard::emit_trade_outcome(
559            "futu_cancel_all_order",
560            outcome_key_id,
561            &args_hash,
562            Self::result_as_str(&result),
563        );
564        result
565    }
566}
567
568#[cfg(test)]
569#[path = "trade_write/tests.rs"]
570mod tests;
571
572include!(concat!(
573    env!("OUT_DIR"),
574    "/generated_mcp_routes_trade_write.rs"
575));