Skip to main content

brokapi_finam/map/
stop.rs

1//! Stop orders: core requests <-> Finam `Order` (STOP, STOP_LIMIT) and `SLTPOrder`.
2//!
3//! Finam keeps stop orders in `OrdersService`:
4//! - stop-loss and stop-limit go through `PlaceOrder` with type `STOP` / `STOP_LIMIT`, a `stop_price`
5//!   and an explicit `stop_condition` (LAST_UP: the trigger price is above the current price,
6//!   LAST_DOWN: below);
7//! - take-profit goes through `PlaceSLTPOrder` (the take-profit leg only).
8//!
9//! Quantity is in pieces, as everywhere in Finam. The cid is carried like for plain orders: the
10//! lossless form in `comment`, a 20-hex tag in `client_order_id`.
11
12use brokapi_core::{
13    BrokerOrderId, CidCodec, ClientOrderId, ConfigError, Error, PlaceStopOrderRequest, Price, Qty,
14    Side, StopExpiry, StopOrderKind, StopOrderSnapshot, StopOrderState,
15};
16use brokapi_finam_protocol::pb::grpc::tradeapi::v1::Side as PbSide;
17use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders as pb;
18use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders::{
19    OrderStatus, OrderType as PbOrderType,
20};
21use std::time::Instant;
22
23use crate::cid::FinamCidCodec;
24use crate::map::order::side_from_pb;
25use crate::util::{
26    instrument_from_symbol, price, price0, qty, qty0, symbol_of, timestamps, to_decimal,
27    to_timestamp, ts_nanos,
28};
29
30fn rejected(code: &str, message: impl Into<String>) -> Error {
31    Error::Rejected {
32        code: code.into(),
33        message: message.into(),
34    }
35}
36
37fn positive(what: &str, p: Price) -> Result<(), Error> {
38    if p.is_positive() {
39        Ok(())
40    } else {
41        Err(rejected(
42            "invalid_price",
43            format!("{what} must be a positive number, got {p}"),
44        ))
45    }
46}
47
48/// Which Finam call carries the request.
49#[derive(Debug, Clone, PartialEq)]
50pub enum StopWire {
51    /// `PlaceOrder` with type STOP or STOP_LIMIT.
52    Order(pb::Order),
53    /// `PlaceSLTPOrder` with the take-profit leg only.
54    TakeProfit(pb::SltpOrder),
55}
56
57/// Checks that need no network, then the wire request.
58pub fn build(account_id: &str, req: &PlaceStopOrderRequest) -> Result<StopWire, Error> {
59    let symbol = symbol_of(&req.instrument)?;
60    if !req.qty.is_positive() {
61        return Err(rejected("invalid_qty", "quantity must be > 0"));
62    }
63    positive("stop_price", req.stop_price)?;
64    if let Some(p) = req.limit_price {
65        positive("limit_price", p)?;
66    }
67    if req.kind == StopOrderKind::StopLimit && req.limit_price.is_none() {
68        return Err(rejected(
69            "missing_limit_price",
70            "a stop-limit order needs a limit price",
71        ));
72    }
73    let quantity = to_decimal(req.qty.decimal());
74    let stop_price = to_decimal(req.stop_price.decimal());
75    let codec = FinamCidCodec;
76    let comment = codec
77        .encode(req.cid)
78        .map_err(|e| Error::Config(ConfigError::Message(e.to_string())))?
79        .0;
80    let tag = codec.client_order_id(req.cid);
81    let side = match req.side {
82        Side::Buy => PbSide::Buy as i32,
83        Side::Sell => PbSide::Sell as i32,
84    };
85
86    match req.kind {
87        StopOrderKind::TakeProfit => {
88            if req.limit_price.is_some() {
89                return Err(rejected(
90                    "unsupported_limit_price",
91                    "Finam sets a take-profit limit through a guard spread, not a price: place the take-profit without a limit price",
92                ));
93            }
94            let (valid_before, valid_expiry_time) = match req.expiry {
95                StopExpiry::GoodTillCancel => (pb::ValidBefore::GoodTillCancel, None),
96                StopExpiry::EndOfDay => (pb::ValidBefore::EndOfDay, None),
97                StopExpiry::GoodTillDate(t) => {
98                    (pb::ValidBefore::GoodTillDate, Some(to_timestamp(t)))
99                }
100            };
101            Ok(StopWire::TakeProfit(pb::SltpOrder {
102                account_id: account_id.to_string(),
103                symbol,
104                side,
105                quantity_tp: Some(quantity),
106                tp_price: Some(stop_price),
107                tp_qty_measure: pb::SltpQtyMeasure::Value as i32,
108                client_order_id: tag,
109                valid_before: valid_before as i32,
110                valid_expiry_time,
111                comment,
112                ..Default::default()
113            }))
114        }
115        StopOrderKind::StopLoss | StopOrderKind::StopLimit => {
116            let valid_before = match req.expiry {
117                StopExpiry::GoodTillCancel => pb::ValidBefore::GoodTillCancel,
118                StopExpiry::EndOfDay => pb::ValidBefore::EndOfDay,
119                StopExpiry::GoodTillDate(_) => {
120                    return Err(rejected(
121                        "unsupported_expiry",
122                        "Finam supports an expiry date only for SL/TP orders: use GoodTillCancel or EndOfDay, or a take-profit",
123                    ));
124                }
125            };
126            let condition = if req.kind.triggers_on_rise(req.side) {
127                pb::StopCondition::LastUp
128            } else {
129                pb::StopCondition::LastDown
130            };
131            let (ty, limit_price) = match req.limit_price {
132                Some(p) => (PbOrderType::StopLimit, Some(to_decimal(p.decimal()))),
133                None => (PbOrderType::Stop, None),
134            };
135            Ok(StopWire::Order(pb::Order {
136                account_id: account_id.to_string(),
137                symbol,
138                quantity: Some(quantity),
139                side,
140                r#type: ty as i32,
141                limit_price,
142                stop_price: Some(stop_price),
143                stop_condition: condition as i32,
144                client_order_id: tag,
145                valid_before: valid_before as i32,
146                comment,
147                ..Default::default()
148            }))
149        }
150    }
151}
152
153/// A Finam order that is a stop order: type STOP / STOP_LIMIT, or an SL/TP order.
154pub fn is_stop(os: &pb::OrderState) -> bool {
155    os.sltp_order.is_some()
156        || os.order.as_ref().is_some_and(|o| {
157            matches!(
158                PbOrderType::try_from(o.r#type),
159                Ok(PbOrderType::Stop | PbOrderType::StopLimit)
160            )
161        })
162}
163
164/// `(client_order_id, comment)` an order echo carries: from `order`, or for an SL/TP order from
165/// `sltp_order`. This is where the cid is read back.
166pub fn echo_keys(os: &pb::OrderState) -> (String, String) {
167    if let Some(o) = os
168        .order
169        .as_ref()
170        .filter(|o| !o.client_order_id.is_empty() || !o.comment.is_empty())
171    {
172        return (o.client_order_id.clone(), o.comment.clone());
173    }
174    match &os.sltp_order {
175        Some(s) => (s.client_order_id.clone(), s.comment.clone()),
176        None => (String::new(), String::new()),
177    }
178}
179
180/// Lifecycle of a stop order from the Finam status. The names come from the proto; how Finam really
181/// walks through them is not verified against the live API.
182pub fn state_of(status: i32) -> StopOrderState {
183    use OrderStatus as S;
184    match S::try_from(status) {
185        // Waiting for the trigger (guard time and TP correction come before the exchange order).
186        Ok(
187            S::New
188            | S::PendingNew
189            | S::Watching
190            | S::Wait
191            | S::LinkWait
192            | S::Suspended
193            | S::SlGuardTime
194            | S::TpGuardTime
195            | S::TpCorrection
196            | S::TpCorrGuardTime,
197        ) => StopOrderState::Active,
198        // Triggered: the exchange order is on its way or has been executed.
199        Ok(
200            S::Forwarding
201            | S::SlForwarding
202            | S::TpForwarding
203            | S::PartiallyFilled
204            | S::Filled
205            | S::Executed
206            | S::SlExecuted
207            | S::TpExecuted
208            | S::DoneForDay,
209        ) => StopOrderState::Triggered,
210        Ok(S::Canceled | S::Replaced | S::Disabled | S::PendingCancel) => StopOrderState::Cancelled,
211        Ok(S::Expired) => StopOrderState::Expired,
212        Ok(other) => StopOrderState::Other(format!("{other:?}")),
213        Err(_) => StopOrderState::Other(format!("status {status}")),
214    }
215}
216
217/// Core snapshot of a Finam stop order. `cid` is resolved by the caller (`OrderIndex::lookup`).
218pub fn snapshot(
219    os: &pb::OrderState,
220    cid: Option<ClientOrderId>,
221    now: Instant,
222) -> StopOrderSnapshot {
223    let ts = timestamps(os.accept_at.as_ref().or(os.transact_at.as_ref()), now);
224    let state = state_of(os.status);
225    if let Some(s) = &os.sltp_order {
226        // SL/TP order. Only the take-profit leg is ours; a foreign pair is shown by its SL leg.
227        let has_sl = price(&s.sl_price).is_some_and(|p| p.is_positive());
228        let side = side_from_pb(s.side);
229        // `*_qty_measure` PERCENT: the number is a share of the position (0-100), not pieces. A percent cannot be
230        // shown as `qty`, and passing it on as pieces would be wrong, so the quantity is 0 ("not in pieces").
231        let in_pieces =
232            |measure: i32| pb::SltpQtyMeasure::try_from(measure) != Ok(pb::SltpQtyMeasure::Percent);
233        let (kind, qty, stop, limit) = if has_sl {
234            let limit = price(&s.limit_price).filter(|p| p.is_positive());
235            (
236                if limit.is_some() {
237                    StopOrderKind::StopLimit
238                } else {
239                    StopOrderKind::StopLoss
240                },
241                if in_pieces(s.sl_qty_measure) {
242                    qty0(&s.quantity_sl)
243                } else {
244                    Qty::ZERO
245                },
246                price0(&s.sl_price),
247                limit,
248            )
249        } else {
250            (
251                StopOrderKind::TakeProfit,
252                if in_pieces(s.tp_qty_measure) {
253                    qty0(&s.quantity_tp)
254                } else {
255                    Qty::ZERO
256                },
257                price0(&s.tp_price),
258                None,
259            )
260        };
261        let expiry = match pb::ValidBefore::try_from(s.valid_before) {
262            Ok(pb::ValidBefore::EndOfDay) => StopExpiry::EndOfDay,
263            Ok(pb::ValidBefore::GoodTillDate) => s
264                .valid_expiry_time
265                .as_ref()
266                .map(|t| StopExpiry::GoodTillDate(ts_nanos(t)))
267                .unwrap_or(StopExpiry::GoodTillCancel),
268            _ => StopExpiry::GoodTillCancel,
269        };
270        return StopOrderSnapshot {
271            cid,
272            broker_id: BrokerOrderId::new(os.order_id.clone()),
273            instrument: instrument_from_symbol(&s.symbol),
274            side,
275            kind,
276            qty,
277            stop_price: stop,
278            limit_price: limit,
279            expiry,
280            state,
281            ts,
282        };
283    }
284    let o = os.order.clone().unwrap_or_default();
285    let side = side_from_pb(o.side);
286    let condition =
287        pb::StopCondition::try_from(o.stop_condition).unwrap_or(pb::StopCondition::Unspecified);
288    let kind = if o.r#type == PbOrderType::StopLimit as i32 {
289        StopOrderKind::StopLimit
290    } else {
291        // A stop market order that triggers against the position's direction is a take-profit.
292        let rises = condition == pb::StopCondition::LastUp;
293        if rises == StopOrderKind::StopLoss.triggers_on_rise(side) {
294            StopOrderKind::StopLoss
295        } else {
296            StopOrderKind::TakeProfit
297        }
298    };
299    let expiry = match pb::ValidBefore::try_from(o.valid_before) {
300        Ok(pb::ValidBefore::EndOfDay) => StopExpiry::EndOfDay,
301        _ => StopExpiry::GoodTillCancel,
302    };
303    let qty = qty(&os.initial_quantity)
304        .filter(|q| q.is_positive())
305        .or_else(|| self::qty(&o.quantity))
306        .unwrap_or(Qty::ZERO);
307    StopOrderSnapshot {
308        cid,
309        broker_id: BrokerOrderId::new(os.order_id.clone()),
310        instrument: instrument_from_symbol(&o.symbol),
311        side,
312        kind,
313        qty,
314        stop_price: price0(&o.stop_price),
315        limit_price: price(&o.limit_price).filter(|p| p.is_positive()),
316        expiry,
317        state,
318        ts,
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use brokapi_core::{Decimal, dec};
325    use brokapi_core::{InstrumentId, InstrumentKey, NativeId, UnixNanos};
326
327    use super::*;
328
329    fn req(
330        side: Side,
331        kind: StopOrderKind,
332        limit: Option<Decimal>,
333        expiry: StopExpiry,
334    ) -> PlaceStopOrderRequest {
335        PlaceStopOrderRequest {
336            cid: ClientOrderId::from_u128(0xabcdef),
337            instrument: InstrumentId::new(
338                InstrumentKey::new("SBER", "MISX"),
339                NativeId::SymbolMic {
340                    symbol: "SBER".into(),
341                    mic: "MISX".into(),
342                },
343            ),
344            side,
345            kind,
346            qty: Qty::from(10),
347            stop_price: Price::new(dec!(250.5)),
348            limit_price: limit.map(Price::new),
349            expiry,
350        }
351    }
352
353    fn order(w: StopWire) -> pb::Order {
354        match w {
355            StopWire::Order(o) => o,
356            other => panic!("expected PlaceOrder, got {other:?}"),
357        }
358    }
359
360    #[test]
361    fn stop_loss_sell_is_a_stop_that_triggers_down() {
362        let o = order(
363            build(
364                "ACC1",
365                &req(
366                    Side::Sell,
367                    StopOrderKind::StopLoss,
368                    None,
369                    StopExpiry::GoodTillCancel,
370                ),
371            )
372            .unwrap(),
373        );
374        assert_eq!(o.r#type, PbOrderType::Stop as i32);
375        assert_eq!(o.stop_condition, pb::StopCondition::LastDown as i32);
376        assert_eq!(o.stop_price.unwrap().value, "250.5");
377        assert!(o.limit_price.is_none());
378        assert_eq!(o.symbol, "SBER@MISX");
379        assert_eq!(o.quantity.unwrap().value, "10");
380        assert_eq!(o.valid_before, pb::ValidBefore::GoodTillCancel as i32);
381        assert_eq!(o.account_id, "ACC1");
382        // The cid travels like for plain orders.
383        assert!(o.comment.starts_with("bc1:"));
384        assert_eq!(o.client_order_id.len(), 20);
385    }
386
387    #[test]
388    fn stop_loss_buy_triggers_up_and_a_limit_price_makes_it_stop_limit() {
389        let o = order(
390            build(
391                "A",
392                &req(
393                    Side::Buy,
394                    StopOrderKind::StopLoss,
395                    None,
396                    StopExpiry::EndOfDay,
397                ),
398            )
399            .unwrap(),
400        );
401        assert_eq!(o.stop_condition, pb::StopCondition::LastUp as i32);
402        assert_eq!(o.valid_before, pb::ValidBefore::EndOfDay as i32);
403        let o = order(
404            build(
405                "A",
406                &req(
407                    Side::Sell,
408                    StopOrderKind::StopLimit,
409                    Some(dec!(249.0)),
410                    StopExpiry::GoodTillCancel,
411                ),
412            )
413            .unwrap(),
414        );
415        assert_eq!(o.r#type, PbOrderType::StopLimit as i32);
416        assert_eq!(o.limit_price.unwrap().value, "249.0");
417        assert_eq!(o.stop_condition, pb::StopCondition::LastDown as i32);
418    }
419
420    #[test]
421    fn take_profit_goes_through_the_sltp_call() {
422        let t = UnixNanos::from_nanos(1_900_000_000_000_000_000);
423        let w = build(
424            "A",
425            &req(
426                Side::Sell,
427                StopOrderKind::TakeProfit,
428                None,
429                StopExpiry::GoodTillDate(t),
430            ),
431        )
432        .unwrap();
433        let StopWire::TakeProfit(s) = w else {
434            panic!("expected SLTP")
435        };
436        assert_eq!(s.symbol, "SBER@MISX");
437        assert_eq!(s.quantity_tp.unwrap().value, "10");
438        assert_eq!(s.tp_price.unwrap().value, "250.5");
439        assert!(
440            s.quantity_sl.is_none() && s.sl_price.is_none(),
441            "the take-profit leg only"
442        );
443        assert_eq!(s.valid_before, pb::ValidBefore::GoodTillDate as i32);
444        assert_eq!(s.valid_expiry_time.unwrap().seconds, 1_900_000_000);
445        assert!(s.comment.starts_with("bc1:"));
446    }
447
448    #[test]
449    fn refusals_need_no_network() {
450        let cases = [
451            (
452                req(
453                    Side::Sell,
454                    StopOrderKind::StopLimit,
455                    None,
456                    StopExpiry::GoodTillCancel,
457                ),
458                "missing_limit_price",
459            ),
460            (
461                req(
462                    Side::Sell,
463                    StopOrderKind::TakeProfit,
464                    Some(dec!(1.0)),
465                    StopExpiry::GoodTillCancel,
466                ),
467                "unsupported_limit_price",
468            ),
469            (
470                req(
471                    Side::Sell,
472                    StopOrderKind::StopLoss,
473                    None,
474                    StopExpiry::GoodTillDate(UnixNanos::from_nanos(1)),
475                ),
476                "unsupported_expiry",
477            ),
478            (
479                req(
480                    Side::Sell,
481                    StopOrderKind::StopLoss,
482                    Some(dec!(-3.0)),
483                    StopExpiry::GoodTillCancel,
484                ),
485                "invalid_price",
486            ),
487        ];
488        for (r, want) in cases {
489            match build("A", &r) {
490                Err(Error::Rejected { code, .. }) => assert_eq!(code, want),
491                other => panic!("{want}: {other:?}"),
492            }
493        }
494        let mut r = req(
495            Side::Sell,
496            StopOrderKind::StopLoss,
497            None,
498            StopExpiry::GoodTillCancel,
499        );
500        r.qty = Qty::from(0);
501        assert!(build("A", &r).is_err());
502    }
503
504    #[test]
505    fn states_group_the_finam_statuses() {
506        assert_eq!(
507            state_of(OrderStatus::Watching as i32),
508            StopOrderState::Active
509        );
510        assert_eq!(
511            state_of(OrderStatus::SlGuardTime as i32),
512            StopOrderState::Active
513        );
514        assert_eq!(
515            state_of(OrderStatus::TpForwarding as i32),
516            StopOrderState::Triggered
517        );
518        assert_eq!(
519            state_of(OrderStatus::SlExecuted as i32),
520            StopOrderState::Triggered
521        );
522        assert_eq!(
523            state_of(OrderStatus::Canceled as i32),
524            StopOrderState::Cancelled
525        );
526        assert_eq!(
527            state_of(OrderStatus::Expired as i32),
528            StopOrderState::Expired
529        );
530        assert!(matches!(
531            state_of(OrderStatus::Rejected as i32),
532            StopOrderState::Other(_)
533        ));
534        assert!(matches!(state_of(999), StopOrderState::Other(_)));
535    }
536
537    #[test]
538    fn the_snapshot_round_trips_what_was_sent() {
539        let cid = ClientOrderId::from_u128(0xabcdef);
540        let o = order(
541            build(
542                "A",
543                &req(
544                    Side::Sell,
545                    StopOrderKind::StopLimit,
546                    Some(dec!(249.0)),
547                    StopExpiry::GoodTillCancel,
548                ),
549            )
550            .unwrap(),
551        );
552        let os = pb::OrderState {
553            order_id: "ORD7".into(),
554            status: OrderStatus::Watching as i32,
555            order: Some(o),
556            initial_quantity: Some(crate::util::to_decimal(dec!(10.0))),
557            ..Default::default()
558        };
559        assert!(is_stop(&os));
560        let s = snapshot(&os, Some(cid), Instant::now());
561        assert_eq!(s.cid, Some(cid));
562        assert_eq!(s.broker_id.as_str(), "ORD7");
563        assert_eq!(s.kind, StopOrderKind::StopLimit);
564        assert_eq!(s.side, Side::Sell);
565        assert_eq!(s.qty.decimal(), dec!(10.0));
566        assert_eq!(s.stop_price.decimal(), dec!(250.5));
567        assert_eq!(s.limit_price.map(Price::decimal), Some(dec!(249.0)));
568        assert_eq!(s.state, StopOrderState::Active);
569        assert_eq!(s.instrument.key.symbol, "SBER");
570    }
571
572    #[test]
573    fn a_plain_order_is_not_a_stop() {
574        let os = pb::OrderState {
575            order: Some(pb::Order {
576                r#type: PbOrderType::Limit as i32,
577                ..Default::default()
578            }),
579            ..Default::default()
580        };
581        assert!(!is_stop(&os));
582    }
583
584    #[test]
585    fn a_percent_quantity_of_an_sltp_order_is_not_shown_as_pieces() {
586        let sltp = |sl_measure: pb::SltpQtyMeasure, tp_measure: pb::SltpQtyMeasure, sl: bool| {
587            let leg = |v: &str| Some(crate::util::to_decimal(v.parse::<Decimal>().unwrap()));
588            pb::OrderState {
589                order_id: "S1".into(),
590                status: OrderStatus::Watching as i32,
591                sltp_order: Some(pb::SltpOrder {
592                    symbol: "SBER@MISX".into(),
593                    side: PbSide::Sell as i32,
594                    quantity_sl: leg("25"),
595                    sl_price: sl.then(|| leg("240.0")).flatten(),
596                    quantity_tp: leg("40"),
597                    tp_price: (!sl).then(|| leg("260.0")).flatten(),
598                    sl_qty_measure: sl_measure as i32,
599                    tp_qty_measure: tp_measure as i32,
600                    ..Default::default()
601                }),
602                ..Default::default()
603            }
604        };
605        use pb::SltpQtyMeasure as M;
606        let qty_of = |os: &pb::OrderState| snapshot(os, None, Instant::now()).qty.decimal();
607        // Pieces (or the old answers with no measure): the number is the quantity.
608        assert_eq!(qty_of(&sltp(M::Value, M::Value, true)), dec!(25));
609        assert_eq!(qty_of(&sltp(M::Undefined, M::Undefined, false)), dec!(40));
610        // Percent of the position: no quantity in pieces, not "25".
611        assert_eq!(qty_of(&sltp(M::Percent, M::Value, true)), dec!(0));
612        assert_eq!(qty_of(&sltp(M::Value, M::Percent, false)), dec!(0));
613        // The measure of the other leg does not matter.
614        assert_eq!(qty_of(&sltp(M::Value, M::Percent, true)), dec!(25));
615    }
616
617    #[test]
618    fn a_stop_that_triggers_against_the_position_reads_as_take_profit() {
619        let os = pb::OrderState {
620            order_id: "1".into(),
621            status: OrderStatus::Watching as i32,
622            order: Some(pb::Order {
623                side: PbSide::Sell as i32,
624                r#type: PbOrderType::Stop as i32,
625                stop_condition: pb::StopCondition::LastUp as i32,
626                stop_price: Some(crate::util::to_decimal(dec!(300.0))),
627                ..Default::default()
628            }),
629            ..Default::default()
630        };
631        assert_eq!(
632            snapshot(&os, None, Instant::now()).kind,
633            StopOrderKind::TakeProfit
634        );
635    }
636}