1use ahash::{AHashMap, AHashSet};
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25 data::{
26 BookOrder, InstrumentClose, InstrumentStatus, OrderBookDelta, OrderBookDeltas, TradeTick,
27 },
28 enums::{
29 AggressorSide, BookAction, InstrumentCloseType, LiquiditySide, MarketStatusAction,
30 OrderSide, OrderType, RecordFlag, TimeInForce,
31 },
32 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
33 reports::{FillReport, OrderStatusReport},
34 types::{Currency, Money, Price, Quantity},
35};
36use rust_decimal::Decimal;
37
38use crate::{
39 common::{
40 enums::{MarketStatus, RunnerStatus, StreamingOrderStatus, resolve_streaming_order_status},
41 parse::{
42 make_instrument_id, normalize_betfair_price, normalize_betfair_quantity,
43 parse_betfair_price, parse_betfair_quantity, parse_millis_timestamp,
44 },
45 },
46 data_types::{
47 BetfairBspBookDelta, BetfairCricketMatch, BetfairRaceProgress, BetfairRaceRunnerData,
48 BetfairStartingPrice, BetfairTicker,
49 },
50 stream::messages::{
51 CricketChange, MarketDefinition, RaceProgressChange, RaceRunnerChange, RunnerChange,
52 UnmatchedOrder,
53 },
54};
55
56pub fn parse_runner_book_deltas(
78 instrument_id: InstrumentId,
79 rc: &RunnerChange,
80 is_snapshot: bool,
81 sequence: u64,
82 ts_event: UnixNanos,
83 ts_init: UnixNanos,
84) -> anyhow::Result<Option<OrderBookDeltas>> {
85 let atb_len = rc.atb.as_ref().map_or(0, Vec::len);
86 let atl_len = rc.atl.as_ref().map_or(0, Vec::len);
87 let total_levels = atb_len + atl_len;
88
89 if total_levels == 0 && !is_snapshot {
90 return Ok(None);
91 }
92
93 let snapshot_flags = if is_snapshot {
94 RecordFlag::F_SNAPSHOT as u8
95 } else {
96 0
97 };
98 let mut deltas = Vec::with_capacity(total_levels + usize::from(is_snapshot));
99
100 if is_snapshot {
101 let mut clear = OrderBookDelta::clear(instrument_id, sequence, ts_event, ts_init);
102
103 if total_levels == 0 {
104 clear.flags |= RecordFlag::F_LAST as u8;
105 }
106 deltas.push(clear);
107 }
108
109 for pv in rc.atb.as_deref().unwrap_or(&[]) {
111 if is_snapshot && pv.volume == Decimal::ZERO {
112 continue;
113 }
114
115 let action = if is_snapshot {
116 BookAction::Add
117 } else if pv.volume == Decimal::ZERO {
118 BookAction::Delete
119 } else {
120 BookAction::Update
121 };
122
123 deltas.push(OrderBookDelta::new(
124 instrument_id,
125 action,
126 BookOrder::new(
127 OrderSide::Buy,
128 parse_betfair_price(pv.price)?,
129 parse_betfair_quantity(pv.volume)?,
130 0,
131 ),
132 snapshot_flags,
133 sequence,
134 ts_event,
135 ts_init,
136 ));
137 }
138
139 for pv in rc.atl.as_deref().unwrap_or(&[]) {
141 if is_snapshot && pv.volume == Decimal::ZERO {
142 continue;
143 }
144
145 let action = if is_snapshot {
146 BookAction::Add
147 } else if pv.volume == Decimal::ZERO {
148 BookAction::Delete
149 } else {
150 BookAction::Update
151 };
152
153 deltas.push(OrderBookDelta::new(
154 instrument_id,
155 action,
156 BookOrder::new(
157 OrderSide::Sell,
158 parse_betfair_price(pv.price)?,
159 parse_betfair_quantity(pv.volume)?,
160 0,
161 ),
162 snapshot_flags,
163 sequence,
164 ts_event,
165 ts_init,
166 ));
167 }
168
169 if let Some(last) = deltas.last_mut() {
171 last.flags |= RecordFlag::F_LAST as u8;
172 }
173
174 Ok(Some(OrderBookDeltas::new(instrument_id, deltas)))
175}
176
177#[must_use]
182pub fn make_trade_tick(
183 instrument_id: InstrumentId,
184 price: Price,
185 size: Quantity,
186 trade_id: TradeId,
187 ts_event: UnixNanos,
188 ts_init: UnixNanos,
189) -> TradeTick {
190 TradeTick::new(
191 instrument_id,
192 price,
193 size,
194 AggressorSide::NoAggressor,
195 trade_id,
196 ts_event,
197 ts_init,
198 )
199}
200
201#[must_use]
210pub fn parse_instrument_statuses(
211 market_id: &str,
212 def: &MarketDefinition,
213 ts_event: UnixNanos,
214 ts_init: UnixNanos,
215) -> Vec<InstrumentStatus> {
216 let Some(status) = def.status else {
217 return Vec::new();
218 };
219 let Some(runners) = &def.runners else {
220 return Vec::new();
221 };
222 let in_play = def.in_play.unwrap_or(false);
223
224 if status == MarketStatus::Unknown {
225 log::warn!("Skipping unmodeled Betfair market status for market {market_id}");
226 return Vec::new();
227 }
228
229 runners
230 .iter()
231 .filter_map(|rd| {
232 let handicap = rd.hc.unwrap_or(Decimal::ZERO);
233 let instrument_id = make_instrument_id(market_id, rd.id, handicap);
234 if rd.status == Some(RunnerStatus::Unknown) {
235 log::warn!("Skipping unmodeled Betfair runner status for {instrument_id}");
236 return None;
237 }
238 let action = match rd.status {
239 Some(RunnerStatus::Removed | RunnerStatus::RemovedVacant) => {
240 MarketStatusAction::Close
241 }
242 _ => match (status, in_play) {
243 (MarketStatus::Inactive, _) => MarketStatusAction::Close,
244 (MarketStatus::Open, false) => MarketStatusAction::PreOpen,
245 (MarketStatus::Open, true) => MarketStatusAction::Trading,
246 (MarketStatus::Suspended, _) => MarketStatusAction::Pause,
247 (MarketStatus::Closed, _) => MarketStatusAction::Close,
248 (MarketStatus::Unknown, _) => MarketStatusAction::None,
250 },
251 };
252 let is_trading = matches!(status, MarketStatus::Open)
253 && in_play
254 && !matches!(
255 rd.status,
256 Some(RunnerStatus::Removed | RunnerStatus::RemovedVacant)
257 );
258 Some(InstrumentStatus::new(
259 instrument_id,
260 action,
261 ts_event,
262 ts_init,
263 None,
264 None,
265 Some(is_trading),
266 None,
267 None,
268 ))
269 })
270 .collect()
271}
272
273pub fn make_trade_id(uo: &UnmatchedOrder) -> TradeId {
278 let sm = normalize_betfair_quantity(uo.sm.unwrap_or(Decimal::ZERO));
279 make_trade_id_for_size(&uo.id, sm)
280}
281
282#[derive(Debug, Default)]
289pub struct FillTracker {
290 filled_qty: AHashMap<String, Decimal>,
291 avg_px: AHashMap<String, Decimal>,
292 published_trade_ids: AHashSet<String>,
293}
294
295impl FillTracker {
296 #[must_use]
298 pub fn new() -> Self {
299 Self::default()
300 }
301
302 #[expect(clippy::too_many_arguments)]
307 pub fn maybe_fill_report(
308 &mut self,
309 uo: &UnmatchedOrder,
310 order_qty: Decimal,
311 instrument_id: InstrumentId,
312 account_id: AccountId,
313 currency: Currency,
314 ts_event: UnixNanos,
315 ts_init: UnixNanos,
316 ) -> Option<FillReport> {
317 let raw_sm = uo.sm?;
318 let sm = normalize_betfair_quantity(raw_sm);
319
320 if sm <= Decimal::ZERO {
321 return None;
322 }
323
324 let prev_filled = self
325 .filled_qty
326 .get(&uo.id)
327 .copied()
328 .map_or(Decimal::ZERO, normalize_betfair_quantity);
329
330 if sm <= prev_filled {
331 return None;
332 }
333
334 let order_qty = normalize_betfair_quantity(resolve_stream_order_quantity(order_qty, uo));
335
336 if sm > order_qty {
338 log::warn!(
339 "Rejecting potential overfill for bet_id={}: order_qty={order_qty}, sm={sm}",
340 uo.id,
341 );
342 return None;
343 }
344
345 let trade_id = make_trade_id_for_size(&uo.id, sm);
346 let raw_trade_id = make_trade_id_for_size(&uo.id, raw_sm);
347
348 if self.published_trade_ids.contains(trade_id.as_str())
349 || self.published_trade_ids.contains(raw_trade_id.as_str())
350 {
351 return None;
352 }
353
354 let fill_qty_dec = sm - prev_filled;
355 let fill_price = self.compute_fill_price(uo, prev_filled, sm);
356
357 let last_qty = parse_betfair_quantity(fill_qty_dec).ok()?;
358 let last_px = parse_betfair_price(fill_price).ok()?;
359
360 self.filled_qty.insert(uo.id.clone(), sm);
362
363 if let Some(avp) = uo.avp.map(normalize_betfair_price) {
364 self.avg_px.insert(uo.id.clone(), avp);
365 }
366
367 self.published_trade_ids.insert(trade_id.to_string());
368
369 let venue_order_id = VenueOrderId::from(uo.id.as_str());
370 let order_side = OrderSide::from(uo.side);
371 let client_order_id = uo
372 .rfo
373 .as_deref()
374 .filter(|s| !s.is_empty())
375 .map(ClientOrderId::from);
376 let ts_fill = uo.md.map_or(ts_event, parse_millis_timestamp);
377
378 Some(make_fill_report(
379 account_id,
380 instrument_id,
381 venue_order_id,
382 trade_id,
383 order_side,
384 last_qty,
385 last_px,
386 currency,
387 client_order_id,
388 ts_fill,
389 ts_init,
390 ))
391 }
392
393 fn compute_fill_price(
400 &self,
401 uo: &UnmatchedOrder,
402 prev_filled: Decimal,
403 sm: Decimal,
404 ) -> Decimal {
405 let Some(avp) = uo.avp.map(normalize_betfair_price) else {
406 return uo.p;
407 };
408
409 if prev_filled == Decimal::ZERO {
410 return avp;
411 }
412
413 let Some(prev_avg) = self.avg_px.get(&uo.id).copied() else {
414 return avp;
415 };
416
417 if prev_avg == avp {
418 return avp;
419 }
420
421 let fill_size = sm - prev_filled;
422
423 if fill_size == Decimal::ZERO {
424 return prev_avg;
425 }
426
427 let fill_price = (avp * sm - prev_avg * prev_filled) / fill_size;
428
429 if fill_price <= Decimal::ZERO {
430 log::warn!(
431 "Calculated fill price {fill_price} is invalid for bet_id={}, falling back to avp={avp}",
432 uo.id,
433 );
434 return avp;
435 }
436
437 fill_price
438 }
439
440 pub fn sync_order(&mut self, bet_id: &str, filled_qty: Decimal, avg_px: Decimal) {
446 let filled_qty = normalize_betfair_quantity(filled_qty);
447 let avg_px = normalize_betfair_price(avg_px);
448 let current = self
449 .filled_qty
450 .get(bet_id)
451 .copied()
452 .map_or(Decimal::ZERO, normalize_betfair_quantity);
453
454 if filled_qty > current {
455 self.filled_qty.insert(bet_id.to_string(), filled_qty);
456 if avg_px > Decimal::ZERO {
457 self.avg_px.insert(bet_id.to_string(), avg_px);
458 }
459 }
460 }
461
462 pub fn seed_published_trade_ids<I, S>(&mut self, trade_ids: I)
465 where
466 I: IntoIterator<Item = S>,
467 S: Into<String>,
468 {
469 for id in trade_ids {
470 self.published_trade_ids.insert(id.into());
471 }
472 }
473
474 pub fn prune(&mut self, bet_id: &str) {
476 self.filled_qty.remove(bet_id);
477 self.avg_px.remove(bet_id);
478
479 let prefix = format!("{bet_id}-");
480 self.published_trade_ids
481 .retain(|id| !id.starts_with(&prefix));
482 }
483}
484
485fn make_trade_id_for_size(bet_id: &str, size_matched: Decimal) -> TradeId {
486 TradeId::new(format!("{bet_id}-{size_matched}"))
487}
488
489#[must_use]
491pub fn has_cancel_quantity(uo: &UnmatchedOrder) -> bool {
492 let sc = uo.sc.unwrap_or(Decimal::ZERO);
493 let sl = uo.sl.unwrap_or(Decimal::ZERO);
494 let sv = uo.sv.unwrap_or(Decimal::ZERO);
495 (sc + sl + sv) > Decimal::ZERO
496}
497
498#[must_use]
500pub fn is_lapsed(uo: &UnmatchedOrder) -> bool {
501 uo.status == StreamingOrderStatus::ExecutionComplete && uo.lsrc.is_some()
502}
503
504pub fn parse_order_status_report(
513 uo: &UnmatchedOrder,
514 instrument_id: InstrumentId,
515 account_id: AccountId,
516 ts_event: UnixNanos,
517 ts_init: UnixNanos,
518) -> anyhow::Result<OrderStatusReport> {
519 let order_side = OrderSide::from(uo.side);
520 let order_type = OrderType::from(uo.ot);
521 let time_in_force = parse_stream_time_in_force(uo)?;
522
523 let size_matched = uo.sm.unwrap_or(Decimal::ZERO);
524 let size_cancelled = uo.sc.unwrap_or(Decimal::ZERO);
525 let size_lapsed = uo.sl.unwrap_or(Decimal::ZERO);
526 let size_voided = uo.sv.unwrap_or(Decimal::ZERO);
527
528 let size_closed = size_cancelled + size_lapsed + size_voided;
530 let order_status = resolve_streaming_order_status(uo.status, size_matched, size_closed);
531
532 let quantity_decimal = stream_order_quantity(uo);
533 anyhow::ensure!(
534 quantity_decimal > Decimal::ZERO,
535 "failed to resolve positive quantity for stream order update {} \
536 (order_type={:?}, persistence_type={:?}, size={}, bsp_liability={:?}, \
537 size_matched={:?}, size_remaining={:?}, size_cancelled={:?}, size_lapsed={:?}, size_voided={:?})",
538 uo.id,
539 uo.ot,
540 uo.pt,
541 uo.s,
542 uo.bsp,
543 uo.sm,
544 uo.sr,
545 uo.sc,
546 uo.sl,
547 uo.sv,
548 );
549 let quantity = parse_betfair_quantity(quantity_decimal)?;
550 let filled_qty = parse_betfair_quantity(size_matched)?;
551
552 let ts_accepted = parse_millis_timestamp(uo.pd);
553
554 let ts_last = [uo.md, uo.cd, uo.ld]
556 .into_iter()
557 .flatten()
558 .max()
559 .map_or(ts_event, parse_millis_timestamp);
560
561 let venue_order_id = VenueOrderId::from(uo.id.as_str());
562 let client_order_id = uo
563 .rfo
564 .as_deref()
565 .filter(|s| !s.is_empty())
566 .map(ClientOrderId::from);
567
568 let price = parse_betfair_price(uo.p)?;
569
570 let mut report = OrderStatusReport::new(
571 account_id,
572 instrument_id,
573 client_order_id,
574 venue_order_id,
575 order_side,
576 order_type,
577 time_in_force,
578 order_status,
579 quantity,
580 filled_qty,
581 ts_accepted,
582 ts_last,
583 ts_init,
584 None,
585 )
586 .with_price(price);
587
588 report.avg_px = uo.avp;
589 if let Some(lsrc) = uo.lsrc {
590 report.cancel_reason = Some(lsrc.to_string());
591 }
592
593 Ok(report)
594}
595
596fn parse_stream_time_in_force(uo: &UnmatchedOrder) -> anyhow::Result<TimeInForce> {
597 match uo.pt {
598 Some(persistence_type) => Ok(TimeInForce::from(persistence_type)),
599 None if matches!(
600 uo.ot,
601 crate::common::enums::StreamingOrderType::LimitOnClose
602 | crate::common::enums::StreamingOrderType::MarketOnClose
603 ) =>
604 {
605 Ok(TimeInForce::AtTheClose)
606 }
607 None => anyhow::bail!("missing persistence type for order update {}", uo.id),
608 }
609}
610
611fn stream_order_quantity(uo: &UnmatchedOrder) -> Decimal {
612 if uo.s > Decimal::ZERO {
613 return uo.s;
614 }
615
616 let lifecycle_qty = uo.sm.unwrap_or(Decimal::ZERO)
617 + uo.sr.unwrap_or(Decimal::ZERO)
618 + uo.sc.unwrap_or(Decimal::ZERO)
619 + uo.sl.unwrap_or(Decimal::ZERO)
620 + uo.sv.unwrap_or(Decimal::ZERO);
621
622 if lifecycle_qty > Decimal::ZERO {
623 return lifecycle_qty;
624 }
625
626 if uses_liability_based_stream_quantity(uo) {
627 return uo.bsp.unwrap_or(Decimal::ZERO);
628 }
629
630 Decimal::ZERO
631}
632
633fn resolve_stream_order_quantity(order_qty: Decimal, uo: &UnmatchedOrder) -> Decimal {
634 if order_qty > Decimal::ZERO {
635 order_qty
636 } else {
637 stream_order_quantity(uo)
638 }
639}
640
641fn uses_liability_based_stream_quantity(uo: &UnmatchedOrder) -> bool {
642 matches!(
643 uo.ot,
644 crate::common::enums::StreamingOrderType::LimitOnClose
645 | crate::common::enums::StreamingOrderType::MarketOnClose
646 )
647}
648
649#[must_use]
654#[expect(clippy::too_many_arguments)]
655pub fn make_fill_report(
656 account_id: AccountId,
657 instrument_id: InstrumentId,
658 venue_order_id: VenueOrderId,
659 trade_id: TradeId,
660 order_side: OrderSide,
661 last_qty: Quantity,
662 last_px: Price,
663 currency: Currency,
664 client_order_id: Option<ClientOrderId>,
665 ts_event: UnixNanos,
666 ts_init: UnixNanos,
667) -> FillReport {
668 FillReport::new(
669 account_id,
670 instrument_id,
671 venue_order_id,
672 trade_id,
673 order_side,
674 last_qty,
675 last_px,
676 Money::zero(currency),
677 LiquiditySide::NoLiquiditySide,
678 client_order_id,
679 None,
680 ts_event,
681 ts_init,
682 None,
683 )
684}
685
686#[must_use]
690pub fn parse_betfair_ticker(
691 instrument_id: InstrumentId,
692 rc: &RunnerChange,
693 ts_event: UnixNanos,
694 ts_init: UnixNanos,
695) -> Option<BetfairTicker> {
696 if rc.ltp.is_none() && rc.tv.is_none() && rc.spn.is_none() && rc.spf.is_none() {
697 return None;
698 }
699
700 Some(BetfairTicker::new(
701 instrument_id,
702 rc.ltp,
703 rc.tv,
704 rc.spn,
705 rc.spf,
706 ts_event,
707 ts_init,
708 ))
709}
710
711#[must_use]
715pub fn parse_betfair_starting_prices(
716 market_id: &str,
717 def: &MarketDefinition,
718 ts_event: UnixNanos,
719 ts_init: UnixNanos,
720) -> Vec<BetfairStartingPrice> {
721 let Some(runners) = &def.runners else {
722 return Vec::new();
723 };
724
725 runners
726 .iter()
727 .filter_map(|rd| {
728 let bsp = rd.bsp?;
729 let handicap = rd.hc.unwrap_or(Decimal::ZERO);
730 let instrument_id = make_instrument_id(market_id, rd.id, handicap);
731 Some(BetfairStartingPrice::new(
732 instrument_id,
733 bsp,
734 ts_event,
735 ts_init,
736 ))
737 })
738 .collect()
739}
740
741#[must_use]
747pub fn parse_bsp_book_deltas(
748 instrument_id: InstrumentId,
749 rc: &RunnerChange,
750 ts_event: UnixNanos,
751 ts_init: UnixNanos,
752) -> Vec<BetfairBspBookDelta> {
753 let spb_len = rc.spb.as_ref().map_or(0, Vec::len);
754 let spl_len = rc.spl.as_ref().map_or(0, Vec::len);
755
756 if spb_len + spl_len == 0 {
757 return Vec::new();
758 }
759
760 let mut result = Vec::with_capacity(spb_len + spl_len);
761
762 for pv in rc.spb.as_deref().unwrap_or(&[]) {
764 let action = if pv.volume == Decimal::ZERO {
765 BookAction::Delete as u32
766 } else {
767 BookAction::Update as u32
768 };
769
770 result.push(BetfairBspBookDelta::new(
771 instrument_id,
772 action,
773 OrderSide::Sell as u32,
774 pv.price,
775 pv.volume,
776 ts_event,
777 ts_init,
778 ));
779 }
780
781 for pv in rc.spl.as_deref().unwrap_or(&[]) {
783 let action = if pv.volume == Decimal::ZERO {
784 BookAction::Delete as u32
785 } else {
786 BookAction::Update as u32
787 };
788
789 result.push(BetfairBspBookDelta::new(
790 instrument_id,
791 action,
792 OrderSide::Buy as u32,
793 pv.price,
794 pv.volume,
795 ts_event,
796 ts_init,
797 ));
798 }
799
800 result
801}
802
803#[must_use]
808pub fn parse_instrument_closes(
809 market_id: &str,
810 def: &MarketDefinition,
811 ts_event: UnixNanos,
812 ts_init: UnixNanos,
813) -> Vec<InstrumentClose> {
814 let Some(runners) = &def.runners else {
815 return Vec::new();
816 };
817
818 runners
819 .iter()
820 .filter_map(|rd| {
821 let status = rd.status.as_ref()?;
822 let close_price = match status {
823 RunnerStatus::Winner | RunnerStatus::Placed => Price::from("1.00"),
824 RunnerStatus::Loser | RunnerStatus::Removed | RunnerStatus::RemovedVacant => {
825 Price::from("0.00")
826 }
827 RunnerStatus::Active | RunnerStatus::Hidden | RunnerStatus::Unknown => return None,
828 };
829
830 let handicap = rd.hc.unwrap_or(Decimal::ZERO);
831 let instrument_id = make_instrument_id(market_id, rd.id, handicap);
832
833 Some(InstrumentClose::new(
834 instrument_id,
835 close_price,
836 InstrumentCloseType::ContractExpired,
837 ts_event,
838 ts_init,
839 ))
840 })
841 .collect()
842}
843
844#[must_use]
848pub fn parse_race_runner_data(
849 race_id: &str,
850 market_id: &str,
851 rrc: &RaceRunnerChange,
852 ts_event: UnixNanos,
853 ts_init: UnixNanos,
854) -> Option<BetfairRaceRunnerData> {
855 let selection_id = rrc.id?;
856
857 Some(BetfairRaceRunnerData::new(
858 race_id.to_string(),
859 market_id.to_string(),
860 selection_id,
861 rrc.lat.unwrap_or(f64::NAN),
862 rrc.lng.unwrap_or(f64::NAN),
863 rrc.spd.unwrap_or(f64::NAN),
864 rrc.prg.unwrap_or(f64::NAN),
865 rrc.sfq.unwrap_or(f64::NAN),
866 ts_event,
867 ts_init,
868 ))
869}
870
871#[must_use]
873pub fn parse_race_progress(
874 race_id: &str,
875 market_id: &str,
876 rpc: &RaceProgressChange,
877 ts_event: UnixNanos,
878 ts_init: UnixNanos,
879) -> BetfairRaceProgress {
880 let order_json = serialize_json_value(rpc.ord.as_ref());
881 let jumps_json = serialize_json_value(rpc.jumps.as_ref());
882
883 BetfairRaceProgress::new(
884 race_id.to_string(),
885 market_id.to_string(),
886 rpc.g.clone().unwrap_or_default(),
887 rpc.st.unwrap_or(f64::NAN),
888 rpc.rt.unwrap_or(f64::NAN),
889 rpc.spd.unwrap_or(f64::NAN),
890 rpc.prg.unwrap_or(f64::NAN),
891 order_json,
892 jumps_json,
893 ts_event,
894 ts_init,
895 )
896}
897
898#[must_use]
899pub fn parse_cricket_match(
900 cricket: &CricketChange,
901 ts_event: UnixNanos,
902 ts_init: UnixNanos,
903) -> Option<BetfairCricketMatch> {
904 let event_id = cricket.event_id.clone()?;
905 let market_id = cricket.market_id.clone()?;
906
907 Some(BetfairCricketMatch::new(
908 event_id,
909 market_id,
910 serialize_json_value(cricket.fixture_info.as_ref()),
911 serialize_json_value(cricket.home_team.as_ref()),
912 serialize_json_value(cricket.away_team.as_ref()),
913 serialize_json_value(cricket.match_stats.as_ref()),
914 serialize_json_value(cricket.incident_list_wrapper.as_ref()),
915 ts_event,
916 ts_init,
917 ))
918}
919
920fn serialize_json_value<T>(value: Option<&T>) -> String
921where
922 T: serde::Serialize,
923{
924 value
925 .map(|value| serde_json::to_string(value).unwrap_or_default())
926 .unwrap_or_default()
927}
928
929#[cfg(test)]
930mod tests {
931 use nautilus_model::enums::{MarketStatusAction, OrderStatus, TimeInForce};
932 use rstest::rstest;
933
934 use super::*;
935 use crate::{
936 common::{
937 consts::{BETFAIR_PRICE_PRECISION, BETFAIR_QUANTITY_PRECISION},
938 enums::{StreamingOrderType, StreamingPersistenceType, StreamingSide},
939 testing::load_test_json,
940 },
941 stream::messages::{PV, RunnerChange, RunnerDefinition, StreamMessage, stream_decode},
942 };
943
944 fn assert_decimal_option_eq(actual: Option<Decimal>, expected: Decimal) {
945 assert_eq!(actual, Some(expected));
946 }
947
948 #[rstest]
949 fn test_parse_runner_book_snapshot() {
950 let data = load_test_json("stream/mcm_live_IMAGE.json");
951 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
952
953 if let StreamMessage::MarketChange(mcm) = msg {
954 let mc = mcm.mc.as_ref().unwrap();
955 let change = &mc[0];
956 let rc = &change.rc.as_ref().unwrap()[0];
957 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
958
959 let deltas = parse_runner_book_deltas(
960 instrument_id,
961 rc,
962 true,
963 mcm.pt,
964 parse_millis_timestamp(mcm.pt),
965 parse_millis_timestamp(mcm.pt),
966 )
967 .unwrap()
968 .expect("should produce deltas");
969
970 let atb_len = rc.atb.as_ref().unwrap().len();
972 let atl_len = rc.atl.as_ref().unwrap().len();
973 assert_eq!(deltas.deltas.len(), 1 + atb_len + atl_len);
974
975 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
977 assert!(RecordFlag::F_SNAPSHOT.matches(deltas.deltas[0].flags));
978
979 for delta in &deltas.deltas[1..] {
981 assert_eq!(delta.action, BookAction::Add);
982 assert!(RecordFlag::F_SNAPSHOT.matches(delta.flags));
983 }
984
985 let last = deltas.deltas.last().unwrap();
987 assert!(RecordFlag::F_LAST.matches(last.flags));
988
989 let buy_count = deltas
991 .deltas
992 .iter()
993 .filter(|d| d.order.side == OrderSide::Buy)
994 .count();
995 let sell_count = deltas
996 .deltas
997 .iter()
998 .filter(|d| d.order.side == OrderSide::Sell)
999 .count();
1000 assert_eq!(buy_count, atb_len);
1001 assert_eq!(sell_count, atl_len);
1002 } else {
1003 panic!("expected MarketChange");
1004 }
1005 }
1006
1007 #[rstest]
1008 fn test_parse_runner_book_snapshot_skips_zero_volume_levels() {
1009 let rc: RunnerChange = serde_json::from_str(
1010 r#"{
1011 "id": 123,
1012 "atb": [[2.0, 0.0], [2.1, 3.0]],
1013 "atl": [[2.2, 0.0], [2.3, 4.0]]
1014 }"#,
1015 )
1016 .unwrap();
1017 let instrument_id = make_instrument_id("1.234", rc.id, Decimal::ZERO);
1018
1019 let deltas = parse_runner_book_deltas(
1020 instrument_id,
1021 &rc,
1022 true,
1023 1_551_400_000_000,
1024 parse_millis_timestamp(1_551_400_000_000),
1025 parse_millis_timestamp(1_551_400_000_000),
1026 )
1027 .unwrap()
1028 .expect("should produce deltas");
1029
1030 assert_eq!(deltas.deltas.len(), 3);
1031 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1032 assert!(
1033 deltas
1034 .deltas
1035 .iter()
1036 .filter(|delta| delta.action == BookAction::Add)
1037 .all(|delta| !delta.order.size.is_zero())
1038 );
1039 }
1040
1041 #[rstest]
1042 fn test_parse_runner_book_update() {
1043 let data = load_test_json("stream/mcm_UPDATE.json");
1044 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1045
1046 if let StreamMessage::MarketChange(mcm) = msg {
1047 let mc = mcm.mc.as_ref().unwrap();
1048 let change = &mc[0];
1049 let rc = &change.rc.as_ref().unwrap()[0];
1050 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
1051
1052 let deltas = parse_runner_book_deltas(
1053 instrument_id,
1054 rc,
1055 false,
1056 mcm.pt,
1057 parse_millis_timestamp(mcm.pt),
1058 parse_millis_timestamp(mcm.pt),
1059 )
1060 .unwrap()
1061 .expect("should produce deltas");
1062
1063 assert!(deltas.deltas.iter().all(|d| d.action != BookAction::Clear));
1065
1066 let last = deltas.deltas.last().unwrap();
1068 assert!(RecordFlag::F_LAST.matches(last.flags));
1069
1070 for delta in &deltas.deltas {
1072 assert!(!RecordFlag::F_SNAPSHOT.matches(delta.flags));
1073 }
1074 } else {
1075 panic!("expected MarketChange");
1076 }
1077 }
1078
1079 #[rstest]
1080 fn test_parse_runner_book_update_zero_volume_is_delete() {
1081 let data = load_test_json("stream/mcm_UPDATE.json");
1082 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1083
1084 if let StreamMessage::MarketChange(mcm) = msg {
1085 let mc = mcm.mc.as_ref().unwrap();
1086 let change = &mc[0];
1087 let rc = &change.rc.as_ref().unwrap()[0];
1088 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
1089
1090 let deltas = parse_runner_book_deltas(
1091 instrument_id,
1092 rc,
1093 false,
1094 mcm.pt,
1095 parse_millis_timestamp(mcm.pt),
1096 parse_millis_timestamp(mcm.pt),
1097 )
1098 .unwrap()
1099 .unwrap();
1100
1101 assert!(
1103 deltas.deltas.iter().any(|d| d.action == BookAction::Delete),
1104 "zero volume should produce Delete action"
1105 );
1106 } else {
1107 panic!("expected MarketChange");
1108 }
1109 }
1110
1111 #[rstest]
1112 fn test_parse_runner_book_no_levels_returns_none() {
1113 let rc = RunnerChange {
1114 id: 12345,
1115 hc: None,
1116 atb: None,
1117 atl: None,
1118 batb: None,
1119 batl: None,
1120 bdatb: None,
1121 bdatl: None,
1122 spb: None,
1123 spl: None,
1124 spn: None,
1125 spf: None,
1126 trd: None,
1127 ltp: None,
1128 tv: None,
1129 };
1130
1131 let result = parse_runner_book_deltas(
1132 make_instrument_id("1.123", 12345, Decimal::ZERO),
1133 &rc,
1134 false,
1135 0,
1136 UnixNanos::default(),
1137 UnixNanos::default(),
1138 );
1139
1140 assert!(result.unwrap().is_none());
1141 }
1142
1143 #[rstest]
1144 fn test_make_trade_tick() {
1145 let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
1146 let tick = make_trade_tick(
1147 instrument_id,
1148 Price::new(2.42, BETFAIR_PRICE_PRECISION),
1149 Quantity::new(100.0, BETFAIR_QUANTITY_PRECISION),
1150 TradeId::from("test-trade-1"),
1151 UnixNanos::default(),
1152 UnixNanos::default(),
1153 );
1154
1155 assert_eq!(tick.instrument_id, instrument_id);
1156 assert_eq!(tick.price.as_f64(), 2.42);
1157 assert_eq!(tick.size.as_f64(), 100.0);
1158 assert_eq!(tick.aggressor_side, AggressorSide::NoAggressor);
1159 }
1160
1161 fn make_status_def(
1162 status: MarketStatus,
1163 in_play: bool,
1164 runner_status: RunnerStatus,
1165 ) -> MarketDefinition {
1166 MarketDefinition {
1167 runners: Some(vec![RunnerDefinition {
1168 id: 456,
1169 hc: None,
1170 sort_priority: None,
1171 name: None,
1172 status: Some(runner_status),
1173 adjustment_factor: None,
1174 bsp: None,
1175 removal_date: None,
1176 }]),
1177 bet_delay: None,
1178 betting_type: None,
1179 bsp_market: None,
1180 bsp_reconciled: None,
1181 competition_id: None,
1182 competition_name: None,
1183 complete: None,
1184 country_code: None,
1185 cross_matching: None,
1186 discount_allowed: None,
1187 each_way_divisor: None,
1188 event_id: None,
1189 event_name: None,
1190 event_type_id: None,
1191 event_type_name: None,
1192 in_play: Some(in_play),
1193 line_interval: None,
1194 line_max_unit: None,
1195 line_min_unit: None,
1196 market_base_rate: None,
1197 market_id: None,
1198 market_name: None,
1199 market_time: None,
1200 market_type: None,
1201 number_of_active_runners: None,
1202 number_of_winners: None,
1203 open_date: None,
1204 persistence_enabled: None,
1205 price_ladder_definition: None,
1206 race_type: None,
1207 regulators: None,
1208 runners_voidable: None,
1209 settled_time: None,
1210 status: Some(status),
1211 suspend_time: None,
1212 timezone: None,
1213 turn_in_play_enabled: None,
1214 venue: None,
1215 version: None,
1216 }
1217 }
1218
1219 #[rstest]
1220 #[case(MarketStatus::Open, false, MarketStatusAction::PreOpen, false)]
1221 #[case(MarketStatus::Open, true, MarketStatusAction::Trading, true)]
1222 #[case(MarketStatus::Closed, false, MarketStatusAction::Close, false)]
1223 #[case(MarketStatus::Closed, true, MarketStatusAction::Close, false)]
1224 #[case(MarketStatus::Suspended, false, MarketStatusAction::Pause, false)]
1225 #[case(MarketStatus::Suspended, true, MarketStatusAction::Pause, false)]
1226 #[case(MarketStatus::Inactive, false, MarketStatusAction::Close, false)]
1227 fn test_parse_instrument_statuses_market_state(
1228 #[case] status: MarketStatus,
1229 #[case] in_play: bool,
1230 #[case] expected_action: MarketStatusAction,
1231 #[case] expected_is_trading: bool,
1232 ) {
1233 let def = make_status_def(status, in_play, RunnerStatus::Active);
1234 let results =
1235 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1236
1237 assert_eq!(results.len(), 1);
1238 assert_eq!(results[0].action, expected_action);
1239 assert_eq!(results[0].is_trading, Some(expected_is_trading));
1240 }
1241
1242 #[rstest]
1243 fn test_parse_instrument_statuses_skips_unknown_market_status() {
1244 let def = make_status_def(MarketStatus::Unknown, true, RunnerStatus::Active);
1247 let results =
1248 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1249
1250 assert!(results.is_empty());
1251 }
1252
1253 #[rstest]
1254 fn test_parse_instrument_statuses_skips_unknown_runner() {
1255 let def = make_status_def(MarketStatus::Open, true, RunnerStatus::Unknown);
1258 let results =
1259 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1260
1261 assert!(results.is_empty());
1262 }
1263
1264 #[rstest]
1265 #[case(RunnerStatus::Removed)]
1266 #[case(RunnerStatus::RemovedVacant)]
1267 fn test_parse_instrument_statuses_scratched_runner_closes(#[case] runner_status: RunnerStatus) {
1268 let def = make_status_def(MarketStatus::Open, true, runner_status);
1270 let results =
1271 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1272
1273 assert_eq!(results.len(), 1);
1274 assert_eq!(results[0].action, MarketStatusAction::Close);
1275 assert_eq!(results[0].is_trading, Some(false));
1276 }
1277
1278 #[rstest]
1279 #[case::missing_runners("runners")]
1280 #[case::missing_status("status")]
1281 fn test_parse_instrument_statuses_returns_empty(#[case] drop_field: &str) {
1282 let mut def = make_status_def(MarketStatus::Open, true, RunnerStatus::Active);
1283 match drop_field {
1284 "runners" => def.runners = None,
1285 "status" => def.status = None,
1286 _ => unreachable!(),
1287 }
1288
1289 let results =
1290 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1291
1292 assert!(results.is_empty());
1293 }
1294
1295 #[rstest]
1296 fn test_parse_instrument_statuses_mixed_runners() {
1297 let mut def = make_status_def(MarketStatus::Open, true, RunnerStatus::Active);
1301 def.runners = Some(vec![
1302 RunnerDefinition {
1303 id: 101,
1304 hc: None,
1305 sort_priority: Some(1),
1306 name: None,
1307 status: Some(RunnerStatus::Active),
1308 adjustment_factor: None,
1309 bsp: None,
1310 removal_date: None,
1311 },
1312 RunnerDefinition {
1313 id: 202,
1314 hc: Some(Decimal::new(25, 1)), sort_priority: Some(2),
1316 name: None,
1317 status: Some(RunnerStatus::Removed),
1318 adjustment_factor: None,
1319 bsp: None,
1320 removal_date: None,
1321 },
1322 RunnerDefinition {
1323 id: 303,
1324 hc: None,
1325 sort_priority: Some(3),
1326 name: None,
1327 status: Some(RunnerStatus::RemovedVacant),
1328 adjustment_factor: None,
1329 bsp: None,
1330 removal_date: None,
1331 },
1332 ]);
1333
1334 let results =
1335 parse_instrument_statuses("1.999", &def, UnixNanos::default(), UnixNanos::default());
1336
1337 assert_eq!(results.len(), 3);
1338
1339 assert_eq!(results[0].action, MarketStatusAction::Trading);
1340 assert_eq!(results[0].is_trading, Some(true));
1341
1342 assert_eq!(results[1].action, MarketStatusAction::Close);
1343 assert_eq!(results[1].is_trading, Some(false));
1344
1345 assert_eq!(results[2].action, MarketStatusAction::Close);
1346 assert_eq!(results[2].is_trading, Some(false));
1347
1348 assert_ne!(results[0].instrument_id, results[1].instrument_id);
1350 assert_ne!(results[1].instrument_id, results[2].instrument_id);
1351 assert_ne!(results[0].instrument_id, results[2].instrument_id);
1352 }
1353
1354 #[rstest]
1355 fn test_parse_order_status_report_new_order() {
1356 let data = load_test_json("stream/ocm_NEW_FULL_IMAGE.json");
1357 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1358
1359 if let StreamMessage::OrderChange(ocm) = msg {
1360 let oc = ocm.oc.as_ref().unwrap();
1361 let omc = &oc[0];
1362 let orc = &omc.orc.as_ref().unwrap()[0];
1363 let uo = &orc.uo.as_ref().unwrap()[0];
1364
1365 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1366 let report = parse_order_status_report(
1367 uo,
1368 instrument_id,
1369 AccountId::from("BETFAIR-001"),
1370 parse_millis_timestamp(ocm.pt),
1371 parse_millis_timestamp(ocm.pt),
1372 )
1373 .unwrap();
1374
1375 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1377 assert_eq!(report.order_side, OrderSide::Sell); assert_eq!(report.order_type, OrderType::Limit);
1379 assert_eq!(report.filled_qty.as_f64(), 4.75);
1380 assert_eq!(report.quantity.as_f64(), 5.0);
1381 assert!(report.price.is_some());
1382 assert_eq!(report.price.unwrap().as_f64(), 12.0);
1383 } else {
1384 panic!("expected OrderChange");
1385 }
1386 }
1387
1388 #[rstest]
1389 fn test_parse_order_status_report_filled() {
1390 let data = load_test_json("stream/ocm_FILLED.json");
1391 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1392
1393 if let StreamMessage::OrderChange(ocm) = msg {
1394 let oc = ocm.oc.as_ref().unwrap();
1395 let omc = &oc[0];
1396 let orc = &omc.orc.as_ref().unwrap()[0];
1397 let uo = &orc.uo.as_ref().unwrap()[0];
1398
1399 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1400 let report = parse_order_status_report(
1401 uo,
1402 instrument_id,
1403 AccountId::from("BETFAIR-001"),
1404 parse_millis_timestamp(ocm.pt),
1405 parse_millis_timestamp(ocm.pt),
1406 )
1407 .unwrap();
1408
1409 assert_eq!(report.order_status, OrderStatus::Filled);
1410 assert_eq!(report.order_side, OrderSide::Buy); assert_eq!(report.filled_qty.as_f64(), 10.0);
1412 assert_eq!(report.quantity.as_f64(), 10.0);
1413
1414 assert!(report.client_order_id.is_some());
1416 } else {
1417 panic!("expected OrderChange");
1418 }
1419 }
1420
1421 #[rstest]
1422 fn test_parse_order_status_report_cancelled() {
1423 let data = load_test_json("stream/ocm_CANCEL.json");
1424 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1425
1426 if let StreamMessage::OrderChange(ocm) = msg {
1427 let oc = ocm.oc.as_ref().unwrap();
1428 let omc = &oc[0];
1429 let orc = &omc.orc.as_ref().unwrap()[0];
1430 let uo = &orc.uo.as_ref().unwrap()[0];
1431
1432 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1433 let report = parse_order_status_report(
1434 uo,
1435 instrument_id,
1436 AccountId::from("BETFAIR-001"),
1437 parse_millis_timestamp(ocm.pt),
1438 parse_millis_timestamp(ocm.pt),
1439 )
1440 .unwrap();
1441
1442 assert_eq!(report.order_status, OrderStatus::Canceled);
1443 assert_eq!(report.order_side, OrderSide::Sell); assert_eq!(report.filled_qty.as_f64(), 0.0);
1445 assert_eq!(report.quantity.as_f64(), 10.0);
1446 } else {
1447 panic!("expected OrderChange");
1448 }
1449 }
1450
1451 #[rstest]
1452 fn test_parse_order_status_report_duplicate_execution() {
1453 let data = load_test_json("stream/ocm_DUPLICATE_EXECUTION.json");
1454 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
1455
1456 if let StreamMessage::OrderChange(ocm) = &msgs[0] {
1457 let oc = ocm.oc.as_ref().unwrap();
1458 let omc = &oc[0];
1459 let orc = &omc.orc.as_ref().unwrap()[0];
1460 let uo = &orc.uo.as_ref().unwrap()[0];
1461
1462 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1463 let report = parse_order_status_report(
1464 uo,
1465 instrument_id,
1466 AccountId::from("BETFAIR-001"),
1467 parse_millis_timestamp(ocm.pt),
1468 parse_millis_timestamp(ocm.pt),
1469 )
1470 .unwrap();
1471
1472 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1474 assert_eq!(report.order_side, OrderSide::Buy); assert_eq!(report.filled_qty.as_f64(), 1.12);
1476 } else {
1477 panic!("expected OrderChange");
1478 }
1479 }
1480
1481 #[rstest]
1482 fn test_parse_order_status_report_multiple_fills() {
1483 let data = load_test_json("stream/ocm_multiple_fills.json");
1484 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
1485
1486 if let StreamMessage::OrderChange(ocm) = &msgs[0] {
1487 let oc = ocm.oc.as_ref().unwrap();
1488 let omc = &oc[0];
1489 let orc = &omc.orc.as_ref().unwrap()[0];
1490 let uo = &orc.uo.as_ref().unwrap()[0];
1491
1492 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1493 let report = parse_order_status_report(
1494 uo,
1495 instrument_id,
1496 AccountId::from("BETFAIR-001"),
1497 parse_millis_timestamp(ocm.pt),
1498 parse_millis_timestamp(ocm.pt),
1499 )
1500 .unwrap();
1501
1502 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1504 assert_eq!(report.order_side, OrderSide::Sell); assert_eq!(report.filled_qty.as_f64(), 16.19);
1506 assert!(report.client_order_id.is_some());
1507 assert!(report.avg_px.is_some());
1508 } else {
1509 panic!("expected OrderChange");
1510 }
1511 }
1512
1513 #[rstest]
1514 fn test_parse_runner_book_snapshot_empty_book() {
1515 let rc = RunnerChange {
1517 id: 12345,
1518 hc: None,
1519 atb: Some(vec![]),
1520 atl: Some(vec![]),
1521 batb: None,
1522 batl: None,
1523 bdatb: None,
1524 bdatl: None,
1525 spb: None,
1526 spl: None,
1527 spn: None,
1528 spf: None,
1529 trd: None,
1530 ltp: None,
1531 tv: None,
1532 };
1533
1534 let result = parse_runner_book_deltas(
1535 make_instrument_id("1.123", 12345, Decimal::ZERO),
1536 &rc,
1537 true,
1538 1000,
1539 UnixNanos::default(),
1540 UnixNanos::default(),
1541 )
1542 .unwrap()
1543 .expect("should produce snapshot deltas");
1544
1545 assert_eq!(result.deltas.len(), 1);
1547 assert_eq!(result.deltas[0].action, BookAction::Clear);
1548 assert!(RecordFlag::F_LAST.matches(result.deltas[0].flags));
1549 assert!(RecordFlag::F_SNAPSHOT.matches(result.deltas[0].flags));
1550 }
1551
1552 #[rstest]
1553 fn test_make_fill_report() {
1554 let instrument_id = make_instrument_id("1.180604981", 1209555, Decimal::ZERO);
1555 let fill = make_fill_report(
1556 AccountId::from("BETFAIR-001"),
1557 instrument_id,
1558 VenueOrderId::from("229430281339"),
1559 TradeId::from("229430281339-0"),
1560 OrderSide::Buy,
1561 Quantity::new(10.0, BETFAIR_QUANTITY_PRECISION),
1562 Price::new(1.1, BETFAIR_PRICE_PRECISION),
1563 Currency::GBP(),
1564 None,
1565 UnixNanos::default(),
1566 UnixNanos::default(),
1567 );
1568
1569 assert_eq!(fill.instrument_id, instrument_id);
1570 assert_eq!(fill.order_side, OrderSide::Buy);
1571 assert_eq!(fill.last_qty.as_f64(), 10.0);
1572 assert_eq!(fill.last_px.as_f64(), 1.1);
1573 assert_eq!(fill.commission.as_f64(), 0.0);
1574 assert_eq!(fill.liquidity_side, LiquiditySide::NoLiquiditySide);
1575 }
1576
1577 #[rstest]
1578 fn test_fill_tracker_single_full_fill() {
1579 let data = load_test_json("stream/ocm_FILLED.json");
1580 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1581
1582 if let StreamMessage::OrderChange(ocm) = msg {
1583 let oc = ocm.oc.as_ref().unwrap();
1584 let omc = &oc[0];
1585 let orc = &omc.orc.as_ref().unwrap()[0];
1586 let uo = &orc.uo.as_ref().unwrap()[0];
1587 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1588 let ts = parse_millis_timestamp(ocm.pt);
1589
1590 let mut tracker = FillTracker::new();
1591 let fill = tracker
1592 .maybe_fill_report(
1593 uo,
1594 uo.s,
1595 instrument_id,
1596 AccountId::from("BETFAIR-001"),
1597 Currency::GBP(),
1598 ts,
1599 ts,
1600 )
1601 .expect("should produce fill");
1602
1603 assert_eq!(fill.last_qty.as_f64(), 10.0);
1604 assert_eq!(fill.last_px.as_f64(), 1.1);
1605 assert_eq!(fill.order_side, OrderSide::Buy);
1606 assert!(fill.client_order_id.is_some());
1607 } else {
1608 panic!("expected OrderChange");
1609 }
1610 }
1611
1612 #[rstest]
1613 fn test_fill_tracker_incremental_fills() {
1614 let data = load_test_json("stream/ocm_multiple_fills.json");
1615 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
1616 let instrument_id = make_instrument_id("1.179082386", 50210, Decimal::ZERO);
1617
1618 let mut tracker = FillTracker::new();
1619 let account_id = AccountId::from("BETFAIR-001");
1620 let currency = Currency::GBP();
1621
1622 let uo1 = extract_uo(&msgs[0]);
1624 let ts1 = extract_ts(&msgs[0]);
1625 let fill1 = tracker
1626 .maybe_fill_report(uo1, uo1.s, instrument_id, account_id, currency, ts1, ts1)
1627 .expect("should produce first fill");
1628 assert_eq!(fill1.last_qty.as_f64(), 16.19);
1629 assert_eq!(fill1.last_px.as_f64(), 5.8);
1630
1631 let uo2 = extract_uo(&msgs[1]);
1633 let ts2 = extract_ts(&msgs[1]);
1634 let fill2 = tracker
1635 .maybe_fill_report(uo2, uo2.s, instrument_id, account_id, currency, ts2, ts2)
1636 .expect("should produce second fill");
1637 assert_eq!(fill2.last_qty.as_f64(), 0.77);
1638 assert_eq!(fill2.last_px.as_f64(), 5.8);
1639
1640 let uo3 = extract_uo(&msgs[2]);
1642 let ts3 = extract_ts(&msgs[2]);
1643 let fill3 = tracker
1644 .maybe_fill_report(uo3, uo3.s, instrument_id, account_id, currency, ts3, ts3)
1645 .expect("should produce third fill");
1646 assert_eq!(fill3.last_qty.as_f64(), 0.77);
1647 }
1648
1649 #[rstest]
1650 fn test_fill_tracker_different_price() {
1651 let data = load_test_json("stream/ocm_filled_different_price.json");
1652 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1653
1654 if let StreamMessage::OrderChange(ocm) = msg {
1655 let oc = ocm.oc.as_ref().unwrap();
1656 let omc = &oc[0];
1657 let orc = &omc.orc.as_ref().unwrap()[0];
1658 let uo = &orc.uo.as_ref().unwrap()[0];
1659 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1660 let ts = parse_millis_timestamp(ocm.pt);
1661
1662 let mut tracker = FillTracker::new();
1663 let fill = tracker
1664 .maybe_fill_report(
1665 uo,
1666 uo.s,
1667 instrument_id,
1668 AccountId::from("BETFAIR-001"),
1669 Currency::GBP(),
1670 ts,
1671 ts,
1672 )
1673 .expect("should produce fill");
1674
1675 assert_eq!(fill.last_qty.as_f64(), 20.0);
1677 assert_eq!(fill.last_px.as_f64(), 1.2);
1678 } else {
1679 panic!("expected OrderChange");
1680 }
1681 }
1682
1683 #[rstest]
1684 fn test_fill_tracker_cancel_no_fill() {
1685 let data = load_test_json("stream/ocm_CANCEL.json");
1686 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1687
1688 if let StreamMessage::OrderChange(ocm) = msg {
1689 let oc = ocm.oc.as_ref().unwrap();
1690 let omc = &oc[0];
1691 let orc = &omc.orc.as_ref().unwrap()[0];
1692 let uo = &orc.uo.as_ref().unwrap()[0];
1693 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1694 let ts = parse_millis_timestamp(ocm.pt);
1695
1696 let mut tracker = FillTracker::new();
1697 let result = tracker.maybe_fill_report(
1698 uo,
1699 uo.s,
1700 instrument_id,
1701 AccountId::from("BETFAIR-001"),
1702 Currency::GBP(),
1703 ts,
1704 ts,
1705 );
1706 assert!(result.is_none(), "cancelled order should not produce fill");
1707 } else {
1708 panic!("expected OrderChange");
1709 }
1710 }
1711
1712 #[rstest]
1713 fn test_fill_tracker_lapsed_no_fill() {
1714 let data = load_test_json("stream/ocm_error_fill.json");
1715 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1716
1717 if let StreamMessage::OrderChange(ocm) = msg {
1718 let oc = ocm.oc.as_ref().unwrap();
1719 let omc = &oc[0];
1720 let orc = &omc.orc.as_ref().unwrap()[0];
1721 let uo = &orc.uo.as_ref().unwrap()[0];
1722 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1723 let ts = parse_millis_timestamp(ocm.pt);
1724
1725 let mut tracker = FillTracker::new();
1726 let result = tracker.maybe_fill_report(
1727 uo,
1728 uo.s,
1729 instrument_id,
1730 AccountId::from("BETFAIR-001"),
1731 Currency::GBP(),
1732 ts,
1733 ts,
1734 );
1735 assert!(result.is_none(), "lapsed order should not produce fill");
1736 } else {
1737 panic!("expected OrderChange");
1738 }
1739 }
1740
1741 #[rstest]
1742 fn test_fill_tracker_duplicate_dedup() {
1743 let data = load_test_json("stream/ocm_FILLED.json");
1744 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1745
1746 if let StreamMessage::OrderChange(ocm) = msg {
1747 let oc = ocm.oc.as_ref().unwrap();
1748 let omc = &oc[0];
1749 let orc = &omc.orc.as_ref().unwrap()[0];
1750 let uo = &orc.uo.as_ref().unwrap()[0];
1751 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1752 let ts = parse_millis_timestamp(ocm.pt);
1753 let account_id = AccountId::from("BETFAIR-001");
1754 let currency = Currency::GBP();
1755
1756 let mut tracker = FillTracker::new();
1757
1758 let fill1 =
1760 tracker.maybe_fill_report(uo, uo.s, instrument_id, account_id, currency, ts, ts);
1761 assert!(fill1.is_some());
1762
1763 let fill2 =
1765 tracker.maybe_fill_report(uo, uo.s, instrument_id, account_id, currency, ts, ts);
1766 assert!(fill2.is_none(), "duplicate fill should be suppressed");
1767 } else {
1768 panic!("expected OrderChange");
1769 }
1770 }
1771
1772 #[rstest]
1773 fn test_fill_tracker_price_back_calculation() {
1774 let data = load_test_json("stream/ocm_multiple_fills.json");
1775 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
1776 let instrument_id = make_instrument_id("1.179082386", 50210, Decimal::ZERO);
1777 let account_id = AccountId::from("BETFAIR-001");
1778 let currency = Currency::GBP();
1779 let mut tracker = FillTracker::new();
1780
1781 let uo1 = extract_uo(&msgs[0]);
1783 let ts1 = extract_ts(&msgs[0]);
1784 let fill1 = tracker
1785 .maybe_fill_report(uo1, uo1.s, instrument_id, account_id, currency, ts1, ts1)
1786 .unwrap();
1787 assert_eq!(fill1.last_px.as_f64(), 5.8);
1788
1789 let uo2 = extract_uo(&msgs[1]);
1791 let ts2 = extract_ts(&msgs[1]);
1792 let fill2 = tracker
1793 .maybe_fill_report(uo2, uo2.s, instrument_id, account_id, currency, ts2, ts2)
1794 .unwrap();
1795 assert_eq!(fill2.last_px.as_f64(), 5.8);
1796 }
1797
1798 #[rstest]
1799 fn test_has_cancel_quantity() {
1800 let data = load_test_json("stream/ocm_CANCEL.json");
1801 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1802
1803 if let StreamMessage::OrderChange(ocm) = msg {
1804 let uo = &ocm.oc.as_ref().unwrap()[0].orc.as_ref().unwrap()[0]
1805 .uo
1806 .as_ref()
1807 .unwrap()[0];
1808 assert!(has_cancel_quantity(uo));
1809 } else {
1810 panic!("expected OrderChange");
1811 }
1812 }
1813
1814 #[rstest]
1815 fn test_has_cancel_quantity_filled_order() {
1816 let data = load_test_json("stream/ocm_FILLED.json");
1817 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1818
1819 if let StreamMessage::OrderChange(ocm) = msg {
1820 let uo = &ocm.oc.as_ref().unwrap()[0].orc.as_ref().unwrap()[0]
1821 .uo
1822 .as_ref()
1823 .unwrap()[0];
1824 assert!(!has_cancel_quantity(uo));
1825 } else {
1826 panic!("expected OrderChange");
1827 }
1828 }
1829
1830 fn extract_uo(msg: &StreamMessage) -> &UnmatchedOrder {
1831 if let StreamMessage::OrderChange(ocm) = msg {
1832 &ocm.oc.as_ref().unwrap()[0].orc.as_ref().unwrap()[0]
1833 .uo
1834 .as_ref()
1835 .unwrap()[0]
1836 } else {
1837 panic!("expected OrderChange")
1838 }
1839 }
1840
1841 fn extract_ts(msg: &StreamMessage) -> UnixNanos {
1842 if let StreamMessage::OrderChange(ocm) = msg {
1843 parse_millis_timestamp(ocm.pt)
1844 } else {
1845 panic!("expected OrderChange")
1846 }
1847 }
1848
1849 #[rstest]
1850 fn test_parse_race_runner_data_from_fixture() {
1851 let data = load_test_json("stream/rcm_single.json");
1852 let msg = stream_decode(data.as_bytes()).unwrap();
1853
1854 let StreamMessage::RaceChange(rcm) = msg else {
1855 panic!("expected RaceChange");
1856 };
1857
1858 let race = &rcm.rc.as_ref().unwrap()[0];
1859 let rrc = &race.rrc.as_ref().unwrap()[0];
1860 let ts = parse_millis_timestamp(rcm.pt);
1861
1862 let runner = parse_race_runner_data(
1863 race.id.as_deref().unwrap(),
1864 race.mid.as_deref().unwrap(),
1865 rrc,
1866 ts,
1867 ts,
1868 )
1869 .unwrap();
1870
1871 assert_eq!(runner.race_id, "28587288.1650");
1872 assert_eq!(runner.market_id, "1.1234567");
1873 assert_eq!(runner.selection_id, 7390417);
1874 assert!((runner.latitude - 51.4189543).abs() < 1e-6);
1875 assert!((runner.longitude - (-0.4058491)).abs() < 1e-6);
1876 assert!((runner.speed - 17.8).abs() < 1e-6);
1877 assert!((runner.progress - 2051.0).abs() < 1e-6);
1878 assert!((runner.stride_frequency - 2.07).abs() < 1e-6);
1879 }
1880
1881 #[rstest]
1882 fn test_parse_race_progress_from_fixture() {
1883 let data = load_test_json("stream/rcm_single.json");
1884 let msg = stream_decode(data.as_bytes()).unwrap();
1885
1886 let StreamMessage::RaceChange(rcm) = msg else {
1887 panic!("expected RaceChange");
1888 };
1889
1890 let race = &rcm.rc.as_ref().unwrap()[0];
1891 let rpc = race.rpc.as_ref().unwrap();
1892 let ts = parse_millis_timestamp(rcm.pt);
1893
1894 let progress = parse_race_progress(
1895 race.id.as_deref().unwrap(),
1896 race.mid.as_deref().unwrap(),
1897 rpc,
1898 ts,
1899 ts,
1900 );
1901
1902 assert_eq!(progress.race_id, "28587288.1650");
1903 assert_eq!(progress.market_id, "1.1234567");
1904 assert_eq!(progress.gate_name, "1f");
1905 assert!((progress.sectional_time - 10.6).abs() < 1e-6);
1906 assert!((progress.running_time - 46.7).abs() < 1e-6);
1907 assert!((progress.speed - 17.8).abs() < 1e-6);
1908 assert!((progress.progress - 87.5).abs() < 1e-6);
1909
1910 let order: Vec<i64> = serde_json::from_str(&progress.order).unwrap();
1911 assert_eq!(order, vec![7390417, 5600338, 11527189, 6395118, 8706072]);
1912
1913 let jumps: Vec<serde_json::Value> = serde_json::from_str(&progress.jumps).unwrap();
1914 assert_eq!(jumps.len(), 2);
1915 assert_eq!(jumps[0]["J"], 2);
1916 }
1917
1918 #[rstest]
1919 fn test_parse_cricket_match_from_fixture() {
1920 let data = load_test_json("stream/ccm_single.json");
1921 let msg = stream_decode(data.as_bytes()).unwrap();
1922
1923 let StreamMessage::CricketChange(ccm) = msg else {
1924 panic!("expected CricketChange");
1925 };
1926
1927 let change = &ccm.cc.as_ref().unwrap()[0];
1928 let ts = parse_millis_timestamp(ccm.pt);
1929 let cricket = parse_cricket_match(change, ts, ts).unwrap();
1930
1931 assert_eq!(cricket.event_id, "35741575");
1932 assert_eq!(cricket.market_id, "1.259334639");
1933 let stats: serde_json::Value = serde_json::from_str(&cricket.match_stats).unwrap();
1934 assert_eq!(stats["inningsStats"][0]["inningsRuns"], 100);
1935 }
1936
1937 #[rstest]
1938 #[case(None, Some("1.259334639".to_string()))]
1939 #[case(Some("35741575".to_string()), None)]
1940 fn test_parse_cricket_match_missing_required_id_returns_none(
1941 #[case] event_id: Option<String>,
1942 #[case] market_id: Option<String>,
1943 ) {
1944 let change = CricketChange {
1945 event_id,
1946 market_id,
1947 fixture_info: None,
1948 home_team: None,
1949 away_team: None,
1950 match_stats: Some(serde_json::json!({"inningsStats": []})),
1951 incident_list_wrapper: None,
1952 };
1953 let ts = UnixNanos::from(1_000_000_000u64);
1954 let result = parse_cricket_match(&change, ts, ts);
1955
1956 assert!(result.is_none());
1957 }
1958
1959 #[rstest]
1960 fn test_parse_race_runner_data_multi_runner() {
1961 let data = load_test_json("stream/rcm_multi_runner.json");
1962 let msg = stream_decode(data.as_bytes()).unwrap();
1963
1964 let StreamMessage::RaceChange(rcm) = msg else {
1965 panic!("expected RaceChange");
1966 };
1967
1968 let race = &rcm.rc.as_ref().unwrap()[0];
1969 let ts = parse_millis_timestamp(rcm.pt);
1970 let race_id = race.id.as_deref().unwrap();
1971 let market_id = race.mid.as_deref().unwrap();
1972
1973 let runners: Vec<_> = race
1974 .rrc
1975 .as_ref()
1976 .unwrap()
1977 .iter()
1978 .filter_map(|rrc| parse_race_runner_data(race_id, market_id, rrc, ts, ts))
1979 .collect();
1980
1981 assert_eq!(runners.len(), 5);
1982 assert_eq!(runners[0].selection_id, 35467839);
1983 assert_eq!(runners[4].selection_id, 41694785);
1984 assert!((runners[0].speed - 16.33).abs() < 1e-6);
1985 assert!((runners[4].speed - 17.11).abs() < 1e-6);
1986 }
1987
1988 #[rstest]
1989 fn test_parse_race_runner_data_missing_id_returns_none() {
1990 let rrc = RaceRunnerChange {
1991 ft: Some(1000),
1992 id: None,
1993 lat: Some(51.0),
1994 lng: Some(-0.4),
1995 spd: Some(15.0),
1996 prg: Some(500.0),
1997 sfq: Some(2.0),
1998 };
1999 let ts = UnixNanos::from(1_000_000_000u64);
2000 let result = parse_race_runner_data("race1", "market1", &rrc, ts, ts);
2001 assert!(result.is_none());
2002 }
2003
2004 #[rstest]
2005 fn test_parse_race_runner_data_absent_fields_are_nan() {
2006 let rrc = RaceRunnerChange {
2007 ft: None,
2008 id: Some(12345),
2009 lat: None,
2010 lng: None,
2011 spd: None,
2012 prg: None,
2013 sfq: None,
2014 };
2015 let ts = UnixNanos::from(1_000_000_000u64);
2016 let runner = parse_race_runner_data("race1", "market1", &rrc, ts, ts).unwrap();
2017 assert!(runner.latitude.is_nan());
2018 assert!(runner.longitude.is_nan());
2019 assert!(runner.speed.is_nan());
2020 assert!(runner.progress.is_nan());
2021 assert!(runner.stride_frequency.is_nan());
2022 }
2023
2024 #[rstest]
2025 fn test_parse_race_progress_absent_fields() {
2026 let rpc = RaceProgressChange {
2027 ft: None,
2028 g: None,
2029 st: None,
2030 rt: None,
2031 spd: None,
2032 prg: None,
2033 ord: None,
2034 jumps: None,
2035 };
2036 let ts = UnixNanos::from(1_000_000_000u64);
2037 let progress = parse_race_progress("race1", "market1", &rpc, ts, ts);
2038 assert_eq!(progress.gate_name, "");
2039 assert!(progress.sectional_time.is_nan());
2040 assert!(progress.running_time.is_nan());
2041 assert_eq!(progress.order, "");
2042 assert_eq!(progress.jumps, "");
2043 }
2044
2045 fn runner_change_with_ticker(
2046 id: u64,
2047 ltp: Option<Decimal>,
2048 tv: Option<Decimal>,
2049 spn: Option<Decimal>,
2050 spf: Option<Decimal>,
2051 ) -> RunnerChange {
2052 RunnerChange {
2053 id,
2054 hc: None,
2055 atb: None,
2056 atl: None,
2057 batb: None,
2058 batl: None,
2059 bdatb: None,
2060 bdatl: None,
2061 spb: None,
2062 spl: None,
2063 spn,
2064 spf,
2065 trd: None,
2066 ltp,
2067 tv,
2068 }
2069 }
2070
2071 #[rstest]
2072 fn test_parse_betfair_ticker_all_fields() {
2073 let rc = runner_change_with_ticker(
2074 9249757,
2075 Some(Decimal::new(55, 1)),
2076 Some(Decimal::new(189032, 2)),
2077 Some(Decimal::new(568, 2)),
2078 Some(Decimal::new(573, 2)),
2079 );
2080 let ts = UnixNanos::from(1_000_000_000u64);
2081 let instrument_id = make_instrument_id("1.185781465", 9249757, Decimal::ZERO);
2082
2083 let ticker = parse_betfair_ticker(instrument_id, &rc, ts, ts).unwrap();
2084
2085 assert_eq!(ticker.instrument_id, instrument_id);
2086 assert_decimal_option_eq(ticker.last_traded_price, Decimal::new(55, 1));
2087 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(189032, 2));
2088 assert_decimal_option_eq(ticker.starting_price_near, Decimal::new(568, 2));
2089 assert_decimal_option_eq(ticker.starting_price_far, Decimal::new(573, 2));
2090 }
2091
2092 #[rstest]
2093 fn test_parse_betfair_ticker_partial_fields() {
2094 let rc = runner_change_with_ticker(
2095 9249757,
2096 Some(Decimal::new(55, 1)),
2097 Some(Decimal::new(189032, 2)),
2098 None,
2099 None,
2100 );
2101 let ts = UnixNanos::from(1_000_000_000u64);
2102 let instrument_id = make_instrument_id("1.185781465", 9249757, Decimal::ZERO);
2103
2104 let ticker = parse_betfair_ticker(instrument_id, &rc, ts, ts).unwrap();
2105
2106 assert_decimal_option_eq(ticker.last_traded_price, Decimal::new(55, 1));
2107 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(189032, 2));
2108 assert!(ticker.starting_price_near.is_none());
2109 assert!(ticker.starting_price_far.is_none());
2110 }
2111
2112 #[rstest]
2113 fn test_parse_betfair_ticker_no_fields_returns_none() {
2114 let rc = runner_change_with_ticker(9249757, None, None, None, None);
2115 let ts = UnixNanos::from(1_000_000_000u64);
2116 let instrument_id = make_instrument_id("1.185781465", 9249757, Decimal::ZERO);
2117
2118 assert!(parse_betfair_ticker(instrument_id, &rc, ts, ts).is_none());
2119 }
2120
2121 #[rstest]
2122 fn test_parse_betfair_ticker_only_tv() {
2123 let rc =
2124 runner_change_with_ticker(40273293, None, Some(Decimal::new(320115, 2)), None, None);
2125 let ts = UnixNanos::from(1_000_000_000u64);
2126 let instrument_id = make_instrument_id("1.185781465", 40273293, Decimal::ZERO);
2127
2128 let ticker = parse_betfair_ticker(instrument_id, &rc, ts, ts).unwrap();
2129
2130 assert!(ticker.last_traded_price.is_none());
2131 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(320115, 2));
2132 assert!(ticker.starting_price_near.is_none());
2133 assert!(ticker.starting_price_far.is_none());
2134 }
2135
2136 #[rstest]
2137 fn test_parse_betfair_ticker_from_fixture() {
2138 let data = load_test_json("stream/mcm_BSP_settled.json");
2139 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2140
2141 if let StreamMessage::MarketChange(mcm) = msg {
2142 let mc = mcm.mc.as_ref().unwrap();
2143 let change = &mc[0];
2144 let rc_list = change.rc.as_ref().unwrap();
2145
2146 let rc = rc_list.iter().find(|r| r.id == 9249757).unwrap();
2148 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
2149 let ts = parse_millis_timestamp(mcm.pt);
2150
2151 let ticker = parse_betfair_ticker(instrument_id, rc, ts, ts).unwrap();
2152
2153 assert_decimal_option_eq(ticker.last_traded_price, Decimal::new(55, 1));
2154 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(189032, 2));
2155 assert_decimal_option_eq(ticker.starting_price_near, Decimal::new(568, 2));
2156 assert_decimal_option_eq(ticker.starting_price_far, Decimal::new(573, 2));
2157
2158 let rc2 = rc_list.iter().find(|r| r.id == 40273293).unwrap();
2160 let instrument_id2 = make_instrument_id(&change.id, rc2.id, Decimal::ZERO);
2161 let ticker2 = parse_betfair_ticker(instrument_id2, rc2, ts, ts).unwrap();
2162
2163 assert_decimal_option_eq(ticker2.last_traded_price, Decimal::new(21, 1));
2164 assert_decimal_option_eq(ticker2.traded_volume, Decimal::new(320115, 2));
2165 assert!(ticker2.starting_price_near.is_none());
2166 assert!(ticker2.starting_price_far.is_none());
2167
2168 let rc3 = rc_list.iter().find(|r| r.id == 23678734).unwrap();
2170 let instrument_id3 = make_instrument_id(&change.id, rc3.id, Decimal::ZERO);
2171 let ticker3 = parse_betfair_ticker(instrument_id3, rc3, ts, ts).unwrap();
2172
2173 assert!(ticker3.last_traded_price.is_none());
2174 assert_decimal_option_eq(ticker3.traded_volume, Decimal::ZERO);
2175 } else {
2176 panic!("Expected MarketChange");
2177 }
2178 }
2179
2180 #[rstest]
2181 fn test_parse_betfair_starting_prices_from_fixture() {
2182 let data = load_test_json("stream/mcm_BSP_settled.json");
2183 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2184
2185 if let StreamMessage::MarketChange(mcm) = msg {
2186 let mc = mcm.mc.as_ref().unwrap();
2187 let def = mc[0].market_definition.as_ref().unwrap();
2188 let ts = parse_millis_timestamp(mcm.pt);
2189
2190 let prices = parse_betfair_starting_prices(&mc[0].id, def, ts, ts);
2191
2192 assert_eq!(prices.len(), 3);
2194
2195 let bsp_map: std::collections::HashMap<String, Decimal> = prices
2196 .iter()
2197 .map(|p| (p.instrument_id.to_string(), p.bsp))
2198 .collect();
2199
2200 let id_winner = make_instrument_id("1.185781465", 9249757, Decimal::ZERO).to_string();
2201 let id_placed = make_instrument_id("1.185781465", 40273293, Decimal::ZERO).to_string();
2202 let id_loser = make_instrument_id("1.185781465", 11120000, Decimal::ZERO).to_string();
2203
2204 assert_eq!(bsp_map[&id_winner], Decimal::new(573, 2));
2205 assert_eq!(bsp_map[&id_placed], Decimal::new(214, 2));
2206 assert_eq!(bsp_map[&id_loser], Decimal::new(2856, 2));
2207 } else {
2208 panic!("Expected MarketChange");
2209 }
2210 }
2211
2212 #[rstest]
2213 fn test_parse_betfair_starting_prices_no_runners() {
2214 let def = MarketDefinition {
2215 runners: None,
2216 bet_delay: None,
2217 betting_type: None,
2218 bsp_market: None,
2219 bsp_reconciled: None,
2220 competition_id: None,
2221 competition_name: None,
2222 complete: None,
2223 country_code: None,
2224 cross_matching: None,
2225 discount_allowed: None,
2226 each_way_divisor: None,
2227 event_id: None,
2228 event_name: None,
2229 event_type_id: None,
2230 event_type_name: None,
2231 in_play: None,
2232 line_interval: None,
2233 line_max_unit: None,
2234 line_min_unit: None,
2235 market_base_rate: None,
2236 market_id: None,
2237 market_name: None,
2238 market_time: None,
2239 market_type: None,
2240 number_of_active_runners: None,
2241 number_of_winners: None,
2242 open_date: None,
2243 persistence_enabled: None,
2244 price_ladder_definition: None,
2245 race_type: None,
2246 regulators: None,
2247 runners_voidable: None,
2248 settled_time: None,
2249 status: None,
2250 suspend_time: None,
2251 timezone: None,
2252 turn_in_play_enabled: None,
2253 venue: None,
2254 version: None,
2255 };
2256 let ts = UnixNanos::from(1_000_000_000u64);
2257
2258 let prices = parse_betfair_starting_prices("1.12345", &def, ts, ts);
2259
2260 assert!(prices.is_empty());
2261 }
2262
2263 #[rstest]
2264 fn test_parse_bsp_book_deltas_from_fixture() {
2265 let data = load_test_json("stream/mcm_BSP.json");
2266 let messages: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2267
2268 let mcm = messages
2270 .iter()
2271 .find_map(|m| match m {
2272 StreamMessage::MarketChange(mcm) => {
2273 let mc = mcm.mc.as_ref()?;
2274 let has_spb = mc.iter().any(|c| {
2275 c.rc.as_ref()
2276 .is_some_and(|rcs| rcs.iter().any(|r| r.spb.is_some()))
2277 });
2278
2279 if has_spb { Some(mcm) } else { None }
2280 }
2281 _ => None,
2282 })
2283 .expect("fixture should contain MCM with spb data");
2284
2285 let mc = mcm.mc.as_ref().unwrap();
2286 let change = &mc[0];
2287 let rc_list = change.rc.as_ref().unwrap();
2288
2289 let rc = rc_list.iter().find(|r| r.id == 9249757).unwrap();
2291 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
2292 let ts = parse_millis_timestamp(mcm.pt);
2293
2294 let deltas = parse_bsp_book_deltas(instrument_id, rc, ts, ts);
2295
2296 let spb_count = rc.spb.as_ref().unwrap().len();
2297 let spl_count = rc.spl.as_ref().unwrap().len();
2298 assert_eq!(deltas.len(), spb_count + spl_count);
2299
2300 assert_eq!(deltas[0].side, OrderSide::Sell as u32);
2302 assert_eq!(deltas[0].price, Decimal::new(1000, 0));
2303 assert_eq!(deltas[0].size, Decimal::new(3338, 2));
2304 assert_eq!(deltas[0].action, BookAction::Update as u32);
2305
2306 let spl_start = spb_count;
2308 assert_eq!(deltas[spl_start].side, OrderSide::Buy as u32);
2309 assert_eq!(deltas[spl_start].price, Decimal::new(7, 0));
2310 assert_eq!(deltas[spl_start].size, Decimal::new(10, 0));
2311 }
2312
2313 #[rstest]
2314 fn test_parse_bsp_book_deltas_zero_volume_is_delete() {
2315 let rc = RunnerChange {
2316 id: 12345,
2317 hc: None,
2318 atb: None,
2319 atl: None,
2320 batb: None,
2321 batl: None,
2322 bdatb: None,
2323 bdatl: None,
2324 spb: Some(vec![PV {
2325 price: Decimal::new(50, 1),
2326 volume: Decimal::ZERO,
2327 }]),
2328 spl: None,
2329 spn: None,
2330 spf: None,
2331 trd: None,
2332 ltp: None,
2333 tv: None,
2334 };
2335 let ts = UnixNanos::from(1_000_000_000u64);
2336 let instrument_id = make_instrument_id("1.12345", 12345, Decimal::ZERO);
2337
2338 let deltas = parse_bsp_book_deltas(instrument_id, &rc, ts, ts);
2339
2340 assert_eq!(deltas.len(), 1);
2341 assert_eq!(deltas[0].action, BookAction::Delete as u32);
2342 assert_eq!(deltas[0].price, Decimal::new(5, 0));
2343 assert_eq!(deltas[0].size, Decimal::ZERO);
2344 }
2345
2346 #[rstest]
2347 fn test_parse_bsp_book_deltas_no_spb_spl_returns_empty() {
2348 let rc = runner_change_with_ticker(12345, None, None, None, None);
2349 let ts = UnixNanos::from(1_000_000_000u64);
2350 let instrument_id = make_instrument_id("1.12345", 12345, Decimal::ZERO);
2351
2352 let deltas = parse_bsp_book_deltas(instrument_id, &rc, ts, ts);
2353
2354 assert!(deltas.is_empty());
2355 }
2356
2357 #[rstest]
2358 fn test_parse_instrument_closes_from_fixture() {
2359 let data = load_test_json("stream/mcm_BSP_settled.json");
2360 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2361
2362 if let StreamMessage::MarketChange(mcm) = msg {
2363 let mc = mcm.mc.as_ref().unwrap();
2364 let def = mc[0].market_definition.as_ref().unwrap();
2365 let ts = parse_millis_timestamp(mcm.pt);
2366
2367 let closes = parse_instrument_closes(&mc[0].id, def, ts, ts);
2368
2369 assert_eq!(closes.len(), 4);
2371
2372 let close_map: std::collections::HashMap<String, Price> = closes
2373 .iter()
2374 .map(|c| (c.instrument_id.to_string(), c.close_price))
2375 .collect();
2376
2377 let id_winner = make_instrument_id("1.185781465", 9249757, Decimal::ZERO).to_string();
2378 let id_placed = make_instrument_id("1.185781465", 40273293, Decimal::ZERO).to_string();
2379 let id_loser = make_instrument_id("1.185781465", 11120000, Decimal::ZERO).to_string();
2380 let id_removed = make_instrument_id("1.185781465", 37433527, Decimal::ZERO).to_string();
2381
2382 assert_eq!(close_map[&id_winner], Price::from("1.00"));
2383 assert_eq!(close_map[&id_placed], Price::from("1.00"));
2384 assert_eq!(close_map[&id_loser], Price::from("0.00"));
2385 assert_eq!(close_map[&id_removed], Price::from("0.00"));
2386 } else {
2387 panic!("Expected MarketChange");
2388 }
2389 }
2390
2391 #[rstest]
2392 fn test_parse_instrument_closes_active_runners_excluded() {
2393 let data = load_test_json("stream/mcm_BSP.json");
2394 let messages: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2395
2396 let mcm = messages
2398 .iter()
2399 .find_map(|m| match m {
2400 StreamMessage::MarketChange(mcm) => {
2401 let mc = mcm.mc.as_ref()?;
2402 mc.iter()
2403 .find(|c| c.market_definition.is_some())
2404 .map(|_| mcm)
2405 }
2406 _ => None,
2407 })
2408 .expect("fixture should contain MCM with market definition");
2409
2410 let mc = mcm.mc.as_ref().unwrap();
2411 let change = mc.iter().find(|c| c.market_definition.is_some()).unwrap();
2412 let def = change.market_definition.as_ref().unwrap();
2413 let ts = parse_millis_timestamp(mcm.pt);
2414
2415 let closes = parse_instrument_closes(&change.id, def, ts, ts);
2416
2417 assert!(
2418 closes.is_empty(),
2419 "Active runners should not produce close events, found {}",
2420 closes.len()
2421 );
2422 }
2423
2424 fn make_test_uo(
2425 bet_id: &str,
2426 size: Decimal,
2427 sm: Option<Decimal>,
2428 avp: Option<Decimal>,
2429 ) -> UnmatchedOrder {
2430 UnmatchedOrder {
2431 id: bet_id.to_string(),
2432 p: Decimal::new(25, 1),
2433 s: size,
2434 side: StreamingSide::Back,
2435 status: StreamingOrderStatus::Executable,
2436 pt: Some(StreamingPersistenceType::Lapse),
2437 ot: StreamingOrderType::Limit,
2438 pd: 1616568581000,
2439 bsp: None,
2440 rfo: None,
2441 rfs: None,
2442 rc: None,
2443 rac: None,
2444 md: None,
2445 cd: None,
2446 ld: None,
2447 avp,
2448 sm,
2449 sr: None,
2450 sl: None,
2451 sc: None,
2452 sv: None,
2453 lsrc: None,
2454 }
2455 }
2456
2457 #[rstest]
2458 fn test_make_trade_id_normalizes_float_tail_sm() {
2459 let uo = make_test_uo(
2460 "430069890490",
2461 Decimal::new(4287, 2),
2462 Some(Decimal::new(4_287_000_000_000_001, 14)),
2463 Some(Decimal::new(25, 1)),
2464 );
2465
2466 let trade_id = make_trade_id(&uo);
2467
2468 assert_eq!(trade_id.as_str(), "430069890490-42.87");
2469 }
2470
2471 #[rstest]
2472 fn test_fill_tracker_sync_order_prevents_duplicate_fill() {
2473 let mut tracker = FillTracker::new();
2474
2475 tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
2477
2478 let uo = make_test_uo(
2479 "123456",
2480 Decimal::new(20, 0),
2481 Some(Decimal::new(10, 0)),
2482 Some(Decimal::new(25, 1)),
2483 );
2484
2485 let instrument_id = InstrumentId::from("1.234567-123456-0.0.BETFAIR");
2486 let account_id = AccountId::from("BETFAIR-001");
2487 let currency = Currency::from("GBP");
2488 let ts = UnixNanos::default();
2489
2490 let result =
2492 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
2493 assert!(
2494 result.is_none(),
2495 "should not emit fill for already-synced qty"
2496 );
2497 }
2498
2499 #[rstest]
2500 fn test_fill_tracker_sync_order_is_monotonic() {
2501 let mut tracker = FillTracker::new();
2502
2503 tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
2505
2506 tracker.sync_order("123456", Decimal::new(5, 0), Decimal::new(20, 1));
2508
2509 let uo = make_test_uo(
2511 "123456",
2512 Decimal::new(20, 0),
2513 Some(Decimal::new(15, 0)),
2514 Some(Decimal::new(25, 1)),
2515 );
2516 let result = tracker.maybe_fill_report(
2517 &uo,
2518 uo.s,
2519 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2520 AccountId::from("BETFAIR-001"),
2521 Currency::from("GBP"),
2522 UnixNanos::default(),
2523 UnixNanos::default(),
2524 );
2525 let fill = result.expect("should emit incremental fill");
2526 assert_eq!(fill.last_qty, Quantity::from("5.00"));
2527 }
2528
2529 #[rstest]
2530 fn test_fill_tracker_sync_order_allows_incremental_fill() {
2531 let mut tracker = FillTracker::new();
2532
2533 tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
2535
2536 let uo = make_test_uo(
2537 "123456",
2538 Decimal::new(20, 0),
2539 Some(Decimal::new(15, 0)),
2540 Some(Decimal::new(26, 1)),
2541 );
2542
2543 let instrument_id = InstrumentId::from("1.234567-123456-0.0.BETFAIR");
2544 let account_id = AccountId::from("BETFAIR-001");
2545 let currency = Currency::from("GBP");
2546 let ts = UnixNanos::default();
2547
2548 let result =
2550 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
2551 assert!(result.is_some(), "should emit fill for new matched qty");
2552 let fill = result.unwrap();
2553 assert_eq!(fill.last_qty, Quantity::from("5.00"));
2554 }
2555
2556 #[rstest]
2557 fn test_fill_tracker_seed_published_trade_ids_blocks_replay() {
2558 let mut tracker = FillTracker::new();
2559
2560 let uo_for_id = make_test_uo(
2561 "123456",
2562 Decimal::new(20, 0),
2563 Some(Decimal::new(10, 0)),
2564 Some(Decimal::new(25, 1)),
2565 );
2566 let trade_id = make_trade_id(&uo_for_id);
2567
2568 tracker.seed_published_trade_ids([trade_id.to_string()]);
2571
2572 let result = tracker.maybe_fill_report(
2573 &uo_for_id,
2574 uo_for_id.s,
2575 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2576 AccountId::from("BETFAIR-001"),
2577 Currency::from("GBP"),
2578 UnixNanos::default(),
2579 UnixNanos::default(),
2580 );
2581
2582 assert!(
2583 result.is_none(),
2584 "seeded trade-id should suppress duplicate fill",
2585 );
2586 }
2587
2588 #[rstest]
2589 fn test_fill_tracker_accepts_sm_float_tail_at_order_quantity() {
2590 let mut tracker = FillTracker::new();
2591 let uo = make_test_uo(
2592 "430069890490",
2593 Decimal::new(4287, 2),
2594 Some(Decimal::new(4_287_000_000_000_001, 14)),
2595 Some(Decimal::new(25, 1)),
2596 );
2597
2598 let result = tracker.maybe_fill_report(
2599 &uo,
2600 uo.s,
2601 InstrumentId::from("1.234567-430069890490-0.0.BETFAIR"),
2602 AccountId::from("BETFAIR-001"),
2603 Currency::from("GBP"),
2604 UnixNanos::default(),
2605 UnixNanos::default(),
2606 );
2607
2608 let fill = result.expect("float tail at order quantity should emit fill");
2609 assert_eq!(fill.last_qty, Quantity::from("42.87"));
2610 assert_eq!(fill.trade_id.as_str(), "430069890490-42.87");
2611
2612 let duplicate = tracker.maybe_fill_report(
2613 &uo,
2614 uo.s,
2615 InstrumentId::from("1.234567-430069890490-0.0.BETFAIR"),
2616 AccountId::from("BETFAIR-001"),
2617 Currency::from("GBP"),
2618 UnixNanos::default(),
2619 UnixNanos::default(),
2620 );
2621 assert!(
2622 duplicate.is_none(),
2623 "same normalized sm should not emit a duplicate fill"
2624 );
2625 }
2626
2627 #[rstest]
2628 fn test_fill_tracker_overfill_rejected() {
2629 let mut tracker = FillTracker::new();
2630
2631 let uo = make_test_uo(
2633 "999001",
2634 Decimal::new(20, 0),
2635 Some(Decimal::new(30, 0)),
2636 Some(Decimal::new(25, 1)),
2637 );
2638
2639 let instrument_id = InstrumentId::from("1.234567-999001-0.0.BETFAIR");
2640 let account_id = AccountId::from("BETFAIR-001");
2641 let currency = Currency::from("GBP");
2642 let ts = UnixNanos::default();
2643
2644 let result =
2645 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
2646 assert!(
2647 result.is_none(),
2648 "overfill (sm > order_qty) should be rejected"
2649 );
2650 }
2651
2652 #[rstest]
2653 fn test_fill_tracker_zero_sm_returns_none() {
2654 let mut tracker = FillTracker::new();
2655
2656 let uo = make_test_uo("999002", Decimal::new(10, 0), Some(Decimal::ZERO), None);
2657
2658 let instrument_id = InstrumentId::from("1.234567-999002-0.0.BETFAIR");
2659 let result = tracker.maybe_fill_report(
2660 &uo,
2661 uo.s,
2662 instrument_id,
2663 AccountId::from("BETFAIR-001"),
2664 Currency::from("GBP"),
2665 UnixNanos::default(),
2666 UnixNanos::default(),
2667 );
2668 assert!(result.is_none(), "zero sm should not produce a fill");
2669 }
2670
2671 #[rstest]
2672 fn test_fill_tracker_no_avp_uses_order_price() {
2673 let data = load_test_json("stream/ocm_FILLED_no_avp.json");
2674 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2675
2676 if let StreamMessage::OrderChange(ocm) = msg {
2677 let oc = ocm.oc.as_ref().unwrap();
2678 let omc = &oc[0];
2679 let orc = &omc.orc.as_ref().unwrap()[0];
2680 let uo = &orc.uo.as_ref().unwrap()[0];
2681 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2682 let ts = parse_millis_timestamp(ocm.pt);
2683
2684 let mut tracker = FillTracker::new();
2685 let fill = tracker
2686 .maybe_fill_report(
2687 uo,
2688 uo.s,
2689 instrument_id,
2690 AccountId::from("BETFAIR-001"),
2691 Currency::GBP(),
2692 ts,
2693 ts,
2694 )
2695 .expect("should produce fill even without avp");
2696
2697 assert_eq!(fill.last_qty.as_f64(), 25.0);
2699 assert_eq!(fill.last_px.as_f64(), 3.5);
2700 } else {
2701 panic!("expected OrderChange");
2702 }
2703 }
2704
2705 #[rstest]
2706 fn test_fill_tracker_weighted_avg_back_calculation() {
2707 let mut tracker = FillTracker::new();
2708 let instrument_id = InstrumentId::from("1.234567-999003-0.0.BETFAIR");
2709 let account_id = AccountId::from("BETFAIR-001");
2710 let currency = Currency::from("GBP");
2711 let ts = UnixNanos::default();
2712
2713 let uo1 = make_test_uo(
2715 "999003",
2716 Decimal::new(30, 0),
2717 Some(Decimal::new(10, 0)),
2718 Some(Decimal::new(20, 1)),
2719 );
2720 let fill1 = tracker
2721 .maybe_fill_report(&uo1, uo1.s, instrument_id, account_id, currency, ts, ts)
2722 .expect("first fill");
2723 assert_eq!(fill1.last_px.as_f64(), 2.0);
2724 assert_eq!(fill1.last_qty.as_f64(), 10.0);
2725
2726 let uo2 = make_test_uo(
2729 "999003",
2730 Decimal::new(30, 0),
2731 Some(Decimal::new(20, 0)),
2732 Some(Decimal::new(25, 1)),
2733 );
2734 let fill2 = tracker
2735 .maybe_fill_report(&uo2, uo2.s, instrument_id, account_id, currency, ts, ts)
2736 .expect("second fill");
2737 assert_eq!(fill2.last_qty.as_f64(), 10.0);
2738 assert_eq!(fill2.last_px.as_f64(), 3.0);
2739 }
2740
2741 #[rstest]
2742 fn test_fill_tracker_negative_fill_price_falls_back_to_avp() {
2743 let mut tracker = FillTracker::new();
2744 let instrument_id = InstrumentId::from("1.234567-999004-0.0.BETFAIR");
2745 let account_id = AccountId::from("BETFAIR-001");
2746 let currency = Currency::from("GBP");
2747 let ts = UnixNanos::default();
2748
2749 let uo1 = make_test_uo(
2751 "999004",
2752 Decimal::new(20, 0),
2753 Some(Decimal::new(10, 0)),
2754 Some(Decimal::new(50, 1)),
2755 );
2756 tracker
2757 .maybe_fill_report(&uo1, uo1.s, instrument_id, account_id, currency, ts, ts)
2758 .expect("first fill");
2759
2760 let uo2 = make_test_uo(
2764 "999004",
2765 Decimal::new(20, 0),
2766 Some(Decimal::new(15, 0)),
2767 Some(Decimal::new(10, 1)),
2768 );
2769 let fill2 = tracker
2770 .maybe_fill_report(&uo2, uo2.s, instrument_id, account_id, currency, ts, ts)
2771 .expect("second fill should use avp fallback");
2772 assert_eq!(fill2.last_qty.as_f64(), 5.0);
2773 assert_eq!(fill2.last_px.as_f64(), 1.0);
2774 }
2775
2776 #[rstest]
2777 fn test_fill_tracker_prune_clears_state() {
2778 let mut tracker = FillTracker::new();
2779 let instrument_id = InstrumentId::from("1.234567-999005-0.0.BETFAIR");
2780 let account_id = AccountId::from("BETFAIR-001");
2781 let currency = Currency::from("GBP");
2782 let ts = UnixNanos::default();
2783
2784 let uo = make_test_uo(
2786 "999005",
2787 Decimal::new(10, 0),
2788 Some(Decimal::new(10, 0)),
2789 Some(Decimal::new(25, 1)),
2790 );
2791 let fill1 =
2792 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
2793 assert!(fill1.is_some());
2794
2795 let fill2 =
2797 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
2798 assert!(fill2.is_none(), "should be deduplicated");
2799
2800 tracker.prune("999005");
2802
2803 let fill3 =
2805 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
2806 assert!(fill3.is_some(), "after prune, should produce fill again");
2807 }
2808
2809 #[rstest]
2810 fn test_fill_tracker_sm_none_returns_none() {
2811 let mut tracker = FillTracker::new();
2812
2813 let uo = make_test_uo("999006", Decimal::new(10, 0), None, None);
2815
2816 let instrument_id = InstrumentId::from("1.234567-999006-0.0.BETFAIR");
2817 let result = tracker.maybe_fill_report(
2818 &uo,
2819 uo.s,
2820 instrument_id,
2821 AccountId::from("BETFAIR-001"),
2822 Currency::from("GBP"),
2823 UnixNanos::default(),
2824 UnixNanos::default(),
2825 );
2826 assert!(result.is_none(), "None sm should not produce a fill");
2827 }
2828
2829 #[rstest]
2830 fn test_parse_order_status_report_missing_persistence_type_for_market_on_close() {
2831 let uo = UnmatchedOrder {
2832 s: Decimal::ZERO,
2833 pt: None,
2834 ot: StreamingOrderType::MarketOnClose,
2835 sr: Some(Decimal::new(10, 0)),
2836 ..make_test_uo("999007", Decimal::new(10, 0), Some(Decimal::ZERO), None)
2837 };
2838
2839 let report = parse_order_status_report(
2840 &uo,
2841 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2842 AccountId::from("BETFAIR-001"),
2843 UnixNanos::default(),
2844 UnixNanos::default(),
2845 )
2846 .unwrap();
2847
2848 assert_eq!(report.quantity, Quantity::from("10.00"));
2849 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
2850 }
2851
2852 #[rstest]
2853 fn test_parse_order_status_report_missing_persistence_type_for_limit_on_close() {
2854 let uo = UnmatchedOrder {
2855 s: Decimal::ZERO,
2856 pt: None,
2857 ot: StreamingOrderType::LimitOnClose,
2858 sr: Some(Decimal::new(10, 0)),
2859 ..make_test_uo("999013", Decimal::new(10, 0), Some(Decimal::ZERO), None)
2860 };
2861
2862 let report = parse_order_status_report(
2863 &uo,
2864 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2865 AccountId::from("BETFAIR-001"),
2866 UnixNanos::default(),
2867 UnixNanos::default(),
2868 )
2869 .unwrap();
2870
2871 assert_eq!(report.quantity, Quantity::from("10.00"));
2872 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
2873 }
2874
2875 #[rstest]
2876 fn test_parse_order_status_report_market_on_close_uses_bsp_liability() {
2877 let uo = UnmatchedOrder {
2878 s: Decimal::ZERO,
2879 bsp: Some(Decimal::new(20, 1)),
2880 pt: None,
2881 ot: StreamingOrderType::MarketOnClose,
2882 ..make_test_uo("999010", Decimal::new(10, 0), Some(Decimal::ZERO), None)
2883 };
2884
2885 let report = parse_order_status_report(
2886 &uo,
2887 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2888 AccountId::from("BETFAIR-001"),
2889 UnixNanos::default(),
2890 UnixNanos::default(),
2891 )
2892 .unwrap();
2893
2894 assert_eq!(report.quantity, Quantity::from("2.00"));
2895 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
2896 }
2897
2898 #[rstest]
2899 fn test_parse_order_status_report_fails_for_non_positive_quantity() {
2900 let uo = UnmatchedOrder {
2901 s: Decimal::ZERO,
2902 sm: Some(Decimal::ZERO),
2903 sr: Some(Decimal::ZERO),
2904 sc: Some(Decimal::ZERO),
2905 sl: Some(Decimal::ZERO),
2906 sv: Some(Decimal::ZERO),
2907 ..make_test_uo("999014", Decimal::ZERO, Some(Decimal::ZERO), None)
2908 };
2909
2910 let result = parse_order_status_report(
2911 &uo,
2912 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2913 AccountId::from("BETFAIR-001"),
2914 UnixNanos::default(),
2915 UnixNanos::default(),
2916 );
2917
2918 assert!(result.is_err());
2919 assert!(
2920 result
2921 .unwrap_err()
2922 .to_string()
2923 .contains("failed to resolve positive quantity for stream order update 999014")
2924 );
2925 }
2926
2927 #[rstest]
2928 fn test_parse_order_status_report_includes_lapse_reason() {
2929 let uo = UnmatchedOrder {
2930 status: StreamingOrderStatus::ExecutionComplete,
2931 sl: Some(Decimal::ONE),
2932 lsrc: Some(crate::common::enums::LapseStatusReasonCode::SpInPlay),
2933 ..make_test_uo("999012", Decimal::new(10, 0), Some(Decimal::ZERO), None)
2934 };
2935
2936 let report = parse_order_status_report(
2937 &uo,
2938 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2939 AccountId::from("BETFAIR-001"),
2940 UnixNanos::default(),
2941 UnixNanos::default(),
2942 )
2943 .unwrap();
2944
2945 assert_eq!(report.order_status, OrderStatus::Canceled);
2946 assert_eq!(report.cancel_reason.as_deref(), Some("SP_IN_PLAY"));
2947 }
2948
2949 #[rstest]
2950 fn test_parse_order_status_report_blank_rfo_normalizes_to_none() {
2951 let uo = UnmatchedOrder {
2954 rfo: Some(String::new()),
2955 ..make_test_uo("999013", Decimal::new(10, 0), Some(Decimal::ZERO), None)
2956 };
2957
2958 let report = parse_order_status_report(
2959 &uo,
2960 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2961 AccountId::from("BETFAIR-001"),
2962 UnixNanos::default(),
2963 UnixNanos::default(),
2964 )
2965 .unwrap();
2966
2967 assert!(report.client_order_id.is_none());
2968 }
2969
2970 #[rstest]
2971 fn test_fill_tracker_blank_rfo_normalizes_to_none() {
2972 let mut tracker = FillTracker::new();
2973 let uo = UnmatchedOrder {
2974 rfo: Some(String::new()),
2975 sm: Some(Decimal::new(5, 0)),
2976 avp: Some(Decimal::new(25, 1)),
2977 ..make_test_uo("999015", Decimal::new(10, 0), Some(Decimal::ZERO), None)
2978 };
2979
2980 let fill = tracker
2981 .maybe_fill_report(
2982 &uo,
2983 uo.s,
2984 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
2985 AccountId::from("BETFAIR-001"),
2986 Currency::from("GBP"),
2987 UnixNanos::default(),
2988 UnixNanos::default(),
2989 )
2990 .expect("blank rfo should still produce a fill");
2991
2992 assert!(fill.client_order_id.is_none());
2993 }
2994
2995 #[rstest]
2996 fn test_parse_order_status_report_missing_persistence_type_fails_for_limit_order() {
2997 let uo = UnmatchedOrder {
2998 pt: None,
2999 ..make_test_uo("999008", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3000 };
3001
3002 let result = parse_order_status_report(
3003 &uo,
3004 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3005 AccountId::from("BETFAIR-001"),
3006 UnixNanos::default(),
3007 UnixNanos::default(),
3008 );
3009
3010 assert!(result.is_err());
3011 assert_eq!(
3012 result.unwrap_err().to_string(),
3013 "missing persistence type for order update 999008"
3014 );
3015 }
3016
3017 #[rstest]
3018 fn test_fill_tracker_uses_lifecycle_quantity_when_stream_size_is_zero() {
3019 let mut tracker = FillTracker::new();
3020 let uo = UnmatchedOrder {
3021 s: Decimal::ZERO,
3022 sr: Some(Decimal::new(10, 0)),
3023 sm: Some(Decimal::new(5, 0)),
3024 avp: Some(Decimal::new(20, 1)),
3025 ..make_test_uo("999009", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3026 };
3027
3028 let fill = tracker
3029 .maybe_fill_report(
3030 &uo,
3031 uo.s,
3032 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3033 AccountId::from("BETFAIR-001"),
3034 Currency::from("GBP"),
3035 UnixNanos::default(),
3036 UnixNanos::default(),
3037 )
3038 .expect("zero stream size should fall back to lifecycle quantities");
3039
3040 assert_eq!(fill.last_qty, Quantity::from("5.00"));
3041 }
3042
3043 #[rstest]
3044 fn test_fill_tracker_uses_bsp_liability_when_stream_size_is_zero() {
3045 let mut tracker = FillTracker::new();
3046 let uo = UnmatchedOrder {
3047 s: Decimal::ZERO,
3048 bsp: Some(Decimal::new(20, 1)),
3049 pt: None,
3050 ot: StreamingOrderType::MarketOnClose,
3051 sm: Some(Decimal::new(10, 1)),
3052 avp: Some(Decimal::new(20, 1)),
3053 ..make_test_uo("999011", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3054 };
3055
3056 let fill = tracker
3057 .maybe_fill_report(
3058 &uo,
3059 uo.s,
3060 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3061 AccountId::from("BETFAIR-001"),
3062 Currency::from("GBP"),
3063 UnixNanos::default(),
3064 UnixNanos::default(),
3065 )
3066 .expect("zero stream size should fall back to bsp liability");
3067
3068 assert_eq!(fill.last_qty, Quantity::from("1.00"));
3069 }
3070
3071 #[rstest]
3072 fn test_fill_tracker_partial_void_still_emits_fill() {
3073 let data = load_test_json("stream/ocm_VOIDED_partial.json");
3074 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
3075
3076 if let StreamMessage::OrderChange(ocm) = msg {
3077 let oc = ocm.oc.as_ref().unwrap();
3078 let omc = &oc[0];
3079 let orc = &omc.orc.as_ref().unwrap()[0];
3080 let uo = &orc.uo.as_ref().unwrap()[0];
3081 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
3082 let ts = parse_millis_timestamp(ocm.pt);
3083
3084 let mut tracker = FillTracker::new();
3085 let fill = tracker
3086 .maybe_fill_report(
3087 uo,
3088 uo.s,
3089 instrument_id,
3090 AccountId::from("BETFAIR-001"),
3091 Currency::GBP(),
3092 ts,
3093 ts,
3094 )
3095 .expect("should produce fill for matched portion");
3096
3097 assert_eq!(fill.last_qty.as_f64(), 60.0);
3099 assert_eq!(fill.last_px.as_f64(), 1.5);
3100 assert_eq!(fill.order_side, OrderSide::Sell);
3101 } else {
3102 panic!("expected OrderChange");
3103 }
3104 }
3105
3106 #[rstest]
3107 fn test_fill_tracker_no_fill_when_sv_zero_and_fully_filled() {
3108 let data = load_test_json("stream/ocm_FILLED_sv_zero.json");
3109 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
3110
3111 if let StreamMessage::OrderChange(ocm) = msg {
3112 let oc = ocm.oc.as_ref().unwrap();
3113 let omc = &oc[0];
3114 let orc = &omc.orc.as_ref().unwrap()[0];
3115 let uo = &orc.uo.as_ref().unwrap()[0];
3116 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
3117 let ts = parse_millis_timestamp(ocm.pt);
3118
3119 let mut tracker = FillTracker::new();
3120 let fill = tracker
3121 .maybe_fill_report(
3122 uo,
3123 uo.s,
3124 instrument_id,
3125 AccountId::from("BETFAIR-001"),
3126 Currency::GBP(),
3127 ts,
3128 ts,
3129 )
3130 .expect("fully filled order should produce fill");
3131
3132 assert_eq!(fill.last_qty.as_f64(), 50.0);
3133 assert_eq!(fill.last_px.as_f64(), 2.0);
3134
3135 assert_eq!(uo.sv, Some(Decimal::ZERO));
3137 } else {
3138 panic!("expected OrderChange");
3139 }
3140 }
3141}