1use std::time::Instant;
4
5use brokapi_core::{
6 BrokerOrderId, ClientOrderId, InstrumentId, OrderSnapshot, OrderState, OrderType, Price, Qty,
7 Side,
8};
9use brokapi_tbank_protocol::v1 as pb;
10use brokapi_tbank_protocol::v1::order_state_stream_response as stream;
11
12use crate::cid::{parse_request_id, synthetic_cid};
13use crate::ids::{id_from_parts, price_of_money, stamps};
14use crate::lots::lots_to_qty;
15
16#[derive(Debug, Clone)]
18pub struct OrderParts {
19 pub exchange_id: String,
21 pub request_id: String,
24 pub status: pb::OrderExecutionReportStatus,
26 pub lots_requested: i64,
28 pub lots_executed: i64,
30 pub lot_size: i64,
33 pub unit_price: Option<Price>,
35 pub direction: pb::OrderDirection,
37 pub order_type: pb::OrderType,
39 pub instrument: InstrumentId,
41 pub reason: String,
44 pub broker_ts: Option<prost_types::Timestamp>,
46}
47
48impl OrderParts {
49 pub fn with_lot(mut self, lot_size: i64) -> Self {
51 self.lot_size = lot_size.max(1);
52 self
53 }
54
55 pub fn qty_requested(&self) -> Qty {
57 lots_to_qty(self.lots_requested, self.lot_size)
58 }
59
60 pub fn qty_executed(&self) -> Qty {
62 lots_to_qty(self.lots_executed, self.lot_size)
63 }
64
65 pub fn qty_left(&self) -> Qty {
67 lots_to_qty(
68 (self.lots_requested - self.lots_executed).max(0),
69 self.lot_size,
70 )
71 }
72
73 pub fn cid(&self) -> ClientOrderId {
75 parse_request_id(&self.request_id).unwrap_or_else(|| synthetic_cid(&self.exchange_id))
76 }
77
78 pub fn is_ours(&self) -> bool {
80 parse_request_id(&self.request_id).is_some()
81 }
82}
83
84pub fn side(d: pb::OrderDirection) -> Side {
86 match d {
87 pb::OrderDirection::Sell => Side::Sell,
88 _ => Side::Buy,
89 }
90}
91
92pub fn direction(s: Side) -> pb::OrderDirection {
94 match s {
95 Side::Buy => pb::OrderDirection::Buy,
96 Side::Sell => pb::OrderDirection::Sell,
97 }
98}
99
100pub fn order_type(t: pb::OrderType) -> OrderType {
102 match t {
103 pb::OrderType::Limit => OrderType::Limit,
104 _ => OrderType::Market,
105 }
106}
107
108pub fn wire_order_type(t: OrderType) -> pb::OrderType {
110 match t {
111 OrderType::Limit => pb::OrderType::Limit,
112 OrderType::Market => pb::OrderType::Market,
113 }
114}
115
116pub fn state(p: &OrderParts, cid: ClientOrderId, now: Instant) -> OrderState {
122 use pb::OrderExecutionReportStatus as S;
123 let broker_id = BrokerOrderId::new(p.exchange_id.clone());
124 match p.status {
125 S::ExecutionReportStatusFill => OrderState::Filled { cid },
126 S::ExecutionReportStatusCancelled => OrderState::Cancelled { cid },
127 S::ExecutionReportStatusRejected => OrderState::Rejected {
128 cid,
129 reason: if p.reason.is_empty() {
130 "REJECTED".into()
131 } else {
132 p.reason.clone()
133 },
134 },
135 S::ExecutionReportStatusNew => OrderState::Acked { cid, broker_id },
136 S::ExecutionReportStatusPartiallyfill => OrderState::PartiallyFilled {
137 cid,
138 filled: p.qty_executed(),
139 left: p.qty_left(),
140 },
141 S::ExecutionReportStatusUnspecified => OrderState::Unknown { cid, since: now },
142 }
143}
144
145pub fn snapshot(p: &OrderParts, now: Instant) -> OrderSnapshot {
150 let cid = p.cid();
151 OrderSnapshot::new(
152 cid,
153 p.instrument.clone(),
154 state(p, cid, now),
155 side(p.direction),
156 order_type(p.order_type),
157 p.qty_requested(),
158 stamps(now, p.broker_ts.as_ref()),
159 )
160 .with_broker_id((!p.exchange_id.is_empty()).then(|| BrokerOrderId::new(p.exchange_id.clone())))
161 .with_filled(p.qty_executed())
162 .with_price(p.unit_price)
163}
164
165fn price_of(m: &Option<pb::MoneyValue>) -> Option<Price> {
166 m.as_ref().map(price_of_money).filter(|p| !p.is_zero())
167}
168
169pub fn parts_from_state(o: &pb::OrderState) -> OrderParts {
171 OrderParts {
172 exchange_id: o.order_id.clone(),
173 request_id: o.order_request_id.clone(),
174 status: o.execution_report_status(),
175 lots_requested: o.lots_requested,
176 lots_executed: o.lots_executed,
177 lot_size: 1,
178 unit_price: price_of(&o.initial_security_price),
179 direction: o.direction(),
180 order_type: o.order_type(),
181 instrument: id_from_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code),
182 reason: String::new(),
183 broker_ts: o.order_date,
184 }
185}
186
187pub fn parts_from_post(r: &pb::PostOrderResponse) -> OrderParts {
189 OrderParts {
190 exchange_id: r.order_id.clone(),
191 request_id: r.order_request_id.clone(),
192 status: r.execution_report_status(),
193 lots_requested: r.lots_requested,
194 lots_executed: r.lots_executed,
195 lot_size: 1,
196 unit_price: price_of(&r.initial_security_price),
197 direction: r.direction(),
198 order_type: r.order_type(),
199 instrument: id_from_parts(&r.instrument_uid, &r.figi, &r.ticker, &r.class_code),
200 reason: r.message.clone(),
201 broker_ts: None,
202 }
203}
204
205pub fn parts_from_stream(o: &stream::OrderState) -> OrderParts {
208 let cause = o
209 .status_info
210 .and_then(|c| stream::StatusCauseInfo::try_from(c).ok())
211 .map(|c| c.as_str_name().to_string())
212 .unwrap_or_default();
213 OrderParts {
214 exchange_id: o.order_id.clone(),
215 request_id: o.order_request_id.clone().unwrap_or_default(),
216 status: o.execution_report_status(),
217 lots_requested: o.lots_requested,
218 lots_executed: o.lots_executed,
219 lot_size: i64::from(o.lot_size).max(1),
220 unit_price: price_of(&Some(o.order_price.clone().unwrap_or_default())),
221 direction: o.direction(),
222 order_type: o.order_type(),
223 instrument: id_from_parts(&o.instrument_uid, "", &o.ticker, &o.class_code),
224 reason: cause,
225 broker_ts: o.created_at,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use brokapi_core::{Decimal, dec};
233 use brokapi_tbank_protocol::money::money;
234
235 fn base() -> pb::OrderState {
236 pb::OrderState {
237 order_id: "5001".into(),
238 execution_report_status: pb::OrderExecutionReportStatus::ExecutionReportStatusNew
239 as i32,
240 lots_requested: 10,
241 lots_executed: 0,
242 initial_security_price: Some(money("rub", "250.5")),
243 direction: pb::OrderDirection::Buy as i32,
244 order_type: pb::OrderType::Limit as i32,
245 order_request_id: "6f1c4f6e-0a54-4a43-8c46-0f6d3b2c8a11".into(),
246 ticker: "SBER".into(),
247 class_code: "TQBR".into(),
248 instrument_uid: "uid-1".into(),
249 ..Default::default()
250 }
251 }
252
253 fn snap(o: &pb::OrderState) -> OrderSnapshot {
254 snapshot(&parts_from_state(o), Instant::now())
255 }
256
257 #[test]
258 fn new_order_is_acked_with_our_cid() {
259 let s = snap(&base());
260 assert_eq!(s.cid.to_string(), "6f1c4f6e-0a54-4a43-8c46-0f6d3b2c8a11");
261 assert!(matches!(s.state, OrderState::Acked { .. }));
262 assert_eq!(s.broker_id.as_ref().unwrap().as_str(), "5001");
263 assert_eq!(
264 (s.qty.decimal(), s.filled.decimal()),
265 (dec!(10.0), dec!(0.0))
266 );
267 assert_eq!(s.price.unwrap().decimal(), dec!(250.5));
268 assert_eq!((s.side, s.order_type), (Side::Buy, OrderType::Limit));
269 }
270
271 #[test]
272 fn partial_fill_counts_left_lots() {
273 let mut o = base();
274 o.execution_report_status =
275 pb::OrderExecutionReportStatus::ExecutionReportStatusPartiallyfill as i32;
276 o.lots_executed = 4;
277 let s = snap(&o);
278 match s.state {
279 OrderState::PartiallyFilled { filled, left, .. } => {
280 assert_eq!((filled.decimal(), left.decimal()), (dec!(4.0), dec!(6.0)));
281 }
282 other => panic!("{other:?}"),
283 }
284 }
285
286 #[test]
287 fn terminal_statuses() {
288 use pb::OrderExecutionReportStatus as S;
289 for (st, check) in [
290 (S::ExecutionReportStatusFill, 0),
291 (S::ExecutionReportStatusCancelled, 1),
292 (S::ExecutionReportStatusRejected, 2),
293 ] {
294 let mut o = base();
295 o.execution_report_status = st as i32;
296 let s = snap(&o);
297 match (check, &s.state) {
298 (0, OrderState::Filled { .. }) | (1, OrderState::Cancelled { .. }) => {}
299 (2, OrderState::Rejected { reason, .. }) => assert_eq!(reason, "REJECTED"),
300 other => panic!("{other:?}"),
301 }
302 }
303 }
304
305 #[test]
306 fn unspecified_status_is_unknown() {
307 let mut o = base();
308 o.execution_report_status = 0;
309 assert!(matches!(snap(&o).state, OrderState::Unknown { .. }));
310 }
311
312 #[test]
313 fn foreign_order_gets_synthetic_cid() {
314 let mut o = base();
315 o.order_request_id.clear();
316 let p = parts_from_state(&o);
317 assert!(!p.is_ours());
318 assert!(!p.cid().is_nil());
319 assert_eq!(p.cid(), parts_from_state(&o).cid());
320 }
321
322 #[test]
323 fn stream_state_with_cause() {
324 let o = stream::OrderState {
325 order_id: "7".into(),
326 order_request_id: Some("6f1c4f6e-0a54-4a43-8c46-0f6d3b2c8a11".into()),
327 execution_report_status: pb::OrderExecutionReportStatus::ExecutionReportStatusRejected
328 as i32,
329 status_info: Some(stream::StatusCauseInfo::CauseRejectedByExchange as i32),
330 lots_requested: 1,
331 ticker: "SBER".into(),
332 class_code: "TQBR".into(),
333 ..Default::default()
334 };
335 let s = snapshot(&parts_from_stream(&o), Instant::now());
336 match s.state {
337 OrderState::Rejected { reason, .. } => assert_eq!(reason, "CAUSE_REJECTED_BY_EXCHANGE"),
338 other => panic!("{other:?}"),
339 }
340 }
341}