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::{ComboOrderProtoJsonReq, ProtoJsonReq};
11use crate::tool_auth::{http_bearer_token, outcome_key_id_from_snapshot};
12
13use super::FutuServer;
14
15#[tool_router(router = combo_tool_router, vis = "pub(crate)")]
16impl FutuServer {
17 #[tool(
18 description = "Get option quote data for one option leg or a combo-option leg set. Futu API v10.7: OpenQuoteContext.get_option_quote. `c2s_json` must be generated-proto JSON."
19 )]
20 async fn futu_get_option_quote(
21 &self,
22 Parameters(req): Parameters<ProtoJsonReq>,
23 req_ctx: RequestContext<RoleServer>,
24 ) -> std::result::Result<String, String> {
25 let c2s = match handlers::proto_json::parse_c2s_json::<futu_proto::qot_get_option_quote::C2s>(
26 "option-quote",
27 &req.c2s_json,
28 ) {
29 Ok(c2s) => c2s,
30 Err(err) => return Self::tool_err(err),
31 };
32 let client = self
33 .read_client_or_err(
34 "futu_get_option_quote",
35 &req_ctx,
36 req.api_key.as_deref(),
37 None,
38 )
39 .await?;
40 Self::wrap_result(handlers::proto_json::option_quote(&client, c2s).await)
41 }
42
43 #[tool(
44 description = "Get option strategy candidates for an underlying security. Futu API v10.7: OpenQuoteContext.get_option_strategy. `c2s_json` must be generated-proto JSON."
45 )]
46 async fn futu_get_option_strategy(
47 &self,
48 Parameters(req): Parameters<ProtoJsonReq>,
49 req_ctx: RequestContext<RoleServer>,
50 ) -> std::result::Result<String, String> {
51 let c2s = match handlers::proto_json::parse_c2s_json::<
52 futu_proto::qot_get_option_strategy::C2s,
53 >("option-strategy", &req.c2s_json)
54 {
55 Ok(c2s) => c2s,
56 Err(err) => return Self::tool_err(err),
57 };
58 let client = self
59 .read_client_or_err(
60 "futu_get_option_strategy",
61 &req_ctx,
62 req.api_key.as_deref(),
63 None,
64 )
65 .await?;
66 Self::wrap_result(handlers::proto_json::option_strategy(&client, c2s).await)
67 }
68
69 #[tool(
70 description = "Analyze payoff/risk metrics for a combo-option leg set. Futu API v10.7: OpenQuoteContext.get_option_strategy_analysis. `c2s_json` must be generated-proto JSON."
71 )]
72 async fn futu_get_option_strategy_analysis(
73 &self,
74 Parameters(req): Parameters<ProtoJsonReq>,
75 req_ctx: RequestContext<RoleServer>,
76 ) -> std::result::Result<String, String> {
77 let c2s = match handlers::proto_json::parse_c2s_json::<
78 futu_proto::qot_get_option_strategy_analysis::C2s,
79 >("option-strategy-analysis", &req.c2s_json)
80 {
81 Ok(c2s) => c2s,
82 Err(err) => return Self::tool_err(err),
83 };
84 let client = self
85 .read_client_or_err(
86 "futu_get_option_strategy_analysis",
87 &req_ctx,
88 req.api_key.as_deref(),
89 None,
90 )
91 .await?;
92 Self::wrap_result(handlers::proto_json::option_strategy_analysis(&client, c2s).await)
93 }
94
95 #[tool(
96 description = "Get available strike/expiry spread metadata for an option strategy. Futu API v10.7: OpenQuoteContext.get_option_strategy_spread. `c2s_json` must be generated-proto JSON."
97 )]
98 async fn futu_get_option_strategy_spread(
99 &self,
100 Parameters(req): Parameters<ProtoJsonReq>,
101 req_ctx: RequestContext<RoleServer>,
102 ) -> std::result::Result<String, String> {
103 let c2s = match handlers::proto_json::parse_c2s_json::<
104 futu_proto::qot_get_option_strategy_spread::C2s,
105 >("option-strategy-spread", &req.c2s_json)
106 {
107 Ok(c2s) => c2s,
108 Err(err) => return Self::tool_err(err),
109 };
110 let client = self
111 .read_client_or_err(
112 "futu_get_option_strategy_spread",
113 &req_ctx,
114 req.api_key.as_deref(),
115 None,
116 )
117 .await?;
118 Self::wrap_result(handlers::proto_json::option_strategy_spread(&client, c2s).await)
119 }
120
121 #[tool(
122 description = "Get maximum trade quantity for a combo-option order. Futu API v10.7: OpenSecTradeContext.get_combo_max_trd_qtys. `c2s_json` must include header.acc_id."
123 )]
124 async fn futu_get_combo_max_trd_qtys(
125 &self,
126 Parameters(req): Parameters<ProtoJsonReq>,
127 req_ctx: RequestContext<RoleServer>,
128 ) -> std::result::Result<String, String> {
129 let c2s = match handlers::proto_json::parse_combo_max_c2s_json(&req.c2s_json) {
130 Ok(c2s) => c2s,
131 Err(err) => return Self::tool_err(err),
132 };
133 let acc_id = match handlers::proto_json::combo_max_context(&c2s) {
134 Ok(acc_id) => acc_id,
135 Err(err) => return Self::tool_err(err),
136 };
137 let client = self
138 .read_client_or_err(
139 "futu_get_combo_max_trd_qtys",
140 &req_ctx,
141 req.api_key.as_deref(),
142 Some(acc_id),
143 )
144 .await?;
145 Self::wrap_result(handlers::proto_json::combo_max_trd_qtys(&client, c2s).await)
146 }
147
148 #[tool(
149 description = "⚠️ REAL MONEY when header.trd_env=1. Place a combo-option order. REQUIRES --enable-trading; real env additionally requires --allow-real-trading. `c2s_json` must include header.acc_id, header.trd_env, header.trd_market, combo_legs, qty, and order_type."
150 )]
151 async fn futu_place_combo_order(
152 &self,
153 Parameters(req): Parameters<ComboOrderProtoJsonReq>,
154 req_ctx: RequestContext<RoleServer>,
155 ) -> std::result::Result<String, String> {
156 let header_token = http_bearer_token(&req_ctx);
157 let override_key = req.api_key.as_deref().or(header_token.as_deref());
158 let args_hash = guard::args_short_hash(&req);
159
160 let c2s = match handlers::proto_json::parse_place_combo_c2s_json(&req.c2s_json) {
161 Ok(c2s) => c2s,
162 Err(err) => return Self::tool_err(err),
163 };
164 let combo_ctx = match handlers::proto_json::place_combo_context(&c2s) {
165 Ok(ctx) => ctx,
166 Err(err) => return Self::tool_err(err),
167 };
168
169 tracing::warn!(
170 target: futu_auth::audit::TARGET,
171 iface = "mcp",
172 endpoint = "futu_place_combo_order",
173 env = %combo_ctx.env,
174 market = %combo_ctx.market,
175 acc_id = combo_ctx.acc_id,
176 order_value = crate::state::audit_fmt::opt_f64(combo_ctx.order_value),
177 args_hash = %args_hash,
178 outcome = "request",
179 "place_combo_order request received"
180 );
181
182 let caller_key_rec =
183 match self.require_caller_key_strict("futu_place_combo_order", override_key) {
184 Ok(rec) => rec,
185 Err(reject_json) => return Err(reject_json),
186 };
187 if let Some(reject) = self.require_trading_scope_only(
188 "futu_place_combo_order",
189 combo_ctx.env,
190 caller_key_rec.as_ref(),
191 ) {
192 return Err(reject);
193 }
194
195 let ctx = CheckCtx {
196 market: combo_ctx.market.clone(),
197 symbol: String::new(),
198 order_value: combo_ctx.order_value,
199 trd_side: None,
200 acc_id: Some(combo_ctx.acc_id),
201 mutation_no_exposure: false,
202 currency: None,
203 };
204 if let Some(reject) = self.require_trading(
205 "futu_place_combo_order",
206 combo_ctx.env,
207 Some(ctx),
208 override_key,
209 ) {
210 return Err(reject);
211 }
212
213 let client = self.client_or_err().await?;
214 let result = Self::wrap_result(
215 handlers::proto_json::place_combo_order(&client, c2s, req.idempotency_key).await,
216 );
217 let startup_key = self.state.authed_key();
218 let outcome_key_id =
219 outcome_key_id_from_snapshot(caller_key_rec.as_ref(), startup_key.as_ref());
220 guard::emit_trade_outcome(
221 "futu_place_combo_order",
222 outcome_key_id,
223 &args_hash,
224 Self::result_as_str(&result),
225 );
226 result
227 }
228}