Skip to main content

brokapi_bybit/map/
stop.rs

1//! Stop orders: conditional orders of the exchange.
2//!
3//! Bybit has no separate stop order method for category linear. A **conditional order** is an ordinary order with a
4//! `triggerPrice`: it waits as `Untriggered` and becomes a working order when the price crosses the trigger. The shared
5//! kinds map as follows.
6//!
7//! | Kind | Order | Trigger direction |
8//! |---|---|---|
9//! | `StopLoss` | market | a sell waits for a fall, a buy for a rise |
10//! | `TakeProfit` | market | a sell waits for a rise, a buy for a fall |
11//! | `StopLimit` | limit at `limit_price` | as `StopLoss` |
12//!
13//! The take profit and stop loss that the exchange keeps on a *position* (`/v5/position/trading-stop`) are conditional
14//! orders too: they are listed here, with a made-up cid, but this connector does not create them (they need an open
15//! position and cannot carry a client order id).
16
17use std::time::Instant;
18
19use brokapi_bybit_protocol::http::models::{CreateOrderRequest, OrderRecord};
20use brokapi_core::{
21    BrokerOrderId, ClientOrderId, Error, PlaceStopOrderRequest, Price, Qty, Side, StopExpiry,
22    StopOrderKind, StopOrderSnapshot, StopOrderState,
23};
24
25use super::{broker_ts, side_of, side_word, timestamps};
26use crate::util::instrument_id;
27
28/// The trigger direction of the API: `1` when the order fires as the price rises to the trigger price, `2` when it
29/// falls to it.
30pub fn trigger_direction(kind: StopOrderKind, side: Side) -> i64 {
31    if kind.triggers_on_rise(side) { 1 } else { 2 }
32}
33
34/// The request for a stop order.
35///
36/// # Errors
37///
38/// [`Error::Rejected`] with code `missing_price` for a stop-limit without `limit_price`, and with code
39/// `unsupported_expiry` when the expiry is not good-till-cancel: the exchange keeps a conditional order until it fires or
40/// is cancelled, and has no time limit to give it.
41pub fn build(
42    req: &PlaceStopOrderRequest,
43    symbol: &str,
44    category: &str,
45    cid: ClientOrderId,
46) -> Result<CreateOrderRequest, Error> {
47    if req.expiry != StopExpiry::GoodTillCancel {
48        return Err(Error::Rejected {
49            code: "unsupported_expiry".into(),
50            message: "Bybit keeps a conditional order until it fires or is cancelled: use StopExpiry::GoodTillCancel"
51                .into(),
52        });
53    }
54    let limit = match (req.kind, req.limit_price) {
55        (StopOrderKind::StopLimit, None) => {
56            return Err(Error::Rejected {
57                code: "missing_price".into(),
58                message: "a stop-limit order requires limit_price".into(),
59            });
60        }
61        (StopOrderKind::StopLimit, limit) => limit,
62        // A stop loss or a take profit is a market order when it fires: a limit price is not used.
63        (_, _) => None,
64    };
65    Ok(CreateOrderRequest {
66        category: category.to_string(),
67        symbol: symbol.to_string(),
68        side: side_word(req.side).to_string(),
69        order_type: if limit.is_some() { "Limit" } else { "Market" }.to_string(),
70        qty: req.qty.decimal(),
71        price: limit.map(Price::decimal),
72        time_in_force: Some("GTC".into()),
73        order_link_id: Some(cid.hyphenated().to_string()),
74        trigger_price: Some(req.stop_price.decimal()),
75        trigger_direction: Some(trigger_direction(req.kind, req.side)),
76        trigger_by: Some("LastPrice".into()),
77        position_idx: Some(0),
78        ..Default::default()
79    })
80}
81
82/// The state of a conditional order by the status of the exchange.
83pub fn state_of(status: &str) -> StopOrderState {
84    match status {
85        "Untriggered" => StopOrderState::Active,
86        "Triggered" | "New" | "PartiallyFilled" | "Filled" => StopOrderState::Triggered,
87        "Cancelled" | "Deactivated" | "PartiallyFilledCanceled" => StopOrderState::Cancelled,
88        other => StopOrderState::Other(other.to_string()),
89    }
90}
91
92/// The snapshot of a conditional order.
93pub fn snapshot(
94    r: &OrderRecord,
95    cid: Option<ClientOrderId>,
96    category: &str,
97    now: Instant,
98) -> Option<StopOrderSnapshot> {
99    let side = side_of(&r.side)?;
100    let stop_price = Price::new(r.trigger_price.filter(|p| !p.is_zero())?);
101    let limit = r.order_type == "Limit";
102    let rises = r.trigger_direction == 1;
103    let kind = if limit {
104        StopOrderKind::StopLimit
105    } else if rises == (side == Side::Buy) {
106        StopOrderKind::StopLoss
107    } else {
108        StopOrderKind::TakeProfit
109    };
110    let category = if r.category.is_empty() {
111        category
112    } else {
113        &r.category
114    };
115    Some(StopOrderSnapshot {
116        cid,
117        broker_id: BrokerOrderId::new(r.order_id.clone()),
118        instrument: instrument_id(&r.symbol, category),
119        side,
120        kind,
121        qty: Qty::new(r.qty),
122        stop_price,
123        limit_price: if limit { r.price.map(Price::new) } else { None },
124        expiry: StopExpiry::GoodTillCancel,
125        state: state_of(&r.order_status),
126        ts: timestamps(
127            now,
128            broker_ts(r.updated_time).or_else(|| broker_ts(r.created_time)),
129        ),
130    })
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use brokapi_core::{Decimal, dec};
137    use uuid::Uuid;
138
139    fn request(kind: StopOrderKind, side: Side, limit: Option<Price>) -> PlaceStopOrderRequest {
140        PlaceStopOrderRequest {
141            cid: Uuid::new_v4(),
142            instrument: instrument_id("BTCUSDT", "linear"),
143            side,
144            kind,
145            qty: Qty::new(dec!(0.2)),
146            stop_price: Price::new(dec!(80000.5)),
147            limit_price: limit,
148            expiry: StopExpiry::GoodTillCancel,
149        }
150    }
151
152    #[test]
153    fn a_sell_stop_loss_waits_for_a_fall_and_a_sell_take_profit_for_a_rise() {
154        let b = |k, s, l| build(&request(k, s, l), "BTCUSDT", "linear", Uuid::new_v4()).unwrap();
155        let w = b(StopOrderKind::StopLoss, Side::Sell, None);
156        assert_eq!(
157            (w.trigger_direction, w.order_type.as_str()),
158            (Some(2), "Market")
159        );
160        let w = b(StopOrderKind::TakeProfit, Side::Sell, None);
161        assert_eq!(w.trigger_direction, Some(1));
162        let w = b(StopOrderKind::StopLoss, Side::Buy, None);
163        assert_eq!(w.trigger_direction, Some(1));
164        let w = b(
165            StopOrderKind::StopLimit,
166            Side::Sell,
167            Some(Price::new(dec!(80000))),
168        );
169        assert_eq!(
170            (w.order_type.as_str(), w.price),
171            ("Limit", Some(dec!(80000)))
172        );
173        assert_eq!(w.trigger_price, Some(dec!(80000.5)));
174        assert_eq!(w.order_link_id.as_ref().map(String::len), Some(36));
175    }
176
177    #[test]
178    fn a_stop_limit_without_a_price_and_a_time_limit_are_refused() {
179        let err = build(
180            &request(StopOrderKind::StopLimit, Side::Sell, None),
181            "BTCUSDT",
182            "linear",
183            Uuid::new_v4(),
184        )
185        .unwrap_err();
186        assert!(matches!(err, Error::Rejected { ref code, .. } if code == "missing_price"));
187        let mut req = request(StopOrderKind::StopLoss, Side::Sell, None);
188        req.expiry = StopExpiry::EndOfDay;
189        let err = build(&req, "BTCUSDT", "linear", Uuid::new_v4()).unwrap_err();
190        assert!(matches!(err, Error::Rejected { ref code, .. } if code == "unsupported_expiry"));
191    }
192
193    #[test]
194    fn a_listed_conditional_order_reads_back_with_its_kind() {
195        let r: OrderRecord = serde_json::from_str(
196            r#"{"orderId":"9","symbol":"BTCUSDT","side":"Sell","orderType":"Market","orderStatus":"Untriggered","qty":"0.2","triggerPrice":"80000.5","triggerDirection":2}"#,
197        )
198        .unwrap();
199        let s = snapshot(&r, None, "linear", Instant::now()).unwrap();
200        assert_eq!(s.kind, StopOrderKind::StopLoss);
201        assert_eq!(s.stop_price.to_string(), "80000.5");
202        assert_eq!(s.state, StopOrderState::Active);
203        let r: OrderRecord = serde_json::from_str(
204            r#"{"orderId":"9","symbol":"BTCUSDT","side":"Sell","orderType":"Limit","price":"79000","orderStatus":"Triggered","qty":"0.2","triggerPrice":"80000.5","triggerDirection":1}"#,
205        )
206        .unwrap();
207        let s = snapshot(&r, None, "linear", Instant::now()).unwrap();
208        assert_eq!(
209            (s.kind, s.state),
210            (StopOrderKind::StopLimit, StopOrderState::Triggered)
211        );
212        assert_eq!(s.limit_price.unwrap().to_string(), "79000");
213    }
214}