1use std::time::Instant;
4
5use brokapi_bybit_protocol::http::models::OrderRecord;
6use brokapi_core::{
7 BrokerOrderId, ClientOrderId, Decimal, OrderAck, OrderSnapshot, OrderState, OrderType, Price,
8 Qty,
9};
10
11use super::{broker_ts, side_of, timestamps};
12use crate::util::instrument_id;
13
14pub fn state_of(
21 status: &str,
22 cid: ClientOrderId,
23 broker_id: &str,
24 qty: Qty,
25 filled: Qty,
26 reject_reason: &str,
27) -> OrderState {
28 match status {
29 "Filled" => OrderState::Filled { cid },
30 "Cancelled" | "PartiallyFilledCanceled" | "Deactivated" => OrderState::Cancelled { cid },
31 "Rejected" => OrderState::Rejected {
32 cid,
33 reason: if reject_reason.is_empty() || reject_reason == "EC_NoError" {
34 "rejected by the exchange".to_string()
35 } else {
36 reject_reason.to_string()
37 },
38 },
39 _ if filled.is_positive() => OrderState::PartiallyFilled {
40 cid,
41 filled,
42 left: Qty::new((qty.decimal() - filled.decimal()).max(Decimal::ZERO)),
43 },
44 _ => OrderState::Acked {
45 cid,
46 broker_id: BrokerOrderId::new(broker_id),
47 },
48 }
49}
50
51pub fn is_untriggered(r: &OrderRecord) -> bool {
53 r.order_status == "Untriggered"
54}
55
56pub fn is_final(r: &OrderRecord) -> bool {
58 matches!(
59 r.order_status.as_str(),
60 "Filled" | "Cancelled" | "PartiallyFilledCanceled" | "Deactivated" | "Rejected"
61 )
62}
63
64pub fn order_snapshot(
66 r: &OrderRecord,
67 cid: ClientOrderId,
68 category: &str,
69 now: Instant,
70) -> Option<OrderSnapshot> {
71 let qty = Qty::new(r.qty);
72 let filled = Qty::new(r.cum_exec_qty);
73 let side = side_of(&r.side)?;
74 let order_type = if r.order_type == "Market" {
75 OrderType::Market
76 } else {
77 OrderType::Limit
78 };
79 let category = if r.category.is_empty() {
80 category
81 } else {
82 &r.category
83 };
84 let broker_ts = broker_ts(r.updated_time).or_else(|| broker_ts(r.created_time));
85 Some(
86 OrderSnapshot::new(
87 cid,
88 instrument_id(&r.symbol, category),
89 state_of(
90 &r.order_status,
91 cid,
92 &r.order_id,
93 qty,
94 filled,
95 &r.reject_reason,
96 ),
97 side,
98 order_type,
99 qty,
100 timestamps(now, broker_ts),
101 )
102 .with_broker_id(Some(BrokerOrderId::new(r.order_id.clone())))
103 .with_filled(filled)
104 .with_price(r.price.filter(|p| !p.is_zero()).map(Price::new)),
105 )
106}
107
108pub fn order_ack(cid: ClientOrderId, order_id: &str, now: Instant) -> OrderAck {
110 OrderAck {
111 cid,
112 broker_id: BrokerOrderId::new(order_id),
113 ts: timestamps(now, None),
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use uuid::Uuid;
121
122 fn rec(json: &str) -> OrderRecord {
123 serde_json::from_str(json).unwrap()
124 }
125
126 #[test]
127 fn working_orders_are_acked_or_partly_filled() {
128 let cid = Uuid::new_v4();
129 let r = rec(
130 r#"{"orderId":"77","symbol":"BTCUSDT","orderType":"Limit","side":"Buy","orderStatus":"New","qty":"0.20","cumExecQty":"0.00","price":"100.5"}"#,
131 );
132 let s = order_snapshot(&r, cid, "linear", Instant::now()).unwrap();
133 assert!(matches!(s.state, OrderState::Acked { .. }));
134 assert_eq!(s.price.unwrap().to_string(), "100.5");
135 assert_eq!(s.broker_id.unwrap().as_str(), "77");
136 let r = rec(
137 r#"{"orderId":"77","symbol":"BTCUSDT","orderType":"Limit","side":"Buy","orderStatus":"PartiallyFilled","qty":"0.20","cumExecQty":"0.05"}"#,
138 );
139 let s = order_snapshot(&r, cid, "linear", Instant::now()).unwrap();
140 match s.state {
141 OrderState::PartiallyFilled { filled, left, .. } => {
142 assert_eq!(
143 (filled.to_string(), left.to_string()),
144 ("0.05".into(), "0.15".into())
145 );
146 }
147 other => panic!("{other:?}"),
148 }
149 }
150
151 #[test]
152 fn final_states_follow_the_status() {
153 let cid = Uuid::new_v4();
154 let st = |s: &str| state_of(s, cid, "1", Qty::from(1), Qty::ZERO, "");
155 assert!(matches!(st("Filled"), OrderState::Filled { .. }));
156 assert!(matches!(st("Cancelled"), OrderState::Cancelled { .. }));
157 assert!(matches!(st("Deactivated"), OrderState::Cancelled { .. }));
158 assert!(matches!(st("Rejected"), OrderState::Rejected { .. }));
159 assert!(matches!(st("SomethingNew"), OrderState::Acked { .. }));
161 let r = rec(
163 r#"{"orderId":"1","symbol":"BTCUSDT","side":"Buy","orderStatus":"Cancelled","qty":"1","cumExecQty":"0.4"}"#,
164 );
165 let s = order_snapshot(&r, cid, "linear", Instant::now()).unwrap();
166 assert!(matches!(s.state, OrderState::Cancelled { .. }));
167 assert_eq!(s.filled.to_string(), "0.4");
168 }
169
170 #[test]
171 fn the_reject_reason_is_kept_unless_there_is_none() {
172 let cid = Uuid::new_v4();
173 let reason = |r: &str| match state_of("Rejected", cid, "1", Qty::from(1), Qty::ZERO, r) {
174 OrderState::Rejected { reason, .. } => reason,
175 other => panic!("{other:?}"),
176 };
177 assert_eq!(reason("EC_PerCancelRequest"), "EC_PerCancelRequest");
178 assert_eq!(reason("EC_NoError"), "rejected by the exchange");
179 }
180
181 #[test]
182 fn a_record_with_a_side_the_connector_cannot_read_is_skipped() {
183 let r =
184 rec(r#"{"orderId":"1","symbol":"BTCUSDT","side":"","orderStatus":"New","qty":"1"}"#);
185 assert!(order_snapshot(&r, Uuid::new_v4(), "linear", Instant::now()).is_none());
186 }
187}