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 order_type_requires_price(params.order_type) {
158        let price = params.price.ok_or_else(|| {
159            request_validation_error("place_order.price is required for price-based order types")
160        })?;
161        ensure_positive_finite(price, "place_order.price")?;
162    } else {
163        validate_optional_positive_price(params.price, "place_order.price")?;
164    }
165
166    validate_optional_positive_price(params.aux_price, "place_order.aux_price")?;
167    Ok(())
168}
169
170fn validate_modify_order_params(params: &ModifyOrderParams) -> Result<()> {
171    if params.modify_order_op == ModifyOrderOp::Normal {
172        let qty = params.qty.ok_or_else(|| {
173            request_validation_error("modify_order.qty is required for normal modify")
174        })?;
175        ensure_positive_finite(qty, "modify_order.qty")?;
176        validate_optional_positive_price(params.price, "modify_order.price")?;
177    }
178    Ok(())
179}
180
181fn build_place_order_request(
182    packet_id: futu_proto::common::PacketId,
183    params: &PlaceOrderParams,
184    options: &PlaceOrderOptions,
185) -> futu_proto::trd_place_order::Request {
186    futu_proto::trd_place_order::Request {
187        c2s: futu_proto::trd_place_order::C2s {
188            packet_id,
189            header: params.header.to_proto(),
190            trd_side: params.trd_side as i32,
191            order_type: params.order_type as i32,
192            code: params.code.clone(),
193            qty: params.qty,
194            price: params.price,
195            adjust_price: params.adjust_price,
196            adjust_side_and_limit: params.adjust_side_and_limit,
197            sec_market: Some(derive_sec_market_client(
198                params.header.trd_market as i32,
199                &params.code,
200            )),
201            remark: None,
202            time_in_force: options.time_in_force,
203            fill_outside_rth: options.fill_outside_rth,
204            // v1.4.53 F1 条件单:透传 aux_price / trail_* 到 FTAPI
205            aux_price: params.aux_price,
206            trail_type: params.trail_type,
207            trail_value: params.trail_value,
208            trail_spread: params.trail_spread,
209            session: options.session,
210            position_id: None,
211            expire_time: options.expire_time.clone(),
212            amount: options.amount,
213            pred_side: options.pred_side,
214        },
215    }
216}
217
218pub async fn place_order(
219    client: &FutuClient,
220    params: &PlaceOrderParams,
221) -> Result<PlaceOrderResult> {
222    place_order_with_options(client, params, &PlaceOrderOptions::default()).await
223}
224
225/// 下单并返回 numeric `orderID` 与无损 `orderIDEx`。
226///
227/// 参照版本在成功响应中同时返回二者。新调用者需要把订单身份跨 JSON、脚本或
228/// modify/cancel 流程传递时应使用本函数;既有 [`place_order`] 保持原返回类型。
229pub async fn place_order_with_identity(
230    client: &FutuClient,
231    params: &PlaceOrderParams,
232) -> Result<PlaceOrderResultWithIdentity> {
233    place_order_with_options_and_identity(client, params, &PlaceOrderOptions::default()).await
234}
235
236/// 下单(带官方 FTAPI optional 字段)。
237///
238/// `PlaceOrderOptions` 只透传 `Trd_PlaceOrder.C2S` 已定义字段,不改变 gateway
239/// 的 C++ 对齐校验。普通用户继续用 [`place_order`];需要美股盘前/盘后、GTD
240/// 等语义时调用本函数。
241pub async fn place_order_with_options(
242    client: &FutuClient,
243    params: &PlaceOrderParams,
244    options: &PlaceOrderOptions,
245) -> Result<PlaceOrderResult> {
246    let result = place_order_with_options_and_identity(client, params, options).await?;
247    Ok(PlaceOrderResult {
248        order_id: result.order_id,
249    })
250}
251
252/// 下单(带官方 FTAPI optional 字段)并返回 numeric/string 双订单身份。
253pub async fn place_order_with_options_and_identity(
254    client: &FutuClient,
255    params: &PlaceOrderParams,
256    options: &PlaceOrderOptions,
257) -> Result<PlaceOrderResultWithIdentity> {
258    // v1.4.102 codex 28 F3 (P1) fix: SDK 层也拒 fund market 写入.
259    //
260    // **历史**: REST/MCP/CLI wrapper 都加了 fund market reject (codex 26 F1
261    // / 27 F7), 但直接 Rust SDK / gRPC / direct proto caller 仍可构造
262    // `header.trd_market = 113/123/124/125/126` 调用本 fn. `derive_sec_market_client`
263    // 把 fund market 归到主市场后, backend 看到 normal write 不会拒 → 用户
264    // 用 fund 账户号下单 → silent 误路由风险.
265    //
266    // **修法**: SDK fn 入口拒 canonical fund markets, 让所有 caller (REST/MCP/CLI/gRPC/
267    // direct SDK) 共享同一 runtime contract.
268    let trd_market = params.header.trd_market;
269    if let Some(label) = crate::market::canonical_fund_trd_market_label(trd_market) {
270        return Err(FutuError::ServerError {
271            ret_type: -1,
272            msg: format!(
273                "place_order: trd_market {label} 仅支持 view-only read endpoints; \
274                 write 路径 (place_order) 用对应主市场. v1.4.102 audit 28 F3 fix."
275            ),
276        });
277    }
278    validate_place_order_params(params)?;
279
280    let packet_id = params
281        .idempotency_key
282        .as_deref()
283        .map(packet_id_for_idempotency_key)
284        .map(Ok)
285        .unwrap_or_else(|| client_conn_id(client).map(next_packet_id))?;
286    let req = build_place_order_request(packet_id, params, options);
287
288    let body = prost::Message::encode_to_vec(&req);
289    let resp_frame = client.request(proto_id::TRD_PLACE_ORDER, body).await?;
290
291    parse_place_order_response_body(resp_frame.body.as_ref())
292}
293
294/// 修改/撤销订单
295pub async fn modify_order(client: &FutuClient, params: &ModifyOrderParams) -> Result<u64> {
296    // v1.4.102 codex 28 F3 (P1) fix: SDK 层 modify_order 也拒 fund market.
297    let trd_market = params.header.trd_market;
298    if let Some(label) = crate::market::canonical_fund_trd_market_label(trd_market) {
299        return Err(FutuError::ServerError {
300            ret_type: -1,
301            msg: format!(
302                "modify_order: trd_market {label} 仅支持 view-only read endpoints; \
303                 write 路径用对应主市场. v1.4.102 audit 28 F3 fix."
304            ),
305        });
306    }
307    validate_modify_order_params(params)?;
308
309    let packet_id = params
310        .idempotency_key
311        .as_deref()
312        .map(packet_id_for_idempotency_key)
313        .map(Ok)
314        .unwrap_or_else(|| client_conn_id(client).map(next_packet_id))?;
315    let req = futu_proto::trd_modify_order::Request {
316        c2s: futu_proto::trd_modify_order::C2s {
317            packet_id,
318            header: params.header.to_proto(),
319            order_id: params.order_id,
320            modify_order_op: params.modify_order_op as i32,
321            for_all: params.for_all,
322            trd_market: None,
323            qty: params.qty,
324            price: params.price,
325            adjust_price: None,
326            adjust_side_and_limit: None,
327            aux_price: None,
328            trail_type: None,
329            trail_value: None,
330            trail_spread: None,
331            order_id_ex: params.order_id_ex.clone(),
332        },
333    };
334
335    let body = prost::Message::encode_to_vec(&req);
336    let resp_frame = client.request(proto_id::TRD_MODIFY_ORDER, body).await?;
337
338    let resp: futu_proto::trd_modify_order::Response =
339        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
340
341    if resp.ret_type != 0 {
342        return Err(crate::server_err(
343            resp.ret_type,
344            resp.ret_msg,
345            resp.err_code,
346        ));
347    }
348
349    let s2c = resp
350        .s2c
351        .ok_or(FutuError::Codec("missing s2c in ModifyOrder".into()))?;
352
353    Ok(s2c.order_id)
354}
355
356/// 撤单(modify_order 的便捷封装)
357pub async fn cancel_order(
358    client: &FutuClient,
359    header: &crate::types::TrdHeader,
360    order_id: u64,
361) -> Result<u64> {
362    modify_order(
363        client,
364        &ModifyOrderParams {
365            header: header.clone(),
366            order_id,
367            order_id_ex: None,
368            modify_order_op: crate::types::ModifyOrderOp::Cancel,
369            qty: None,
370            price: None,
371            for_all: None,
372            idempotency_key: None,
373        },
374    )
375    .await
376}
377
378#[cfg(test)]
379mod order_response_contract_tests;