Skip to main content

brokapi_tbank/map/
stop.rs

1//! Stop orders: core requests <-> `StopOrdersService` messages.
2//!
3//! Units as for plain orders: the domain request counts in units of the instrument and the wire counts in
4//! lots, so the functions here take or read the lot size. Prices are per one instrument.
5
6use std::time::Instant;
7
8use brokapi_core::{
9    BrokerOrderId, ClientOrderId, Error, PlaceStopOrderRequest, Price, Side, StopExpiry,
10    StopOrderKind, StopOrderSnapshot, StopOrderState,
11};
12use brokapi_tbank_protocol::v1 as pb;
13
14use crate::ids::{
15    id_from_parts, instrument_ref, nanos_to_proto, price_of_money, price_to_quotation,
16    proto_to_nanos, stamps,
17};
18use crate::lots::lots_to_qty;
19
20fn rejected(code: &str, message: impl Into<String>) -> Error {
21    Error::Rejected {
22        code: code.into(),
23        message: message.into(),
24    }
25}
26
27fn positive(what: &str, p: Price) -> Result<(), Error> {
28    if p.is_positive() {
29        Ok(())
30    } else {
31        Err(rejected(
32            "invalid_price",
33            format!("{what} must be a positive number, got {p}"),
34        ))
35    }
36}
37
38/// Checks that need no network. Run before any quota is spent or anything is registered.
39pub fn validate(req: &PlaceStopOrderRequest) -> Result<(), Error> {
40    positive("stop_price", req.stop_price)?;
41    match (req.kind, req.limit_price) {
42        (StopOrderKind::StopLimit, None) => {
43            return Err(rejected(
44                "missing_limit_price",
45                "a stop-limit order needs a limit price",
46            ));
47        }
48        (_, Some(p)) => positive("limit_price", p)?,
49        _ => {}
50    }
51    if matches!(req.expiry, StopExpiry::EndOfDay) {
52        return Err(rejected(
53            "unsupported_expiry",
54            "T-Bank stop orders live until cancelled or until a date: use GoodTillCancel or GoodTillDate",
55        ));
56    }
57    Ok(())
58}
59
60/// Builds the `PostStopOrder` request for `account` from a domain request. `lots` is the quantity in
61/// lots (the request is in units, the caller converts with the lot size of the instrument).
62///
63/// The `cid` goes to `order_id`, the idempotency key. The request is checked with [`validate`]
64/// first, so nothing invalid is built.
65///
66/// # Errors
67///
68/// [`Error::Rejected`] with code `invalid_price`, `missing_limit_price` or
69/// `unsupported_expiry`, as [`validate`] lists.
70pub fn build_post(
71    account: &str,
72    req: &PlaceStopOrderRequest,
73    lots: i64,
74) -> Result<pb::PostStopOrderRequest, Error> {
75    validate(req)?;
76    let (expiration_type, expire_date) = match req.expiry {
77        StopExpiry::GoodTillCancel => (pb::StopOrderExpirationType::GoodTillCancel, None),
78        StopExpiry::GoodTillDate(t) => (
79            pb::StopOrderExpirationType::GoodTillDate,
80            Some(nanos_to_proto(t)),
81        ),
82        StopExpiry::EndOfDay => unreachable!("validate() refuses it"),
83    };
84    let stop_order_type = match req.kind {
85        StopOrderKind::StopLoss => pb::StopOrderType::StopLoss,
86        StopOrderKind::TakeProfit => pb::StopOrderType::TakeProfit,
87        StopOrderKind::StopLimit => pb::StopOrderType::StopLimit,
88    };
89    // The child exchange order: a limit order when there is a limit price, else a market one.
90    let exchange_order_type = if req.limit_price.is_some() {
91        pb::ExchangeOrderType::Limit
92    } else {
93        pb::ExchangeOrderType::Market
94    };
95    Ok(pb::PostStopOrderRequest {
96        quantity: lots,
97        price: req.limit_price.map(price_to_quotation).transpose()?,
98        stop_price: Some(price_to_quotation(req.stop_price)?),
99        direction: match req.side {
100            Side::Buy => pb::StopOrderDirection::Buy,
101            Side::Sell => pb::StopOrderDirection::Sell,
102        } as i32,
103        account_id: account.to_string(),
104        expiration_type: expiration_type as i32,
105        stop_order_type: stop_order_type as i32,
106        expire_date,
107        instrument_id: instrument_ref(&req.instrument),
108        exchange_order_type: exchange_order_type as i32,
109        take_profit_type: if req.kind == StopOrderKind::TakeProfit {
110            pb::TakeProfitType::Regular as i32
111        } else {
112            pb::TakeProfitType::Unspecified as i32
113        },
114        order_id: req.cid.hyphenated().to_string(),
115        ..Default::default()
116    })
117}
118
119fn kind_of(t: pb::StopOrderType) -> StopOrderKind {
120    match t {
121        pb::StopOrderType::TakeProfit => StopOrderKind::TakeProfit,
122        pb::StopOrderType::StopLimit => StopOrderKind::StopLimit,
123        // Unspecified is shown as the plainest kind rather than hiding the order.
124        pb::StopOrderType::StopLoss | pb::StopOrderType::Unspecified => StopOrderKind::StopLoss,
125    }
126}
127
128fn state_of(s: pb::StopOrderStatusOption) -> StopOrderState {
129    use pb::StopOrderStatusOption as S;
130    match s {
131        S::StopOrderStatusActive => StopOrderState::Active,
132        S::StopOrderStatusExecuted => StopOrderState::Triggered,
133        S::StopOrderStatusCanceled => StopOrderState::Cancelled,
134        S::StopOrderStatusExpired => StopOrderState::Expired,
135        other => StopOrderState::Other(format!("{other:?}")),
136    }
137}
138
139/// The limit price of a broker stop order: shown for a stop-limit order and for a limit child order.
140fn limit_of(o: &pb::StopOrder, kind: StopOrderKind) -> Option<Price> {
141    o.price.as_ref().map(price_of_money).filter(|p| {
142        p.is_positive()
143            && (kind == StopOrderKind::StopLimit
144                || o.exchange_order_type() == pb::ExchangeOrderType::Limit)
145    })
146}
147
148/// `true` when the broker stop order `o` is what `req` asks for: the same instrument, side, kind,
149/// number of lots (`lots`), stop price and limit price. Nothing else is compared: the broker does not
150/// return the `cid`, so this is how a stop order whose placement got no clear answer is recognised.
151pub fn same_as_request(o: &pb::StopOrder, req: &PlaceStopOrderRequest, lots: i64) -> bool {
152    let kind = kind_of(o.order_type());
153    let side = match o.direction() {
154        pb::StopOrderDirection::Sell => Side::Sell,
155        pb::StopOrderDirection::Buy => Side::Buy,
156        pb::StopOrderDirection::Unspecified => return false,
157    };
158    let instrument = id_from_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code);
159    let same_instrument = match (&req.instrument.native, &instrument.native) {
160        (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) => a == b,
161        _ => req.instrument.key == instrument.key,
162    };
163    same_instrument
164        && side == req.side
165        && kind == req.kind
166        && o.lots_requested == lots
167        && o.stop_price.as_ref().map(price_of_money) == Some(req.stop_price)
168        && limit_of(o, kind) == req.limit_price
169}
170
171/// Core snapshot of a broker stop order. `cid` comes from what this connector registered at
172/// `place_stop` (the broker's `StopOrder` does not carry the idempotency key). `lot` is the lot size of
173/// the instrument: the quantity of the snapshot is in units.
174pub fn snapshot(
175    o: &pb::StopOrder,
176    cid: Option<ClientOrderId>,
177    now: Instant,
178    lot: i64,
179) -> StopOrderSnapshot {
180    let kind = kind_of(o.order_type());
181    let limit = limit_of(o, kind);
182    let expiry = match o.expiration_time.as_ref().filter(|t| t.seconds != 0) {
183        Some(t) => StopExpiry::GoodTillDate(proto_to_nanos(t)),
184        None => StopExpiry::GoodTillCancel,
185    };
186    StopOrderSnapshot {
187        cid,
188        broker_id: BrokerOrderId::new(o.stop_order_id.clone()),
189        instrument: id_from_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code),
190        side: match o.direction() {
191            pb::StopOrderDirection::Sell => Side::Sell,
192            _ => Side::Buy,
193        },
194        kind,
195        qty: lots_to_qty(o.lots_requested, lot),
196        stop_price: o
197            .stop_price
198            .as_ref()
199            .map(price_of_money)
200            .unwrap_or(Price::ZERO),
201        limit_price: limit,
202        expiry,
203        state: state_of(o.status()),
204        ts: stamps(now, o.create_date.as_ref()),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use brokapi_core::{Decimal, dec};
211    use brokapi_core::{InstrumentId, InstrumentKey, NativeId, UnixNanos};
212
213    use super::*;
214    use brokapi_core::Qty;
215
216    fn req(kind: StopOrderKind, limit: Option<Price>, expiry: StopExpiry) -> PlaceStopOrderRequest {
217        PlaceStopOrderRequest {
218            cid: ClientOrderId::from_u128(0x1234),
219            instrument: InstrumentId::new(
220                InstrumentKey::new("SBER", "TQBR"),
221                NativeId::Uid("uid-sber".into()),
222            ),
223            side: Side::Sell,
224            kind,
225            qty: Qty::from(3),
226            stop_price: Price::new(dec!(250.5)),
227            limit_price: limit,
228            expiry,
229        }
230    }
231
232    #[test]
233    fn stop_loss_is_a_market_child_order() {
234        let w = build_post(
235            "acc",
236            &req(StopOrderKind::StopLoss, None, StopExpiry::GoodTillCancel),
237            3,
238        )
239        .unwrap();
240        assert_eq!(w.quantity, 3);
241        assert_eq!(w.stop_order_type(), pb::StopOrderType::StopLoss);
242        assert_eq!(w.exchange_order_type(), pb::ExchangeOrderType::Market);
243        assert_eq!(w.direction(), pb::StopOrderDirection::Sell);
244        assert_eq!(
245            w.expiration_type(),
246            pb::StopOrderExpirationType::GoodTillCancel
247        );
248        assert!(w.price.is_none() && w.expire_date.is_none());
249        assert_eq!(
250            w.order_id,
251            ClientOrderId::from_u128(0x1234).hyphenated().to_string()
252        );
253        assert_eq!(w.instrument_id, "uid-sber");
254        assert_eq!(w.account_id, "acc");
255        let stop = w.stop_price.unwrap();
256        assert_eq!((stop.units, stop.nano), (250, 500_000_000));
257    }
258
259    #[test]
260    fn take_profit_with_limit_and_date() {
261        let t = UnixNanos::from_nanos(1_800_000_000_000_000_000);
262        let w = build_post(
263            "acc",
264            &req(
265                StopOrderKind::TakeProfit,
266                Some(Price::from(251)),
267                StopExpiry::GoodTillDate(t),
268            ),
269            3,
270        )
271        .unwrap();
272        assert_eq!(w.stop_order_type(), pb::StopOrderType::TakeProfit);
273        assert_eq!(w.take_profit_type(), pb::TakeProfitType::Regular);
274        assert_eq!(w.exchange_order_type(), pb::ExchangeOrderType::Limit);
275        assert_eq!(
276            w.expiration_type(),
277            pb::StopOrderExpirationType::GoodTillDate
278        );
279        assert_eq!(w.expire_date.unwrap().seconds, 1_800_000_000);
280        assert_eq!(w.price.unwrap().units, 251);
281    }
282
283    #[test]
284    fn refusals_need_no_network() {
285        let cases = [
286            (
287                req(StopOrderKind::StopLimit, None, StopExpiry::GoodTillCancel),
288                "missing_limit_price",
289            ),
290            (
291                req(StopOrderKind::StopLoss, None, StopExpiry::EndOfDay),
292                "unsupported_expiry",
293            ),
294            (
295                req(
296                    StopOrderKind::StopLoss,
297                    Some(Price::new(dec!(-1.0))),
298                    StopExpiry::GoodTillCancel,
299                ),
300                "invalid_price",
301            ),
302        ];
303        for (r, want) in cases {
304            match validate(&r) {
305                Err(Error::Rejected { code, .. }) => assert_eq!(code, want),
306                other => panic!("{want}: {other:?}"),
307            }
308        }
309        let mut r = req(StopOrderKind::StopLoss, None, StopExpiry::GoodTillCancel);
310        r.stop_price = Price::from(0);
311        assert!(validate(&r).is_err(), "zero stop price");
312    }
313
314    #[test]
315    fn snapshot_reads_the_broker_message() {
316        let o = pb::StopOrder {
317            stop_order_id: "SO1".into(),
318            lots_requested: 2,
319            direction: pb::StopOrderDirection::Sell as i32,
320            order_type: pb::StopOrderType::StopLimit as i32,
321            price: Some(brokapi_tbank_protocol::money::money("rub", "249.0")),
322            stop_price: Some(brokapi_tbank_protocol::money::money("rub", "250.0")),
323            instrument_uid: "uid-sber".into(),
324            ticker: "SBER".into(),
325            class_code: "TQBR".into(),
326            status: pb::StopOrderStatusOption::StopOrderStatusActive as i32,
327            ..Default::default()
328        };
329        let cid = ClientOrderId::from_u128(7);
330        let s = snapshot(&o, Some(cid), Instant::now(), 10);
331        assert_eq!(s.cid, Some(cid));
332        assert_eq!(s.broker_id.as_str(), "SO1");
333        assert_eq!(s.kind, StopOrderKind::StopLimit);
334        assert_eq!(s.side, Side::Sell);
335        // 2 lots of 10 shares.
336        assert_eq!(s.qty.decimal(), dec!(20));
337        assert_eq!(s.stop_price.decimal(), dec!(250.0));
338        assert_eq!(s.limit_price.map(Price::decimal), Some(dec!(249.0)));
339        assert_eq!(s.state, StopOrderState::Active);
340        assert_eq!(s.expiry, StopExpiry::GoodTillCancel);
341        assert_eq!(s.instrument.key.symbol, "SBER");
342    }
343
344    #[test]
345    fn a_zero_price_is_not_a_limit_price() {
346        let o = pb::StopOrder {
347            order_type: pb::StopOrderType::StopLoss as i32,
348            price: Some(brokapi_tbank_protocol::money::money("rub", "0.0")),
349            stop_price: Some(brokapi_tbank_protocol::money::money("rub", "10.0")),
350            ..Default::default()
351        };
352        assert!(snapshot(&o, None, Instant::now(), 1).limit_price.is_none());
353    }
354}