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-session fields**: US pre/post-market orders must pass the same FTAPI fields as C++ OpenD (`fill_outside_rth=true` and/or `session`: 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT; optional `time_in_force`: 0=DAY, 1=GTC, 2=IOC, 3=GTD). Backend still validates symbol, order_type, TIF, and account eligibility; if backend refuses, daemon returns the server error rather than queuing locally."
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 time_in_force = req.time_in_force.unwrap_or(-1),
47 fill_outside_rth = req.fill_outside_rth.unwrap_or(false),
48 session = req.session.unwrap_or(-1),
49 args_hash = %args_hash,
50 outcome = "request",
51 "place_order request received"
52 );
53 req.validate()?;
54 let caller_key_rec = match self.require_caller_key_strict("futu_place_order", override_key)
58 {
59 Ok(rec) => rec,
60 Err(reject_json) => return Err(reject_json),
61 };
62 if let Some(reject) =
67 self.require_trading_scope_only("futu_place_order", &req.env, caller_key_rec.as_ref())
68 {
69 return Err(reject);
70 }
71 let client = self.client_or_err().await?;
76 let allowed_card_nums = caller_key_rec
82 .as_ref()
83 .and_then(|r| r.allowed_card_nums.as_deref());
84 let caller_allowed_acc_ids = caller_key_rec
88 .as_ref()
89 .and_then(|r| r.allowed_acc_ids.as_ref());
90 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
91 &client,
92 req.acc_id.unwrap_or(0),
93 req.card_num.as_deref(),
94 allowed_card_nums,
95 caller_allowed_acc_ids,
96 )
97 .await
98 {
99 Ok(id) => id,
100 Err(msg) => return Self::tool_err(msg),
101 };
102 let ctx = CheckCtx {
103 market: req.market.trim().to_ascii_uppercase(),
104 symbol: format!(
105 "{}.{}",
106 req.market.trim().to_ascii_uppercase(),
107 req.code.trim()
108 ),
109 order_value: req.price.map(|p| p * req.qty),
110 trd_side: Some(req.side.trim().to_ascii_uppercase()),
111 acc_id: Some(resolved_acc_id), mutation_no_exposure: false,
113 currency: None,
114 };
115 if let Some(rej) =
116 self.require_trading("futu_place_order", &req.env, Some(ctx), override_key)
117 {
118 return Err(rej);
120 }
121 let result = Self::wrap_result(
122 handlers::trade_write::place_order(
123 &client,
124 handlers::trade_write::PlaceOrderInput {
125 env: &req.env,
126 acc_id: resolved_acc_id,
127 market: &req.market,
128 side: &req.side,
129 order_type: &req.order_type,
130 code: &req.code,
131 qty: req.qty,
132 price: req.price,
133 time_in_force: req.time_in_force,
134 fill_outside_rth: req.fill_outside_rth,
135 session: req.session,
136 expire_time: req.expire_time.as_deref(),
137 jp_acc_type: req.jp_acc_type,
138 idempotency_key: req.idempotency_key.clone(),
139 stop_price: req.stop_price,
141 trail_type: req.trail_type,
142 trail_value: req.trail_value,
143 trail_spread: req.trail_spread,
144 },
145 )
146 .await,
147 );
148 let startup_key = self.state.authed_key();
155 let outcome_key_id =
156 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
157 guard::emit_trade_outcome(
158 "futu_place_order",
159 outcome_key_id,
160 &args_hash,
161 Self::result_as_str(&result),
162 );
163 result
164 }
165
166 #[tool(
167 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`."
168 )]
169 async fn futu_modify_order(
170 &self,
171 Parameters(req): Parameters<ModifyOrderReq>,
172 req_ctx: RequestContext<RoleServer>,
173 ) -> std::result::Result<String, String> {
174 let header_token = http_bearer_token(&req_ctx);
175 let override_key = req.api_key.as_deref().or(header_token.as_deref());
176 let args_hash = guard::args_short_hash(&req);
177 tracing::warn!(
178 target: futu_auth::audit::TARGET,
179 iface = "mcp",
180 endpoint = "futu_modify_order",
181 env = %req.env,
182 market = %req.market,
183 acc_id = req.acc_id.unwrap_or(0), card_num_provided = req.card_num.is_some(),
185 order_id = %req.order_id,
186 op = %req.op,
187 qty = crate::state::audit_fmt::opt_f64(req.qty),
189 price = crate::state::audit_fmt::opt_f64(req.price),
190 args_hash = %args_hash,
191 outcome = "request",
192 "modify_order request received"
193 );
194 req.validate()?;
195 let caller_key_rec = match self.require_caller_key_strict("futu_modify_order", override_key)
197 {
198 Ok(rec) => rec,
199 Err(reject_json) => return Err(reject_json),
200 };
201 if let Some(reject) =
203 self.require_trading_scope_only("futu_modify_order", &req.env, caller_key_rec.as_ref())
204 {
205 return Err(reject);
206 }
207 let client = self.client_or_err().await?;
209 let allowed_card_nums = caller_key_rec
215 .as_ref()
216 .and_then(|r| r.allowed_card_nums.as_deref());
217 let caller_allowed_acc_ids = caller_key_rec
220 .as_ref()
221 .and_then(|r| r.allowed_acc_ids.as_ref());
222 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
223 &client,
224 req.acc_id.unwrap_or(0),
225 req.card_num.as_deref(),
226 allowed_card_nums,
227 caller_allowed_acc_ids,
228 )
229 .await
230 {
231 Ok(id) => id,
232 Err(msg) => return Self::tool_err(msg),
233 };
234 let mutation_ctx = CheckCtx {
238 market: req.market.trim().to_ascii_uppercase(),
239 symbol: String::new(),
240 order_value: None,
241 trd_side: None,
242 acc_id: Some(resolved_acc_id), mutation_no_exposure: false,
244 currency: None,
245 };
246 if let Some(rej) = self.require_trading(
247 "futu_modify_order",
248 &req.env,
249 Some(mutation_ctx),
250 override_key,
251 ) {
252 return Err(rej);
254 }
255 let result = Self::wrap_result(
256 handlers::trade_write::modify_order(
257 &client,
258 handlers::trade_write::ModifyOrderInput {
259 env: &req.env,
260 acc_id: resolved_acc_id,
261 market: &req.market,
262 order_id: &req.order_id,
263 op: &req.op,
264 qty: req.qty,
265 price: req.price,
266 jp_acc_type: req.jp_acc_type,
267 idempotency_key: req.idempotency_key.clone(),
268 },
269 )
270 .await,
271 );
272 let startup_key = self.state.authed_key();
274 let outcome_key_id =
275 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
276 guard::emit_trade_outcome(
277 "futu_modify_order",
278 outcome_key_id,
279 &args_hash,
280 Self::result_as_str(&result),
281 );
282 result
283 }
284
285 #[tool(
286 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`."
287 )]
288 async fn futu_cancel_order(
289 &self,
290 Parameters(req): Parameters<CancelOrderReq>,
291 req_ctx: RequestContext<RoleServer>,
292 ) -> std::result::Result<String, String> {
293 let header_token = http_bearer_token(&req_ctx);
294 let override_key = req.api_key.as_deref().or(header_token.as_deref());
295 let args_hash = guard::args_short_hash(&req);
296 tracing::warn!(
297 target: futu_auth::audit::TARGET,
298 iface = "mcp",
299 endpoint = "futu_cancel_order",
300 env = %req.env,
301 market = %req.market,
302 acc_id = req.acc_id.unwrap_or(0), card_num_provided = req.card_num.is_some(),
304 order_id = %req.order_id,
305 args_hash = %args_hash,
306 outcome = "request",
307 "cancel_order request received"
308 );
309 let caller_key_rec = match self.require_caller_key_strict("futu_cancel_order", override_key)
311 {
312 Ok(rec) => rec,
313 Err(reject_json) => return Err(reject_json),
314 };
315 if let Some(reject) =
317 self.require_trading_scope_only("futu_cancel_order", &req.env, caller_key_rec.as_ref())
318 {
319 return Err(reject);
320 }
321 let client = self.client_or_err().await?;
323 let allowed_card_nums = caller_key_rec
329 .as_ref()
330 .and_then(|r| r.allowed_card_nums.as_deref());
331 let caller_allowed_acc_ids = caller_key_rec
334 .as_ref()
335 .and_then(|r| r.allowed_acc_ids.as_ref());
336 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
337 &client,
338 req.acc_id.unwrap_or(0),
339 req.card_num.as_deref(),
340 allowed_card_nums,
341 caller_allowed_acc_ids,
342 )
343 .await
344 {
345 Ok(id) => id,
346 Err(msg) => return Self::tool_err(msg),
347 };
348 let mutation_ctx = CheckCtx {
349 market: req.market.trim().to_ascii_uppercase(),
350 symbol: String::new(),
351 order_value: None,
352 trd_side: None,
353 acc_id: Some(resolved_acc_id), mutation_no_exposure: false,
355 currency: None,
356 };
357 if let Some(rej) = self.require_trading(
358 "futu_cancel_order",
359 &req.env,
360 Some(mutation_ctx),
361 override_key,
362 ) {
363 return Err(rej);
365 }
366 let result = Self::wrap_result(
367 handlers::trade_write::cancel_order(
368 &client,
369 &req.env,
370 resolved_acc_id,
371 &req.market,
372 &req.order_id,
373 req.jp_acc_type,
374 req.idempotency_key.clone(),
375 )
376 .await,
377 );
378 let startup_key = self.state.authed_key();
380 let outcome_key_id =
381 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
382 guard::emit_trade_outcome(
383 "futu_cancel_order",
384 outcome_key_id,
385 &args_hash,
386 Self::result_as_str(&result),
387 );
388 result
389 }
390
391 #[tool(
392 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."
393 )]
394 async fn futu_reconfirm_order(
395 &self,
396 Parameters(req): Parameters<ReconfirmOrderReq>,
397 req_ctx: RequestContext<RoleServer>,
398 ) -> std::result::Result<String, String> {
399 let header_token = http_bearer_token(&req_ctx);
400 let override_key = req.api_key.as_deref().or(header_token.as_deref());
401 let args_hash = guard::args_short_hash(&req);
402 tracing::warn!(
403 target: futu_auth::audit::TARGET,
404 iface = "mcp",
405 endpoint = "futu_reconfirm_order",
406 env = %req.env,
407 market = %req.market,
408 acc_id = req.acc_id.unwrap_or(0),
409 card_num_provided = req.card_num.is_some(),
410 order_id = %req.order_id,
411 reason = req.reason,
412 args_hash = %args_hash,
413 outcome = "request",
414 "reconfirm_order request received"
415 );
416 let caller_key_rec =
417 match self.require_caller_key_strict("futu_reconfirm_order", override_key) {
418 Ok(rec) => rec,
419 Err(reject_json) => return Err(reject_json),
420 };
421 if let Some(reject) = self.require_trading_scope_only(
422 "futu_reconfirm_order",
423 &req.env,
424 caller_key_rec.as_ref(),
425 ) {
426 return Err(reject);
427 }
428 let client = self.client_or_err().await?;
429 let allowed_card_nums = caller_key_rec
430 .as_ref()
431 .and_then(|r| r.allowed_card_nums.as_deref());
432 let caller_allowed_acc_ids = caller_key_rec
433 .as_ref()
434 .and_then(|r| r.allowed_acc_ids.as_ref());
435 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
436 &client,
437 req.acc_id.unwrap_or(0),
438 req.card_num.as_deref(),
439 allowed_card_nums,
440 caller_allowed_acc_ids,
441 )
442 .await
443 {
444 Ok(id) => id,
445 Err(msg) => return Self::tool_err(msg),
446 };
447 let mutation_ctx = CheckCtx {
448 market: req.market.trim().to_ascii_uppercase(),
449 symbol: String::new(),
450 order_value: None,
451 trd_side: None,
452 acc_id: Some(resolved_acc_id),
453 mutation_no_exposure: false,
454 currency: None,
455 };
456 if let Some(rej) = self.require_trading(
457 "futu_reconfirm_order",
458 &req.env,
459 Some(mutation_ctx),
460 override_key,
461 ) {
462 return Err(rej);
463 }
464 let result = Self::wrap_result(
465 handlers::trade_write::reconfirm_order(
466 &client,
467 handlers::trade_write::ReconfirmOrderInput {
468 env: &req.env,
469 acc_id: resolved_acc_id,
470 market: &req.market,
471 order_id: &req.order_id,
472 reason: req.reason,
473 jp_acc_type: req.jp_acc_type,
474 },
475 )
476 .await,
477 );
478 let startup_key = self.state.authed_key();
479 let outcome_key_id =
480 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
481 guard::emit_trade_outcome(
482 "futu_reconfirm_order",
483 outcome_key_id,
484 &args_hash,
485 Self::result_as_str(&result),
486 );
487 result
488 }
489
490 #[tool(
491 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."
492 )]
493 async fn futu_cancel_all_order(
494 &self,
495 Parameters(req): Parameters<CancelAllOrderReq>,
496 req_ctx: RequestContext<RoleServer>,
497 ) -> std::result::Result<String, String> {
498 let header_token = http_bearer_token(&req_ctx);
499 let override_key = req.api_key.as_deref().or(header_token.as_deref());
500 let args_hash = guard::args_short_hash(&req);
501 tracing::warn!(
502 target: futu_auth::audit::TARGET,
503 iface = "mcp",
504 endpoint = "futu_cancel_all_order",
505 env = %req.env,
506 market = %req.market,
507 acc_id = req.acc_id,
508 args_hash = %args_hash,
509 outcome = "request",
510 "cancel_all_order request received"
511 );
512 req.validate()?;
517 let caller_key_rec =
522 match self.require_caller_key_strict("futu_cancel_all_order", override_key) {
523 Ok(rec) => rec,
524 Err(reject_json) => return Err(reject_json),
525 };
526 let market_trimmed = req.market.trim();
527 let mutation_ctx = CheckCtx {
528 market: market_trimmed.to_ascii_uppercase(),
529 symbol: String::new(),
530 order_value: None,
531 trd_side: None,
532 acc_id: Some(req.acc_id), mutation_no_exposure: false,
534 currency: None,
535 };
536 if let Some(rej) = self.require_trading(
537 "futu_cancel_all_order",
538 &req.env,
539 Some(mutation_ctx),
540 override_key,
541 ) {
542 return Err(rej);
544 }
545 let client = self.client_or_err().await?;
546 let result = Self::wrap_result(
547 handlers::trade_write::cancel_all_order(&client, &req.env, req.acc_id, &req.market)
548 .await,
549 );
550 let startup_key = self.state.authed_key();
552 let outcome_key_id =
553 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
554 guard::emit_trade_outcome(
555 "futu_cancel_all_order",
556 outcome_key_id,
557 &args_hash,
558 Self::result_as_str(&result),
559 );
560 result
561 }
562}