1use brokapi_core::{
13 BrokerOrderId, CidCodec, ClientOrderId, ConfigError, Error, PlaceStopOrderRequest, Price, Qty,
14 Side, StopExpiry, StopOrderKind, StopOrderSnapshot, StopOrderState,
15};
16use brokapi_finam_protocol::pb::grpc::tradeapi::v1::Side as PbSide;
17use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders as pb;
18use brokapi_finam_protocol::pb::grpc::tradeapi::v1::orders::{
19 OrderStatus, OrderType as PbOrderType,
20};
21use std::time::Instant;
22
23use crate::cid::FinamCidCodec;
24use crate::map::order::side_from_pb;
25use crate::util::{
26 instrument_from_symbol, price, price0, qty, qty0, symbol_of, timestamps, to_decimal,
27 to_timestamp, ts_nanos,
28};
29
30fn rejected(code: &str, message: impl Into<String>) -> Error {
31 Error::Rejected {
32 code: code.into(),
33 message: message.into(),
34 }
35}
36
37fn positive(what: &str, p: Price) -> Result<(), Error> {
38 if p.is_positive() {
39 Ok(())
40 } else {
41 Err(rejected(
42 "invalid_price",
43 format!("{what} must be a positive number, got {p}"),
44 ))
45 }
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub enum StopWire {
51 Order(pb::Order),
53 TakeProfit(pb::SltpOrder),
55}
56
57pub fn build(account_id: &str, req: &PlaceStopOrderRequest) -> Result<StopWire, Error> {
59 let symbol = symbol_of(&req.instrument)?;
60 if !req.qty.is_positive() {
61 return Err(rejected("invalid_qty", "quantity must be > 0"));
62 }
63 positive("stop_price", req.stop_price)?;
64 if let Some(p) = req.limit_price {
65 positive("limit_price", p)?;
66 }
67 if req.kind == StopOrderKind::StopLimit && req.limit_price.is_none() {
68 return Err(rejected(
69 "missing_limit_price",
70 "a stop-limit order needs a limit price",
71 ));
72 }
73 let quantity = to_decimal(req.qty.decimal());
74 let stop_price = to_decimal(req.stop_price.decimal());
75 let codec = FinamCidCodec;
76 let comment = codec
77 .encode(req.cid)
78 .map_err(|e| Error::Config(ConfigError::Message(e.to_string())))?
79 .0;
80 let tag = codec.client_order_id(req.cid);
81 let side = match req.side {
82 Side::Buy => PbSide::Buy as i32,
83 Side::Sell => PbSide::Sell as i32,
84 };
85
86 match req.kind {
87 StopOrderKind::TakeProfit => {
88 if req.limit_price.is_some() {
89 return Err(rejected(
90 "unsupported_limit_price",
91 "Finam sets a take-profit limit through a guard spread, not a price: place the take-profit without a limit price",
92 ));
93 }
94 let (valid_before, valid_expiry_time) = match req.expiry {
95 StopExpiry::GoodTillCancel => (pb::ValidBefore::GoodTillCancel, None),
96 StopExpiry::EndOfDay => (pb::ValidBefore::EndOfDay, None),
97 StopExpiry::GoodTillDate(t) => {
98 (pb::ValidBefore::GoodTillDate, Some(to_timestamp(t)))
99 }
100 };
101 Ok(StopWire::TakeProfit(pb::SltpOrder {
102 account_id: account_id.to_string(),
103 symbol,
104 side,
105 quantity_tp: Some(quantity),
106 tp_price: Some(stop_price),
107 tp_qty_measure: pb::SltpQtyMeasure::Value as i32,
108 client_order_id: tag,
109 valid_before: valid_before as i32,
110 valid_expiry_time,
111 comment,
112 ..Default::default()
113 }))
114 }
115 StopOrderKind::StopLoss | StopOrderKind::StopLimit => {
116 let valid_before = match req.expiry {
117 StopExpiry::GoodTillCancel => pb::ValidBefore::GoodTillCancel,
118 StopExpiry::EndOfDay => pb::ValidBefore::EndOfDay,
119 StopExpiry::GoodTillDate(_) => {
120 return Err(rejected(
121 "unsupported_expiry",
122 "Finam supports an expiry date only for SL/TP orders: use GoodTillCancel or EndOfDay, or a take-profit",
123 ));
124 }
125 };
126 let condition = if req.kind.triggers_on_rise(req.side) {
127 pb::StopCondition::LastUp
128 } else {
129 pb::StopCondition::LastDown
130 };
131 let (ty, limit_price) = match req.limit_price {
132 Some(p) => (PbOrderType::StopLimit, Some(to_decimal(p.decimal()))),
133 None => (PbOrderType::Stop, None),
134 };
135 Ok(StopWire::Order(pb::Order {
136 account_id: account_id.to_string(),
137 symbol,
138 quantity: Some(quantity),
139 side,
140 r#type: ty as i32,
141 limit_price,
142 stop_price: Some(stop_price),
143 stop_condition: condition as i32,
144 client_order_id: tag,
145 valid_before: valid_before as i32,
146 comment,
147 ..Default::default()
148 }))
149 }
150 }
151}
152
153pub fn is_stop(os: &pb::OrderState) -> bool {
155 os.sltp_order.is_some()
156 || os.order.as_ref().is_some_and(|o| {
157 matches!(
158 PbOrderType::try_from(o.r#type),
159 Ok(PbOrderType::Stop | PbOrderType::StopLimit)
160 )
161 })
162}
163
164pub fn echo_keys(os: &pb::OrderState) -> (String, String) {
167 if let Some(o) = os
168 .order
169 .as_ref()
170 .filter(|o| !o.client_order_id.is_empty() || !o.comment.is_empty())
171 {
172 return (o.client_order_id.clone(), o.comment.clone());
173 }
174 match &os.sltp_order {
175 Some(s) => (s.client_order_id.clone(), s.comment.clone()),
176 None => (String::new(), String::new()),
177 }
178}
179
180pub fn state_of(status: i32) -> StopOrderState {
183 use OrderStatus as S;
184 match S::try_from(status) {
185 Ok(
187 S::New
188 | S::PendingNew
189 | S::Watching
190 | S::Wait
191 | S::LinkWait
192 | S::Suspended
193 | S::SlGuardTime
194 | S::TpGuardTime
195 | S::TpCorrection
196 | S::TpCorrGuardTime,
197 ) => StopOrderState::Active,
198 Ok(
200 S::Forwarding
201 | S::SlForwarding
202 | S::TpForwarding
203 | S::PartiallyFilled
204 | S::Filled
205 | S::Executed
206 | S::SlExecuted
207 | S::TpExecuted
208 | S::DoneForDay,
209 ) => StopOrderState::Triggered,
210 Ok(S::Canceled | S::Replaced | S::Disabled | S::PendingCancel) => StopOrderState::Cancelled,
211 Ok(S::Expired) => StopOrderState::Expired,
212 Ok(other) => StopOrderState::Other(format!("{other:?}")),
213 Err(_) => StopOrderState::Other(format!("status {status}")),
214 }
215}
216
217pub fn snapshot(
219 os: &pb::OrderState,
220 cid: Option<ClientOrderId>,
221 now: Instant,
222) -> StopOrderSnapshot {
223 let ts = timestamps(os.accept_at.as_ref().or(os.transact_at.as_ref()), now);
224 let state = state_of(os.status);
225 if let Some(s) = &os.sltp_order {
226 let has_sl = price(&s.sl_price).is_some_and(|p| p.is_positive());
228 let side = side_from_pb(s.side);
229 let in_pieces =
232 |measure: i32| pb::SltpQtyMeasure::try_from(measure) != Ok(pb::SltpQtyMeasure::Percent);
233 let (kind, qty, stop, limit) = if has_sl {
234 let limit = price(&s.limit_price).filter(|p| p.is_positive());
235 (
236 if limit.is_some() {
237 StopOrderKind::StopLimit
238 } else {
239 StopOrderKind::StopLoss
240 },
241 if in_pieces(s.sl_qty_measure) {
242 qty0(&s.quantity_sl)
243 } else {
244 Qty::ZERO
245 },
246 price0(&s.sl_price),
247 limit,
248 )
249 } else {
250 (
251 StopOrderKind::TakeProfit,
252 if in_pieces(s.tp_qty_measure) {
253 qty0(&s.quantity_tp)
254 } else {
255 Qty::ZERO
256 },
257 price0(&s.tp_price),
258 None,
259 )
260 };
261 let expiry = match pb::ValidBefore::try_from(s.valid_before) {
262 Ok(pb::ValidBefore::EndOfDay) => StopExpiry::EndOfDay,
263 Ok(pb::ValidBefore::GoodTillDate) => s
264 .valid_expiry_time
265 .as_ref()
266 .map(|t| StopExpiry::GoodTillDate(ts_nanos(t)))
267 .unwrap_or(StopExpiry::GoodTillCancel),
268 _ => StopExpiry::GoodTillCancel,
269 };
270 return StopOrderSnapshot {
271 cid,
272 broker_id: BrokerOrderId::new(os.order_id.clone()),
273 instrument: instrument_from_symbol(&s.symbol),
274 side,
275 kind,
276 qty,
277 stop_price: stop,
278 limit_price: limit,
279 expiry,
280 state,
281 ts,
282 };
283 }
284 let o = os.order.clone().unwrap_or_default();
285 let side = side_from_pb(o.side);
286 let condition =
287 pb::StopCondition::try_from(o.stop_condition).unwrap_or(pb::StopCondition::Unspecified);
288 let kind = if o.r#type == PbOrderType::StopLimit as i32 {
289 StopOrderKind::StopLimit
290 } else {
291 let rises = condition == pb::StopCondition::LastUp;
293 if rises == StopOrderKind::StopLoss.triggers_on_rise(side) {
294 StopOrderKind::StopLoss
295 } else {
296 StopOrderKind::TakeProfit
297 }
298 };
299 let expiry = match pb::ValidBefore::try_from(o.valid_before) {
300 Ok(pb::ValidBefore::EndOfDay) => StopExpiry::EndOfDay,
301 _ => StopExpiry::GoodTillCancel,
302 };
303 let qty = qty(&os.initial_quantity)
304 .filter(|q| q.is_positive())
305 .or_else(|| self::qty(&o.quantity))
306 .unwrap_or(Qty::ZERO);
307 StopOrderSnapshot {
308 cid,
309 broker_id: BrokerOrderId::new(os.order_id.clone()),
310 instrument: instrument_from_symbol(&o.symbol),
311 side,
312 kind,
313 qty,
314 stop_price: price0(&o.stop_price),
315 limit_price: price(&o.limit_price).filter(|p| p.is_positive()),
316 expiry,
317 state,
318 ts,
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use brokapi_core::{Decimal, dec};
325 use brokapi_core::{InstrumentId, InstrumentKey, NativeId, UnixNanos};
326
327 use super::*;
328
329 fn req(
330 side: Side,
331 kind: StopOrderKind,
332 limit: Option<Decimal>,
333 expiry: StopExpiry,
334 ) -> PlaceStopOrderRequest {
335 PlaceStopOrderRequest {
336 cid: ClientOrderId::from_u128(0xabcdef),
337 instrument: InstrumentId::new(
338 InstrumentKey::new("SBER", "MISX"),
339 NativeId::SymbolMic {
340 symbol: "SBER".into(),
341 mic: "MISX".into(),
342 },
343 ),
344 side,
345 kind,
346 qty: Qty::from(10),
347 stop_price: Price::new(dec!(250.5)),
348 limit_price: limit.map(Price::new),
349 expiry,
350 }
351 }
352
353 fn order(w: StopWire) -> pb::Order {
354 match w {
355 StopWire::Order(o) => o,
356 other => panic!("expected PlaceOrder, got {other:?}"),
357 }
358 }
359
360 #[test]
361 fn stop_loss_sell_is_a_stop_that_triggers_down() {
362 let o = order(
363 build(
364 "ACC1",
365 &req(
366 Side::Sell,
367 StopOrderKind::StopLoss,
368 None,
369 StopExpiry::GoodTillCancel,
370 ),
371 )
372 .unwrap(),
373 );
374 assert_eq!(o.r#type, PbOrderType::Stop as i32);
375 assert_eq!(o.stop_condition, pb::StopCondition::LastDown as i32);
376 assert_eq!(o.stop_price.unwrap().value, "250.5");
377 assert!(o.limit_price.is_none());
378 assert_eq!(o.symbol, "SBER@MISX");
379 assert_eq!(o.quantity.unwrap().value, "10");
380 assert_eq!(o.valid_before, pb::ValidBefore::GoodTillCancel as i32);
381 assert_eq!(o.account_id, "ACC1");
382 assert!(o.comment.starts_with("bc1:"));
384 assert_eq!(o.client_order_id.len(), 20);
385 }
386
387 #[test]
388 fn stop_loss_buy_triggers_up_and_a_limit_price_makes_it_stop_limit() {
389 let o = order(
390 build(
391 "A",
392 &req(
393 Side::Buy,
394 StopOrderKind::StopLoss,
395 None,
396 StopExpiry::EndOfDay,
397 ),
398 )
399 .unwrap(),
400 );
401 assert_eq!(o.stop_condition, pb::StopCondition::LastUp as i32);
402 assert_eq!(o.valid_before, pb::ValidBefore::EndOfDay as i32);
403 let o = order(
404 build(
405 "A",
406 &req(
407 Side::Sell,
408 StopOrderKind::StopLimit,
409 Some(dec!(249.0)),
410 StopExpiry::GoodTillCancel,
411 ),
412 )
413 .unwrap(),
414 );
415 assert_eq!(o.r#type, PbOrderType::StopLimit as i32);
416 assert_eq!(o.limit_price.unwrap().value, "249.0");
417 assert_eq!(o.stop_condition, pb::StopCondition::LastDown as i32);
418 }
419
420 #[test]
421 fn take_profit_goes_through_the_sltp_call() {
422 let t = UnixNanos::from_nanos(1_900_000_000_000_000_000);
423 let w = build(
424 "A",
425 &req(
426 Side::Sell,
427 StopOrderKind::TakeProfit,
428 None,
429 StopExpiry::GoodTillDate(t),
430 ),
431 )
432 .unwrap();
433 let StopWire::TakeProfit(s) = w else {
434 panic!("expected SLTP")
435 };
436 assert_eq!(s.symbol, "SBER@MISX");
437 assert_eq!(s.quantity_tp.unwrap().value, "10");
438 assert_eq!(s.tp_price.unwrap().value, "250.5");
439 assert!(
440 s.quantity_sl.is_none() && s.sl_price.is_none(),
441 "the take-profit leg only"
442 );
443 assert_eq!(s.valid_before, pb::ValidBefore::GoodTillDate as i32);
444 assert_eq!(s.valid_expiry_time.unwrap().seconds, 1_900_000_000);
445 assert!(s.comment.starts_with("bc1:"));
446 }
447
448 #[test]
449 fn refusals_need_no_network() {
450 let cases = [
451 (
452 req(
453 Side::Sell,
454 StopOrderKind::StopLimit,
455 None,
456 StopExpiry::GoodTillCancel,
457 ),
458 "missing_limit_price",
459 ),
460 (
461 req(
462 Side::Sell,
463 StopOrderKind::TakeProfit,
464 Some(dec!(1.0)),
465 StopExpiry::GoodTillCancel,
466 ),
467 "unsupported_limit_price",
468 ),
469 (
470 req(
471 Side::Sell,
472 StopOrderKind::StopLoss,
473 None,
474 StopExpiry::GoodTillDate(UnixNanos::from_nanos(1)),
475 ),
476 "unsupported_expiry",
477 ),
478 (
479 req(
480 Side::Sell,
481 StopOrderKind::StopLoss,
482 Some(dec!(-3.0)),
483 StopExpiry::GoodTillCancel,
484 ),
485 "invalid_price",
486 ),
487 ];
488 for (r, want) in cases {
489 match build("A", &r) {
490 Err(Error::Rejected { code, .. }) => assert_eq!(code, want),
491 other => panic!("{want}: {other:?}"),
492 }
493 }
494 let mut r = req(
495 Side::Sell,
496 StopOrderKind::StopLoss,
497 None,
498 StopExpiry::GoodTillCancel,
499 );
500 r.qty = Qty::from(0);
501 assert!(build("A", &r).is_err());
502 }
503
504 #[test]
505 fn states_group_the_finam_statuses() {
506 assert_eq!(
507 state_of(OrderStatus::Watching as i32),
508 StopOrderState::Active
509 );
510 assert_eq!(
511 state_of(OrderStatus::SlGuardTime as i32),
512 StopOrderState::Active
513 );
514 assert_eq!(
515 state_of(OrderStatus::TpForwarding as i32),
516 StopOrderState::Triggered
517 );
518 assert_eq!(
519 state_of(OrderStatus::SlExecuted as i32),
520 StopOrderState::Triggered
521 );
522 assert_eq!(
523 state_of(OrderStatus::Canceled as i32),
524 StopOrderState::Cancelled
525 );
526 assert_eq!(
527 state_of(OrderStatus::Expired as i32),
528 StopOrderState::Expired
529 );
530 assert!(matches!(
531 state_of(OrderStatus::Rejected as i32),
532 StopOrderState::Other(_)
533 ));
534 assert!(matches!(state_of(999), StopOrderState::Other(_)));
535 }
536
537 #[test]
538 fn the_snapshot_round_trips_what_was_sent() {
539 let cid = ClientOrderId::from_u128(0xabcdef);
540 let o = order(
541 build(
542 "A",
543 &req(
544 Side::Sell,
545 StopOrderKind::StopLimit,
546 Some(dec!(249.0)),
547 StopExpiry::GoodTillCancel,
548 ),
549 )
550 .unwrap(),
551 );
552 let os = pb::OrderState {
553 order_id: "ORD7".into(),
554 status: OrderStatus::Watching as i32,
555 order: Some(o),
556 initial_quantity: Some(crate::util::to_decimal(dec!(10.0))),
557 ..Default::default()
558 };
559 assert!(is_stop(&os));
560 let s = snapshot(&os, Some(cid), Instant::now());
561 assert_eq!(s.cid, Some(cid));
562 assert_eq!(s.broker_id.as_str(), "ORD7");
563 assert_eq!(s.kind, StopOrderKind::StopLimit);
564 assert_eq!(s.side, Side::Sell);
565 assert_eq!(s.qty.decimal(), dec!(10.0));
566 assert_eq!(s.stop_price.decimal(), dec!(250.5));
567 assert_eq!(s.limit_price.map(Price::decimal), Some(dec!(249.0)));
568 assert_eq!(s.state, StopOrderState::Active);
569 assert_eq!(s.instrument.key.symbol, "SBER");
570 }
571
572 #[test]
573 fn a_plain_order_is_not_a_stop() {
574 let os = pb::OrderState {
575 order: Some(pb::Order {
576 r#type: PbOrderType::Limit as i32,
577 ..Default::default()
578 }),
579 ..Default::default()
580 };
581 assert!(!is_stop(&os));
582 }
583
584 #[test]
585 fn a_percent_quantity_of_an_sltp_order_is_not_shown_as_pieces() {
586 let sltp = |sl_measure: pb::SltpQtyMeasure, tp_measure: pb::SltpQtyMeasure, sl: bool| {
587 let leg = |v: &str| Some(crate::util::to_decimal(v.parse::<Decimal>().unwrap()));
588 pb::OrderState {
589 order_id: "S1".into(),
590 status: OrderStatus::Watching as i32,
591 sltp_order: Some(pb::SltpOrder {
592 symbol: "SBER@MISX".into(),
593 side: PbSide::Sell as i32,
594 quantity_sl: leg("25"),
595 sl_price: sl.then(|| leg("240.0")).flatten(),
596 quantity_tp: leg("40"),
597 tp_price: (!sl).then(|| leg("260.0")).flatten(),
598 sl_qty_measure: sl_measure as i32,
599 tp_qty_measure: tp_measure as i32,
600 ..Default::default()
601 }),
602 ..Default::default()
603 }
604 };
605 use pb::SltpQtyMeasure as M;
606 let qty_of = |os: &pb::OrderState| snapshot(os, None, Instant::now()).qty.decimal();
607 assert_eq!(qty_of(&sltp(M::Value, M::Value, true)), dec!(25));
609 assert_eq!(qty_of(&sltp(M::Undefined, M::Undefined, false)), dec!(40));
610 assert_eq!(qty_of(&sltp(M::Percent, M::Value, true)), dec!(0));
612 assert_eq!(qty_of(&sltp(M::Value, M::Percent, false)), dec!(0));
613 assert_eq!(qty_of(&sltp(M::Value, M::Percent, true)), dec!(25));
615 }
616
617 #[test]
618 fn a_stop_that_triggers_against_the_position_reads_as_take_profit() {
619 let os = pb::OrderState {
620 order_id: "1".into(),
621 status: OrderStatus::Watching as i32,
622 order: Some(pb::Order {
623 side: PbSide::Sell as i32,
624 r#type: PbOrderType::Stop as i32,
625 stop_condition: pb::StopCondition::LastUp as i32,
626 stop_price: Some(crate::util::to_decimal(dec!(300.0))),
627 ..Default::default()
628 }),
629 ..Default::default()
630 };
631 assert_eq!(
632 snapshot(&os, None, Instant::now()).kind,
633 StopOrderKind::TakeProfit
634 );
635 }
636}