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
15pub(crate) fn validate_unlock_trade_acc_ids(acc_ids: Option<&[u64]>) -> Result<(), String> {
16 if let Some(ids) = acc_ids
17 && let Some((idx, _)) = ids.iter().enumerate().find(|(_, id)| **id == 0)
18 {
19 return Err(format!(
20 "futu_unlock_trade: acc_ids[{idx}] must be a positive non-zero acc_id; got 0"
21 ));
22 }
23 Ok(())
24}
25
26#[tool_router(router = trade_write_tool_router, vis = "pub(crate)")]
27impl FutuServer {
28 #[tool(
31 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."
32 )]
33 async fn futu_place_order(
34 &self,
35 Parameters(req): Parameters<PlaceOrderReq>,
36 req_ctx: RequestContext<RoleServer>,
37 ) -> std::result::Result<String, String> {
38 let header_token = http_bearer_token(&req_ctx);
40 let override_key = req.api_key.as_deref().or(header_token.as_deref());
41 let args_hash = guard::args_short_hash(&req);
42 tracing::warn!(
43 target: futu_auth::audit::TARGET,
44 iface = "mcp",
45 endpoint = "futu_place_order",
46 env = %req.env,
47 market = %req.market,
48 acc_id = req.acc_id.unwrap_or(0), card_num_provided = req.card_num.is_some(),
50 side = %req.side,
51 order_type = %req.order_type,
52 code = %req.code,
53 qty = req.qty,
54 price = crate::state::audit_fmt::opt_f64(req.price),
57 args_hash = %args_hash,
58 outcome = "request",
59 "place_order request received"
60 );
61 let caller_key_rec = match self.require_caller_key_strict("futu_place_order", override_key)
65 {
66 Ok(rec) => rec,
67 Err(reject_json) => return Err(reject_json),
68 };
69 if let Some(reject) =
74 self.require_trading_scope_only("futu_place_order", &req.env, caller_key_rec.as_ref())
75 {
76 return Err(reject);
77 }
78 let client = self.client_or_err().await?;
83 let allowed_card_nums = caller_key_rec
89 .as_ref()
90 .and_then(|r| r.allowed_card_nums.as_deref());
91 let caller_allowed_acc_ids = caller_key_rec
95 .as_ref()
96 .and_then(|r| r.allowed_acc_ids.as_ref());
97 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
98 &client,
99 req.acc_id.unwrap_or(0),
100 req.card_num.as_deref(),
101 allowed_card_nums,
102 caller_allowed_acc_ids,
103 )
104 .await
105 {
106 Ok(id) => id,
107 Err(msg) => return Self::tool_err(msg),
108 };
109 let ctx = CheckCtx {
110 market: req.market.trim().to_ascii_uppercase(),
111 symbol: format!(
112 "{}.{}",
113 req.market.trim().to_ascii_uppercase(),
114 req.code.trim()
115 ),
116 order_value: req.price.map(|p| p * req.qty),
117 trd_side: Some(req.side.trim().to_ascii_uppercase()),
118 acc_id: Some(resolved_acc_id), mutation_no_exposure: false,
120 currency: None,
121 };
122 if let Some(rej) =
123 self.require_trading("futu_place_order", &req.env, Some(ctx), override_key)
124 {
125 return Err(rej);
127 }
128 let result = Self::wrap_result(
129 handlers::trade_write::place_order(
130 &client,
131 handlers::trade_write::PlaceOrderInput {
132 env: &req.env,
133 acc_id: resolved_acc_id,
134 market: &req.market,
135 side: &req.side,
136 order_type: &req.order_type,
137 code: &req.code,
138 qty: req.qty,
139 price: req.price,
140 idempotency_key: req.idempotency_key.clone(),
141 stop_price: req.stop_price,
143 trail_type: req.trail_type,
144 trail_value: req.trail_value,
145 trail_spread: req.trail_spread,
146 },
147 )
148 .await,
149 );
150 let outcome_key_id =
157 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
158 guard::emit_trade_outcome(
159 "futu_place_order",
160 outcome_key_id,
161 &args_hash,
162 Self::result_as_str(&result),
163 );
164 result
165 }
166
167 #[tool(
168 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`."
169 )]
170 async fn futu_modify_order(
171 &self,
172 Parameters(req): Parameters<ModifyOrderReq>,
173 req_ctx: RequestContext<RoleServer>,
174 ) -> std::result::Result<String, String> {
175 let header_token = http_bearer_token(&req_ctx);
176 let override_key = req.api_key.as_deref().or(header_token.as_deref());
177 let args_hash = guard::args_short_hash(&req);
178 tracing::warn!(
179 target: futu_auth::audit::TARGET,
180 iface = "mcp",
181 endpoint = "futu_modify_order",
182 env = %req.env,
183 market = %req.market,
184 acc_id = req.acc_id.unwrap_or(0), card_num_provided = req.card_num.is_some(),
186 order_id = %req.order_id,
187 op = %req.op,
188 qty = crate::state::audit_fmt::opt_f64(req.qty),
190 price = crate::state::audit_fmt::opt_f64(req.price),
191 args_hash = %args_hash,
192 outcome = "request",
193 "modify_order request received"
194 );
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 idempotency_key: req.idempotency_key.clone(),
267 },
268 )
269 .await,
270 );
271 let outcome_key_id =
273 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
274 guard::emit_trade_outcome(
275 "futu_modify_order",
276 outcome_key_id,
277 &args_hash,
278 Self::result_as_str(&result),
279 );
280 result
281 }
282
283 #[tool(
284 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`."
285 )]
286 async fn futu_cancel_order(
287 &self,
288 Parameters(req): Parameters<CancelOrderReq>,
289 req_ctx: RequestContext<RoleServer>,
290 ) -> std::result::Result<String, String> {
291 let header_token = http_bearer_token(&req_ctx);
292 let override_key = req.api_key.as_deref().or(header_token.as_deref());
293 let args_hash = guard::args_short_hash(&req);
294 tracing::warn!(
295 target: futu_auth::audit::TARGET,
296 iface = "mcp",
297 endpoint = "futu_cancel_order",
298 env = %req.env,
299 market = %req.market,
300 acc_id = req.acc_id.unwrap_or(0), card_num_provided = req.card_num.is_some(),
302 order_id = %req.order_id,
303 args_hash = %args_hash,
304 outcome = "request",
305 "cancel_order request received"
306 );
307 let caller_key_rec = match self.require_caller_key_strict("futu_cancel_order", override_key)
309 {
310 Ok(rec) => rec,
311 Err(reject_json) => return Err(reject_json),
312 };
313 if let Some(reject) =
315 self.require_trading_scope_only("futu_cancel_order", &req.env, caller_key_rec.as_ref())
316 {
317 return Err(reject);
318 }
319 let client = self.client_or_err().await?;
321 let allowed_card_nums = caller_key_rec
327 .as_ref()
328 .and_then(|r| r.allowed_card_nums.as_deref());
329 let caller_allowed_acc_ids = caller_key_rec
332 .as_ref()
333 .and_then(|r| r.allowed_acc_ids.as_ref());
334 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
335 &client,
336 req.acc_id.unwrap_or(0),
337 req.card_num.as_deref(),
338 allowed_card_nums,
339 caller_allowed_acc_ids,
340 )
341 .await
342 {
343 Ok(id) => id,
344 Err(msg) => return Self::tool_err(msg),
345 };
346 let mutation_ctx = CheckCtx {
347 market: req.market.trim().to_ascii_uppercase(),
348 symbol: String::new(),
349 order_value: None,
350 trd_side: None,
351 acc_id: Some(resolved_acc_id), mutation_no_exposure: false,
353 currency: None,
354 };
355 if let Some(rej) = self.require_trading(
356 "futu_cancel_order",
357 &req.env,
358 Some(mutation_ctx),
359 override_key,
360 ) {
361 return Err(rej);
363 }
364 let result = Self::wrap_result(
365 handlers::trade_write::cancel_order(
366 &client,
367 &req.env,
368 resolved_acc_id,
369 &req.market,
370 &req.order_id,
371 req.idempotency_key.clone(),
372 )
373 .await,
374 );
375 let outcome_key_id =
377 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
378 guard::emit_trade_outcome(
379 "futu_cancel_order",
380 outcome_key_id,
381 &args_hash,
382 Self::result_as_str(&result),
383 );
384 result
385 }
386
387 #[tool(
388 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."
389 )]
390 async fn futu_reconfirm_order(
391 &self,
392 Parameters(req): Parameters<ReconfirmOrderReq>,
393 req_ctx: RequestContext<RoleServer>,
394 ) -> std::result::Result<String, String> {
395 let header_token = http_bearer_token(&req_ctx);
396 let override_key = req.api_key.as_deref().or(header_token.as_deref());
397 let args_hash = guard::args_short_hash(&req);
398 tracing::warn!(
399 target: futu_auth::audit::TARGET,
400 iface = "mcp",
401 endpoint = "futu_reconfirm_order",
402 env = %req.env,
403 market = %req.market,
404 acc_id = req.acc_id.unwrap_or(0),
405 card_num_provided = req.card_num.is_some(),
406 order_id = %req.order_id,
407 reason = req.reason,
408 args_hash = %args_hash,
409 outcome = "request",
410 "reconfirm_order request received"
411 );
412 let caller_key_rec =
413 match self.require_caller_key_strict("futu_reconfirm_order", override_key) {
414 Ok(rec) => rec,
415 Err(reject_json) => return Err(reject_json),
416 };
417 if let Some(reject) = self.require_trading_scope_only(
418 "futu_reconfirm_order",
419 &req.env,
420 caller_key_rec.as_ref(),
421 ) {
422 return Err(reject);
423 }
424 let client = self.client_or_err().await?;
425 let allowed_card_nums = caller_key_rec
426 .as_ref()
427 .and_then(|r| r.allowed_card_nums.as_deref());
428 let caller_allowed_acc_ids = caller_key_rec
429 .as_ref()
430 .and_then(|r| r.allowed_acc_ids.as_ref());
431 let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
432 &client,
433 req.acc_id.unwrap_or(0),
434 req.card_num.as_deref(),
435 allowed_card_nums,
436 caller_allowed_acc_ids,
437 )
438 .await
439 {
440 Ok(id) => id,
441 Err(msg) => return Self::tool_err(msg),
442 };
443 let mutation_ctx = CheckCtx {
444 market: req.market.trim().to_ascii_uppercase(),
445 symbol: String::new(),
446 order_value: None,
447 trd_side: None,
448 acc_id: Some(resolved_acc_id),
449 mutation_no_exposure: false,
450 currency: None,
451 };
452 if let Some(rej) = self.require_trading(
453 "futu_reconfirm_order",
454 &req.env,
455 Some(mutation_ctx),
456 override_key,
457 ) {
458 return Err(rej);
459 }
460 let result = Self::wrap_result(
461 handlers::trade_write::reconfirm_order(
462 &client,
463 handlers::trade_write::ReconfirmOrderInput {
464 env: &req.env,
465 acc_id: resolved_acc_id,
466 market: &req.market,
467 order_id: &req.order_id,
468 reason: req.reason,
469 },
470 )
471 .await,
472 );
473 let outcome_key_id =
474 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
475 guard::emit_trade_outcome(
476 "futu_reconfirm_order",
477 outcome_key_id,
478 &args_hash,
479 Self::result_as_str(&result),
480 );
481 result
482 }
483
484 #[tool(
485 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."
486 )]
487 async fn futu_cancel_all_order(
488 &self,
489 Parameters(req): Parameters<CancelAllOrderReq>,
490 req_ctx: RequestContext<RoleServer>,
491 ) -> std::result::Result<String, String> {
492 let header_token = http_bearer_token(&req_ctx);
493 let override_key = req.api_key.as_deref().or(header_token.as_deref());
494 let args_hash = guard::args_short_hash(&req);
495 tracing::warn!(
496 target: futu_auth::audit::TARGET,
497 iface = "mcp",
498 endpoint = "futu_cancel_all_order",
499 env = %req.env,
500 market = %req.market,
501 acc_id = req.acc_id,
502 args_hash = %args_hash,
503 outcome = "request",
504 "cancel_all_order request received"
505 );
506 req.validate()?;
511 let caller_key_rec =
516 match self.require_caller_key_strict("futu_cancel_all_order", override_key) {
517 Ok(rec) => rec,
518 Err(reject_json) => return Err(reject_json),
519 };
520 let market_trimmed = req.market.trim();
521 let mutation_ctx = CheckCtx {
522 market: market_trimmed.to_ascii_uppercase(),
523 symbol: String::new(),
524 order_value: None,
525 trd_side: None,
526 acc_id: Some(req.acc_id), mutation_no_exposure: false,
528 currency: None,
529 };
530 if let Some(rej) = self.require_trading(
531 "futu_cancel_all_order",
532 &req.env,
533 Some(mutation_ctx),
534 override_key,
535 ) {
536 return Err(rej);
538 }
539 let client = self.client_or_err().await?;
540 let result = Self::wrap_result(
541 handlers::trade_write::cancel_all_order(&client, &req.env, req.acc_id, &req.market)
542 .await,
543 );
544 let outcome_key_id =
546 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_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
556 #[tool(
557 description = "⚠️ Opens a trade window for subsequent `futu_place_order` / `futu_modify_order` / `futu_cancel_order` / `futu_reconfirm_order` calls. Password is read from account-scoped OS keychain (via `futucli set-trade-pwd --account <login-account>` plus futu-mcp `--trade-pwd-account <login-account>`) or FUTU_TRADE_PWD env — NEVER pass it via tool args. Requires `trade:unlock` scope. **Lifetime**: once unlocked, all subsequent trade calls succeed without re-authenticating until gateway restart or an explicit `unlock=false` lock-back. Do NOT call this on every trade (server-side anti-abuse may throttle)."
558 )]
559 async fn futu_unlock_trade(
560 &self,
561 Parameters(req): Parameters<UnlockTradeReq>,
562 req_ctx: RequestContext<RoleServer>,
563 ) -> std::result::Result<String, String> {
564 let args_hash = guard::args_short_hash(&req);
565 tracing::warn!(
566 target: futu_auth::audit::TARGET,
567 iface = "mcp",
568 endpoint = "futu_unlock_trade",
569 unlock = req.unlock,
570 args_hash = %args_hash,
571 outcome = "request",
572 "unlock_trade request received"
573 );
574 let bearer_token = http_bearer_token(&req_ctx);
590 let credential: futu_auth_pipeline::Credential<'_> = match bearer_token
591 .as_deref()
592 .filter(|s| !s.is_empty())
593 {
594 Some(t) => match self.state.key_store.verify(t) {
595 Some(rec) => futu_auth_pipeline::Credential::PreVerified(rec),
596 None => {
597 futu_auth::audit::reject(
599 "mcp",
600 "futu_unlock_trade",
601 "<bearer-invalid>",
602 "invalid Bearer (v1.4.103 codex F5.3 fail-closed)",
603 );
604 return Err(serde_json::json!({
605 "error": "futu_unlock_trade: invalid Bearer token (v1.4.103 codex F5.3 fail-closed)",
606 "status": "error",
607 })
608 .to_string());
609 }
610 },
611 None => match self
615 .state
616 .authed_key
617 .as_ref()
618 .and_then(|k| self.state.key_store.get_by_id_for_current_machine(&k.id))
619 {
620 Some(rec) => futu_auth_pipeline::Credential::PreVerified(rec),
621 None => futu_auth_pipeline::Credential::None,
622 },
623 };
624
625 let unlock_env = futu_auth_pipeline::AuthEnvelope {
626 surface: futu_auth_pipeline::SurfaceId::Mcp,
627 endpoint: futu_auth_pipeline::Endpoint::McpTool("futu_unlock_trade"),
628 needed_scope: Some(futu_auth::Scope::TradeUnlock),
629 credential,
630 proto_id: None,
631 body: &[],
632 explicit_acc_id: None, explicit_ctx: None,
634 commit_rate: false, audit_emit: true,
636 };
637 let caller_key_for_unlock = match futu_auth_pipeline::authenticate_request(
638 &self.state.key_store,
639 &self.state.counters,
640 unlock_env,
641 ) {
642 futu_auth_pipeline::AuthDecision::Allow { rec, .. } => rec,
643 futu_auth_pipeline::AuthDecision::Reject {
644 kind,
645 reason,
646 audit_key_id,
647 } => {
648 use futu_auth_pipeline::RejectKind;
649 let err_msg = match kind {
650 RejectKind::Unauthenticated => {
651 format!("futu_unlock_trade: API key required or expired ({reason})")
652 }
653 RejectKind::Forbidden => format!(
654 "futu_unlock_trade: API key {audit_key_id:?} forbidden — needs trade:unlock scope ({reason})"
655 ),
656 _ => reason.clone(),
657 };
658 return Err(serde_json::json!({
659 "error": err_msg,
660 "status": "error",
661 })
662 .to_string());
663 }
664 };
665 if let Err(err) = validate_unlock_trade_acc_ids(req.acc_ids.as_deref()) {
666 return Err(serde_json::json!({
667 "error": err,
668 "status": "error",
669 })
670 .to_string());
671 }
672 if let Some(ref rec) = caller_key_for_unlock
677 && let Some(ref allowed) = rec.allowed_acc_ids
678 && !allowed.is_empty()
679 {
680 match req.acc_ids.as_ref() {
681 Some(ids) if !ids.is_empty() => {
682 for id in ids {
683 if !allowed.contains(id) {
684 futu_auth::audit::reject(
685 "mcp",
686 "futu_unlock_trade",
687 &rec.id,
688 &format!("acc_id {id} not in allowed list"),
689 );
690 return Err(serde_json::json!({
691 "error": format!(
692 "futu_unlock_trade: API key {:?} not allowed to unlock acc_id {id} (allowed_acc_ids restriction)",
693 rec.id
694 ),
695 "status": "error",
696 })
697 .to_string());
698 }
699 }
700 }
701 _ => {
702 return Err(serde_json::json!({
704 "error": format!(
705 "futu_unlock_trade: API key {:?} has allowed_acc_ids restriction \
706 but acc_ids not specified. Restricted keys must explicitly pass \
707 acc_ids; unlock-all is rejected to prevent unauthorized broker \
708 unlock side effects.",
709 rec.id
710 ),
711 "status": "error",
712 "hint": "pass acc_ids: [<your-allowed-acc-id>]",
713 })
714 .to_string());
715 }
716 }
717 }
718
719 let pwd_md5 = if req.unlock {
723 match crate::trade_pwd::get_trade_password_md5_for_account(
724 self.state.trade_pwd_account.as_deref(),
725 ) {
726 Ok(md5) => md5,
727 Err(e) => {
728 let err = format!("unlock failed: {e}");
729 tracing::warn!(
730 target: futu_auth::audit::TARGET,
731 iface = "mcp",
732 endpoint = "futu_unlock_trade",
733 outcome = "failure",
734 reason = %err,
735 "unlock failed: no password source"
736 );
737 return Self::tool_err(err);
738 }
739 }
740 } else {
741 String::new()
742 };
743
744 let client = self.client_or_err().await?;
745 let result = match futu_trd::account::unlock_trade(
746 &client,
747 &pwd_md5,
748 req.unlock,
749 req.otp.as_deref(),
750 req.security_firm,
751 req.acc_ids.clone().unwrap_or_default(),
752 )
753 .await
754 {
755 Ok(outcome) => {
756 if outcome.need_otp {
757 Err(serde_json::json!({
763 "error": "unlock requires OTP (2FA token); pass otp= and retry",
764 "need_otp": true,
765 "message": outcome.message.unwrap_or_else(||
766 "此账号开启了令牌动态密码(2FA)。\
767 请重新调用 futu_unlock_trade 带 `otp` 参数(明文 OTP)".into()),
768 "failed_accounts": outcome.failed_accounts,
769 "status": "need_otp_retry",
770 })
771 .to_string())
772 } else if !req.unlock {
773 Ok(
774 serde_json::json!({ "ok": true, "message": "trade locked on gateway" })
775 .to_string(),
776 )
777 } else {
778 let msg = if outcome.total_unlocked < outcome.total_requested {
779 format!(
780 "部分账户解锁成功({}/{})。\
781 失败账户:{:?}(常见原因:品种权限未开通 / 影子子账户)",
782 outcome.total_unlocked,
783 outcome.total_requested,
784 outcome.failed_accounts
785 )
786 } else {
787 format!(
788 "trade unlocked ({} accounts); cipher cached until gateway restarts",
789 outcome.total_unlocked
790 )
791 };
792 if outcome.total_unlocked > 0 {
793 Ok(serde_json::json!({
794 "ok": true,
795 "need_otp": false,
796 "total_requested": outcome.total_requested,
797 "total_unlocked": outcome.total_unlocked,
798 "failed_accounts": outcome.failed_accounts,
799 "message": msg,
800 })
801 .to_string())
802 } else {
803 Err(serde_json::json!({
806 "ok": false,
807 "error": "all accounts failed to unlock",
808 "need_otp": false,
809 "total_requested": outcome.total_requested,
810 "total_unlocked": outcome.total_unlocked,
811 "failed_accounts": outcome.failed_accounts,
812 "message": msg,
813 })
814 .to_string())
815 }
816 }
817 }
818 Err(e) => Err(format!("unlock_trade RPC failed: {e}")),
819 };
820 let outcome_key_id = outcome_key_id_from_snapshot(
831 caller_key_for_unlock.as_ref(),
832 self.state.authed_key.as_ref(),
833 );
834 guard::emit_trade_outcome(
835 "futu_unlock_trade",
836 outcome_key_id,
837 &args_hash,
838 Self::result_as_str(&result),
839 );
840 result
841 }
842}