Skip to main content

brokapi_bcs/map/
order.rs

1//! Order and execution mapping.
2
3use brokapi_core::Decimal;
4use std::time::Instant;
5
6use brokapi_bcs_protocol::http::models::{
7    OrderExecutionData as RestOrderExecutionData, OrderOperationResponse, OrderRecord,
8    OrderStateListResponse, OrderStatusResponse,
9};
10use brokapi_bcs_protocol::models::enums::{OrderSide, OrderStatus, OrderType as BcsOrderType};
11use brokapi_bcs_protocol::ws::models::{OrderExecutionData, OrderMessage};
12use brokapi_core::{
13    BrokerOrderId, ClientOrderId, Event, OrderAck, OrderSnapshot, OrderState, OrderType, Price,
14    Qty, Side, StopExpiry, StopOrderKind, StopOrderSnapshot, StopOrderState,
15};
16use uuid::Uuid;
17
18use super::{instrument_id_ticker, timestamps};
19
20/// Maps the plain answer of a place request (`status: OK`) to an `Event::Order` that says "acknowledged".
21///
22/// The answer only says that the request was accepted, so the snapshot is a thin one: no instrument,
23/// quantity or price (empty and zero), and the broker order id is the client order id as text. Use
24/// [`map_state_list`] on the answer of a synchronous call to get the full report.
25pub fn map_place_response(resp: &OrderOperationResponse, now: Instant) -> Event {
26    Event::Order(map_operation_ack(
27        resp,
28        now,
29        OrderState::Acked {
30            cid: resp.client_order_id,
31            broker_id: BrokerOrderId::new(resp.client_order_id.hyphenated().to_string()),
32        },
33    ))
34}
35
36/// Maps an order status answer to an `Event::Order`. `None` when the client order id is not a UUID
37/// or the answer has no report.
38pub fn map_order_status(resp: &OrderStatusResponse, now: Instant) -> Option<Event> {
39    let cid = parse_cid(&resp.client_order_id)?;
40    let data = resp.data.as_ref()?;
41    Some(Event::Order(map_execution_data(cid, data, now)))
42}
43
44/// The cid of the order an `orders/events/ws` message is about.
45///
46/// The two ids of the message mean: `clientOrderId` is the id of the request that caused the message,
47/// `originalClientOrderId` is the order that was changed (docs: "идентификатор измененной заявки"). For a
48/// cancel the request id is a fresh id of the cancel, for a replace the new order's id. So:
49///
50/// - only one of them is a UUID: that one (the docs sample of a fill has `clientOrderId` empty);
51/// - both are, and the message is a cancel, a replace or a cancel in progress (`orderStatus` 4, 5, 6):
52///   `originalClientOrderId`, the order the news is about, not the id of the request;
53/// - both are, any other status: `clientOrderId`, the id of the order that was placed or changed.
54///
55/// Not verified against the live API: which id the broker puts in which field for each kind of message.
56/// `None` when neither is a UUID.
57pub fn message_cid(msg: &OrderMessage) -> Option<ClientOrderId> {
58    use brokapi_bcs_protocol::models::enums::OrderStatus as S;
59    let client = parse_cid(&msg.client_order_id);
60    let original = msg.original_client_order_id.as_deref().and_then(parse_cid);
61    match (client, original) {
62        (Some(c), Some(o)) => {
63            let about_the_original = msg.data.as_ref().is_some_and(|d| {
64                matches!(
65                    d.order_status,
66                    S::Cancelled | S::Replaced | S::PendingCancel
67                )
68            });
69            Some(if about_the_original { o } else { c })
70        }
71        (c, o) => c.or(o),
72    }
73}
74
75/// Maps a message of the order events WebSocket to an `Event::Order`.
76///
77/// The client order id is chosen by [`message_cid`]. `None` when neither id is a UUID or the message has no
78/// report. Fills are mapped separately by [`crate::map::execution::map_ws_execution`].
79pub fn map_ws_order_message(msg: &OrderMessage, now: Instant) -> Option<Event> {
80    let cid = message_cid(msg)?;
81    let data = msg.data.as_ref()?;
82    let original = msg
83        .original_client_order_id
84        .as_deref()
85        .unwrap_or(&msg.client_order_id);
86    Some(Event::Order(map_ws_execution_data(
87        cid, data, now, original,
88    )))
89}
90
91/// Order types of the order LIST (`POST /orders/search`) that are plain orders: 1 market, 2 limit,
92/// 3 iceberg, 10 limit for 30 days. Types 4..7 and 11 are conditional (stop) orders.
93pub const PLAIN_LIST_TYPES: [i32; 4] = [1, 2, 3, 10];
94/// Order types of the list that are conditional orders: 4 stop-limit, 5 and 11 take-profit,
95/// 6 stop-loss, 7 take-profit and stop-loss.
96pub const STOP_LIST_TYPES: [i32; 5] = [4, 5, 6, 7, 11];
97/// Order status of the list: 3 = active.
98pub const LIST_STATUS_ACTIVE: i32 = 3;
99
100/// A plain order of the list. `cid` is what the caller knows about this order (the list does not carry
101/// our `clientOrderId`).
102pub fn map_order_record(
103    record: &OrderRecord,
104    cid: ClientOrderId,
105    now: Instant,
106) -> Option<OrderSnapshot> {
107    let ticker = record.ticker.as_deref()?;
108    let class_code = record.class_code.as_deref()?;
109    let instrument = instrument_id_ticker(ticker, class_code);
110    let side = map_side_int(record.side)?;
111    let order_type = map_order_type_int(record.order_type)?;
112    let qty = record.order_quantity.unwrap_or(Decimal::ZERO);
113    let filled = record.executed_quantity.unwrap_or(Decimal::ZERO);
114    let state = list_state(record, cid);
115    Some(
116        OrderSnapshot::new(
117            cid,
118            instrument,
119            state,
120            side,
121            order_type,
122            Qty::new(qty),
123            timestamps(now, None),
124        )
125        .with_broker_id(Some(BrokerOrderId::new(record.order_id.clone())))
126        .with_filled(Qty::new(filled))
127        .with_price(record.price.filter(|p| *p > Decimal::ZERO).map(Price::new)),
128    )
129}
130
131/// Status of the list (`orderStatus`): 1 cancelled, 2 executed, 3 active. An active order with some
132/// quantity executed is partially filled.
133fn list_state(record: &OrderRecord, cid: ClientOrderId) -> OrderState {
134    let broker_id = BrokerOrderId::new(record.order_id.clone());
135    let executed = record.executed_quantity.unwrap_or(Decimal::ZERO);
136    match record.order_status {
137        Some(1) => OrderState::Cancelled { cid },
138        Some(2) => OrderState::Filled { cid },
139        Some(3) if executed > Decimal::ZERO => OrderState::PartiallyFilled {
140            cid,
141            filled: Qty::new(executed),
142            left: Qty::new(record.remained_quantity.unwrap_or_else(|| {
143                (record.order_quantity.unwrap_or(Decimal::ZERO) - executed).max(Decimal::ZERO)
144            })),
145        },
146        Some(3) => OrderState::Acked { cid, broker_id },
147        _ => OrderState::Unknown {
148            cid,
149            since: brokapi_runtime::now(),
150        },
151    }
152}
153
154/// A conditional order of the list as one or two stop snapshots (two for type 7, a take-profit and
155/// stop-loss pair: the pair is one order at the broker, shown as its two legs with the same ids).
156/// `None` for plain orders.
157pub fn map_stop_record(
158    record: &OrderRecord,
159    cid: Option<ClientOrderId>,
160    now: Instant,
161) -> Vec<StopOrderSnapshot> {
162    let (Some(ticker), Some(class_code)) = (record.ticker.as_deref(), record.class_code.as_deref())
163    else {
164        return Vec::new();
165    };
166    let Some(side) = map_side_int(record.side) else {
167        return Vec::new();
168    };
169    let state = match record.order_status {
170        Some(3) => StopOrderState::Active,
171        // "Executed" for a conditional order means it triggered and produced its order.
172        Some(2) => StopOrderState::Triggered,
173        Some(1) => StopOrderState::Cancelled,
174        other => StopOrderState::Other(format!("orderStatus={other:?}")),
175    };
176    let limit = |market_flag: Option<i32>| {
177        (market_flag == Some(2))
178            .then_some(record.price)
179            .flatten()
180            .filter(|p| *p > Decimal::ZERO)
181            .map(Price::new)
182    };
183    let mut legs: Vec<(StopOrderKind, Decimal, Option<Price>)> = Vec::new();
184    match record.order_type {
185        // Stop-limit: always a limit order at `price`.
186        Some(4) => legs.push((
187            StopOrderKind::StopLimit,
188            record.stop_price.unwrap_or(Decimal::ZERO),
189            record.price.filter(|p| *p > Decimal::ZERO).map(Price::new),
190        )),
191        Some(6) => legs.push((
192            StopOrderKind::StopLoss,
193            record.stop_price.unwrap_or(Decimal::ZERO),
194            limit(record.market_stop_loss),
195        )),
196        Some(5) | Some(11) => legs.push((
197            StopOrderKind::TakeProfit,
198            record.take_price.unwrap_or(Decimal::ZERO),
199            limit(record.market_take_profit),
200        )),
201        Some(7) => {
202            legs.push((
203                StopOrderKind::StopLoss,
204                record.stop_price.unwrap_or(Decimal::ZERO),
205                limit(record.market_stop_loss),
206            ));
207            legs.push((
208                StopOrderKind::TakeProfit,
209                record.take_price.unwrap_or(Decimal::ZERO),
210                limit(record.market_take_profit),
211            ));
212        }
213        _ => return Vec::new(),
214    }
215    legs.into_iter()
216        .map(|(kind, stop_price, limit_price)| StopOrderSnapshot {
217            cid,
218            broker_id: BrokerOrderId::new(record.order_id.clone()),
219            instrument: instrument_id_ticker(ticker, class_code),
220            side,
221            kind,
222            qty: Qty::new(record.order_quantity.unwrap_or(Decimal::ZERO)),
223            stop_price: Price::new(stop_price),
224            limit_price,
225            // The list does not report the lifetime of a conditional order (live question 12).
226            expiry: StopExpiry::GoodTillCancel,
227            state: state.clone(),
228            ts: timestamps(now, None),
229        })
230        .collect()
231}
232
233/// The latest report of an answer with a `data` list (`GET /orders`, `/orders*/sync`) as a snapshot.
234/// `known_cid` is the cid the caller asked about (a status answer is about that order); without it the
235/// ids of the answer are used.
236pub fn map_state_list(
237    known_cid: Option<ClientOrderId>,
238    resp: &OrderStateListResponse,
239    now: Instant,
240) -> Option<OrderSnapshot> {
241    // The newest report wins (equal or missing times: the later one in the list).
242    let data = resp.data.iter().max_by_key(|d| d.transaction_time)?;
243    let cid = known_cid
244        .or_else(|| parse_cid(&resp.client_order_id))
245        .or_else(|| resp.original_client_order_id.as_deref().and_then(parse_cid))?;
246    Some(map_execution_data(cid, data, now))
247}
248
249/// `true` when the report says the broker refused the order (`orderStatus` 8), with its reason.
250pub fn rejection_reason(snap: &OrderSnapshot) -> Option<&str> {
251    match &snap.state {
252        OrderState::Rejected { reason, .. } => Some(reason.as_str()),
253        _ => None,
254    }
255}
256
257fn map_operation_ack(
258    resp: &OrderOperationResponse,
259    now: Instant,
260    state: OrderState,
261) -> OrderSnapshot {
262    OrderSnapshot::new(
263        resp.client_order_id,
264        instrument_id_ticker("", ""),
265        state,
266        Side::Buy,
267        OrderType::Limit,
268        Qty::ZERO,
269        timestamps(now, None),
270    )
271    .with_broker_id(Some(BrokerOrderId::new(
272        resp.client_order_id.hyphenated().to_string(),
273    )))
274}
275
276fn map_ws_execution_data(
277    cid: ClientOrderId,
278    data: &OrderExecutionData,
279    now: Instant,
280    original_cid: &str,
281) -> OrderSnapshot {
282    let instrument = instrument_id_ticker(&data.ticker, &data.class_code);
283    let state = map_bcs_status(data, cid);
284    let side = map_bcs_side(data.side);
285    let order_type = data
286        .order_type
287        .map(map_bcs_order_type)
288        .unwrap_or(OrderType::Limit);
289    let qty = Qty::new(
290        data.order_quantity
291            .unwrap_or(data.last_quantity + data.remained_quantity),
292    );
293    let filled = Qty::new(data.executed_quantity.unwrap_or(data.last_quantity));
294    let broker_ts = data.transaction_time.map(super::unix_nanos);
295    let _original = parse_cid(original_cid);
296
297    OrderSnapshot::new(
298        cid,
299        instrument,
300        state,
301        side,
302        order_type,
303        qty,
304        timestamps(now, broker_ts),
305    )
306    .with_broker_id(
307        data.order_id
308            .as_ref()
309            .map(|id| BrokerOrderId::new(id.clone())),
310    )
311    .with_filled(filled)
312    .with_price(Some(Price::new(data.price)))
313}
314
315fn map_execution_data(
316    cid: ClientOrderId,
317    data: &RestOrderExecutionData,
318    now: Instant,
319) -> OrderSnapshot {
320    let ticker = data.ticker.as_deref().unwrap_or("");
321    let class_code = data.class_code.as_deref().unwrap_or("");
322    let instrument = instrument_id_ticker(ticker, class_code);
323    let state = map_rest_status(data, cid);
324    let side = data.side.as_deref().map(map_side_str).unwrap_or(Side::Buy);
325    let order_type = data
326        .order_type
327        .as_deref()
328        .map(map_order_type_str)
329        .unwrap_or(OrderType::Limit);
330    let qty = Qty::new(data.order_quantity.unwrap_or(Decimal::ZERO));
331    let filled = Qty::new(data.executed_quantity.unwrap_or(Decimal::ZERO));
332
333    OrderSnapshot::new(
334        cid,
335        instrument,
336        state,
337        side,
338        order_type,
339        qty,
340        timestamps(now, data.transaction_time.map(super::unix_nanos)),
341    )
342    .with_broker_id(
343        data.order_id
344            .as_ref()
345            .filter(|id| !id.is_empty())
346            .map(|id| BrokerOrderId::new(id.clone())),
347    )
348    .with_filled(filled)
349    .with_price(data.price.filter(|p| *p > Decimal::ZERO).map(Price::new))
350}
351
352fn parse_cid(raw: &str) -> Option<ClientOrderId> {
353    Uuid::parse_str(raw).ok()
354}
355
356fn map_bcs_status(data: &OrderExecutionData, cid: ClientOrderId) -> OrderState {
357    let broker_id = BrokerOrderId::new(
358        data.order_id
359            .clone()
360            .filter(|id| !id.is_empty())
361            .unwrap_or_else(|| cid.hyphenated().to_string()),
362    );
363    match data.order_status {
364        OrderStatus::New
365        | OrderStatus::PendingConfirmation
366        | OrderStatus::Replaced
367        | OrderStatus::PendingReplace => OrderState::Acked { cid, broker_id },
368        OrderStatus::PartiallyFilled => {
369            let filled = data.executed_quantity.unwrap_or(data.last_quantity);
370            OrderState::PartiallyFilled {
371                cid,
372                filled: Qty::new(filled),
373                left: Qty::new(data.remained_quantity),
374            }
375        }
376        OrderStatus::Filled => OrderState::Filled { cid },
377        OrderStatus::Cancelled => OrderState::Cancelled { cid },
378        // "Cancelling" (6) is not a result: the order is still in the book and may be filled before the
379        // cancel is done. It is reported as `Unknown` (the final state comes with status 4 or a fill).
380        OrderStatus::PendingCancel => OrderState::Unknown {
381            cid,
382            since: brokapi_runtime::now(),
383        },
384        OrderStatus::Rejected => OrderState::Rejected {
385            cid,
386            reason: "rejected".into(),
387        },
388        OrderStatus::Unknown => OrderState::Unknown {
389            cid,
390            since: brokapi_runtime::now(),
391        },
392    }
393}
394
395/// `orderStatus` of a REST report (the same codes as the WebSocket): 0 new, 1 partially filled,
396/// 2 filled, 4 cancelled, 5 replaced, 6 cancelling, 8 rejected, 9 replacing, 10 waiting for the new
397/// order's confirmation. The orders replaced or still being changed are alive for the caller. 6 is
398/// `Unknown`, not `Cancelled`: the order is still alive and may be filled before the cancel is done.
399fn map_rest_status(data: &RestOrderExecutionData, cid: ClientOrderId) -> OrderState {
400    let broker_id = BrokerOrderId::new(
401        data.order_id
402            .clone()
403            .filter(|id| !id.is_empty())
404            .unwrap_or_else(|| cid.hyphenated().to_string()),
405    );
406    match data.order_status.as_deref() {
407        Some("0") | Some("NEW") | Some("5") | Some("9") | Some("10") => {
408            OrderState::Acked { cid, broker_id }
409        }
410        Some("1") => {
411            let filled = data.executed_quantity.unwrap_or(Decimal::ZERO);
412            OrderState::PartiallyFilled {
413                cid,
414                filled: Qty::new(filled),
415                left: Qty::new(data.remained_quantity.unwrap_or_else(|| {
416                    (data.order_quantity.unwrap_or(Decimal::ZERO) - filled).max(Decimal::ZERO)
417                })),
418            }
419        }
420        Some("2") => OrderState::Filled { cid },
421        Some("4") => OrderState::Cancelled { cid },
422        Some("6") => OrderState::Unknown {
423            cid,
424            since: brokapi_runtime::now(),
425        },
426        Some("8") => OrderState::Rejected {
427            cid,
428            reason: data
429                .reject_reason
430                .clone()
431                .filter(|r| !r.is_empty())
432                .unwrap_or_else(|| "rejected".into()),
433        },
434        _ => OrderState::Unknown {
435            cid,
436            since: brokapi_runtime::now(),
437        },
438    }
439}
440
441fn map_bcs_side(side: OrderSide) -> Side {
442    match side {
443        OrderSide::Buy => Side::Buy,
444        OrderSide::Sell => Side::Sell,
445        OrderSide::Unknown => Side::Buy,
446    }
447}
448
449fn map_bcs_order_type(t: BcsOrderType) -> OrderType {
450    match t {
451        BcsOrderType::Market => OrderType::Market,
452        BcsOrderType::Limit => OrderType::Limit,
453        BcsOrderType::Unknown => OrderType::Limit,
454    }
455}
456
457fn map_side_int(v: Option<i32>) -> Option<Side> {
458    match v {
459        Some(1) => Some(Side::Buy),
460        Some(2) => Some(Side::Sell),
461        _ => None,
462    }
463}
464
465/// Plain order types of the list: market, limit, iceberg and limit-for-30-days (the last two are
466/// limit orders). Conditional types are not plain orders.
467fn map_order_type_int(v: Option<i32>) -> Option<OrderType> {
468    match v {
469        Some(1) => Some(OrderType::Market),
470        Some(2) | Some(3) | Some(10) => Some(OrderType::Limit),
471        _ => None,
472    }
473}
474
475fn map_side_str(raw: &str) -> Side {
476    match raw {
477        "1" | "Buy" | "BUY" => Side::Buy,
478        _ => Side::Sell,
479    }
480}
481
482fn map_order_type_str(raw: &str) -> OrderType {
483    match raw {
484        "1" | "Market" => OrderType::Market,
485        _ => OrderType::Limit,
486    }
487}
488
489/// Builds an [`OrderAck`] for a client order id and the broker's order id. The ack carries the local
490/// receive time only, the broker gives no time with it.
491pub fn map_order_ack(cid: ClientOrderId, broker_id: BrokerOrderId, now: Instant) -> OrderAck {
492    OrderAck {
493        cid,
494        broker_id,
495        ts: timestamps(now, None),
496    }
497}