Skip to main content

brokapi_bcs/map/
stop.rs

1//! Stop orders: `PlaceStopOrderRequest` -> BCS conditional order (`orderType = "5"`).
2//!
3//! The mapping follows the current docs (`POST /orders`, checked 2026-10-19). What the docs do NOT say
4//! is settled here in the safest way:
5//!
6//! - **Trigger direction.** Not a field of the request. The broker decides it, from the side and the
7//!   kind (stop-loss of a SELL = price falls to `stopPrice`, take-profit of a SELL = price rises to
8//!   `takePrice`). The connector sends no direction and does not guess one.
9//! - **Lifetime.** `timeInForce = 3` is "algorithmic orders only", and the docs give no lifetime for a
10//!   conditional order. So only `StopExpiry::GoodTillCancel` is accepted and NO time-in-force field is
11//!   sent: the order lives as long as the broker's default for conditional orders. If that is shorter
12//!   than "until cancelled", the stop disappears at the broker's deadline: the live run must find
13//!   out. `EndOfDay` and `GoodTillDate` are refused (`unsupported_expiry`) rather than sent into a
14//!   field the docs reserve for another order type.
15//! - **`price`.** With `executionStopLossType = 2` / `executionTakeProfitType = 2` (limit execution)
16//!   `price` is taken as the limit price of the order put at the trigger. With market execution (`1`)
17//!   `price` is not sent.
18//! - **Pairs.** One request is one leg. The connector never sends `stopPrice` and `takePrice` together,
19//!   so it never creates the TP+SL pair (list type 7) whose cancellation the docs do not describe.
20//! - **Quantity.** `orderQuantity` of a conditional order is sent like for plain orders (whole units).
21
22use brokapi_bcs_protocol::http::models::{
23    CreateOrderRequest, OrderExtras, OrderType as BcsOrderType,
24};
25use brokapi_bcs_protocol::models::enums::OrderSide as BcsOrderSide;
26use brokapi_core::{Error, PlaceStopOrderRequest, Price, Side, StopExpiry, StopOrderKind};
27
28use crate::util::{class_code, ticker};
29
30fn refused(code: &str, message: impl Into<String>) -> Error {
31    Error::Rejected {
32        code: code.into(),
33        message: message.into(),
34    }
35}
36
37/// Wire body of a stop order. Everything that can be checked locally is checked before anything is
38/// sent or any quota is spent.
39pub fn build(req: &PlaceStopOrderRequest) -> Result<CreateOrderRequest, Error> {
40    match req.expiry {
41        StopExpiry::GoodTillCancel => {}
42        StopExpiry::EndOfDay | StopExpiry::GoodTillDate(_) => {
43            return Err(refused(
44                "unsupported_expiry",
45                "BCS documents no lifetime for conditional orders: only GoodTillCancel (no time-in-force is sent)",
46            ));
47        }
48    }
49    let qty = match req.qty.to_whole() {
50        Some(n) if n >= 1 => n,
51        _ => {
52            return Err(refused(
53                "invalid_qty",
54                format!(
55                    "stop order quantity must be a whole number >= 1, got {}",
56                    req.qty
57                ),
58            ));
59        }
60    };
61    let stop = req.stop_price;
62    if !stop.is_positive() {
63        return Err(refused(
64            "invalid_price",
65            format!("stop price must be > 0, got {stop}"),
66        ));
67    }
68    let limit = match req.limit_price {
69        Some(p) if !p.is_positive() => {
70            return Err(refused(
71                "invalid_price",
72                format!("limit price must be > 0, got {p}"),
73            ));
74        }
75        other => other,
76    };
77    if req.kind == StopOrderKind::StopLimit && limit.is_none() {
78        return Err(refused(
79            "missing_limit_price",
80            "a stop-limit order needs a limit price",
81        ));
82    }
83
84    // 1 = market, 2 = limit execution at the trigger.
85    let execution = if limit.is_some() { 2 } else { 1 };
86    let mut extras = OrderExtras::default();
87    match req.kind {
88        StopOrderKind::StopLoss | StopOrderKind::StopLimit => {
89            extras.stop_price = Some(stop.decimal());
90            extras.execution_stop_loss_type = Some(execution);
91        }
92        StopOrderKind::TakeProfit => {
93            extras.take_price = Some(stop.decimal());
94            extras.execution_take_profit_type = Some(execution);
95        }
96    }
97    let side = match req.side {
98        Side::Buy => BcsOrderSide::Buy,
99        Side::Sell => BcsOrderSide::Sell,
100    };
101    Ok(CreateOrderRequest {
102        client_order_id: req.cid,
103        side,
104        order_type: BcsOrderType::Conditional,
105        order_quantity: qty,
106        ticker: ticker(&req.instrument)?,
107        class_code: class_code(&req.instrument)?,
108        price: limit.map(Price::decimal),
109        extras,
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use brokapi_core::{
117        ClientOrderId, InstrumentId, InstrumentKey, NativeId, Price, Qty, UnixNanos,
118    };
119    use brokapi_core::{Decimal, dec};
120
121    fn sber() -> InstrumentId {
122        InstrumentId::new(
123            InstrumentKey::new("SBER", "TQBR"),
124            NativeId::TickerClass {
125                ticker: "SBER".into(),
126                class_code: "TQBR".into(),
127            },
128        )
129    }
130
131    fn req(
132        kind: StopOrderKind,
133        side: Side,
134        limit: Option<Decimal>,
135        expiry: StopExpiry,
136    ) -> PlaceStopOrderRequest {
137        PlaceStopOrderRequest {
138            cid: ClientOrderId::from_bytes([5; 16]),
139            instrument: sber(),
140            side,
141            kind,
142            qty: Qty::from(3),
143            stop_price: Price::from(250),
144            limit_price: limit.map(Price::new),
145            expiry,
146        }
147    }
148
149    fn json(r: &CreateOrderRequest) -> serde_json::Value {
150        serde_json::to_value(r).unwrap()
151    }
152
153    #[test]
154    fn market_stop_loss_is_a_conditional_order_with_stop_price_only() {
155        let w = json(
156            &build(&req(
157                StopOrderKind::StopLoss,
158                Side::Sell,
159                None,
160                StopExpiry::GoodTillCancel,
161            ))
162            .unwrap(),
163        );
164        assert_eq!(w["orderType"], "5");
165        assert_eq!(w["side"], "2");
166        assert_eq!(w["stopPrice"], 250.0);
167        assert_eq!(w["executionStopLossType"], 1);
168        assert!(
169            w.get("price").is_none(),
170            "market execution sends no price: {w}"
171        );
172        assert!(w.get("takePrice").is_none());
173        assert!(
174            w.get("timeInForce").is_none() && w.get("expiryDate").is_none(),
175            "no lifetime is sent: {w}"
176        );
177        assert_eq!(w["orderQuantity"], 3);
178    }
179
180    #[test]
181    fn stop_limit_and_limit_stop_loss_send_the_limit_price() {
182        for kind in [StopOrderKind::StopLimit, StopOrderKind::StopLoss] {
183            let w = json(
184                &build(&req(
185                    kind,
186                    Side::Sell,
187                    Some(dec!(249.0)),
188                    StopExpiry::GoodTillCancel,
189                ))
190                .unwrap(),
191            );
192            assert_eq!(w["executionStopLossType"], 2);
193            assert_eq!(w["price"], 249.0);
194            assert_eq!(w["stopPrice"], 250.0);
195        }
196    }
197
198    #[test]
199    fn take_profit_uses_take_price_not_stop_price() {
200        let w = json(
201            &build(&req(
202                StopOrderKind::TakeProfit,
203                Side::Sell,
204                None,
205                StopExpiry::GoodTillCancel,
206            ))
207            .unwrap(),
208        );
209        assert_eq!(w["takePrice"], 250.0);
210        assert_eq!(w["executionTakeProfitType"], 1);
211        assert!(
212            w.get("stopPrice").is_none() && w.get("executionStopLossType").is_none(),
213            "one leg only: {w}"
214        );
215        let w = json(
216            &build(&req(
217                StopOrderKind::TakeProfit,
218                Side::Buy,
219                Some(dec!(240.0)),
220                StopExpiry::GoodTillCancel,
221            ))
222            .unwrap(),
223        );
224        assert_eq!(w["executionTakeProfitType"], 2);
225        assert_eq!(w["price"], 240.0);
226    }
227
228    #[test]
229    fn refused_before_anything_is_sent() {
230        let t = UnixNanos::from_nanos(1);
231        for (r, code) in [
232            (
233                req(
234                    StopOrderKind::StopLimit,
235                    Side::Sell,
236                    None,
237                    StopExpiry::GoodTillCancel,
238                ),
239                "missing_limit_price",
240            ),
241            (
242                req(
243                    StopOrderKind::StopLoss,
244                    Side::Sell,
245                    None,
246                    StopExpiry::EndOfDay,
247                ),
248                "unsupported_expiry",
249            ),
250            (
251                req(
252                    StopOrderKind::StopLoss,
253                    Side::Sell,
254                    None,
255                    StopExpiry::GoodTillDate(t),
256                ),
257                "unsupported_expiry",
258            ),
259            (
260                req(
261                    StopOrderKind::StopLoss,
262                    Side::Sell,
263                    Some(dec!(-1.0)),
264                    StopExpiry::GoodTillCancel,
265                ),
266                "invalid_price",
267            ),
268        ] {
269            match build(&r) {
270                Err(Error::Rejected { code: got, .. }) => assert_eq!(got, code),
271                other => panic!("{code}: {other:?}"),
272            }
273        }
274        let mut frac = req(
275            StopOrderKind::StopLoss,
276            Side::Sell,
277            None,
278            StopExpiry::GoodTillCancel,
279        );
280        frac.qty = Qty::new(dec!(1.5));
281        assert!(matches!(build(&frac), Err(Error::Rejected { code, .. }) if code == "invalid_qty"));
282        let mut bad_stop = req(
283            StopOrderKind::StopLoss,
284            Side::Sell,
285            None,
286            StopExpiry::GoodTillCancel,
287        );
288        bad_stop.stop_price = Price::from(0);
289        assert!(
290            matches!(build(&bad_stop), Err(Error::Rejected { code, .. }) if code == "invalid_price")
291        );
292    }
293}