1use rmcp::schemars;
4use serde::{Deserialize, Serialize};
5
6use crate::tool_enums;
7
8use super::super::{
9 default_env_simulate, default_modify_op, default_order_type, deser_order_id_raw_from_int_or_str,
10};
11
12#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
13#[serde(deny_unknown_fields)]
14pub struct PlaceOrderReq {
15 #[schemars(
16 description = "Trade market — accepts STRING (HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA) OR INT (1=HK, 2=US, 3=CN, 4=HKCC, 5=Futures, 6=SG, 8=AU, 15=JP, 111=MY, 112=CA per Trd_Common.TrdMarket)."
17 )]
18 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
20 pub market: String,
21 #[schemars(
22 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required. Call `futu_list_accounts` first to discover acc_id — gateway does NOT infer a default. Alternatively pass `card_num` (App 显示的 4 位末尾或 16 位完整) and daemon resolves it via GetAccList."
23 )]
24 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub acc_id: Option<u64>,
28 #[schemars(
29 description = "Card number shown by the app. Accepts 4-digit suffix (e.g. `<card-suffix>`, App 内显示如 \"Margin Composite Account (`<card-suffix>`)\") OR 16-digit full (e.g. `<full-card-num>`). 示例为 synthetic placeholder, 不是真实账户信息. Daemon resolves via GetAccList → matched acc_id. **Either `acc_id` OR `card_num` required**; if both passed, daemon validates resolution matches acc_id (mismatch = 400 reject)."
30 )]
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub card_num: Option<String>,
33 #[schemars(
34 description = "Trade environment: real|simulate. Defaults to simulate for safety. Alias: trd_env"
35 )]
36 #[serde(default = "default_env_simulate", alias = "trd_env")]
38 pub env: String,
39 #[schemars(description = "Order side: BUY|SELL|SELL_SHORT|BUY_BACK. Alias: trd_side")]
40 #[serde(alias = "trd_side")]
42 pub side: String,
43 #[schemars(
44 description = "Order type — accepts STRING enum OR INT (Trd_Common.OrderType): \
45 NORMAL=1 (limit) | MARKET=2 | ABSOLUTE_LIMIT=5 | AUCTION=6 | AUCTION_LIMIT=7 | SPECIAL_LIMIT=8 | SPECIAL_LIMIT_ALL=9 | \
46 STOP=10 (止损市价) | STOP_LIMIT=11 (止损限价) | MIT=12 (止盈触及市价) | LIT=13 (止盈触及限价) | TRAILING_STOP=14 (跟踪止损市价) | \
47 TRAILING_STOP_LIMIT=15 (跟踪止损限价) | TWAP_MARKET=16 | TWAP_LIMIT=17 | VWAP_MARKET=18 | VWAP_LIMIT=19. \
48 Values 16-19 are declared by the protocol but currently unsupported by PlaceOrder and are rejected locally; TWAP/VWAP algorithm orders are not implemented. \
49 条件单须搭配 `stop_price` / `trail_type` / `trail_value` / `trail_spread` 字段。alias: LIMIT → NORMAL."
50 )]
51 #[serde(
54 default = "default_order_type",
55 deserialize_with = "tool_enums::deser_order_type_as_string"
56 )]
57 pub order_type: String,
58 #[schemars(description = "Security code WITHOUT market prefix, e.g. 00700 / AAPL / 600519")]
59 pub code: String,
60 #[schemars(description = "Order quantity (shares / contracts)")]
61 pub qty: f64,
62 #[schemars(description = "Limit price (required for NORMAL; optional for MARKET)")]
63 pub price: Option<f64>,
64 #[schemars(
65 description = "Event Contract cash amount. Only valid for market=PREDICTION; the gateway derives effective quantity and rejects this field on non-Prediction markets."
66 )]
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub amount: Option<f64>,
69 #[schemars(
70 description = "Event Contract prediction side: 1=Yes, 2=No. Only valid for market=PREDICTION. Alias: predSide."
71 )]
72 #[serde(default, alias = "predSide", skip_serializing_if = "Option::is_none")]
73 pub pred_side: Option<i32>,
74 #[schemars(
75 description = "Optional order time-in-force: 0=DAY, 1=GTC, 2=IOC, 3=GTD. Alias: timeInForce."
76 )]
77 #[serde(
78 default,
79 alias = "timeInForce",
80 skip_serializing_if = "Option::is_none"
81 )]
82 pub time_in_force: Option<i32>,
83 #[schemars(
84 description = "US limit orders: allow pre-market / after-hours fills. Alias: fillOutsideRTH."
85 )]
86 #[serde(
87 default,
88 alias = "fillOutsideRTH",
89 skip_serializing_if = "Option::is_none"
90 )]
91 pub fill_outside_rth: Option<bool>,
92 #[schemars(description = "US order session: 0=NONE, 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT.")]
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub session: Option<i32>,
95 #[schemars(
96 description = "GTD expire date in YYYY-MM-DD, only used when time_in_force=3. Alias: expireTime."
97 )]
98 #[serde(default, alias = "expireTime", skip_serializing_if = "Option::is_none")]
99 pub expire_time: Option<String>,
100 #[schemars(
101 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Required by JP account backend paths when no position_id/order_id path supplies the sub-account context. Alias: jpAccType."
102 )]
103 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
104 pub jp_acc_type: Option<i32>,
105 #[schemars(
106 description = "Optional per-call API key override (plaintext). When set, this key is used for authorization and usage limits instead of the process-wide FUTU_MCP_API_KEY. Useful for multi-tenant scenarios where different calls should be billed or scoped to different keys."
107 )]
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub api_key: Option<String>,
110 #[schemars(
111 description = "Optional idempotency key. When set, retries with the same key within 90-second TTL return the cached response WITHOUT placing a duplicate order. Example: generate a UUID per logical order intent; if agent retry fires, pass the same key. Without this field, each call places a separate order."
112 )]
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub idempotency_key: Option<String>,
115 #[schemars(
117 description = "Stop / take-profit trigger price (aka aux_price). Required for STOP / STOP_LIMIT / MIT (market-if-touched) / LIT (limit-if-touched). For MIT/LIT it's the take-profit trigger."
118 )]
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub stop_price: Option<f64>,
121 #[schemars(
122 description = "Trailing stop type: 1=Ratio (percentage) / 2=Amount (absolute value). Only for TRAILING_STOP / TRAILING_STOP_LIMIT order types."
123 )]
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub trail_type: Option<i32>,
126 #[schemars(
127 description = "Trailing stop value: trail percentage (if trail_type=1) or amount (if trail_type=2)."
128 )]
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub trail_value: Option<f64>,
131 #[schemars(
132 description = "Trailing stop limit price spread for TRAILING_STOP_LIMIT (limit offset from trigger)."
133 )]
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub trail_spread: Option<f64>,
136}
137
138impl PlaceOrderReq {
139 pub fn validate(&self) -> Result<(), String> {
140 validate_positive_finite_f64("PlaceOrderReq", "qty", self.qty)?;
141 validate_optional_finite_f64("PlaceOrderReq", "price", self.price)?;
142 validate_optional_finite_f64("PlaceOrderReq", "amount", self.amount)?;
143 if let Some(tif) = self.time_in_force
144 && !matches!(tif, 0..=3)
145 {
146 return Err(format!(
147 "PlaceOrderReq.time_in_force invalid: {tif}; expect 0=DAY, 1=GTC, 2=IOC, 3=GTD"
148 ));
149 }
150 if let Some(session) = self.session
151 && !matches!(session, 0..=4)
152 {
153 return Err(format!(
154 "PlaceOrderReq.session invalid: {session}; expect 0=NONE, 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT"
155 ));
156 }
157 validate_optional_finite_f64("PlaceOrderReq", "stop_price", self.stop_price)?;
158 validate_optional_finite_f64("PlaceOrderReq", "trail_value", self.trail_value)?;
159 validate_optional_finite_f64("PlaceOrderReq", "trail_spread", self.trail_spread)?;
160 Ok(())
161 }
162}
163
164#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
165#[serde(deny_unknown_fields)]
166pub struct ModifyOrderReq {
167 #[schemars(
168 description = "Trade market — accepts STRING (HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA) OR INT (1=HK, 2=US, 3=CN, 4=HKCC, 5=Futures, 6=SG, 8=AU, 15=JP, 111=MY, 112=CA per Trd_Common.TrdMarket)."
169 )]
170 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
172 pub market: String,
173 #[schemars(
174 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required; alternatively pass `card_num`."
175 )]
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub acc_id: Option<u64>,
178 #[schemars(
179 description = "Card number (4-digit suffix or 16-digit full). See PlaceOrderReq.card_num for semantics."
180 )]
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub card_num: Option<String>,
183 #[schemars(
184 description = "Trade environment: real|simulate (default simulate); alias: trd_env"
185 )]
186 #[serde(default = "default_env_simulate", alias = "trd_env")]
188 pub env: String,
189 #[schemars(
190 description = "Order ID to modify. Accepts numeric orderID (integer or integer string) OR backend orderIDEx string such as FU.../FH...; string recommended for JS clients since u64 > 2^53 loses precision as JSON number."
191 )]
192 #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
194 pub order_id: String,
195 #[schemars(
196 description = "Modify op: NORMAL (change qty/price) | CANCEL | DISABLE | ENABLE | DELETE"
197 )]
198 #[serde(default = "default_modify_op")]
199 pub op: String,
200 #[schemars(description = "New quantity (for NORMAL op)")]
201 pub qty: Option<f64>,
202 #[schemars(description = "New price (for NORMAL op)")]
203 pub price: Option<f64>,
204 #[schemars(
205 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
206 )]
207 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
208 pub jp_acc_type: Option<i32>,
209 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub api_key: Option<String>,
212 #[schemars(
213 description = "Optional idempotency key (90s TTL). See PlaceOrderReq.idempotency_key."
214 )]
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub idempotency_key: Option<String>,
217}
218
219impl ModifyOrderReq {
220 pub fn validate(&self) -> Result<(), String> {
221 if let Some(qty) = self.qty {
222 validate_non_negative_finite_f64("ModifyOrderReq", "qty", qty)?;
223 }
224 validate_optional_finite_f64("ModifyOrderReq", "price", self.price)?;
225 Ok(())
226 }
227}
228
229#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
230#[serde(deny_unknown_fields)]
231pub struct CancelOrderReq {
232 #[schemars(
233 description = "Trade market — accepts STRING (HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA) OR INT (1=HK, 2=US, 3=CN, 4=HKCC, 5=Futures, 6=SG, 8=AU, 15=JP, 111=MY, 112=CA per Trd_Common.TrdMarket)."
234 )]
235 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
237 pub market: String,
238 #[schemars(
239 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required; alternatively pass `card_num`."
240 )]
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub acc_id: Option<u64>,
243 #[schemars(
244 description = "Card number (4-digit suffix or 16-digit full). See PlaceOrderReq.card_num for semantics."
245 )]
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub card_num: Option<String>,
248 #[schemars(
249 description = "Trade environment: real|simulate (default simulate); alias: trd_env"
250 )]
251 #[serde(default = "default_env_simulate", alias = "trd_env")]
253 pub env: String,
254 #[schemars(
255 description = "Order ID to cancel. Accepts numeric orderID (integer or integer string) OR backend orderIDEx string such as FU.../FH...; string recommended for JS clients since u64 > 2^53 loses precision as JSON number."
256 )]
257 #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
259 pub order_id: String,
260 #[schemars(
261 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
262 )]
263 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
264 pub jp_acc_type: Option<i32>,
265 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub api_key: Option<String>,
268 #[schemars(
269 description = "Optional idempotency key (90s TTL). See PlaceOrderReq.idempotency_key."
270 )]
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub idempotency_key: Option<String>,
273}
274
275#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
276#[serde(deny_unknown_fields)]
277pub struct ReconfirmOrderReq {
278 #[schemars(
279 description = "Trade market — accepts STRING (HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA) OR INT (1=HK, 2=US, 3=CN, 4=HKCC, 5=Futures, 6=SG, 8=AU, 15=JP, 111=MY, 112=CA per Trd_Common.TrdMarket)."
280 )]
281 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
282 pub market: String,
283 #[schemars(
284 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required."
285 )]
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub acc_id: Option<u64>,
288 #[schemars(
289 description = "Card number (4-digit suffix or 16-digit full). Either `acc_id` OR `card_num` is required."
290 )]
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub card_num: Option<String>,
293 #[schemars(
294 description = "Trade environment: real|simulate (default simulate); alias: trd_env"
295 )]
296 #[serde(default = "default_env_simulate", alias = "trd_env")]
297 pub env: String,
298 #[schemars(
299 description = "FTAPI numeric order_id to reconfirm. Accepts JSON number or integer string; orderIDEx strings are not supported by Trd_ReconfirmOrder."
300 )]
301 #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
302 pub order_id: String,
303 #[schemars(description = "Reconfirm reason int per Trd_Common.ReconfirmOrderReason.")]
304 pub reason: i32,
305 #[schemars(
306 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
307 )]
308 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
309 pub jp_acc_type: Option<i32>,
310 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub api_key: Option<String>,
313}
314
315#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
316#[serde(deny_unknown_fields)]
317pub struct ComboOrderProtoJsonReq {
318 #[schemars(
319 description = "Official Trd_PlaceComboOrder.C2S JSON. Field names use generated proto serde snake_case. `packet_id` may be omitted; daemon fills it before forwarding."
320 )]
321 pub c2s_json: String,
322
323 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub api_key: Option<String>,
326
327 #[schemars(
328 description = "Optional idempotency key. When set, retries with the same key derive the same PacketId and hit daemon replay guard instead of placing a duplicate combo order."
329 )]
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub idempotency_key: Option<String>,
332}
333
334fn validate_positive_finite_f64(
335 request_name: &str,
336 field_name: &str,
337 value: f64,
338) -> Result<(), String> {
339 if !value.is_finite() || value <= 0.0 {
340 return Err(format!(
341 "{request_name}: `{field_name}` must be a finite number > 0"
342 ));
343 }
344 Ok(())
345}
346
347fn validate_non_negative_finite_f64(
348 request_name: &str,
349 field_name: &str,
350 value: f64,
351) -> Result<(), String> {
352 if !value.is_finite() || value < 0.0 {
353 return Err(format!(
354 "{request_name}: `{field_name}` must be a finite number >= 0"
355 ));
356 }
357 Ok(())
358}
359
360fn validate_optional_finite_f64(
361 request_name: &str,
362 field_name: &str,
363 value: Option<f64>,
364) -> Result<(), String> {
365 if let Some(v) = value
366 && !v.is_finite()
367 {
368 return Err(format!("{request_name}: `{field_name}` must be finite"));
369 }
370 Ok(())
371}