Skip to main content

brokapi_alor/map/
stop.rs

1//! Stop orders: the request of the shared interface to ALOR's stop and stop-limit orders, and back.
2//!
3//! ALOR has two kinds of stop orders, and both keep the trigger price and the condition (`more`, `less`, ...):
4//! a stop order (a **market** order is made on trigger) and a stop-limit order (a **limit** order is made, with
5//! its own price). The shared interface has three kinds:
6//!
7//! | Request | ALOR |
8//! |---|---|
9//! | `StopLoss`, `TakeProfit` without `limit_price` | a stop order. The condition follows the side and the kind (a sell stop-loss triggers when the price falls to the trigger price, a sell take-profit when it rises to it) |
10//! | `StopLoss`, `TakeProfit` with `limit_price`, `StopLimit` | a stop-limit order (a `StopLimit` without `limit_price` is refused) |
11//!
12//! A stop order read back has the kind `StopLoss` or `TakeProfit` (from the side and the condition) and a
13//! stop-limit order has the kind `StopLimit`: ALOR does not keep which of the two a bot meant.
14
15use std::time::Instant;
16
17use brokapi_alor_protocol::http::models::{
18    InstrumentRef, StopOrderRecord, StopOrderRequest, UserRef,
19};
20use brokapi_core::{
21    BrokerOrderId, ClientOrderId, Error, PlaceStopOrderRequest, Price, Qty, Side, StopExpiry,
22    StopOrderKind, StopOrderSnapshot, StopOrderState,
23};
24
25use super::market::side_of;
26use super::{parse_time, pieces_to_lots, timestamps};
27use crate::cid::cid_from_comment;
28use crate::util::{instrument_id, nanos_to_secs, ticker_board};
29
30/// The stop orders of ALOR end at a given time; a stop order that is "good till cancel" gets this long.
31pub const GTC_HORIZON_SECS: i64 = 365 * 24 * 3600;
32
33/// The trigger condition for the side and the kind.
34pub fn condition(kind: StopOrderKind, side: Side) -> &'static str {
35    if kind.triggers_on_rise(side) {
36        "moreorequal"
37    } else {
38        "lessorequal"
39    }
40}
41
42/// The end of the stop order, Unix seconds, from the expiry. `now_secs` is the time now.
43pub fn end_time(expiry: StopExpiry, now_secs: i64) -> i64 {
44    match expiry {
45        StopExpiry::GoodTillCancel => now_secs + GTC_HORIZON_SECS,
46        // The end of the Moscow day (UTC+3) that now is in.
47        StopExpiry::EndOfDay => {
48            const MSK: i64 = 3 * 3600;
49            ((now_secs + MSK).div_euclid(86_400) + 1) * 86_400 - MSK - 1
50        }
51        StopExpiry::GoodTillDate(ts) => nanos_to_secs(ts),
52    }
53}
54
55/// The wire request and whether it is a stop-limit order (`true`) or a stop order (`false`).
56///
57/// # Errors
58///
59/// [`Error::Rejected`] with `missing_price` for a `StopLimit` without `limit_price`; `invalid_qty` for a quantity
60/// that is not a whole number of lots; [`Error::Config`] for an instrument that is not a `TickerClass` one.
61pub fn build(
62    req: &PlaceStopOrderRequest,
63    lot: brokapi_core::Decimal,
64    portfolio: &str,
65    exchange: &str,
66    now_secs: i64,
67    cid: ClientOrderId,
68) -> Result<(StopOrderRequest, bool), Error> {
69    let (ticker, board) = ticker_board(&req.instrument)?;
70    let limit = match (req.kind, req.limit_price) {
71        (StopOrderKind::StopLimit, None) => {
72            return Err(Error::Rejected {
73                code: "missing_price".into(),
74                message: "a stop-limit order requires limit_price".into(),
75            });
76        }
77        (_, limit) => limit,
78    };
79    let lots = pieces_to_lots(req.qty, lot)?;
80    let wire = StopOrderRequest {
81        side: match req.side {
82            Side::Buy => "buy",
83            Side::Sell => "sell",
84        }
85        .to_string(),
86        condition: condition(req.kind, req.side).to_string(),
87        trigger_price: req.stop_price.decimal(),
88        stop_end_unix_time: Some(end_time(req.expiry, now_secs)),
89        price: limit.map(Price::decimal),
90        quantity: lots,
91        instrument: InstrumentRef {
92            symbol: ticker,
93            exchange: exchange.to_string(),
94            instrument_group: Some(board),
95        },
96        user: UserRef {
97            portfolio: portfolio.to_string(),
98        },
99        comment: Some(cid.hyphenated().to_string()),
100        time_in_force: limit.map(|_| "oneday".to_string()),
101        allow_margin: None,
102        activate: Some(true),
103    };
104    Ok((wire, limit.is_some()))
105}
106
107/// The state of a stop order from its `status`: a `working` one waits for the trigger.
108pub fn state_of(status: Option<&str>) -> StopOrderState {
109    match status.map(str::to_ascii_lowercase).as_deref() {
110        Some("working") | None => StopOrderState::Active,
111        Some("filled") => StopOrderState::Triggered,
112        Some("canceled") | Some("cancelled") => StopOrderState::Cancelled,
113        Some(other) => StopOrderState::Other(other.to_string()),
114    }
115}
116
117/// A stop order of the portfolio as a snapshot. The cid is the one the connector found (`None` if the order is
118/// not ours). `None` if the record lacks the side, the quantity or the trigger price.
119pub fn snapshot(
120    r: &StopOrderRecord,
121    cid: Option<ClientOrderId>,
122    now: Instant,
123) -> Option<StopOrderSnapshot> {
124    let side = side_of(r.side.as_deref())?;
125    let stop_price = Price::new(r.stop_price?);
126    let qty = Qty::new(r.qty_units?);
127    let limit = r.kind.as_deref() == Some("stoplimit");
128    let rises = matches!(
129        r.condition
130            .as_deref()
131            .map(str::to_ascii_lowercase)
132            .as_deref(),
133        Some("more" | "moreorequal")
134    );
135    let kind = if limit {
136        StopOrderKind::StopLimit
137    } else if rises == (side == Side::Buy) {
138        StopOrderKind::StopLoss
139    } else {
140        StopOrderKind::TakeProfit
141    };
142    let expiry = parse_time(r.end_time.as_deref())
143        .map(StopExpiry::GoodTillDate)
144        .unwrap_or(StopExpiry::GoodTillCancel);
145    Some(StopOrderSnapshot {
146        cid,
147        broker_id: BrokerOrderId::new(r.id.clone()),
148        instrument: instrument_id(&r.symbol, r.board.as_deref().unwrap_or_default()),
149        side,
150        kind,
151        qty,
152        stop_price,
153        limit_price: if limit { r.price.map(Price::new) } else { None },
154        expiry,
155        state: state_of(r.status.as_deref()),
156        ts: timestamps(now, parse_time(r.trans_time.as_deref())),
157    })
158}
159
160/// The cid in the comment of a stop order record, if it is ours.
161pub fn cid_of(r: &StopOrderRecord) -> Option<ClientOrderId> {
162    cid_from_comment(r.comment.as_deref())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::util::instrument_id;
169    use brokapi_core::{Decimal, dec};
170    use uuid::Uuid;
171
172    fn request(kind: StopOrderKind, side: Side, limit: Option<Price>) -> PlaceStopOrderRequest {
173        PlaceStopOrderRequest {
174            cid: Uuid::new_v4(),
175            instrument: instrument_id("SBER", "TQBR"),
176            side,
177            kind,
178            qty: Qty::from(20),
179            stop_price: Price::new(dec!(200.5)),
180            limit_price: limit,
181            expiry: StopExpiry::GoodTillCancel,
182        }
183    }
184
185    #[test]
186    fn a_sell_stop_loss_waits_for_a_fall_and_a_sell_take_profit_for_a_rise() {
187        let b = |k, s, l| {
188            build(
189                &request(k, s, l),
190                dec!(10),
191                "D1",
192                "MOEX",
193                1_000_000,
194                Uuid::new_v4(),
195            )
196            .unwrap()
197        };
198        let (w, limit) = b(StopOrderKind::StopLoss, Side::Sell, None);
199        assert_eq!(
200            (w.condition.as_str(), limit, w.quantity),
201            ("lessorequal", false, 2)
202        );
203        let (w, _) = b(StopOrderKind::TakeProfit, Side::Sell, None);
204        assert_eq!(w.condition, "moreorequal");
205        let (w, limit) = b(
206            StopOrderKind::StopLoss,
207            Side::Sell,
208            Some(Price::new(dec!(200))),
209        );
210        assert!(limit);
211        assert_eq!(w.price, Some(dec!(200)));
212        assert_eq!(w.time_in_force.as_deref(), Some("oneday"));
213    }
214
215    #[test]
216    fn a_stop_limit_without_a_price_is_refused_and_a_half_lot_too() {
217        let err = build(
218            &request(StopOrderKind::StopLimit, Side::Sell, None),
219            dec!(10),
220            "D1",
221            "MOEX",
222            0,
223            Uuid::new_v4(),
224        )
225        .unwrap_err();
226        assert!(matches!(err, Error::Rejected { ref code, .. } if code == "missing_price"));
227        let mut r = request(StopOrderKind::StopLoss, Side::Sell, None);
228        r.qty = Qty::from(15);
229        assert!(matches!(
230            build(&r, dec!(10), "D1", "MOEX", 0, Uuid::new_v4()),
231            Err(Error::Rejected { ref code, .. }) if code == "invalid_qty"
232        ));
233    }
234
235    #[test]
236    fn expiries_become_unix_seconds() {
237        // 2026-10-22 12:00:00 UTC is 15:00 in Moscow: the day ends at 2026-10-22 20:59:59 UTC.
238        let noon = 1_792_670_400;
239        assert_eq!(end_time(StopExpiry::EndOfDay, noon), noon + 9 * 3600 - 1);
240        assert_eq!(
241            end_time(StopExpiry::GoodTillCancel, noon),
242            noon + GTC_HORIZON_SECS
243        );
244        let date = brokapi_core::UnixNanos::from_nanos(5_000_000_000);
245        assert_eq!(end_time(StopExpiry::GoodTillDate(date), noon), 5);
246    }
247
248    #[test]
249    fn a_record_is_read_back_with_a_kind() {
250        let r: StopOrderRecord = serde_json::from_str(
251            r#"{"id":"9","symbol":"SBER","board":"TQBR","type":"stop","side":"sell","condition":"lessorequal","status":"working","qtyUnits":20,"stopPrice":200.5}"#,
252        )
253        .unwrap();
254        let s = snapshot(&r, None, Instant::now()).unwrap();
255        assert_eq!(s.kind, StopOrderKind::StopLoss);
256        assert_eq!(s.state, StopOrderState::Active);
257        assert_eq!(s.stop_price.decimal(), dec!(200.5));
258        assert_eq!(s.limit_price, None);
259        let _ = Decimal::ZERO;
260    }
261}