1use std::time::Instant;
16
17use brokapi_alor_protocol::http::models::{
18 InstrumentRef, StopOrderRecord, StopOrderRequest, UserRef,
19};
20use brokapi_core::{
21 BrokerOrderId, ClientOrderId, Error, PlaceStopOrderRequest, Price, Qty, Side, StopExpiry,
22 StopOrderKind, StopOrderSnapshot, StopOrderState,
23};
24
25use super::market::side_of;
26use super::{parse_time, pieces_to_lots, timestamps};
27use crate::cid::cid_from_comment;
28use crate::util::{instrument_id, nanos_to_secs, ticker_board};
29
30pub const GTC_HORIZON_SECS: i64 = 365 * 24 * 3600;
32
33pub fn condition(kind: StopOrderKind, side: Side) -> &'static str {
35 if kind.triggers_on_rise(side) {
36 "moreorequal"
37 } else {
38 "lessorequal"
39 }
40}
41
42pub fn end_time(expiry: StopExpiry, now_secs: i64) -> i64 {
44 match expiry {
45 StopExpiry::GoodTillCancel => now_secs + GTC_HORIZON_SECS,
46 StopExpiry::EndOfDay => {
48 const MSK: i64 = 3 * 3600;
49 ((now_secs + MSK).div_euclid(86_400) + 1) * 86_400 - MSK - 1
50 }
51 StopExpiry::GoodTillDate(ts) => nanos_to_secs(ts),
52 }
53}
54
55pub fn build(
62 req: &PlaceStopOrderRequest,
63 lot: brokapi_core::Decimal,
64 portfolio: &str,
65 exchange: &str,
66 now_secs: i64,
67 cid: ClientOrderId,
68) -> Result<(StopOrderRequest, bool), Error> {
69 let (ticker, board) = ticker_board(&req.instrument)?;
70 let limit = match (req.kind, req.limit_price) {
71 (StopOrderKind::StopLimit, None) => {
72 return Err(Error::Rejected {
73 code: "missing_price".into(),
74 message: "a stop-limit order requires limit_price".into(),
75 });
76 }
77 (_, limit) => limit,
78 };
79 let lots = pieces_to_lots(req.qty, lot)?;
80 let wire = StopOrderRequest {
81 side: match req.side {
82 Side::Buy => "buy",
83 Side::Sell => "sell",
84 }
85 .to_string(),
86 condition: condition(req.kind, req.side).to_string(),
87 trigger_price: req.stop_price.decimal(),
88 stop_end_unix_time: Some(end_time(req.expiry, now_secs)),
89 price: limit.map(Price::decimal),
90 quantity: lots,
91 instrument: InstrumentRef {
92 symbol: ticker,
93 exchange: exchange.to_string(),
94 instrument_group: Some(board),
95 },
96 user: UserRef {
97 portfolio: portfolio.to_string(),
98 },
99 comment: Some(cid.hyphenated().to_string()),
100 time_in_force: limit.map(|_| "oneday".to_string()),
101 allow_margin: None,
102 activate: Some(true),
103 };
104 Ok((wire, limit.is_some()))
105}
106
107pub fn state_of(status: Option<&str>) -> StopOrderState {
109 match status.map(str::to_ascii_lowercase).as_deref() {
110 Some("working") | None => StopOrderState::Active,
111 Some("filled") => StopOrderState::Triggered,
112 Some("canceled") | Some("cancelled") => StopOrderState::Cancelled,
113 Some(other) => StopOrderState::Other(other.to_string()),
114 }
115}
116
117pub fn snapshot(
120 r: &StopOrderRecord,
121 cid: Option<ClientOrderId>,
122 now: Instant,
123) -> Option<StopOrderSnapshot> {
124 let side = side_of(r.side.as_deref())?;
125 let stop_price = Price::new(r.stop_price?);
126 let qty = Qty::new(r.qty_units?);
127 let limit = r.kind.as_deref() == Some("stoplimit");
128 let rises = matches!(
129 r.condition
130 .as_deref()
131 .map(str::to_ascii_lowercase)
132 .as_deref(),
133 Some("more" | "moreorequal")
134 );
135 let kind = if limit {
136 StopOrderKind::StopLimit
137 } else if rises == (side == Side::Buy) {
138 StopOrderKind::StopLoss
139 } else {
140 StopOrderKind::TakeProfit
141 };
142 let expiry = parse_time(r.end_time.as_deref())
143 .map(StopExpiry::GoodTillDate)
144 .unwrap_or(StopExpiry::GoodTillCancel);
145 Some(StopOrderSnapshot {
146 cid,
147 broker_id: BrokerOrderId::new(r.id.clone()),
148 instrument: instrument_id(&r.symbol, r.board.as_deref().unwrap_or_default()),
149 side,
150 kind,
151 qty,
152 stop_price,
153 limit_price: if limit { r.price.map(Price::new) } else { None },
154 expiry,
155 state: state_of(r.status.as_deref()),
156 ts: timestamps(now, parse_time(r.trans_time.as_deref())),
157 })
158}
159
160pub fn cid_of(r: &StopOrderRecord) -> Option<ClientOrderId> {
162 cid_from_comment(r.comment.as_deref())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::util::instrument_id;
169 use brokapi_core::{Decimal, dec};
170 use uuid::Uuid;
171
172 fn request(kind: StopOrderKind, side: Side, limit: Option<Price>) -> PlaceStopOrderRequest {
173 PlaceStopOrderRequest {
174 cid: Uuid::new_v4(),
175 instrument: instrument_id("SBER", "TQBR"),
176 side,
177 kind,
178 qty: Qty::from(20),
179 stop_price: Price::new(dec!(200.5)),
180 limit_price: limit,
181 expiry: StopExpiry::GoodTillCancel,
182 }
183 }
184
185 #[test]
186 fn a_sell_stop_loss_waits_for_a_fall_and_a_sell_take_profit_for_a_rise() {
187 let b = |k, s, l| {
188 build(
189 &request(k, s, l),
190 dec!(10),
191 "D1",
192 "MOEX",
193 1_000_000,
194 Uuid::new_v4(),
195 )
196 .unwrap()
197 };
198 let (w, limit) = b(StopOrderKind::StopLoss, Side::Sell, None);
199 assert_eq!(
200 (w.condition.as_str(), limit, w.quantity),
201 ("lessorequal", false, 2)
202 );
203 let (w, _) = b(StopOrderKind::TakeProfit, Side::Sell, None);
204 assert_eq!(w.condition, "moreorequal");
205 let (w, limit) = b(
206 StopOrderKind::StopLoss,
207 Side::Sell,
208 Some(Price::new(dec!(200))),
209 );
210 assert!(limit);
211 assert_eq!(w.price, Some(dec!(200)));
212 assert_eq!(w.time_in_force.as_deref(), Some("oneday"));
213 }
214
215 #[test]
216 fn a_stop_limit_without_a_price_is_refused_and_a_half_lot_too() {
217 let err = build(
218 &request(StopOrderKind::StopLimit, Side::Sell, None),
219 dec!(10),
220 "D1",
221 "MOEX",
222 0,
223 Uuid::new_v4(),
224 )
225 .unwrap_err();
226 assert!(matches!(err, Error::Rejected { ref code, .. } if code == "missing_price"));
227 let mut r = request(StopOrderKind::StopLoss, Side::Sell, None);
228 r.qty = Qty::from(15);
229 assert!(matches!(
230 build(&r, dec!(10), "D1", "MOEX", 0, Uuid::new_v4()),
231 Err(Error::Rejected { ref code, .. }) if code == "invalid_qty"
232 ));
233 }
234
235 #[test]
236 fn expiries_become_unix_seconds() {
237 let noon = 1_792_670_400;
239 assert_eq!(end_time(StopExpiry::EndOfDay, noon), noon + 9 * 3600 - 1);
240 assert_eq!(
241 end_time(StopExpiry::GoodTillCancel, noon),
242 noon + GTC_HORIZON_SECS
243 );
244 let date = brokapi_core::UnixNanos::from_nanos(5_000_000_000);
245 assert_eq!(end_time(StopExpiry::GoodTillDate(date), noon), 5);
246 }
247
248 #[test]
249 fn a_record_is_read_back_with_a_kind() {
250 let r: StopOrderRecord = serde_json::from_str(
251 r#"{"id":"9","symbol":"SBER","board":"TQBR","type":"stop","side":"sell","condition":"lessorequal","status":"working","qtyUnits":20,"stopPrice":200.5}"#,
252 )
253 .unwrap();
254 let s = snapshot(&r, None, Instant::now()).unwrap();
255 assert_eq!(s.kind, StopOrderKind::StopLoss);
256 assert_eq!(s.state, StopOrderState::Active);
257 assert_eq!(s.stop_price.decimal(), dec!(200.5));
258 assert_eq!(s.limit_price, None);
259 let _ = Decimal::ZERO;
260 }
261}