Skip to main content

brokapi_finam/map/
order.rs

1//! Orders: Finam `OrderState` <-> core snapshots, requests.
2
3use std::time::Instant;
4
5use brokapi_core::CidCodec;
6use brokapi_core::{
7    BrokerOrderId, ClientOrderId, Error, InstrumentId, NativeCid, OrderSnapshot, OrderState,
8    OrderType, PlaceOrderRequest, Qty, Side,
9};
10use brokapi_finam_protocol::pb::grpc::tradeapi::v1::Side as PbSide;
11use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders as pb;
12use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders::{
13    OrderStatus, OrderType as PbOrderType,
14};
15
16use crate::cid::FinamCidCodec;
17use crate::util::{instrument_from_symbol, price, qty, qty0, symbol_of, timestamps, to_decimal};
18
19/// Order is still working at the broker (unknown statuses count as working, never hidden).
20pub fn is_active(status: i32) -> bool {
21    !matches!(
22        OrderStatus::try_from(status),
23        Ok(OrderStatus::Filled
24            | OrderStatus::Canceled
25            | OrderStatus::Replaced
26            | OrderStatus::Rejected
27            | OrderStatus::Expired
28            | OrderStatus::Failed
29            | OrderStatus::DeniedByBroker
30            | OrderStatus::RejectedByExchange
31            | OrderStatus::Executed
32            | OrderStatus::Disabled
33            | OrderStatus::SlExecuted
34            | OrderStatus::TpExecuted)
35    )
36}
37
38/// Finam status -> core state. `filled`/`left` come from executed/remaining quantity.
39pub fn map_status(
40    status: i32,
41    cid: ClientOrderId,
42    broker_id: &str,
43    filled: Qty,
44    left: Qty,
45    description: &str,
46) -> OrderState {
47    use OrderStatus as S;
48    let st = S::try_from(status).unwrap_or(S::Unspecified);
49    match st {
50        S::Filled | S::Executed | S::SlExecuted | S::TpExecuted => OrderState::Filled { cid },
51        S::Canceled | S::Replaced | S::Expired | S::Disabled => OrderState::Cancelled { cid },
52        S::Rejected | S::Failed | S::DeniedByBroker | S::RejectedByExchange => {
53            OrderState::Rejected {
54                cid,
55                reason: if description.is_empty() {
56                    format!("{st:?}")
57                } else {
58                    format!("{st:?}: {description}")
59                },
60            }
61        }
62        S::PartiallyFilled => OrderState::PartiallyFilled { cid, filled, left },
63        // Everything else is a working order; a partial execution shows through the quantities.
64        _ if filled.is_positive() && left.is_positive() => {
65            OrderState::PartiallyFilled { cid, filled, left }
66        }
67        _ => OrderState::Acked {
68            cid,
69            broker_id: BrokerOrderId::new(broker_id),
70        },
71    }
72}
73
74/// Finam side -> core side. An unspecified side reads as `Buy`: check the source of a value before
75/// you trust it on an order that is not yours.
76pub fn side_from_pb(s: i32) -> Side {
77    match PbSide::try_from(s) {
78        Ok(PbSide::Sell) => Side::Sell,
79        _ => Side::Buy,
80    }
81}
82
83/// Stop and multi-leg orders have no core equivalent: shown as the nearest plain type.
84pub fn order_type_from_pb(t: i32) -> OrderType {
85    match PbOrderType::try_from(t) {
86        Ok(PbOrderType::Market | PbOrderType::Stop) => OrderType::Market,
87        _ => OrderType::Limit,
88    }
89}
90
91/// Core snapshot of a Finam order. `cid` is resolved by the caller (see `OrderIndex`).
92pub fn map_order_state(os: &pb::OrderState, cid: ClientOrderId, now: Instant) -> OrderSnapshot {
93    let order = os.order.clone().unwrap_or_default();
94    let filled = qty0(&os.executed_quantity);
95    let left = qty0(&os.remaining_quantity);
96    let total = qty(&os.initial_quantity)
97        .filter(|q| q.is_positive())
98        .or_else(|| qty(&order.quantity))
99        .unwrap_or(filled + left);
100    let description = os.status_description.clone().unwrap_or_default();
101    let limit = price(&order.limit_price).filter(|p| p.is_positive());
102    OrderSnapshot::new(
103        cid,
104        instrument_from_symbol(&order.symbol),
105        map_status(os.status, cid, &os.order_id, filled, left, &description),
106        side_from_pb(order.side),
107        order_type_from_pb(order.r#type),
108        total,
109        timestamps(os.accept_at.as_ref().or(os.transact_at.as_ref()), now),
110    )
111    .with_broker_id((!os.order_id.is_empty()).then(|| BrokerOrderId::new(os.order_id.clone())))
112    .with_filled(filled)
113    .with_price(limit)
114}
115
116/// Cid carried by an order echo: the comment (lossless) wins.
117pub fn cid_from_comment(comment: &str) -> Option<ClientOrderId> {
118    FinamCidCodec.decode(&NativeCid::new(comment))
119}
120
121/// Builds the `PlaceOrder` request. Quantity is in pieces (шт.), as in the Finam API.
122pub fn build_place_order(account_id: &str, req: &PlaceOrderRequest) -> Result<pb::Order, Error> {
123    let codec = FinamCidCodec;
124    let symbol = symbol_of(&req.instrument)?;
125    if !req.qty.is_positive() {
126        return Err(Error::Rejected {
127            code: "invalid_qty".into(),
128            message: format!("quantity must be positive, got {}", req.qty),
129        });
130    }
131    let quantity = to_decimal(req.qty.decimal());
132    let (ty, limit_price) = match req.order_type {
133        OrderType::Market => (PbOrderType::Market, None),
134        OrderType::Limit => {
135            let p = req.price.ok_or_else(|| Error::Rejected {
136                code: "missing_price".into(),
137                message: "limit order requires price".into(),
138            })?;
139            (PbOrderType::Limit, Some(to_decimal(p.decimal())))
140        }
141    };
142    let comment = codec
143        .encode(req.cid)
144        .map_err(|e| Error::Config(brokapi_core::ConfigError::Message(e.to_string())))?;
145    Ok(pb::Order {
146        account_id: account_id.to_string(),
147        symbol,
148        quantity: Some(quantity),
149        side: match req.side {
150            Side::Buy => PbSide::Buy as i32,
151            Side::Sell => PbSide::Sell as i32,
152        },
153        r#type: ty as i32,
154        time_in_force: pb::TimeInForce::Day as i32,
155        limit_price,
156        client_order_id: codec.client_order_id(req.cid),
157        comment: comment.0,
158        ..Default::default()
159    })
160}
161
162/// Rejection coming back as an order status in the `PlaceOrder` response.
163pub fn rejection_of(os: &pb::OrderState) -> Option<Error> {
164    match OrderStatus::try_from(os.status) {
165        Ok(
166            s @ (OrderStatus::Rejected
167            | OrderStatus::Failed
168            | OrderStatus::DeniedByBroker
169            | OrderStatus::RejectedByExchange),
170        ) => Some(Error::Rejected {
171            code: format!("{s:?}").to_lowercase(),
172            message: os.status_description.clone().unwrap_or_default(),
173        }),
174        _ => None,
175    }
176}
177
178/// Instrument of an order echo.
179pub fn order_instrument(os: &pb::OrderState) -> InstrumentId {
180    instrument_from_symbol(os.order.as_ref().map(|o| o.symbol.as_str()).unwrap_or(""))
181}