1use std::time::Instant;
18
19use brokapi_bybit_protocol::http::models::{CreateOrderRequest, OrderRecord};
20use brokapi_core::{
21 BrokerOrderId, ClientOrderId, Error, PlaceStopOrderRequest, Price, Qty, Side, StopExpiry,
22 StopOrderKind, StopOrderSnapshot, StopOrderState,
23};
24
25use super::{broker_ts, side_of, side_word, timestamps};
26use crate::util::instrument_id;
27
28pub fn trigger_direction(kind: StopOrderKind, side: Side) -> i64 {
31 if kind.triggers_on_rise(side) { 1 } else { 2 }
32}
33
34pub fn build(
42 req: &PlaceStopOrderRequest,
43 symbol: &str,
44 category: &str,
45 cid: ClientOrderId,
46) -> Result<CreateOrderRequest, Error> {
47 if req.expiry != StopExpiry::GoodTillCancel {
48 return Err(Error::Rejected {
49 code: "unsupported_expiry".into(),
50 message: "Bybit keeps a conditional order until it fires or is cancelled: use StopExpiry::GoodTillCancel"
51 .into(),
52 });
53 }
54 let limit = match (req.kind, req.limit_price) {
55 (StopOrderKind::StopLimit, None) => {
56 return Err(Error::Rejected {
57 code: "missing_price".into(),
58 message: "a stop-limit order requires limit_price".into(),
59 });
60 }
61 (StopOrderKind::StopLimit, limit) => limit,
62 (_, _) => None,
64 };
65 Ok(CreateOrderRequest {
66 category: category.to_string(),
67 symbol: symbol.to_string(),
68 side: side_word(req.side).to_string(),
69 order_type: if limit.is_some() { "Limit" } else { "Market" }.to_string(),
70 qty: req.qty.decimal(),
71 price: limit.map(Price::decimal),
72 time_in_force: Some("GTC".into()),
73 order_link_id: Some(cid.hyphenated().to_string()),
74 trigger_price: Some(req.stop_price.decimal()),
75 trigger_direction: Some(trigger_direction(req.kind, req.side)),
76 trigger_by: Some("LastPrice".into()),
77 position_idx: Some(0),
78 ..Default::default()
79 })
80}
81
82pub fn state_of(status: &str) -> StopOrderState {
84 match status {
85 "Untriggered" => StopOrderState::Active,
86 "Triggered" | "New" | "PartiallyFilled" | "Filled" => StopOrderState::Triggered,
87 "Cancelled" | "Deactivated" | "PartiallyFilledCanceled" => StopOrderState::Cancelled,
88 other => StopOrderState::Other(other.to_string()),
89 }
90}
91
92pub fn snapshot(
94 r: &OrderRecord,
95 cid: Option<ClientOrderId>,
96 category: &str,
97 now: Instant,
98) -> Option<StopOrderSnapshot> {
99 let side = side_of(&r.side)?;
100 let stop_price = Price::new(r.trigger_price.filter(|p| !p.is_zero())?);
101 let limit = r.order_type == "Limit";
102 let rises = r.trigger_direction == 1;
103 let kind = if limit {
104 StopOrderKind::StopLimit
105 } else if rises == (side == Side::Buy) {
106 StopOrderKind::StopLoss
107 } else {
108 StopOrderKind::TakeProfit
109 };
110 let category = if r.category.is_empty() {
111 category
112 } else {
113 &r.category
114 };
115 Some(StopOrderSnapshot {
116 cid,
117 broker_id: BrokerOrderId::new(r.order_id.clone()),
118 instrument: instrument_id(&r.symbol, category),
119 side,
120 kind,
121 qty: Qty::new(r.qty),
122 stop_price,
123 limit_price: if limit { r.price.map(Price::new) } else { None },
124 expiry: StopExpiry::GoodTillCancel,
125 state: state_of(&r.order_status),
126 ts: timestamps(
127 now,
128 broker_ts(r.updated_time).or_else(|| broker_ts(r.created_time)),
129 ),
130 })
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use brokapi_core::{Decimal, dec};
137 use uuid::Uuid;
138
139 fn request(kind: StopOrderKind, side: Side, limit: Option<Price>) -> PlaceStopOrderRequest {
140 PlaceStopOrderRequest {
141 cid: Uuid::new_v4(),
142 instrument: instrument_id("BTCUSDT", "linear"),
143 side,
144 kind,
145 qty: Qty::new(dec!(0.2)),
146 stop_price: Price::new(dec!(80000.5)),
147 limit_price: limit,
148 expiry: StopExpiry::GoodTillCancel,
149 }
150 }
151
152 #[test]
153 fn a_sell_stop_loss_waits_for_a_fall_and_a_sell_take_profit_for_a_rise() {
154 let b = |k, s, l| build(&request(k, s, l), "BTCUSDT", "linear", Uuid::new_v4()).unwrap();
155 let w = b(StopOrderKind::StopLoss, Side::Sell, None);
156 assert_eq!(
157 (w.trigger_direction, w.order_type.as_str()),
158 (Some(2), "Market")
159 );
160 let w = b(StopOrderKind::TakeProfit, Side::Sell, None);
161 assert_eq!(w.trigger_direction, Some(1));
162 let w = b(StopOrderKind::StopLoss, Side::Buy, None);
163 assert_eq!(w.trigger_direction, Some(1));
164 let w = b(
165 StopOrderKind::StopLimit,
166 Side::Sell,
167 Some(Price::new(dec!(80000))),
168 );
169 assert_eq!(
170 (w.order_type.as_str(), w.price),
171 ("Limit", Some(dec!(80000)))
172 );
173 assert_eq!(w.trigger_price, Some(dec!(80000.5)));
174 assert_eq!(w.order_link_id.as_ref().map(String::len), Some(36));
175 }
176
177 #[test]
178 fn a_stop_limit_without_a_price_and_a_time_limit_are_refused() {
179 let err = build(
180 &request(StopOrderKind::StopLimit, Side::Sell, None),
181 "BTCUSDT",
182 "linear",
183 Uuid::new_v4(),
184 )
185 .unwrap_err();
186 assert!(matches!(err, Error::Rejected { ref code, .. } if code == "missing_price"));
187 let mut req = request(StopOrderKind::StopLoss, Side::Sell, None);
188 req.expiry = StopExpiry::EndOfDay;
189 let err = build(&req, "BTCUSDT", "linear", Uuid::new_v4()).unwrap_err();
190 assert!(matches!(err, Error::Rejected { ref code, .. } if code == "unsupported_expiry"));
191 }
192
193 #[test]
194 fn a_listed_conditional_order_reads_back_with_its_kind() {
195 let r: OrderRecord = serde_json::from_str(
196 r#"{"orderId":"9","symbol":"BTCUSDT","side":"Sell","orderType":"Market","orderStatus":"Untriggered","qty":"0.2","triggerPrice":"80000.5","triggerDirection":2}"#,
197 )
198 .unwrap();
199 let s = snapshot(&r, None, "linear", Instant::now()).unwrap();
200 assert_eq!(s.kind, StopOrderKind::StopLoss);
201 assert_eq!(s.stop_price.to_string(), "80000.5");
202 assert_eq!(s.state, StopOrderState::Active);
203 let r: OrderRecord = serde_json::from_str(
204 r#"{"orderId":"9","symbol":"BTCUSDT","side":"Sell","orderType":"Limit","price":"79000","orderStatus":"Triggered","qty":"0.2","triggerPrice":"80000.5","triggerDirection":1}"#,
205 )
206 .unwrap();
207 let s = snapshot(&r, None, "linear", Instant::now()).unwrap();
208 assert_eq!(
209 (s.kind, s.state),
210 (StopOrderKind::StopLimit, StopOrderState::Triggered)
211 );
212 assert_eq!(s.limit_price.unwrap().to_string(), "79000");
213 }
214}