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