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 | MOC=20 (收盘市价单). \
48 Algorithm values are rejected by ordinary PlaceOrder; use the typed futu_place_algo_order/futu_modify_algo_order tools (daemon --enable-algo-orders, default OFF). \
49 MOC is limited to real US common stock, accepts no price/stop_price, requires idempotency_key, and the daemon must start with --enable-moc-order. \
50 条件单须搭配 `stop_price` / `trail_type` / `trail_value` / `trail_spread` 字段。alias: LIMIT → NORMAL."
51 )]
52 #[serde(
55 default = "default_order_type",
56 deserialize_with = "tool_enums::deser_order_type_as_string"
57 )]
58 pub order_type: String,
59 #[schemars(description = "Security code WITHOUT market prefix, e.g. 00700 / AAPL / 600519")]
60 pub code: String,
61 #[schemars(description = "Order quantity (shares / contracts)")]
62 pub qty: f64,
63 #[schemars(
64 description = "Limit price (required for NORMAL; optional for MARKET; forbidden for MOC)"
65 )]
66 pub price: Option<f64>,
67 #[schemars(
68 description = "Event Contract cash amount. Only valid for market=PREDICTION; the gateway derives effective quantity and rejects this field on non-Prediction markets."
69 )]
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub amount: Option<f64>,
72 #[schemars(
73 description = "Event Contract prediction side: 1=Yes, 2=No. Only valid for market=PREDICTION. Alias: predSide."
74 )]
75 #[serde(default, alias = "predSide", skip_serializing_if = "Option::is_none")]
76 pub pred_side: Option<i32>,
77 #[schemars(
78 description = "Optional order time-in-force: 0=DAY, 1=GTC, 2=IOC, 3=GTD. Alias: timeInForce."
79 )]
80 #[serde(
81 default,
82 alias = "timeInForce",
83 skip_serializing_if = "Option::is_none"
84 )]
85 pub time_in_force: Option<i32>,
86 #[schemars(
87 description = "US limit orders: allow pre-market / after-hours fills. Alias: fillOutsideRTH."
88 )]
89 #[serde(
90 default,
91 alias = "fillOutsideRTH",
92 skip_serializing_if = "Option::is_none"
93 )]
94 pub fill_outside_rth: Option<bool>,
95 #[schemars(description = "US order session: 0=NONE, 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT.")]
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub session: Option<i32>,
98 #[schemars(
99 description = "GTD expire date in YYYY-MM-DD, only used when time_in_force=3. Alias: expireTime."
100 )]
101 #[serde(default, alias = "expireTime", skip_serializing_if = "Option::is_none")]
102 pub expire_time: Option<String>,
103 #[schemars(
104 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."
105 )]
106 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
107 pub jp_acc_type: Option<i32>,
108 #[schemars(
109 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."
110 )]
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub api_key: Option<String>,
113 #[schemars(
114 description = "Optional for legacy order types, REQUIRED for MOC. Retries with the same key return the cached or durably reconciled result WITHOUT placing a duplicate order. Example: generate one UUID per logical order intent and reuse it for retries."
115 )]
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub idempotency_key: Option<String>,
118 #[schemars(
120 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."
121 )]
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub stop_price: Option<f64>,
124 #[schemars(
125 description = "Trailing stop type: 1=Ratio (percentage) / 2=Amount (absolute value). Only for TRAILING_STOP / TRAILING_STOP_LIMIT order types."
126 )]
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub trail_type: Option<i32>,
129 #[schemars(
130 description = "Trailing stop value: trail percentage (if trail_type=1) or amount (if trail_type=2)."
131 )]
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub trail_value: Option<f64>,
134 #[schemars(
135 description = "Trailing stop limit price spread for TRAILING_STOP_LIMIT (limit offset from trigger)."
136 )]
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub trail_spread: Option<f64>,
139}
140
141impl PlaceOrderReq {
142 pub fn validate(&self) -> Result<(), String> {
143 validate_positive_finite_f64("PlaceOrderReq", "qty", self.qty)?;
144 validate_optional_finite_f64("PlaceOrderReq", "price", self.price)?;
145 validate_optional_finite_f64("PlaceOrderReq", "amount", self.amount)?;
146 if let Some(tif) = self.time_in_force
147 && !matches!(tif, 0..=3)
148 {
149 return Err(format!(
150 "PlaceOrderReq.time_in_force invalid: {tif}; expect 0=DAY, 1=GTC, 2=IOC, 3=GTD"
151 ));
152 }
153 if let Some(session) = self.session
154 && !matches!(session, 0..=4)
155 {
156 return Err(format!(
157 "PlaceOrderReq.session invalid: {session}; expect 0=NONE, 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT"
158 ));
159 }
160 validate_optional_finite_f64("PlaceOrderReq", "stop_price", self.stop_price)?;
161 validate_optional_finite_f64("PlaceOrderReq", "trail_value", self.trail_value)?;
162 validate_optional_finite_f64("PlaceOrderReq", "trail_spread", self.trail_spread)?;
163 if self.order_type.eq_ignore_ascii_case("MOC") {
164 if self.price.is_some() || self.stop_price.is_some() {
165 return Err("PlaceOrderReq MOC does not accept price or stop_price".to_string());
166 }
167 if self
168 .idempotency_key
169 .as_deref()
170 .map(str::trim)
171 .filter(|key| !key.is_empty())
172 .is_none()
173 {
174 return Err("PlaceOrderReq MOC requires a non-empty idempotency_key".to_string());
175 }
176 }
177 Ok(())
178 }
179}
180
181#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
182#[serde(deny_unknown_fields)]
183pub struct ModifyOrderReq {
184 #[schemars(
185 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)."
186 )]
187 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
189 pub market: String,
190 #[schemars(
191 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required; alternatively pass `card_num`."
192 )]
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub acc_id: Option<u64>,
195 #[schemars(
196 description = "Card number (4-digit suffix or 16-digit full). See PlaceOrderReq.card_num for semantics."
197 )]
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub card_num: Option<String>,
200 #[schemars(
201 description = "Trade environment: real|simulate (default simulate); alias: trd_env"
202 )]
203 #[serde(default = "default_env_simulate", alias = "trd_env")]
205 pub env: String,
206 #[schemars(
207 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."
208 )]
209 #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
211 pub order_id: String,
212 #[schemars(
213 description = "Modify op: NORMAL (change qty/price) | CANCEL | DISABLE | ENABLE | DELETE"
214 )]
215 #[serde(default = "default_modify_op")]
216 pub op: String,
217 #[schemars(description = "New quantity (for NORMAL op)")]
218 pub qty: Option<f64>,
219 #[schemars(description = "New price (for NORMAL op)")]
220 pub price: Option<f64>,
221 #[schemars(
222 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
223 )]
224 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
225 pub jp_acc_type: Option<i32>,
226 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub api_key: Option<String>,
229 #[schemars(
230 description = "Optional idempotency key (90s TTL). See PlaceOrderReq.idempotency_key."
231 )]
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub idempotency_key: Option<String>,
234}
235
236impl ModifyOrderReq {
237 pub fn validate(&self) -> Result<(), String> {
238 if let Some(qty) = self.qty {
239 validate_non_negative_finite_f64("ModifyOrderReq", "qty", qty)?;
240 }
241 validate_optional_finite_f64("ModifyOrderReq", "price", self.price)?;
242 Ok(())
243 }
244}
245
246#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
247#[serde(deny_unknown_fields)]
248pub struct CancelOrderReq {
249 #[schemars(
250 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)."
251 )]
252 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
254 pub market: String,
255 #[schemars(
256 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required; alternatively pass `card_num`."
257 )]
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub acc_id: Option<u64>,
260 #[schemars(
261 description = "Card number (4-digit suffix or 16-digit full). See PlaceOrderReq.card_num for semantics."
262 )]
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub card_num: Option<String>,
265 #[schemars(
266 description = "Trade environment: real|simulate (default simulate); alias: trd_env"
267 )]
268 #[serde(default = "default_env_simulate", alias = "trd_env")]
270 pub env: String,
271 #[schemars(
272 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."
273 )]
274 #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
276 pub order_id: String,
277 #[schemars(
278 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
279 )]
280 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
281 pub jp_acc_type: Option<i32>,
282 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub api_key: Option<String>,
285 #[schemars(
286 description = "Optional idempotency key (90s TTL). See PlaceOrderReq.idempotency_key."
287 )]
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub idempotency_key: Option<String>,
290}
291
292#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
293#[serde(deny_unknown_fields)]
294pub struct ReconfirmOrderReq {
295 #[schemars(
296 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)."
297 )]
298 #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
299 pub market: String,
300 #[schemars(
301 description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required."
302 )]
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub acc_id: Option<u64>,
305 #[schemars(
306 description = "Card number (4-digit suffix or 16-digit full). Either `acc_id` OR `card_num` is required."
307 )]
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub card_num: Option<String>,
310 #[schemars(
311 description = "Trade environment: real|simulate (default simulate); alias: trd_env"
312 )]
313 #[serde(default = "default_env_simulate", alias = "trd_env")]
314 pub env: String,
315 #[schemars(
316 description = "FTAPI numeric order_id to reconfirm. Accepts JSON number or integer string; orderIDEx strings are not supported by Trd_ReconfirmOrder."
317 )]
318 #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
319 pub order_id: String,
320 #[schemars(description = "Reconfirm reason int per Trd_Common.ReconfirmOrderReason.")]
321 pub reason: i32,
322 #[schemars(
323 description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
324 )]
325 #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
326 pub jp_acc_type: Option<i32>,
327 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub api_key: Option<String>,
330}
331
332#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
333#[serde(deny_unknown_fields)]
334pub struct ComboOrderProtoJsonReq {
335 #[schemars(
336 description = "Official Trd_PlaceComboOrder.C2S JSON. Field names use generated proto serde snake_case. `packet_id` may be omitted; daemon fills it before forwarding."
337 )]
338 pub c2s_json: String,
339
340 #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub api_key: Option<String>,
343
344 #[schemars(
345 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."
346 )]
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub idempotency_key: Option<String>,
349}
350
351fn validate_positive_finite_f64(
352 request_name: &str,
353 field_name: &str,
354 value: f64,
355) -> Result<(), String> {
356 if !value.is_finite() || value <= 0.0 {
357 return Err(format!(
358 "{request_name}: `{field_name}` must be a finite number > 0"
359 ));
360 }
361 Ok(())
362}
363
364fn validate_non_negative_finite_f64(
365 request_name: &str,
366 field_name: &str,
367 value: f64,
368) -> Result<(), String> {
369 if !value.is_finite() || value < 0.0 {
370 return Err(format!(
371 "{request_name}: `{field_name}` must be a finite number >= 0"
372 ));
373 }
374 Ok(())
375}
376
377fn validate_optional_finite_f64(
378 request_name: &str,
379 field_name: &str,
380 value: Option<f64>,
381) -> Result<(), String> {
382 if let Some(v) = value
383 && !v.is_finite()
384 {
385 return Err(format!("{request_name}: `{field_name}` must be finite"));
386 }
387 Ok(())
388}