1use std::time::Instant;
7
8use brokapi_core::{
9 BrokerOrderId, ClientOrderId, Error, PlaceStopOrderRequest, Price, Side, StopExpiry,
10 StopOrderKind, StopOrderSnapshot, StopOrderState,
11};
12use brokapi_tbank_protocol::v1 as pb;
13
14use crate::ids::{
15 id_from_parts, instrument_ref, nanos_to_proto, price_of_money, price_to_quotation,
16 proto_to_nanos, stamps,
17};
18use crate::lots::lots_to_qty;
19
20fn rejected(code: &str, message: impl Into<String>) -> Error {
21 Error::Rejected {
22 code: code.into(),
23 message: message.into(),
24 }
25}
26
27fn positive(what: &str, p: Price) -> Result<(), Error> {
28 if p.is_positive() {
29 Ok(())
30 } else {
31 Err(rejected(
32 "invalid_price",
33 format!("{what} must be a positive number, got {p}"),
34 ))
35 }
36}
37
38pub fn validate(req: &PlaceStopOrderRequest) -> Result<(), Error> {
40 positive("stop_price", req.stop_price)?;
41 match (req.kind, req.limit_price) {
42 (StopOrderKind::StopLimit, None) => {
43 return Err(rejected(
44 "missing_limit_price",
45 "a stop-limit order needs a limit price",
46 ));
47 }
48 (_, Some(p)) => positive("limit_price", p)?,
49 _ => {}
50 }
51 if matches!(req.expiry, StopExpiry::EndOfDay) {
52 return Err(rejected(
53 "unsupported_expiry",
54 "T-Bank stop orders live until cancelled or until a date: use GoodTillCancel or GoodTillDate",
55 ));
56 }
57 Ok(())
58}
59
60pub fn build_post(
71 account: &str,
72 req: &PlaceStopOrderRequest,
73 lots: i64,
74) -> Result<pb::PostStopOrderRequest, Error> {
75 validate(req)?;
76 let (expiration_type, expire_date) = match req.expiry {
77 StopExpiry::GoodTillCancel => (pb::StopOrderExpirationType::GoodTillCancel, None),
78 StopExpiry::GoodTillDate(t) => (
79 pb::StopOrderExpirationType::GoodTillDate,
80 Some(nanos_to_proto(t)),
81 ),
82 StopExpiry::EndOfDay => unreachable!("validate() refuses it"),
83 };
84 let stop_order_type = match req.kind {
85 StopOrderKind::StopLoss => pb::StopOrderType::StopLoss,
86 StopOrderKind::TakeProfit => pb::StopOrderType::TakeProfit,
87 StopOrderKind::StopLimit => pb::StopOrderType::StopLimit,
88 };
89 let exchange_order_type = if req.limit_price.is_some() {
91 pb::ExchangeOrderType::Limit
92 } else {
93 pb::ExchangeOrderType::Market
94 };
95 Ok(pb::PostStopOrderRequest {
96 quantity: lots,
97 price: req.limit_price.map(price_to_quotation).transpose()?,
98 stop_price: Some(price_to_quotation(req.stop_price)?),
99 direction: match req.side {
100 Side::Buy => pb::StopOrderDirection::Buy,
101 Side::Sell => pb::StopOrderDirection::Sell,
102 } as i32,
103 account_id: account.to_string(),
104 expiration_type: expiration_type as i32,
105 stop_order_type: stop_order_type as i32,
106 expire_date,
107 instrument_id: instrument_ref(&req.instrument),
108 exchange_order_type: exchange_order_type as i32,
109 take_profit_type: if req.kind == StopOrderKind::TakeProfit {
110 pb::TakeProfitType::Regular as i32
111 } else {
112 pb::TakeProfitType::Unspecified as i32
113 },
114 order_id: req.cid.hyphenated().to_string(),
115 ..Default::default()
116 })
117}
118
119fn kind_of(t: pb::StopOrderType) -> StopOrderKind {
120 match t {
121 pb::StopOrderType::TakeProfit => StopOrderKind::TakeProfit,
122 pb::StopOrderType::StopLimit => StopOrderKind::StopLimit,
123 pb::StopOrderType::StopLoss | pb::StopOrderType::Unspecified => StopOrderKind::StopLoss,
125 }
126}
127
128fn state_of(s: pb::StopOrderStatusOption) -> StopOrderState {
129 use pb::StopOrderStatusOption as S;
130 match s {
131 S::StopOrderStatusActive => StopOrderState::Active,
132 S::StopOrderStatusExecuted => StopOrderState::Triggered,
133 S::StopOrderStatusCanceled => StopOrderState::Cancelled,
134 S::StopOrderStatusExpired => StopOrderState::Expired,
135 other => StopOrderState::Other(format!("{other:?}")),
136 }
137}
138
139fn limit_of(o: &pb::StopOrder, kind: StopOrderKind) -> Option<Price> {
141 o.price.as_ref().map(price_of_money).filter(|p| {
142 p.is_positive()
143 && (kind == StopOrderKind::StopLimit
144 || o.exchange_order_type() == pb::ExchangeOrderType::Limit)
145 })
146}
147
148pub fn same_as_request(o: &pb::StopOrder, req: &PlaceStopOrderRequest, lots: i64) -> bool {
152 let kind = kind_of(o.order_type());
153 let side = match o.direction() {
154 pb::StopOrderDirection::Sell => Side::Sell,
155 pb::StopOrderDirection::Buy => Side::Buy,
156 pb::StopOrderDirection::Unspecified => return false,
157 };
158 let instrument = id_from_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code);
159 let same_instrument = match (&req.instrument.native, &instrument.native) {
160 (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) => a == b,
161 _ => req.instrument.key == instrument.key,
162 };
163 same_instrument
164 && side == req.side
165 && kind == req.kind
166 && o.lots_requested == lots
167 && o.stop_price.as_ref().map(price_of_money) == Some(req.stop_price)
168 && limit_of(o, kind) == req.limit_price
169}
170
171pub fn snapshot(
175 o: &pb::StopOrder,
176 cid: Option<ClientOrderId>,
177 now: Instant,
178 lot: i64,
179) -> StopOrderSnapshot {
180 let kind = kind_of(o.order_type());
181 let limit = limit_of(o, kind);
182 let expiry = match o.expiration_time.as_ref().filter(|t| t.seconds != 0) {
183 Some(t) => StopExpiry::GoodTillDate(proto_to_nanos(t)),
184 None => StopExpiry::GoodTillCancel,
185 };
186 StopOrderSnapshot {
187 cid,
188 broker_id: BrokerOrderId::new(o.stop_order_id.clone()),
189 instrument: id_from_parts(&o.instrument_uid, &o.figi, &o.ticker, &o.class_code),
190 side: match o.direction() {
191 pb::StopOrderDirection::Sell => Side::Sell,
192 _ => Side::Buy,
193 },
194 kind,
195 qty: lots_to_qty(o.lots_requested, lot),
196 stop_price: o
197 .stop_price
198 .as_ref()
199 .map(price_of_money)
200 .unwrap_or(Price::ZERO),
201 limit_price: limit,
202 expiry,
203 state: state_of(o.status()),
204 ts: stamps(now, o.create_date.as_ref()),
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use brokapi_core::{Decimal, dec};
211 use brokapi_core::{InstrumentId, InstrumentKey, NativeId, UnixNanos};
212
213 use super::*;
214 use brokapi_core::Qty;
215
216 fn req(kind: StopOrderKind, limit: Option<Price>, expiry: StopExpiry) -> PlaceStopOrderRequest {
217 PlaceStopOrderRequest {
218 cid: ClientOrderId::from_u128(0x1234),
219 instrument: InstrumentId::new(
220 InstrumentKey::new("SBER", "TQBR"),
221 NativeId::Uid("uid-sber".into()),
222 ),
223 side: Side::Sell,
224 kind,
225 qty: Qty::from(3),
226 stop_price: Price::new(dec!(250.5)),
227 limit_price: limit,
228 expiry,
229 }
230 }
231
232 #[test]
233 fn stop_loss_is_a_market_child_order() {
234 let w = build_post(
235 "acc",
236 &req(StopOrderKind::StopLoss, None, StopExpiry::GoodTillCancel),
237 3,
238 )
239 .unwrap();
240 assert_eq!(w.quantity, 3);
241 assert_eq!(w.stop_order_type(), pb::StopOrderType::StopLoss);
242 assert_eq!(w.exchange_order_type(), pb::ExchangeOrderType::Market);
243 assert_eq!(w.direction(), pb::StopOrderDirection::Sell);
244 assert_eq!(
245 w.expiration_type(),
246 pb::StopOrderExpirationType::GoodTillCancel
247 );
248 assert!(w.price.is_none() && w.expire_date.is_none());
249 assert_eq!(
250 w.order_id,
251 ClientOrderId::from_u128(0x1234).hyphenated().to_string()
252 );
253 assert_eq!(w.instrument_id, "uid-sber");
254 assert_eq!(w.account_id, "acc");
255 let stop = w.stop_price.unwrap();
256 assert_eq!((stop.units, stop.nano), (250, 500_000_000));
257 }
258
259 #[test]
260 fn take_profit_with_limit_and_date() {
261 let t = UnixNanos::from_nanos(1_800_000_000_000_000_000);
262 let w = build_post(
263 "acc",
264 &req(
265 StopOrderKind::TakeProfit,
266 Some(Price::from(251)),
267 StopExpiry::GoodTillDate(t),
268 ),
269 3,
270 )
271 .unwrap();
272 assert_eq!(w.stop_order_type(), pb::StopOrderType::TakeProfit);
273 assert_eq!(w.take_profit_type(), pb::TakeProfitType::Regular);
274 assert_eq!(w.exchange_order_type(), pb::ExchangeOrderType::Limit);
275 assert_eq!(
276 w.expiration_type(),
277 pb::StopOrderExpirationType::GoodTillDate
278 );
279 assert_eq!(w.expire_date.unwrap().seconds, 1_800_000_000);
280 assert_eq!(w.price.unwrap().units, 251);
281 }
282
283 #[test]
284 fn refusals_need_no_network() {
285 let cases = [
286 (
287 req(StopOrderKind::StopLimit, None, StopExpiry::GoodTillCancel),
288 "missing_limit_price",
289 ),
290 (
291 req(StopOrderKind::StopLoss, None, StopExpiry::EndOfDay),
292 "unsupported_expiry",
293 ),
294 (
295 req(
296 StopOrderKind::StopLoss,
297 Some(Price::new(dec!(-1.0))),
298 StopExpiry::GoodTillCancel,
299 ),
300 "invalid_price",
301 ),
302 ];
303 for (r, want) in cases {
304 match validate(&r) {
305 Err(Error::Rejected { code, .. }) => assert_eq!(code, want),
306 other => panic!("{want}: {other:?}"),
307 }
308 }
309 let mut r = req(StopOrderKind::StopLoss, None, StopExpiry::GoodTillCancel);
310 r.stop_price = Price::from(0);
311 assert!(validate(&r).is_err(), "zero stop price");
312 }
313
314 #[test]
315 fn snapshot_reads_the_broker_message() {
316 let o = pb::StopOrder {
317 stop_order_id: "SO1".into(),
318 lots_requested: 2,
319 direction: pb::StopOrderDirection::Sell as i32,
320 order_type: pb::StopOrderType::StopLimit as i32,
321 price: Some(brokapi_tbank_protocol::money::money("rub", "249.0")),
322 stop_price: Some(brokapi_tbank_protocol::money::money("rub", "250.0")),
323 instrument_uid: "uid-sber".into(),
324 ticker: "SBER".into(),
325 class_code: "TQBR".into(),
326 status: pb::StopOrderStatusOption::StopOrderStatusActive as i32,
327 ..Default::default()
328 };
329 let cid = ClientOrderId::from_u128(7);
330 let s = snapshot(&o, Some(cid), Instant::now(), 10);
331 assert_eq!(s.cid, Some(cid));
332 assert_eq!(s.broker_id.as_str(), "SO1");
333 assert_eq!(s.kind, StopOrderKind::StopLimit);
334 assert_eq!(s.side, Side::Sell);
335 assert_eq!(s.qty.decimal(), dec!(20));
337 assert_eq!(s.stop_price.decimal(), dec!(250.0));
338 assert_eq!(s.limit_price.map(Price::decimal), Some(dec!(249.0)));
339 assert_eq!(s.state, StopOrderState::Active);
340 assert_eq!(s.expiry, StopExpiry::GoodTillCancel);
341 assert_eq!(s.instrument.key.symbol, "SBER");
342 }
343
344 #[test]
345 fn a_zero_price_is_not_a_limit_price() {
346 let o = pb::StopOrder {
347 order_type: pb::StopOrderType::StopLoss as i32,
348 price: Some(brokapi_tbank_protocol::money::money("rub", "0.0")),
349 stop_price: Some(brokapi_tbank_protocol::money::money("rub", "10.0")),
350 ..Default::default()
351 };
352 assert!(snapshot(&o, None, Instant::now(), 1).limit_price.is_none());
353 }
354}