Skip to main content

futu_trd/
order.rs

1use std::sync::atomic::{AtomicU32, Ordering};
2
3use futu_core::error::{FutuError, Result};
4use futu_core::proto_id;
5use futu_net::client::FutuClient;
6
7use crate::types::{ModifyOrderOp, OrderType};
8use crate::types::{
9    ModifyOrderParams, PlaceOrderOptions, PlaceOrderParams, PlaceOrderResult,
10    PlaceOrderResultWithIdentity,
11};
12
13/// 全局唯一的 packet ID 生成器(防重放攻击)
14static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
15
16fn client_conn_id(client: &FutuClient) -> Result<u64> {
17    client.conn_id().ok_or(FutuError::NotInitialized)
18}
19
20fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
21    let serial = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
22    futu_proto::common::PacketId {
23        // Echo InitConnect S2C connID like C++ FTAPI clients. The gateway
24        // replay guard checks this against the actual TCP connection id.
25        conn_id,
26        serial_no: serial,
27    }
28}
29
30/// v1.4.39 (external reviewer exhaustive report 修): 把幂等键映射到 `Common.PacketID`,让 daemon 端
31/// 的 packet_id fallback(`idempotency.rs` 90s TTL cache)能识别"同一键 = 同一请求"。
32///
33/// **设计**:conn_id = u64 hash(key),serial_no = 0 固定。daemon 端把 packet_id
34/// 格式化为 `"tcp-pkt-{conn_id}-{serial_no}"`,所以不同 key → 不同 conn_id → 不同
35/// cache entry;相同 key → 相同 conn_id → 命中 cache。
36fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
37    use std::collections::hash_map::DefaultHasher;
38    use std::hash::{Hash, Hasher};
39    let mut hasher = DefaultHasher::new();
40    key.hash(&mut hasher);
41    futu_proto::common::PacketId {
42        conn_id: hasher.finish(),
43        serial_no: 0,
44    }
45}
46
47/// 下单
48///
49/// 向 FutuOpenD 发送下单请求。
50/// 注意:需要先解锁交易 (`unlock_trade`)。
51/// v1.4.48 #8 修(external reviewer 验收报告 §9 Test 2):客户端侧(futucli → daemon / futucli → C++ OpenD)
52/// 的 `Trd_PlaceOrder.C2S.sec_market` 之前硬编码 `None`,external reviewer wire-level A/B 抓包
53/// 证伪"proto 里有就自动填"—— C++ OpenD 直接拒 `missing Transaction Securities
54/// Market`。
55///
56/// 此 helper 复用 daemon 端同一个 canonical `futu_core::trade_security`
57/// 派生规则。对齐 `Trd_Common.TrdSecMarket` enum:HK=1 / US=2 /
58/// CN_SH=31 / CN_SZ=32 / SG=41 / JP=51 / AU=61 / MY=71 / CA=81 /
59/// CC=101。
60///
61/// 规则:
62/// 1. 显式 code prefix / 期货 ticker 先于 SDK market metadata;
63/// 2. 再按 trd_market 推导(7=Crypto → 101);
64/// 3. 无法推导时返回 0 (Unknown)。
65fn derive_sec_market_client(trd_market: i32, code: &str) -> i32 {
66    futu_core::trade_security::derive_trd_sec_market_like_cpp(
67        futu_core::trade_security::TrdSecMarketInput {
68            ftapi_sec_market: 0,
69            trd_market,
70            code,
71        },
72    )
73}
74
75fn parse_place_order_response_body(body: &[u8]) -> Result<PlaceOrderResultWithIdentity> {
76    let resp: futu_proto::trd_place_order::Response =
77        prost::Message::decode(body).map_err(FutuError::Proto)?;
78
79    if resp.ret_type != 0 {
80        return Err(crate::server_err(
81            resp.ret_type,
82            resp.ret_msg,
83            resp.err_code,
84        ));
85    }
86
87    let s2c = resp
88        .s2c
89        .ok_or(FutuError::Codec("missing s2c in PlaceOrder".into()))?;
90
91    let order_id = s2c.order_id.ok_or_else(|| {
92        // Ref: APIServer_Trd_PlaceOrder.cpp:856-864 sets orderID before success.
93        FutuError::Codec("missing orderID in successful PlaceOrder response".into())
94    })?;
95    let order_id_ex = s2c
96        .order_id_ex
97        .filter(|value| !value.is_empty())
98        .ok_or_else(|| {
99            // Ref: APIServer_Trd_PlaceOrder.cpp:1034-1053 sets both orderID
100            // and orderIDEx for every successful response.
101            FutuError::Codec("missing orderIDEx in successful PlaceOrder response".into())
102        })?;
103
104    Ok(PlaceOrderResultWithIdentity {
105        order_id,
106        order_id_ex,
107    })
108}
109
110fn request_validation_error(msg: impl Into<String>) -> FutuError {
111    FutuError::ServerError {
112        ret_type: -1,
113        msg: msg.into(),
114    }
115}
116
117fn ensure_positive_finite(value: f64, field: &'static str) -> Result<()> {
118    if value.is_finite() && value > 0.0 {
119        Ok(())
120    } else {
121        Err(request_validation_error(format!(
122            "{field} must be finite and > 0 (C++ APIServer trade request validation)"
123        )))
124    }
125}
126
127fn order_type_requires_price(order_type: OrderType) -> bool {
128    matches!(
129        order_type,
130        OrderType::Normal
131            | OrderType::AbsoluteLimit
132            | OrderType::AuctionLimit
133            | OrderType::SpecialLimit
134            | OrderType::SpecialLimitAll
135            | OrderType::StopLimit
136            | OrderType::LimitifTouched
137            | OrderType::TrailingStopLimit
138            | OrderType::TwapLimit
139            | OrderType::VwapLimit
140    )
141}
142
143fn validate_optional_positive_price(price: Option<f64>, field: &'static str) -> Result<()> {
144    if let Some(price) = price {
145        ensure_positive_finite(price, field)?;
146    }
147    Ok(())
148}
149
150fn validate_place_order_params(params: &PlaceOrderParams) -> Result<()> {
151    // Ref: APIServer_Trd_PlaceOrder.cpp:226/244/251/258 and
152    // _APIServer_Trd_Comm.cpp:1121-1163. C++ rejects non-positive qty before
153    // building backend OrderNewReq; do the same at SDK layer so all surfaces
154    // (REST/MCP/CLI/gRPC/raw TCP) share one fail-closed boundary.
155    ensure_positive_finite(params.qty, "place_order.qty")?;
156
157    if params.order_type == OrderType::Moc {
158        if params.price.is_some() || params.aux_price.is_some() {
159            return Err(request_validation_error(
160                "place_order MOC does not accept price or aux_price",
161            ));
162        }
163        if params
164            .idempotency_key
165            .as_deref()
166            .map(str::trim)
167            .filter(|key| !key.is_empty())
168            .is_none()
169        {
170            return Err(request_validation_error(
171                "place_order MOC requires a non-empty idempotency_key",
172            ));
173        }
174    }
175
176    if order_type_requires_price(params.order_type) {
177        let price = params.price.ok_or_else(|| {
178            request_validation_error("place_order.price is required for price-based order types")
179        })?;
180        ensure_positive_finite(price, "place_order.price")?;
181    } else {
182        validate_optional_positive_price(params.price, "place_order.price")?;
183    }
184
185    validate_optional_positive_price(params.aux_price, "place_order.aux_price")?;
186    Ok(())
187}
188
189fn validate_modify_order_params(params: &ModifyOrderParams) -> Result<()> {
190    if params.modify_order_op == ModifyOrderOp::Normal {
191        let qty = params.qty.ok_or_else(|| {
192            request_validation_error("modify_order.qty is required for normal modify")
193        })?;
194        ensure_positive_finite(qty, "modify_order.qty")?;
195        validate_optional_positive_price(params.price, "modify_order.price")?;
196    }
197    Ok(())
198}
199
200fn build_place_order_request(
201    packet_id: futu_proto::common::PacketId,
202    params: &PlaceOrderParams,
203    options: &PlaceOrderOptions,
204) -> futu_proto::trd_place_order::Request {
205    futu_proto::trd_place_order::Request {
206        c2s: futu_proto::trd_place_order::C2s {
207            packet_id,
208            header: params.header.to_proto(),
209            trd_side: params.trd_side as i32,
210            order_type: params.order_type as i32,
211            code: params.code.clone(),
212            qty: params.qty,
213            price: params.price,
214            adjust_price: params.adjust_price,
215            adjust_side_and_limit: params.adjust_side_and_limit,
216            sec_market: Some(derive_sec_market_client(
217                params.header.trd_market as i32,
218                &params.code,
219            )),
220            remark: None,
221            time_in_force: options.time_in_force,
222            fill_outside_rth: options.fill_outside_rth,
223            // v1.4.53 F1 条件单:透传 aux_price / trail_* 到 FTAPI
224            aux_price: params.aux_price,
225            trail_type: params.trail_type,
226            trail_value: params.trail_value,
227            trail_spread: params.trail_spread,
228            session: options.session,
229            position_id: None,
230            expire_time: options.expire_time.clone(),
231            amount: options.amount,
232            pred_side: options.pred_side,
233            extension_version: (params.order_type == OrderType::Moc).then_some(1),
234        },
235    }
236}
237
238pub async fn place_order(
239    client: &FutuClient,
240    params: &PlaceOrderParams,
241) -> Result<PlaceOrderResult> {
242    place_order_with_options(client, params, &PlaceOrderOptions::default()).await
243}
244
245/// 下单并返回 numeric `orderID` 与无损 `orderIDEx`。
246///
247/// 参照版本在成功响应中同时返回二者。新调用者需要把订单身份跨 JSON、脚本或
248/// modify/cancel 流程传递时应使用本函数;既有 [`place_order`] 保持原返回类型。
249pub async fn place_order_with_identity(
250    client: &FutuClient,
251    params: &PlaceOrderParams,
252) -> Result<PlaceOrderResultWithIdentity> {
253    place_order_with_options_and_identity(client, params, &PlaceOrderOptions::default()).await
254}
255
256/// 下单(带官方 FTAPI optional 字段)。
257///
258/// `PlaceOrderOptions` 只透传 `Trd_PlaceOrder.C2S` 已定义字段,不改变 gateway
259/// 的 C++ 对齐校验。普通用户继续用 [`place_order`];需要美股盘前/盘后、GTD
260/// 等语义时调用本函数。
261pub async fn place_order_with_options(
262    client: &FutuClient,
263    params: &PlaceOrderParams,
264    options: &PlaceOrderOptions,
265) -> Result<PlaceOrderResult> {
266    let result = place_order_with_options_and_identity(client, params, options).await?;
267    Ok(PlaceOrderResult {
268        order_id: result.order_id,
269    })
270}
271
272/// 下单(带官方 FTAPI optional 字段)并返回 numeric/string 双订单身份。
273pub async fn place_order_with_options_and_identity(
274    client: &FutuClient,
275    params: &PlaceOrderParams,
276    options: &PlaceOrderOptions,
277) -> Result<PlaceOrderResultWithIdentity> {
278    // v1.4.102 codex 28 F3 (P1) fix: SDK 层也拒 fund market 写入.
279    //
280    // **历史**: REST/MCP/CLI wrapper 都加了 fund market reject (codex 26 F1
281    // / 27 F7), 但直接 Rust SDK / gRPC / direct proto caller 仍可构造
282    // `header.trd_market = 113/123/124/125/126` 调用本 fn. `derive_sec_market_client`
283    // 把 fund market 归到主市场后, backend 看到 normal write 不会拒 → 用户
284    // 用 fund 账户号下单 → silent 误路由风险.
285    //
286    // **修法**: SDK fn 入口拒 canonical fund markets, 让所有 caller (REST/MCP/CLI/gRPC/
287    // direct SDK) 共享同一 runtime contract.
288    let trd_market = params.header.trd_market;
289    if let Some(label) = crate::market::canonical_fund_trd_market_label(trd_market) {
290        return Err(FutuError::ServerError {
291            ret_type: -1,
292            msg: format!(
293                "place_order: trd_market {label} 仅支持 view-only read endpoints; \
294                 write 路径 (place_order) 用对应主市场. v1.4.102 audit 28 F3 fix."
295            ),
296        });
297    }
298    validate_place_order_params(params)?;
299
300    let packet_id = params
301        .idempotency_key
302        .as_deref()
303        .map(packet_id_for_idempotency_key)
304        .map(Ok)
305        .unwrap_or_else(|| client_conn_id(client).map(next_packet_id))?;
306    let req = build_place_order_request(packet_id, params, options);
307
308    let body = prost::Message::encode_to_vec(&req);
309    let resp_frame = client.request(proto_id::TRD_PLACE_ORDER, body).await?;
310
311    parse_place_order_response_body(resp_frame.body.as_ref())
312}
313
314/// 修改/撤销订单
315pub async fn modify_order(client: &FutuClient, params: &ModifyOrderParams) -> Result<u64> {
316    // v1.4.102 codex 28 F3 (P1) fix: SDK 层 modify_order 也拒 fund market.
317    let trd_market = params.header.trd_market;
318    if let Some(label) = crate::market::canonical_fund_trd_market_label(trd_market) {
319        return Err(FutuError::ServerError {
320            ret_type: -1,
321            msg: format!(
322                "modify_order: trd_market {label} 仅支持 view-only read endpoints; \
323                 write 路径用对应主市场. v1.4.102 audit 28 F3 fix."
324            ),
325        });
326    }
327    validate_modify_order_params(params)?;
328
329    let packet_id = params
330        .idempotency_key
331        .as_deref()
332        .map(packet_id_for_idempotency_key)
333        .map(Ok)
334        .unwrap_or_else(|| client_conn_id(client).map(next_packet_id))?;
335    let req = futu_proto::trd_modify_order::Request {
336        c2s: futu_proto::trd_modify_order::C2s {
337            packet_id,
338            header: params.header.to_proto(),
339            order_id: params.order_id,
340            modify_order_op: params.modify_order_op as i32,
341            for_all: params.for_all,
342            trd_market: None,
343            qty: params.qty,
344            price: params.price,
345            adjust_price: None,
346            adjust_side_and_limit: None,
347            aux_price: None,
348            trail_type: None,
349            trail_value: None,
350            trail_spread: None,
351            order_id_ex: params.order_id_ex.clone(),
352        },
353    };
354
355    let body = prost::Message::encode_to_vec(&req);
356    let resp_frame = client.request(proto_id::TRD_MODIFY_ORDER, body).await?;
357
358    let resp: futu_proto::trd_modify_order::Response =
359        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
360
361    if resp.ret_type != 0 {
362        return Err(crate::server_err(
363            resp.ret_type,
364            resp.ret_msg,
365            resp.err_code,
366        ));
367    }
368
369    let s2c = resp
370        .s2c
371        .ok_or(FutuError::Codec("missing s2c in ModifyOrder".into()))?;
372
373    Ok(s2c.order_id)
374}
375
376/// 撤单(modify_order 的便捷封装)
377pub async fn cancel_order(
378    client: &FutuClient,
379    header: &crate::types::TrdHeader,
380    order_id: u64,
381) -> Result<u64> {
382    modify_order(
383        client,
384        &ModifyOrderParams {
385            header: header.clone(),
386            order_id,
387            order_id_ex: None,
388            modify_order_op: crate::types::ModifyOrderOp::Cancel,
389            qty: None,
390            price: None,
391            for_all: None,
392            idempotency_key: None,
393        },
394    )
395    .await
396}
397
398#[cfg(test)]
399mod order_response_contract_tests;