Skip to main content

futu_mcp/tool_args/trd/
write.rs

1//! MCP trade write request schemas split from `tool_args/trd.rs`.
2
3use 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    // v1.4.90 P0-E: int OR string 双接, normalize 到大写 canonical
19    #[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    // v1.4.105 D12: acc_id 改 default=0, 让 user 可改传 card_num. handler 端
25    // 如果 acc_id=0 且 card_num=None 仍 reject (二选一必填).
26    #[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    // v1.4.83 §5 Phase 3: trd_env alias 对齐 py-futu-api TrdEnv
37    #[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    // v1.4.83 §5 Phase 3: trd_side alias 对齐 py-futu-api TrdSide
41    #[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. 条件单须搭配 `stop_price` / `trail_type` / `trail_value` / `trail_spread` 字段。alias: LIMIT → NORMAL."
48    )]
49    // v1.4.90 P0-E: int OR string 双接, normalize 到 canonical proto string
50    // (NORMAL/STOP/MIT/...). 老 6 variant alias 保留 backward-compat.
51    #[serde(
52        default = "default_order_type",
53        deserialize_with = "tool_enums::deser_order_type_as_string"
54    )]
55    pub order_type: String,
56    #[schemars(description = "Security code WITHOUT market prefix, e.g. 00700 / AAPL / 600519")]
57    pub code: String,
58    #[schemars(description = "Order quantity (shares / contracts)")]
59    pub qty: f64,
60    #[schemars(description = "Limit price (required for NORMAL; optional for MARKET)")]
61    pub price: Option<f64>,
62    #[schemars(
63        description = "Optional order time-in-force: 0=DAY, 1=GTC, 2=IOC, 3=GTD. Alias: timeInForce."
64    )]
65    #[serde(
66        default,
67        alias = "timeInForce",
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub time_in_force: Option<i32>,
71    #[schemars(
72        description = "US limit orders: allow pre-market / after-hours fills. Alias: fillOutsideRTH."
73    )]
74    #[serde(
75        default,
76        alias = "fillOutsideRTH",
77        skip_serializing_if = "Option::is_none"
78    )]
79    pub fill_outside_rth: Option<bool>,
80    #[schemars(description = "US order session: 0=NONE, 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT.")]
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub session: Option<i32>,
83    #[schemars(
84        description = "GTD expire date in YYYY-MM-DD, only used when time_in_force=3. Alias: expireTime."
85    )]
86    #[serde(default, alias = "expireTime", skip_serializing_if = "Option::is_none")]
87    pub expire_time: Option<String>,
88    #[schemars(
89        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."
90    )]
91    #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
92    pub jp_acc_type: Option<i32>,
93    #[schemars(
94        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."
95    )]
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub api_key: Option<String>,
98    #[schemars(
99        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."
100    )]
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub idempotency_key: Option<String>,
103    // ===== v1.4.53 F1 条件单字段 =====
104    #[schemars(
105        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."
106    )]
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub stop_price: Option<f64>,
109    #[schemars(
110        description = "Trailing stop type: 1=Ratio (percentage) / 2=Amount (absolute value). Only for TRAILING_STOP / TRAILING_STOP_LIMIT order types."
111    )]
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub trail_type: Option<i32>,
114    #[schemars(
115        description = "Trailing stop value: trail percentage (if trail_type=1) or amount (if trail_type=2)."
116    )]
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub trail_value: Option<f64>,
119    #[schemars(
120        description = "Trailing stop limit price spread for TRAILING_STOP_LIMIT (limit offset from trigger)."
121    )]
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub trail_spread: Option<f64>,
124}
125
126impl PlaceOrderReq {
127    pub fn validate(&self) -> Result<(), String> {
128        validate_positive_finite_f64("PlaceOrderReq", "qty", self.qty)?;
129        validate_optional_finite_f64("PlaceOrderReq", "price", self.price)?;
130        if let Some(tif) = self.time_in_force
131            && !matches!(tif, 0..=3)
132        {
133            return Err(format!(
134                "PlaceOrderReq.time_in_force invalid: {tif}; expect 0=DAY, 1=GTC, 2=IOC, 3=GTD"
135            ));
136        }
137        if let Some(session) = self.session
138            && !matches!(session, 0..=4)
139        {
140            return Err(format!(
141                "PlaceOrderReq.session invalid: {session}; expect 0=NONE, 1=RTH, 2=ETH, 3=ALL, 4=OVERNIGHT"
142            ));
143        }
144        validate_optional_finite_f64("PlaceOrderReq", "stop_price", self.stop_price)?;
145        validate_optional_finite_f64("PlaceOrderReq", "trail_value", self.trail_value)?;
146        validate_optional_finite_f64("PlaceOrderReq", "trail_spread", self.trail_spread)?;
147        Ok(())
148    }
149}
150
151#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
152#[serde(deny_unknown_fields)]
153pub struct ModifyOrderReq {
154    #[schemars(
155        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)."
156    )]
157    // v1.4.90 P0-E: int OR string 双接, normalize 到大写 canonical
158    #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
159    pub market: String,
160    #[schemars(
161        description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required; alternatively pass `card_num`."
162    )]
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub acc_id: Option<u64>,
165    #[schemars(
166        description = "Card number (4-digit suffix or 16-digit full). See PlaceOrderReq.card_num for semantics."
167    )]
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub card_num: Option<String>,
170    #[schemars(
171        description = "Trade environment: real|simulate (default simulate); alias: trd_env"
172    )]
173    // v1.4.83 §5 Phase 3
174    #[serde(default = "default_env_simulate", alias = "trd_env")]
175    pub env: String,
176    #[schemars(
177        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."
178    )]
179    // v1.4.110: 双接 numeric orderID + FU/FH orderIDEx.
180    #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
181    pub order_id: String,
182    #[schemars(
183        description = "Modify op: NORMAL (change qty/price) | CANCEL | DISABLE | ENABLE | DELETE"
184    )]
185    #[serde(default = "default_modify_op")]
186    pub op: String,
187    #[schemars(description = "New quantity (for NORMAL op)")]
188    pub qty: Option<f64>,
189    #[schemars(description = "New price (for NORMAL op)")]
190    pub price: Option<f64>,
191    #[schemars(
192        description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
193    )]
194    #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
195    pub jp_acc_type: Option<i32>,
196    #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub api_key: Option<String>,
199    #[schemars(
200        description = "Optional idempotency key (90s TTL). See PlaceOrderReq.idempotency_key."
201    )]
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub idempotency_key: Option<String>,
204}
205
206impl ModifyOrderReq {
207    pub fn validate(&self) -> Result<(), String> {
208        if let Some(qty) = self.qty {
209            validate_non_negative_finite_f64("ModifyOrderReq", "qty", qty)?;
210        }
211        validate_optional_finite_f64("ModifyOrderReq", "price", self.price)?;
212        Ok(())
213    }
214}
215
216#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
217#[serde(deny_unknown_fields)]
218pub struct CancelOrderReq {
219    #[schemars(
220        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)."
221    )]
222    // v1.4.90 P0-E: int OR string 双接, normalize 到大写 canonical
223    #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
224    pub market: String,
225    #[schemars(
226        description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required; alternatively pass `card_num`."
227    )]
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub acc_id: Option<u64>,
230    #[schemars(
231        description = "Card number (4-digit suffix or 16-digit full). See PlaceOrderReq.card_num for semantics."
232    )]
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub card_num: Option<String>,
235    #[schemars(
236        description = "Trade environment: real|simulate (default simulate); alias: trd_env"
237    )]
238    // v1.4.83 §5 Phase 3
239    #[serde(default = "default_env_simulate", alias = "trd_env")]
240    pub env: String,
241    #[schemars(
242        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."
243    )]
244    // v1.4.110: 双接 numeric orderID + FU/FH orderIDEx.
245    #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
246    pub order_id: String,
247    #[schemars(
248        description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
249    )]
250    #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
251    pub jp_acc_type: Option<i32>,
252    #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub api_key: Option<String>,
255    #[schemars(
256        description = "Optional idempotency key (90s TTL). See PlaceOrderReq.idempotency_key."
257    )]
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub idempotency_key: Option<String>,
260}
261
262#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
263#[serde(deny_unknown_fields)]
264pub struct ReconfirmOrderReq {
265    #[schemars(
266        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)."
267    )]
268    #[serde(deserialize_with = "tool_enums::deser_trd_market_as_string")]
269    pub market: String,
270    #[schemars(
271        description = "Trading account ID (u64). Either `acc_id` OR `card_num` is required."
272    )]
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub acc_id: Option<u64>,
275    #[schemars(
276        description = "Card number (4-digit suffix or 16-digit full). Either `acc_id` OR `card_num` is required."
277    )]
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub card_num: Option<String>,
280    #[schemars(
281        description = "Trade environment: real|simulate (default simulate); alias: trd_env"
282    )]
283    #[serde(default = "default_env_simulate", alias = "trd_env")]
284    pub env: String,
285    #[schemars(
286        description = "FTAPI numeric order_id to reconfirm. Accepts JSON number or integer string; orderIDEx strings are not supported by Trd_ReconfirmOrder."
287    )]
288    #[serde(deserialize_with = "deser_order_id_raw_from_int_or_str")]
289    pub order_id: String,
290    #[schemars(description = "Reconfirm reason int per Trd_Common.ReconfirmOrderReason.")]
291    pub reason: i32,
292    #[schemars(
293        description = "JP sub-account type (Trd_Common.TrdSubAccType / TrdHeader.jpAccType). Alias: jpAccType."
294    )]
295    #[serde(default, alias = "jpAccType", skip_serializing_if = "Option::is_none")]
296    pub jp_acc_type: Option<i32>,
297    #[schemars(description = "Optional per-call API key override. See PlaceOrderReq.api_key.")]
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub api_key: Option<String>,
300}
301
302#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
303#[serde(deny_unknown_fields)]
304pub struct ComboOrderProtoJsonReq {
305    #[schemars(
306        description = "Official Trd_PlaceComboOrder.C2S JSON. Field names use generated proto serde snake_case. `packet_id` may be omitted; daemon fills it before forwarding."
307    )]
308    pub c2s_json: String,
309
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    #[schemars(
315        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."
316    )]
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub idempotency_key: Option<String>,
319}
320
321fn validate_positive_finite_f64(
322    request_name: &str,
323    field_name: &str,
324    value: f64,
325) -> Result<(), String> {
326    if !value.is_finite() || value <= 0.0 {
327        return Err(format!(
328            "{request_name}: `{field_name}` must be a finite number > 0"
329        ));
330    }
331    Ok(())
332}
333
334fn validate_non_negative_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_optional_finite_f64(
348    request_name: &str,
349    field_name: &str,
350    value: Option<f64>,
351) -> Result<(), String> {
352    if let Some(v) = value
353        && !v.is_finite()
354    {
355        return Err(format!("{request_name}: `{field_name}` must be finite"));
356    }
357    Ok(())
358}