1use 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 #[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 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), 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 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 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 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 let client = self.client_or_err().await?;
73 let allowed_card_nums = caller_key_rec
79 .as_ref()
80 .and_then(|r| r.allowed_card_nums.as_deref());
81 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), 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 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 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 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), card_num_provided = req.card_num.is_some(),
178 order_id = %req.order_id,
179 op = %req.op,
180 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 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 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 let client = self.client_or_err().await?;
202 let allowed_card_nums = caller_key_rec
208 .as_ref()
209 .and_then(|r| r.allowed_card_nums.as_deref());
210 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 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), 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 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 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), 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 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 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 let client = self.client_or_err().await?;
316 let allowed_card_nums = caller_key_rec
322 .as_ref()
323 .and_then(|r| r.allowed_card_nums.as_deref());
324 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), 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 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 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 req.validate()?;
510 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), 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 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 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}