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, OrderStatus, 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::{
41 MarketStatus, RunnerStatus, StreamingOrderStatus, StreamingOrderType,
42 resolve_streaming_order_status,
43 },
44 parse::{
45 make_instrument_id, normalize_betfair_price, normalize_betfair_quantity,
46 parse_betfair_price, parse_betfair_quantity, parse_millis_timestamp,
47 },
48 },
49 data_types::{
50 BetfairBspBookDelta, BetfairCricketMatch, BetfairRaceProgress, BetfairRaceRunnerData,
51 BetfairStartingPrice, BetfairTicker,
52 },
53 stream::messages::{
54 CricketChange, MarketDefinition, RaceProgressChange, RaceRunnerChange, RunnerChange,
55 UnmatchedOrder,
56 },
57};
58
59pub fn parse_runner_book_deltas(
81 instrument_id: InstrumentId,
82 rc: &RunnerChange,
83 is_snapshot: bool,
84 sequence: u64,
85 ts_event: UnixNanos,
86 ts_init: UnixNanos,
87) -> anyhow::Result<Option<OrderBookDeltas>> {
88 let atb_len = rc.atb.as_ref().map_or(0, Vec::len);
89 let atl_len = rc.atl.as_ref().map_or(0, Vec::len);
90 let total_levels = atb_len + atl_len;
91
92 if total_levels == 0 && !is_snapshot {
93 return Ok(None);
94 }
95
96 let snapshot_flags = if is_snapshot {
97 RecordFlag::F_SNAPSHOT as u8
98 } else {
99 0
100 };
101 let mut deltas = Vec::with_capacity(total_levels + usize::from(is_snapshot));
102
103 if is_snapshot {
104 let mut clear = OrderBookDelta::clear(instrument_id, sequence, ts_event, ts_init);
105
106 if total_levels == 0 {
107 clear.flags |= RecordFlag::F_LAST as u8;
108 }
109 deltas.push(clear);
110 }
111
112 for (levels, side) in [
113 (rc.atb.as_deref().unwrap_or(&[]), OrderSide::Buy),
114 (rc.atl.as_deref().unwrap_or(&[]), OrderSide::Sell),
115 ] {
116 for pv in levels {
117 if is_snapshot && pv.volume == Decimal::ZERO {
118 continue;
119 }
120
121 let action = if is_snapshot {
122 BookAction::Add
123 } else if pv.volume == Decimal::ZERO {
124 BookAction::Delete
125 } else {
126 BookAction::Update
127 };
128
129 deltas.push(OrderBookDelta::new(
130 instrument_id,
131 action,
132 BookOrder::new(
133 side,
134 parse_betfair_price(pv.price)?,
135 parse_betfair_quantity(pv.volume)?,
136 0,
137 ),
138 snapshot_flags,
139 sequence,
140 ts_event,
141 ts_init,
142 ));
143 }
144 }
145
146 if let Some(last) = deltas.last_mut() {
148 last.flags |= RecordFlag::F_LAST as u8;
149 }
150
151 Ok(Some(OrderBookDeltas::new(instrument_id, deltas)))
152}
153
154#[must_use]
159pub fn make_trade_tick(
160 instrument_id: InstrumentId,
161 price: Price,
162 size: Quantity,
163 trade_id: TradeId,
164 ts_event: UnixNanos,
165 ts_init: UnixNanos,
166) -> TradeTick {
167 TradeTick::new(
168 instrument_id,
169 price,
170 size,
171 AggressorSide::NoAggressor,
172 trade_id,
173 ts_event,
174 ts_init,
175 )
176}
177
178#[must_use]
187pub fn parse_instrument_statuses(
188 market_id: &str,
189 def: &MarketDefinition,
190 ts_event: UnixNanos,
191 ts_init: UnixNanos,
192) -> Vec<InstrumentStatus> {
193 let Some(status) = def.status else {
194 return Vec::new();
195 };
196 let Some(runners) = &def.runners else {
197 return Vec::new();
198 };
199 let in_play = def.in_play.unwrap_or(false);
200
201 if status == MarketStatus::Unknown {
202 log::warn!("Skipping unmodeled Betfair market status for market {market_id}");
203 return Vec::new();
204 }
205
206 runners
207 .iter()
208 .filter_map(|rd| {
209 let handicap = rd.hc.unwrap_or(Decimal::ZERO);
210 let instrument_id = make_instrument_id(market_id, rd.id, handicap);
211 if rd.status == Some(RunnerStatus::Unknown) {
212 log::warn!("Skipping unmodeled Betfair runner status for {instrument_id}");
213 return None;
214 }
215 let action = match rd.status {
216 Some(RunnerStatus::Removed | RunnerStatus::RemovedVacant) => {
217 MarketStatusAction::Close
218 }
219 _ => match (status, in_play) {
220 (MarketStatus::Inactive, _) => MarketStatusAction::Close,
221 (MarketStatus::Open, false) => MarketStatusAction::PreOpen,
222 (MarketStatus::Open, true) => MarketStatusAction::Trading,
223 (MarketStatus::Suspended, _) => MarketStatusAction::Pause,
224 (MarketStatus::Closed, _) => MarketStatusAction::Close,
225 (MarketStatus::Unknown, _) => MarketStatusAction::None,
227 },
228 };
229 let is_trading = action == MarketStatusAction::Trading;
230 Some(InstrumentStatus::new(
231 instrument_id,
232 action,
233 ts_event,
234 ts_init,
235 None,
236 None,
237 Some(is_trading),
238 None,
239 None,
240 ))
241 })
242 .collect()
243}
244
245pub fn make_trade_id(uo: &UnmatchedOrder) -> TradeId {
250 let sm = normalize_betfair_quantity(uo.sm.unwrap_or(Decimal::ZERO));
251 make_trade_id_for_size(&uo.id, sm)
252}
253
254#[derive(Clone, Debug, Default)]
261pub struct FillTracker {
262 filled_qty: AHashMap<String, Decimal>,
263 voided_qty: AHashMap<String, Decimal>,
264 avg_px: AHashMap<String, Decimal>,
265 published_trade_ids: AHashSet<String>,
266 fill_lots: AHashMap<String, Vec<FillLot>>,
267 fill_voids: AHashMap<(String, TradeId), Decimal>,
268}
269
270#[derive(Debug, Clone)]
271struct FillLot {
272 trade_id: TradeId,
273 quantity: Decimal,
274 price: Price,
275 accounting_px: Price,
276}
277
278#[derive(Debug, Clone)]
280pub struct FillVoidAllocation {
281 pub trade_id: TradeId,
282 pub voided_qty: Quantity,
283 pub last_px: Price,
284}
285
286impl FillTracker {
287 #[must_use]
289 pub fn new() -> Self {
290 Self::default()
291 }
292
293 #[expect(clippy::too_many_arguments)]
300 pub fn maybe_fill_report(
301 &mut self,
302 uo: &UnmatchedOrder,
303 order_qty: Decimal,
304 instrument_id: InstrumentId,
305 account_id: AccountId,
306 currency: Currency,
307 ts_event: UnixNanos,
308 ts_init: UnixNanos,
309 ) -> Option<FillReport> {
310 let raw_sm = uo.sm?;
311 let sm = normalize_betfair_quantity(raw_sm);
312 let order_qty = normalize_betfair_quantity(resolve_stream_order_quantity(order_qty, uo));
313
314 if sm > order_qty {
315 log::warn!(
316 "Rejecting potential overfill for bet_id={}: order_qty={order_qty}, sm={sm}",
317 uo.id,
318 );
319 return None;
320 }
321
322 let (trade_id, last_qty, last_px) =
323 if uo.sv.is_some_and(|sv| sv > Decimal::ZERO) && self.has_fill_lots(&uo.id) {
324 self.advance_cumulative_fill_with_voids(
325 &uo.id,
326 raw_sm,
327 uo.sv.unwrap_or(Decimal::ZERO),
328 uo.avp,
329 uo.p,
330 )?
331 } else {
332 self.advance_cumulative_fill(&uo.id, raw_sm, uo.avp, uo.p)?
333 };
334
335 let venue_order_id = VenueOrderId::from(uo.id.as_str());
336 let order_side = OrderSide::from(uo.side);
337 let client_order_id = uo
338 .rfo
339 .as_deref()
340 .filter(|s| !s.is_empty())
341 .map(ClientOrderId::from);
342 let ts_fill = uo.md.map_or(ts_event, parse_millis_timestamp);
343
344 Some(make_fill_report(
345 account_id,
346 instrument_id,
347 venue_order_id,
348 trade_id,
349 order_side,
350 last_qty,
351 last_px,
352 currency,
353 client_order_id,
354 ts_fill,
355 ts_init,
356 ))
357 }
358
359 pub(crate) fn advance_cumulative_fill(
360 &mut self,
361 bet_id: &str,
362 raw_size_matched: Decimal,
363 average_price_matched: Option<Decimal>,
364 fallback_price: Decimal,
365 ) -> Option<(TradeId, Quantity, Price)> {
366 self.advance_cumulative_fill_inner(
367 bet_id,
368 raw_size_matched,
369 None,
370 average_price_matched,
371 fallback_price,
372 )
373 }
374
375 pub(crate) fn advance_cumulative_fill_with_voids(
376 &mut self,
377 bet_id: &str,
378 raw_size_matched: Decimal,
379 cumulative_voided: Decimal,
380 average_price_matched: Option<Decimal>,
381 fallback_price: Decimal,
382 ) -> Option<(TradeId, Quantity, Price)> {
383 self.advance_cumulative_fill_inner(
384 bet_id,
385 raw_size_matched,
386 Some(cumulative_voided),
387 average_price_matched,
388 fallback_price,
389 )
390 }
391
392 fn advance_cumulative_fill_inner(
393 &mut self,
394 bet_id: &str,
395 raw_size_matched: Decimal,
396 cumulative_voided: Option<Decimal>,
397 average_price_matched: Option<Decimal>,
398 fallback_price: Decimal,
399 ) -> Option<(TradeId, Quantity, Price)> {
400 let size_matched = normalize_betfair_quantity(raw_size_matched);
401 if size_matched <= Decimal::ZERO {
402 return None;
403 }
404
405 let previous_filled = self
406 .filled_qty
407 .get(bet_id)
408 .copied()
409 .map_or(Decimal::ZERO, normalize_betfair_quantity);
410
411 if size_matched == previous_filled {
412 let restatement =
413 self.matched_price_restatement(bet_id, cumulative_voided, average_price_matched);
414 if let Some((_, _, last_px)) = restatement {
415 self.set_surviving_accounting_px(bet_id, last_px);
416 }
417
418 self.record_cumulative_state(bet_id, size_matched, average_price_matched);
419 return restatement;
420 }
421
422 if size_matched < previous_filled {
423 return None;
424 }
425
426 let trade_id = make_trade_id_for_size(bet_id, size_matched);
427 let raw_trade_id = make_trade_id_for_size(bet_id, raw_size_matched);
428 if self.published_trade_ids.contains(trade_id.as_str())
429 || self.published_trade_ids.contains(raw_trade_id.as_str())
430 {
431 self.record_cumulative_state(bet_id, size_matched, average_price_matched);
432 self.published_trade_ids.insert(trade_id.to_string());
433 return None;
434 }
435
436 let fill_qty = size_matched - previous_filled;
437 let fill_price = if let Some(cumulative_voided) = cumulative_voided {
438 self.compute_fill_price_with_voids(
439 bet_id,
440 average_price_matched,
441 fallback_price,
442 size_matched,
443 fill_qty,
444 cumulative_voided,
445 )
446 } else {
447 self.compute_fill_price(
448 bet_id,
449 average_price_matched,
450 fallback_price,
451 previous_filled,
452 size_matched,
453 )
454 };
455 let last_qty = parse_betfair_quantity(fill_qty).ok()?;
456 let last_px = parse_betfair_price(fill_price).ok()?;
457
458 self.record_cumulative_state(bet_id, size_matched, average_price_matched);
459 self.published_trade_ids.insert(trade_id.to_string());
460 self.fill_lots
461 .entry(bet_id.to_string())
462 .or_default()
463 .push(FillLot {
464 trade_id,
465 quantity: fill_qty,
466 price: last_px,
467 accounting_px: last_px,
468 });
469
470 Some((trade_id, last_qty, last_px))
471 }
472
473 pub fn maybe_fill_voids(&mut self, uo: &UnmatchedOrder) -> Vec<FillVoidAllocation> {
477 let cumulative = normalize_betfair_quantity(uo.sv.unwrap_or(Decimal::ZERO));
478 let previous = self
479 .voided_qty
480 .get(&uo.id)
481 .copied()
482 .unwrap_or(Decimal::ZERO);
483
484 if cumulative <= previous {
485 return Vec::new();
486 }
487
488 let Some(lots) = self.fill_lots.get(&uo.id) else {
489 self.voided_qty.insert(uo.id.clone(), cumulative);
490 return Vec::new();
491 };
492 let mut remaining = cumulative - previous;
493 let mut desired = Vec::new();
494
495 for lot in lots.iter().rev() {
496 if remaining <= Decimal::ZERO {
497 break;
498 }
499 let key = (uo.id.clone(), lot.trade_id);
500 let prior = self.fill_voids.get(&key).copied().unwrap_or(Decimal::ZERO);
501 let available = lot.quantity.saturating_sub(prior);
502 let increment = remaining.min(available);
503 if increment > Decimal::ZERO {
504 desired.push((lot, prior + increment));
505 remaining -= increment;
506 }
507 }
508
509 if remaining > Decimal::ZERO {
510 log::warn!(
511 "Betfair cumulative void exceeds known fill lots for bet_id={}: sv={cumulative}, unmatched={remaining}",
512 uo.id,
513 );
514 return Vec::new();
515 }
516
517 let mut updates = Vec::new();
518
519 for (lot, allocation) in desired.into_iter().rev() {
520 let key = (uo.id.clone(), lot.trade_id);
521 let Ok(voided_qty) = parse_betfair_quantity(allocation) else {
522 continue;
523 };
524 self.fill_voids.insert(key, allocation);
525 updates.push(FillVoidAllocation {
526 trade_id: lot.trade_id,
527 voided_qty,
528 last_px: lot.price,
529 });
530 }
531 self.voided_qty.insert(uo.id.clone(), cumulative);
532 updates
533 }
534
535 #[must_use]
536 pub(crate) fn has_fill_lots(&self, bet_id: &str) -> bool {
537 self.fill_lots
538 .get(bet_id)
539 .is_some_and(|lots| !lots.is_empty())
540 }
541
542 #[must_use]
544 pub fn has_unseen_fill_void(&self, uo: &UnmatchedOrder) -> bool {
545 let cumulative = normalize_betfair_quantity(uo.sv.unwrap_or(Decimal::ZERO));
546 let previous = self
547 .voided_qty
548 .get(&uo.id)
549 .copied()
550 .unwrap_or(Decimal::ZERO);
551 cumulative > previous
552 }
553
554 #[must_use]
557 pub(crate) fn has_pending_price_restatement(
558 &self,
559 bet_id: &str,
560 size_matched: Decimal,
561 size_voided: Decimal,
562 average_price_matched: Option<Decimal>,
563 ) -> bool {
564 let size_voided = normalize_betfair_quantity(size_voided);
565 let previous_voided = self
566 .voided_qty
567 .get(bet_id)
568 .copied()
569 .unwrap_or(Decimal::ZERO);
570
571 if size_voided > previous_voided {
572 return false;
573 }
574
575 let size_matched = normalize_betfair_quantity(size_matched);
576 let cumulative = if self.has_fill_lots(bet_id) {
577 size_matched + size_voided
578 } else {
579 size_matched
580 };
581
582 let previous = self
583 .filled_qty
584 .get(bet_id)
585 .copied()
586 .unwrap_or(Decimal::ZERO);
587
588 if cumulative != previous || cumulative <= Decimal::ZERO {
589 return false;
590 }
591
592 self.matched_price_restatement(bet_id, Some(size_voided), average_price_matched)
593 .is_some()
594 }
595
596 #[must_use]
599 pub(crate) fn has_unseen_price_restatement(&self, uo: &UnmatchedOrder) -> bool {
600 self.has_pending_price_restatement(
601 &uo.id,
602 uo.sm.unwrap_or(Decimal::ZERO),
603 uo.sv.unwrap_or(Decimal::ZERO),
604 uo.avp,
605 )
606 }
607
608 #[must_use]
610 pub fn has_unseen_fill(&self, uo: &UnmatchedOrder) -> bool {
611 let size_matched = normalize_betfair_quantity(uo.sm.unwrap_or(Decimal::ZERO));
612 let cumulative = if self.has_fill_lots(&uo.id) {
613 size_matched + normalize_betfair_quantity(uo.sv.unwrap_or(Decimal::ZERO))
614 } else {
615 size_matched
616 };
617 let previous = self
618 .filled_qty
619 .get(&uo.id)
620 .copied()
621 .unwrap_or(Decimal::ZERO);
622 let order_qty = normalize_betfair_quantity(resolve_stream_order_quantity(uo.s, uo));
623 cumulative > previous && cumulative <= order_qty
624 }
625
626 pub(crate) fn sync_fill_lot(
627 &mut self,
628 bet_id: &str,
629 trade_id: TradeId,
630 quantity: Decimal,
631 price: Price,
632 voided_qty: Decimal,
633 ) {
634 let lots = self.fill_lots.entry(bet_id.to_string()).or_default();
635 if let Some(lot) = lots.iter_mut().find(|lot| lot.trade_id == trade_id) {
636 let stale_original = lot.accounting_px != price && price == lot.price;
637 if !stale_original {
638 lot.accounting_px = price;
639 }
640 } else {
641 lots.push(FillLot {
642 trade_id,
643 quantity: normalize_betfair_quantity(quantity),
644 price,
645 accounting_px: price,
646 });
647 }
648 let gross_filled = lots.iter().map(|lot| lot.quantity).sum();
649 self.filled_qty.insert(bet_id.to_string(), gross_filled);
650 self.published_trade_ids.insert(trade_id.to_string());
651 if voided_qty > Decimal::ZERO {
652 self.fill_voids.insert(
653 (bet_id.to_string(), trade_id),
654 normalize_betfair_quantity(voided_qty),
655 );
656 }
657 }
658
659 pub(crate) fn set_accounting_px(&mut self, bet_id: &str, trade_id: TradeId, price: Price) {
660 let Some(lots) = self.fill_lots.get_mut(bet_id) else {
661 return;
662 };
663
664 if let Some(lot) = lots.iter_mut().find(|lot| lot.trade_id == trade_id) {
665 lot.accounting_px = price;
666 }
667 }
668
669 pub(crate) fn matched_quantity(&self, bet_id: &str) -> Decimal {
670 let filled = self.filled_qty.get(bet_id).copied().unwrap_or_default();
671 let voided = self.voided_qty.get(bet_id).copied().unwrap_or_default();
672 (filled - voided).max(Decimal::ZERO)
673 }
674
675 pub(crate) fn voided_quantity(&self, bet_id: &str) -> Decimal {
676 self.voided_qty.get(bet_id).copied().unwrap_or_default()
677 }
678
679 pub(crate) fn sync_voided_qty(&mut self, bet_id: &str, voided_qty: Decimal) {
680 self.voided_qty
681 .insert(bet_id.to_string(), normalize_betfair_quantity(voided_qty));
682 }
683
684 fn matched_price_restatement(
685 &self,
686 bet_id: &str,
687 cumulative_voided: Option<Decimal>,
688 average_price_matched: Option<Decimal>,
689 ) -> Option<(TradeId, Quantity, Price)> {
690 if !self.size_voided_unchanged(bet_id, cumulative_voided) {
691 return None;
692 }
693
694 let avp = average_price_matched.map(normalize_betfair_price)?;
695 let prev_avg = self.avg_px.get(bet_id).copied()?;
696 if avp == prev_avg {
697 return None;
698 }
699
700 let (trade_id, surviving_qty) = self.single_surviving_lot(bet_id)?;
701
702 let Ok(last_qty) = parse_betfair_quantity(surviving_qty) else {
703 log::warn!("Cannot restate matched quantity {surviving_qty} for bet_id={bet_id}");
704 return None;
705 };
706
707 let Ok(last_px) = parse_betfair_price(avp) else {
708 log::warn!("Cannot restate matched price {avp} for bet_id={bet_id}");
709 return None;
710 };
711
712 Some((trade_id, last_qty, last_px))
713 }
714
715 fn set_surviving_accounting_px(&mut self, bet_id: &str, price: Price) {
716 let Some(lots) = self.fill_lots.get_mut(bet_id) else {
717 return;
718 };
719
720 for lot in lots.iter_mut() {
721 let voided = self
722 .fill_voids
723 .get(&(bet_id.to_string(), lot.trade_id))
724 .copied()
725 .unwrap_or(Decimal::ZERO);
726
727 if lot.quantity.saturating_sub(voided) > Decimal::ZERO {
728 lot.accounting_px = price;
729 }
730 }
731 }
732
733 fn size_voided_unchanged(&self, bet_id: &str, cumulative_voided: Option<Decimal>) -> bool {
734 let current = cumulative_voided.map_or(Decimal::ZERO, normalize_betfair_quantity);
735 let previous = self
736 .voided_qty
737 .get(bet_id)
738 .copied()
739 .unwrap_or(Decimal::ZERO);
740 current == previous
741 }
742
743 pub(crate) fn seed_missing_averages(&mut self) {
744 let bet_ids = self.fill_lots.keys().cloned().collect::<Vec<_>>();
745 for bet_id in bet_ids {
746 if self.avg_px.contains_key(&bet_id) {
747 continue;
748 }
749
750 let Some(lots) = self.fill_lots.get(&bet_id) else {
751 continue;
752 };
753
754 let mut quantity = Decimal::ZERO;
755 let mut notional = Decimal::ZERO;
756
757 for lot in lots {
758 let voided = self
759 .fill_voids
760 .get(&(bet_id.clone(), lot.trade_id))
761 .copied()
762 .unwrap_or(Decimal::ZERO);
763 let surviving = lot.quantity.saturating_sub(voided);
764 if surviving <= Decimal::ZERO {
765 continue;
766 }
767
768 quantity += surviving;
769 notional += surviving * lot.accounting_px.as_decimal();
770 }
771
772 if quantity > Decimal::ZERO {
773 self.avg_px.insert(bet_id, notional / quantity);
774 }
775 }
776 }
777
778 fn single_surviving_lot(&self, bet_id: &str) -> Option<(TradeId, Decimal)> {
779 let lots = self.fill_lots.get(bet_id)?;
780
781 let mut surviving = lots.iter().filter_map(|lot| {
782 let voided = self
783 .fill_voids
784 .get(&(bet_id.to_string(), lot.trade_id))
785 .copied()
786 .unwrap_or(Decimal::ZERO);
787 let surviving_qty = lot.quantity.saturating_sub(voided);
788 (surviving_qty > Decimal::ZERO).then_some((lot.trade_id, surviving_qty))
789 });
790
791 let lot = surviving.next()?;
792 surviving.next().is_none().then_some(lot)
793 }
794
795 fn record_cumulative_state(
796 &mut self,
797 bet_id: &str,
798 size_matched: Decimal,
799 average_price_matched: Option<Decimal>,
800 ) {
801 self.filled_qty.insert(bet_id.to_string(), size_matched);
802 if let Some(avg_px) = average_price_matched.map(normalize_betfair_price) {
803 self.avg_px.insert(bet_id.to_string(), avg_px);
804 }
805 }
806
807 fn compute_fill_price(
814 &self,
815 bet_id: &str,
816 average_price_matched: Option<Decimal>,
817 fallback_price: Decimal,
818 prev_filled: Decimal,
819 sm: Decimal,
820 ) -> Decimal {
821 let Some(avp) = average_price_matched.map(normalize_betfair_price) else {
822 return fallback_price;
823 };
824
825 if prev_filled == Decimal::ZERO {
826 return avp;
827 }
828
829 let Some(prev_avg) = self.avg_px.get(bet_id).copied() else {
830 return avp;
831 };
832
833 if prev_avg == avp {
834 return avp;
835 }
836
837 let fill_size = sm - prev_filled;
838
839 if fill_size == Decimal::ZERO {
840 return prev_avg;
841 }
842
843 let fill_price = (avp * sm - prev_avg * prev_filled) / fill_size;
844
845 if fill_price <= Decimal::ZERO {
846 log::warn!(
847 "Calculated fill price {fill_price} is invalid for bet_id={bet_id}, falling back to avp={avp}",
848 );
849 return avp;
850 }
851
852 fill_price
853 }
854
855 fn compute_fill_price_with_voids(
856 &self,
857 bet_id: &str,
858 average_price_matched: Option<Decimal>,
859 fallback_price: Decimal,
860 gross_matched: Decimal,
861 fill_qty: Decimal,
862 cumulative_voided: Decimal,
863 ) -> Decimal {
864 let Some(avp) = average_price_matched.map(normalize_betfair_price) else {
865 return fallback_price;
866 };
867 let cumulative_voided = normalize_betfair_quantity(cumulative_voided);
868 let previous_voided = self
869 .voided_qty
870 .get(bet_id)
871 .copied()
872 .map_or(Decimal::ZERO, normalize_betfair_quantity);
873 let incremental_void = cumulative_voided.saturating_sub(previous_voided);
874 let surviving_fill_qty = fill_qty.saturating_sub(incremental_void);
875 if surviving_fill_qty <= Decimal::ZERO {
876 return avp;
877 }
878
879 let mut prior_notional = Decimal::ZERO;
880 let mut effective_lots = Vec::new();
881
882 if let Some(lots) = self.fill_lots.get(bet_id) {
883 for lot in lots {
884 let already_voided = self
885 .fill_voids
886 .get(&(bet_id.to_string(), lot.trade_id))
887 .copied()
888 .unwrap_or(Decimal::ZERO);
889 let effective = lot.quantity.saturating_sub(already_voided);
890 prior_notional += effective * lot.accounting_px.as_decimal();
891 effective_lots.push((effective, lot.accounting_px.as_decimal()));
892 }
893 }
894
895 let mut prior_void = incremental_void.saturating_sub(fill_qty);
896 for (effective, price) in effective_lots.into_iter().rev() {
897 if prior_void <= Decimal::ZERO {
898 break;
899 }
900 let removed = prior_void.min(effective);
901 prior_notional -= removed * price;
902 prior_void -= removed;
903 }
904
905 let surviving_matched = gross_matched.saturating_sub(cumulative_voided);
906 let fill_price = (avp * surviving_matched - prior_notional) / surviving_fill_qty;
907 if fill_price <= Decimal::ZERO {
908 log::warn!(
909 "Calculated post-void fill price {fill_price} is invalid for bet_id={bet_id}, falling back to avp={avp}",
910 );
911 return avp;
912 }
913 fill_price
914 }
915
916 pub fn sync_order(&mut self, bet_id: &str, filled_qty: Decimal, avg_px: Decimal) {
922 let filled_qty = normalize_betfair_quantity(filled_qty);
923 let avg_px = normalize_betfair_price(avg_px);
924 let current = self
925 .filled_qty
926 .get(bet_id)
927 .copied()
928 .map_or(Decimal::ZERO, normalize_betfair_quantity);
929
930 if filled_qty > current {
931 self.filled_qty.insert(bet_id.to_string(), filled_qty);
932 if avg_px > Decimal::ZERO {
933 self.avg_px.insert(bet_id.to_string(), avg_px);
934 }
935 }
936 }
937
938 pub fn seed_published_trade_ids<I, S>(&mut self, trade_ids: I)
941 where
942 I: IntoIterator<Item = S>,
943 S: Into<String>,
944 {
945 for id in trade_ids {
946 self.published_trade_ids.insert(id.into());
947 }
948 }
949
950 pub fn prune(&mut self, bet_id: &str) {
952 self.filled_qty.remove(bet_id);
953 self.voided_qty.remove(bet_id);
954 self.avg_px.remove(bet_id);
955 self.fill_lots.remove(bet_id);
956 self.fill_voids.retain(|(id, _), _| id != bet_id);
957
958 let prefix = format!("{bet_id}-");
959 self.published_trade_ids
960 .retain(|id| !id.starts_with(&prefix));
961 }
962}
963
964fn make_trade_id_for_size(bet_id: &str, size_matched: Decimal) -> TradeId {
965 TradeId::new(format!("{bet_id}-{size_matched}"))
966}
967
968#[must_use]
970pub fn has_cancel_quantity(uo: &UnmatchedOrder) -> bool {
971 let sc = uo.sc.unwrap_or(Decimal::ZERO);
972 let sl = uo.sl.unwrap_or(Decimal::ZERO);
973 let sv = uo.sv.unwrap_or(Decimal::ZERO);
974 (sc + sl + sv) > Decimal::ZERO
975}
976
977#[must_use]
979pub fn is_lapsed(uo: &UnmatchedOrder) -> bool {
980 uo.status == StreamingOrderStatus::ExecutionComplete && uo.lsrc.is_some()
981}
982
983#[must_use]
990pub fn is_resting_sp_bet(uo: &UnmatchedOrder) -> bool {
991 uo.status == StreamingOrderStatus::ExecutionComplete
992 && matches!(
993 uo.ot,
994 StreamingOrderType::LimitOnClose | StreamingOrderType::MarketOnClose
995 )
996 && uo.sm.unwrap_or(Decimal::ZERO) <= Decimal::ZERO
997 && uo.lsrc.is_none()
998 && !has_cancel_quantity(uo)
999}
1000
1001pub fn parse_order_status_report(
1010 uo: &UnmatchedOrder,
1011 instrument_id: InstrumentId,
1012 account_id: AccountId,
1013 ts_event: UnixNanos,
1014 ts_init: UnixNanos,
1015) -> anyhow::Result<OrderStatusReport> {
1016 let order_side = OrderSide::from(uo.side);
1017 let order_type = OrderType::from(uo.ot);
1018 let time_in_force = parse_stream_time_in_force(uo)?;
1019
1020 let size_matched = uo.sm.unwrap_or(Decimal::ZERO);
1021 let size_cancelled = uo.sc.unwrap_or(Decimal::ZERO);
1022 let size_lapsed = uo.sl.unwrap_or(Decimal::ZERO);
1023 let size_voided = uo.sv.unwrap_or(Decimal::ZERO);
1024
1025 let size_closed = size_cancelled + size_lapsed + size_voided;
1027 let order_status = if uo.status == StreamingOrderStatus::ExecutionComplete
1028 && size_voided > Decimal::ZERO
1029 && size_cancelled.is_zero()
1030 && size_lapsed.is_zero()
1031 {
1032 OrderStatus::Voided
1033 } else if is_resting_sp_bet(uo) {
1034 OrderStatus::Accepted
1035 } else {
1036 resolve_streaming_order_status(uo.status, size_matched, size_closed)
1037 };
1038
1039 let quantity_decimal = stream_order_quantity(uo);
1040 anyhow::ensure!(
1041 quantity_decimal > Decimal::ZERO,
1042 "failed to resolve positive quantity for stream order update {} \
1043 (order_type={:?}, persistence_type={:?}, size={}, bsp_liability={:?}, \
1044 size_matched={:?}, size_remaining={:?}, size_cancelled={:?}, size_lapsed={:?}, size_voided={:?})",
1045 uo.id,
1046 uo.ot,
1047 uo.pt,
1048 uo.s,
1049 uo.bsp,
1050 uo.sm,
1051 uo.sr,
1052 uo.sc,
1053 uo.sl,
1054 uo.sv,
1055 );
1056 let quantity = parse_betfair_quantity(quantity_decimal)?;
1057 let filled_qty = parse_betfair_quantity(size_matched)?;
1058
1059 let ts_accepted = parse_millis_timestamp(uo.pd);
1060
1061 let ts_last = [uo.md, uo.cd, uo.ld]
1063 .into_iter()
1064 .flatten()
1065 .max()
1066 .map_or(ts_event, parse_millis_timestamp);
1067
1068 let venue_order_id = VenueOrderId::from(uo.id.as_str());
1069 let client_order_id = uo
1070 .rfo
1071 .as_deref()
1072 .filter(|s| !s.is_empty())
1073 .map(ClientOrderId::from);
1074
1075 let price = parse_betfair_price(uo.p)?;
1076
1077 let mut report = OrderStatusReport::new(
1078 account_id,
1079 instrument_id,
1080 client_order_id,
1081 venue_order_id,
1082 order_side.into(),
1083 order_type,
1084 time_in_force,
1085 order_status,
1086 quantity,
1087 filled_qty,
1088 ts_accepted,
1089 ts_last,
1090 ts_init,
1091 None,
1092 )
1093 .with_price(price);
1094
1095 report.avg_px = uo.avp;
1096 if let Some(lsrc) = uo.lsrc {
1097 report.cancel_reason = Some(lsrc.to_string());
1098 }
1099
1100 Ok(report)
1101}
1102
1103fn parse_stream_time_in_force(uo: &UnmatchedOrder) -> anyhow::Result<TimeInForce> {
1104 if matches!(
1105 uo.ot,
1106 StreamingOrderType::LimitOnClose | StreamingOrderType::MarketOnClose
1107 ) {
1108 return Ok(TimeInForce::AtTheClose);
1111 }
1112
1113 match uo.pt {
1114 Some(persistence_type) => Ok(TimeInForce::from(persistence_type)),
1115 None => anyhow::bail!("missing persistence type for order update {}", uo.id),
1116 }
1117}
1118
1119fn stream_order_quantity(uo: &UnmatchedOrder) -> Decimal {
1120 if uo.s > Decimal::ZERO {
1121 return uo.s;
1122 }
1123
1124 let lifecycle_qty = uo.sm.unwrap_or(Decimal::ZERO)
1125 + uo.sr.unwrap_or(Decimal::ZERO)
1126 + uo.sc.unwrap_or(Decimal::ZERO)
1127 + uo.sl.unwrap_or(Decimal::ZERO)
1128 + uo.sv.unwrap_or(Decimal::ZERO);
1129
1130 if lifecycle_qty > Decimal::ZERO {
1131 return lifecycle_qty;
1132 }
1133
1134 if uses_liability_based_stream_quantity(uo) {
1135 return uo.bsp.unwrap_or(Decimal::ZERO);
1136 }
1137
1138 Decimal::ZERO
1139}
1140
1141fn resolve_stream_order_quantity(order_qty: Decimal, uo: &UnmatchedOrder) -> Decimal {
1142 if order_qty > Decimal::ZERO {
1143 order_qty
1144 } else {
1145 stream_order_quantity(uo)
1146 }
1147}
1148
1149fn uses_liability_based_stream_quantity(uo: &UnmatchedOrder) -> bool {
1150 matches!(
1151 uo.ot,
1152 crate::common::enums::StreamingOrderType::LimitOnClose
1153 | crate::common::enums::StreamingOrderType::MarketOnClose
1154 )
1155}
1156
1157#[must_use]
1162#[expect(clippy::too_many_arguments)]
1163pub fn make_fill_report(
1164 account_id: AccountId,
1165 instrument_id: InstrumentId,
1166 venue_order_id: VenueOrderId,
1167 trade_id: TradeId,
1168 order_side: OrderSide,
1169 last_qty: Quantity,
1170 last_px: Price,
1171 currency: Currency,
1172 client_order_id: Option<ClientOrderId>,
1173 ts_event: UnixNanos,
1174 ts_init: UnixNanos,
1175) -> FillReport {
1176 FillReport::new(
1177 account_id,
1178 instrument_id,
1179 venue_order_id,
1180 trade_id,
1181 order_side,
1182 last_qty,
1183 last_px,
1184 Money::zero(currency),
1185 LiquiditySide::NoLiquiditySide,
1186 client_order_id,
1187 None,
1188 ts_event,
1189 ts_init,
1190 None,
1191 )
1192}
1193
1194#[must_use]
1198pub fn parse_betfair_ticker(
1199 instrument_id: InstrumentId,
1200 rc: &RunnerChange,
1201 ts_event: UnixNanos,
1202 ts_init: UnixNanos,
1203) -> Option<BetfairTicker> {
1204 if rc.ltp.is_none() && rc.tv.is_none() && rc.spn.is_none() && rc.spf.is_none() {
1205 return None;
1206 }
1207
1208 Some(BetfairTicker::new(
1209 instrument_id,
1210 rc.ltp,
1211 rc.tv,
1212 rc.spn,
1213 rc.spf,
1214 ts_event,
1215 ts_init,
1216 ))
1217}
1218
1219#[must_use]
1223pub fn parse_betfair_starting_prices(
1224 market_id: &str,
1225 def: &MarketDefinition,
1226 ts_event: UnixNanos,
1227 ts_init: UnixNanos,
1228) -> Vec<BetfairStartingPrice> {
1229 let Some(runners) = &def.runners else {
1230 return Vec::new();
1231 };
1232
1233 runners
1234 .iter()
1235 .filter_map(|rd| {
1236 let bsp = rd.bsp?;
1237 let handicap = rd.hc.unwrap_or(Decimal::ZERO);
1238 let instrument_id = make_instrument_id(market_id, rd.id, handicap);
1239 Some(BetfairStartingPrice::new(
1240 instrument_id,
1241 bsp,
1242 ts_event,
1243 ts_init,
1244 ))
1245 })
1246 .collect()
1247}
1248
1249#[must_use]
1255pub fn parse_bsp_book_deltas(
1256 instrument_id: InstrumentId,
1257 rc: &RunnerChange,
1258 ts_event: UnixNanos,
1259 ts_init: UnixNanos,
1260) -> Vec<BetfairBspBookDelta> {
1261 let spb_len = rc.spb.as_ref().map_or(0, Vec::len);
1262 let spl_len = rc.spl.as_ref().map_or(0, Vec::len);
1263
1264 if spb_len + spl_len == 0 {
1265 return Vec::new();
1266 }
1267
1268 let mut result = Vec::with_capacity(spb_len + spl_len);
1269
1270 for (levels, side) in [
1271 (rc.spb.as_deref().unwrap_or(&[]), OrderSide::Sell),
1272 (rc.spl.as_deref().unwrap_or(&[]), OrderSide::Buy),
1273 ] {
1274 for pv in levels {
1275 let action = if pv.volume == Decimal::ZERO {
1276 BookAction::Delete
1277 } else {
1278 BookAction::Update
1279 };
1280
1281 result.push(BetfairBspBookDelta::new(
1282 instrument_id,
1283 action,
1284 side,
1285 pv.price,
1286 pv.volume,
1287 ts_event,
1288 ts_init,
1289 ));
1290 }
1291 }
1292
1293 result
1294}
1295
1296#[must_use]
1301pub fn parse_instrument_closes(
1302 market_id: &str,
1303 def: &MarketDefinition,
1304 ts_event: UnixNanos,
1305 ts_init: UnixNanos,
1306) -> Vec<InstrumentClose> {
1307 let Some(runners) = &def.runners else {
1308 return Vec::new();
1309 };
1310
1311 runners
1312 .iter()
1313 .filter_map(|rd| {
1314 let status = rd.status.as_ref()?;
1315 let close_price = match status {
1316 RunnerStatus::Winner | RunnerStatus::Placed => Price::from("1.00"),
1317 RunnerStatus::Loser | RunnerStatus::Removed | RunnerStatus::RemovedVacant => {
1318 Price::from("0.00")
1319 }
1320 RunnerStatus::Active | RunnerStatus::Hidden | RunnerStatus::Unknown => return None,
1321 };
1322
1323 let handicap = rd.hc.unwrap_or(Decimal::ZERO);
1324 let instrument_id = make_instrument_id(market_id, rd.id, handicap);
1325
1326 Some(InstrumentClose::new(
1327 instrument_id,
1328 close_price,
1329 InstrumentCloseType::ContractExpired,
1330 ts_event,
1331 ts_init,
1332 ))
1333 })
1334 .collect()
1335}
1336
1337#[must_use]
1341pub fn parse_race_runner_data(
1342 race_id: &str,
1343 market_id: &str,
1344 rrc: &RaceRunnerChange,
1345 ts_event: UnixNanos,
1346 ts_init: UnixNanos,
1347) -> Option<BetfairRaceRunnerData> {
1348 let selection_id = rrc.id?;
1349
1350 Some(BetfairRaceRunnerData::new(
1351 race_id.to_string(),
1352 market_id.to_string(),
1353 selection_id,
1354 rrc.lat.unwrap_or(f64::NAN),
1355 rrc.lng.unwrap_or(f64::NAN),
1356 rrc.spd.unwrap_or(f64::NAN),
1357 rrc.prg.unwrap_or(f64::NAN),
1358 rrc.sfq.unwrap_or(f64::NAN),
1359 ts_event,
1360 ts_init,
1361 ))
1362}
1363
1364#[must_use]
1366pub fn parse_race_progress(
1367 race_id: &str,
1368 market_id: &str,
1369 rpc: &RaceProgressChange,
1370 ts_event: UnixNanos,
1371 ts_init: UnixNanos,
1372) -> BetfairRaceProgress {
1373 let order_json = serialize_json_value(rpc.ord.as_ref());
1374 let jumps_json = serialize_json_value(rpc.jumps.as_ref());
1375
1376 BetfairRaceProgress::new(
1377 race_id.to_string(),
1378 market_id.to_string(),
1379 rpc.g.clone().unwrap_or_default(),
1380 rpc.st.unwrap_or(f64::NAN),
1381 rpc.rt.unwrap_or(f64::NAN),
1382 rpc.spd.unwrap_or(f64::NAN),
1383 rpc.prg.unwrap_or(f64::NAN),
1384 order_json,
1385 jumps_json,
1386 ts_event,
1387 ts_init,
1388 )
1389}
1390
1391#[must_use]
1392pub fn parse_cricket_match(
1393 cricket: &CricketChange,
1394 ts_event: UnixNanos,
1395 ts_init: UnixNanos,
1396) -> Option<BetfairCricketMatch> {
1397 let event_id = cricket.event_id.clone()?;
1398 let market_id = cricket.market_id.clone()?;
1399
1400 Some(BetfairCricketMatch::new(
1401 event_id,
1402 market_id,
1403 serialize_json_value(cricket.fixture_info.as_ref()),
1404 serialize_json_value(cricket.home_team.as_ref()),
1405 serialize_json_value(cricket.away_team.as_ref()),
1406 serialize_json_value(cricket.match_stats.as_ref()),
1407 serialize_json_value(cricket.incident_list_wrapper.as_ref()),
1408 ts_event,
1409 ts_init,
1410 ))
1411}
1412
1413fn serialize_json_value<T>(value: Option<&T>) -> String
1414where
1415 T: serde::Serialize,
1416{
1417 value
1418 .map(|value| serde_json::to_string(value).unwrap_or_default())
1419 .unwrap_or_default()
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424 use nautilus_model::enums::{MarketStatusAction, OrderStatus, TimeInForce};
1425 use rstest::rstest;
1426
1427 use super::*;
1428 use crate::{
1429 common::{
1430 consts::{BETFAIR_PRICE_PRECISION, BETFAIR_QUANTITY_PRECISION},
1431 enums::{StreamingOrderType, StreamingPersistenceType, StreamingSide},
1432 testing::load_test_json,
1433 },
1434 stream::messages::{PV, RunnerChange, RunnerDefinition, StreamMessage, stream_decode},
1435 };
1436
1437 fn assert_decimal_option_eq(actual: Option<Decimal>, expected: Decimal) {
1438 assert_eq!(actual, Some(expected));
1439 }
1440
1441 #[rstest]
1442 fn test_parse_runner_book_snapshot() {
1443 let data = load_test_json("stream/mcm_live_IMAGE.json");
1444 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1445
1446 if let StreamMessage::MarketChange(mcm) = msg {
1447 let mc = mcm.mc.as_ref().unwrap();
1448 let change = &mc[0];
1449 let rc = &change.rc.as_ref().unwrap()[0];
1450 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
1451
1452 let deltas = parse_runner_book_deltas(
1453 instrument_id,
1454 rc,
1455 true,
1456 mcm.pt,
1457 parse_millis_timestamp(mcm.pt),
1458 parse_millis_timestamp(mcm.pt),
1459 )
1460 .unwrap()
1461 .expect("should produce deltas");
1462
1463 let atb_len = rc.atb.as_ref().unwrap().len();
1465 let atl_len = rc.atl.as_ref().unwrap().len();
1466 assert_eq!(deltas.deltas.len(), 1 + atb_len + atl_len);
1467
1468 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1470 assert!(RecordFlag::F_SNAPSHOT.matches(deltas.deltas[0].flags));
1471
1472 for delta in &deltas.deltas[1..] {
1474 assert_eq!(delta.action, BookAction::Add);
1475 assert!(RecordFlag::F_SNAPSHOT.matches(delta.flags));
1476 }
1477
1478 let last = deltas.deltas.last().unwrap();
1480 assert!(RecordFlag::F_LAST.matches(last.flags));
1481
1482 let buy_count = deltas
1484 .deltas
1485 .iter()
1486 .filter(|d| d.order.side == Some(OrderSide::Buy))
1487 .count();
1488 let sell_count = deltas
1489 .deltas
1490 .iter()
1491 .filter(|d| d.order.side == Some(OrderSide::Sell))
1492 .count();
1493 assert_eq!(buy_count, atb_len);
1494 assert_eq!(sell_count, atl_len);
1495 } else {
1496 panic!("expected MarketChange");
1497 }
1498 }
1499
1500 #[rstest]
1501 fn test_parse_runner_book_snapshot_skips_zero_volume_levels() {
1502 let rc: RunnerChange = serde_json::from_str(
1503 r#"{
1504 "id": 123,
1505 "atb": [[2.0, 0.0], [2.1, 3.0]],
1506 "atl": [[2.2, 0.0], [2.3, 4.0]]
1507 }"#,
1508 )
1509 .unwrap();
1510 let instrument_id = make_instrument_id("1.234", rc.id, Decimal::ZERO);
1511
1512 let deltas = parse_runner_book_deltas(
1513 instrument_id,
1514 &rc,
1515 true,
1516 1_551_400_000_000,
1517 parse_millis_timestamp(1_551_400_000_000),
1518 parse_millis_timestamp(1_551_400_000_000),
1519 )
1520 .unwrap()
1521 .expect("should produce deltas");
1522
1523 assert_eq!(deltas.deltas.len(), 3);
1524 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1525 assert!(
1526 deltas
1527 .deltas
1528 .iter()
1529 .filter(|delta| delta.action == BookAction::Add)
1530 .all(|delta| !delta.order.size.is_zero())
1531 );
1532 }
1533
1534 #[rstest]
1535 fn test_parse_runner_book_update() {
1536 let data = load_test_json("stream/mcm_UPDATE.json");
1537 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1538
1539 if let StreamMessage::MarketChange(mcm) = msg {
1540 let mc = mcm.mc.as_ref().unwrap();
1541 let change = &mc[0];
1542 let rc = &change.rc.as_ref().unwrap()[0];
1543 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
1544
1545 let deltas = parse_runner_book_deltas(
1546 instrument_id,
1547 rc,
1548 false,
1549 mcm.pt,
1550 parse_millis_timestamp(mcm.pt),
1551 parse_millis_timestamp(mcm.pt),
1552 )
1553 .unwrap()
1554 .expect("should produce deltas");
1555
1556 assert!(deltas.deltas.iter().all(|d| d.action != BookAction::Clear));
1558
1559 let last = deltas.deltas.last().unwrap();
1561 assert!(RecordFlag::F_LAST.matches(last.flags));
1562
1563 for delta in &deltas.deltas {
1565 assert!(!RecordFlag::F_SNAPSHOT.matches(delta.flags));
1566 }
1567 } else {
1568 panic!("expected MarketChange");
1569 }
1570 }
1571
1572 #[rstest]
1573 fn test_parse_runner_book_update_zero_volume_is_delete() {
1574 let data = load_test_json("stream/mcm_UPDATE.json");
1575 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1576
1577 if let StreamMessage::MarketChange(mcm) = msg {
1578 let mc = mcm.mc.as_ref().unwrap();
1579 let change = &mc[0];
1580 let rc = &change.rc.as_ref().unwrap()[0];
1581 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
1582
1583 let deltas = parse_runner_book_deltas(
1584 instrument_id,
1585 rc,
1586 false,
1587 mcm.pt,
1588 parse_millis_timestamp(mcm.pt),
1589 parse_millis_timestamp(mcm.pt),
1590 )
1591 .unwrap()
1592 .unwrap();
1593
1594 assert!(
1596 deltas.deltas.iter().any(|d| d.action == BookAction::Delete),
1597 "zero volume should produce Delete action"
1598 );
1599 } else {
1600 panic!("expected MarketChange");
1601 }
1602 }
1603
1604 #[rstest]
1605 fn test_parse_runner_book_no_levels_returns_none() {
1606 let rc = RunnerChange {
1607 id: 12345,
1608 hc: None,
1609 atb: None,
1610 atl: None,
1611 batb: None,
1612 batl: None,
1613 bdatb: None,
1614 bdatl: None,
1615 spb: None,
1616 spl: None,
1617 spn: None,
1618 spf: None,
1619 trd: None,
1620 ltp: None,
1621 tv: None,
1622 };
1623
1624 let result = parse_runner_book_deltas(
1625 make_instrument_id("1.123", 12345, Decimal::ZERO),
1626 &rc,
1627 false,
1628 0,
1629 UnixNanos::default(),
1630 UnixNanos::default(),
1631 );
1632
1633 assert!(result.unwrap().is_none());
1634 }
1635
1636 #[rstest]
1637 fn test_make_trade_tick() {
1638 let instrument_id = make_instrument_id("1.180737206", 19248890, Decimal::ZERO);
1639 let tick = make_trade_tick(
1640 instrument_id,
1641 Price::new(2.42, BETFAIR_PRICE_PRECISION),
1642 Quantity::new(100.0, BETFAIR_QUANTITY_PRECISION),
1643 TradeId::from("test-trade-1"),
1644 UnixNanos::default(),
1645 UnixNanos::default(),
1646 );
1647
1648 assert_eq!(tick.instrument_id, instrument_id);
1649 assert_eq!(tick.price.as_f64(), 2.42);
1650 assert_eq!(tick.size.as_f64(), 100.0);
1651 assert_eq!(tick.aggressor_side, AggressorSide::NoAggressor);
1652 }
1653
1654 fn make_status_def(
1655 status: MarketStatus,
1656 in_play: bool,
1657 runner_status: RunnerStatus,
1658 ) -> MarketDefinition {
1659 MarketDefinition {
1660 runners: Some(vec![RunnerDefinition {
1661 id: 456,
1662 hc: None,
1663 sort_priority: None,
1664 name: None,
1665 status: Some(runner_status),
1666 adjustment_factor: None,
1667 bsp: None,
1668 removal_date: None,
1669 }]),
1670 bet_delay: None,
1671 betting_type: None,
1672 bsp_market: None,
1673 bsp_reconciled: None,
1674 competition_id: None,
1675 competition_name: None,
1676 complete: None,
1677 country_code: None,
1678 cross_matching: None,
1679 discount_allowed: None,
1680 each_way_divisor: None,
1681 event_id: None,
1682 event_name: None,
1683 event_type_id: None,
1684 event_type_name: None,
1685 in_play: Some(in_play),
1686 line_interval: None,
1687 line_max_unit: None,
1688 line_min_unit: None,
1689 market_base_rate: None,
1690 market_id: None,
1691 market_name: None,
1692 market_time: None,
1693 market_type: None,
1694 number_of_active_runners: None,
1695 number_of_winners: None,
1696 open_date: None,
1697 persistence_enabled: None,
1698 price_ladder_definition: None,
1699 race_type: None,
1700 regulators: None,
1701 runners_voidable: None,
1702 settled_time: None,
1703 status: Some(status),
1704 suspend_time: None,
1705 timezone: None,
1706 turn_in_play_enabled: None,
1707 venue: None,
1708 version: None,
1709 }
1710 }
1711
1712 #[rstest]
1713 #[case(MarketStatus::Open, false, MarketStatusAction::PreOpen, false)]
1714 #[case(MarketStatus::Open, true, MarketStatusAction::Trading, true)]
1715 #[case(MarketStatus::Closed, false, MarketStatusAction::Close, false)]
1716 #[case(MarketStatus::Closed, true, MarketStatusAction::Close, false)]
1717 #[case(MarketStatus::Suspended, false, MarketStatusAction::Pause, false)]
1718 #[case(MarketStatus::Suspended, true, MarketStatusAction::Pause, false)]
1719 #[case(MarketStatus::Inactive, false, MarketStatusAction::Close, false)]
1720 fn test_parse_instrument_statuses_market_state(
1721 #[case] status: MarketStatus,
1722 #[case] in_play: bool,
1723 #[case] expected_action: MarketStatusAction,
1724 #[case] expected_is_trading: bool,
1725 ) {
1726 let def = make_status_def(status, in_play, RunnerStatus::Active);
1727 let results =
1728 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1729
1730 assert_eq!(results.len(), 1);
1731 assert_eq!(results[0].action, expected_action);
1732 assert_eq!(results[0].is_trading, Some(expected_is_trading));
1733 }
1734
1735 #[rstest]
1736 fn test_parse_instrument_statuses_skips_unknown_market_status() {
1737 let def = make_status_def(MarketStatus::Unknown, true, RunnerStatus::Active);
1740 let results =
1741 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1742
1743 assert!(results.is_empty());
1744 }
1745
1746 #[rstest]
1747 fn test_parse_instrument_statuses_skips_unknown_runner() {
1748 let def = make_status_def(MarketStatus::Open, true, RunnerStatus::Unknown);
1751 let results =
1752 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1753
1754 assert!(results.is_empty());
1755 }
1756
1757 #[rstest]
1758 #[case(RunnerStatus::Removed)]
1759 #[case(RunnerStatus::RemovedVacant)]
1760 fn test_parse_instrument_statuses_scratched_runner_closes(#[case] runner_status: RunnerStatus) {
1761 let def = make_status_def(MarketStatus::Open, true, runner_status);
1763 let results =
1764 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1765
1766 assert_eq!(results.len(), 1);
1767 assert_eq!(results[0].action, MarketStatusAction::Close);
1768 assert_eq!(results[0].is_trading, Some(false));
1769 }
1770
1771 #[rstest]
1772 #[case::missing_runners("runners")]
1773 #[case::missing_status("status")]
1774 fn test_parse_instrument_statuses_returns_empty(#[case] drop_field: &str) {
1775 let mut def = make_status_def(MarketStatus::Open, true, RunnerStatus::Active);
1776 match drop_field {
1777 "runners" => def.runners = None,
1778 "status" => def.status = None,
1779 _ => unreachable!(),
1780 }
1781
1782 let results =
1783 parse_instrument_statuses("1.123", &def, UnixNanos::default(), UnixNanos::default());
1784
1785 assert!(results.is_empty());
1786 }
1787
1788 #[rstest]
1789 fn test_parse_instrument_statuses_mixed_runners() {
1790 let mut def = make_status_def(MarketStatus::Open, true, RunnerStatus::Active);
1794 def.runners = Some(vec![
1795 RunnerDefinition {
1796 id: 101,
1797 hc: None,
1798 sort_priority: Some(1),
1799 name: None,
1800 status: Some(RunnerStatus::Active),
1801 adjustment_factor: None,
1802 bsp: None,
1803 removal_date: None,
1804 },
1805 RunnerDefinition {
1806 id: 202,
1807 hc: Some(Decimal::new(25, 1)), sort_priority: Some(2),
1809 name: None,
1810 status: Some(RunnerStatus::Removed),
1811 adjustment_factor: None,
1812 bsp: None,
1813 removal_date: None,
1814 },
1815 RunnerDefinition {
1816 id: 303,
1817 hc: None,
1818 sort_priority: Some(3),
1819 name: None,
1820 status: Some(RunnerStatus::RemovedVacant),
1821 adjustment_factor: None,
1822 bsp: None,
1823 removal_date: None,
1824 },
1825 ]);
1826
1827 let results =
1828 parse_instrument_statuses("1.999", &def, UnixNanos::default(), UnixNanos::default());
1829
1830 assert_eq!(results.len(), 3);
1831
1832 assert_eq!(results[0].action, MarketStatusAction::Trading);
1833 assert_eq!(results[0].is_trading, Some(true));
1834
1835 assert_eq!(results[1].action, MarketStatusAction::Close);
1836 assert_eq!(results[1].is_trading, Some(false));
1837
1838 assert_eq!(results[2].action, MarketStatusAction::Close);
1839 assert_eq!(results[2].is_trading, Some(false));
1840
1841 assert_ne!(results[0].instrument_id, results[1].instrument_id);
1843 assert_ne!(results[1].instrument_id, results[2].instrument_id);
1844 assert_ne!(results[0].instrument_id, results[2].instrument_id);
1845 }
1846
1847 #[rstest]
1848 fn test_parse_order_status_report_new_order() {
1849 let data = load_test_json("stream/ocm_NEW_FULL_IMAGE.json");
1850 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1851
1852 if let StreamMessage::OrderChange(ocm) = msg {
1853 let oc = ocm.oc.as_ref().unwrap();
1854 let omc = &oc[0];
1855 let orc = &omc.orc.as_ref().unwrap()[0];
1856 let uo = &orc.uo.as_ref().unwrap()[0];
1857
1858 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1859 let report = parse_order_status_report(
1860 uo,
1861 instrument_id,
1862 AccountId::from("BETFAIR-001"),
1863 parse_millis_timestamp(ocm.pt),
1864 parse_millis_timestamp(ocm.pt),
1865 )
1866 .unwrap();
1867
1868 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1870 assert_eq!(report.order_side, Some(OrderSide::Sell)); assert_eq!(report.order_type, OrderType::Limit);
1872 assert_eq!(report.filled_qty.as_f64(), 4.75);
1873 assert_eq!(report.quantity.as_f64(), 5.0);
1874 assert!(report.price.is_some());
1875 assert_eq!(report.price.unwrap().as_f64(), 12.0);
1876 } else {
1877 panic!("expected OrderChange");
1878 }
1879 }
1880
1881 #[rstest]
1882 fn test_parse_order_status_report_filled() {
1883 let data = load_test_json("stream/ocm_FILLED.json");
1884 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1885
1886 if let StreamMessage::OrderChange(ocm) = msg {
1887 let oc = ocm.oc.as_ref().unwrap();
1888 let omc = &oc[0];
1889 let orc = &omc.orc.as_ref().unwrap()[0];
1890 let uo = &orc.uo.as_ref().unwrap()[0];
1891
1892 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1893 let report = parse_order_status_report(
1894 uo,
1895 instrument_id,
1896 AccountId::from("BETFAIR-001"),
1897 parse_millis_timestamp(ocm.pt),
1898 parse_millis_timestamp(ocm.pt),
1899 )
1900 .unwrap();
1901
1902 assert_eq!(report.order_status, OrderStatus::Filled);
1903 assert_eq!(report.order_side, Some(OrderSide::Buy)); assert_eq!(report.filled_qty.as_f64(), 10.0);
1905 assert_eq!(report.quantity.as_f64(), 10.0);
1906
1907 assert!(report.client_order_id.is_some());
1909 } else {
1910 panic!("expected OrderChange");
1911 }
1912 }
1913
1914 #[rstest]
1915 fn test_parse_order_status_report_cancelled() {
1916 let data = load_test_json("stream/ocm_CANCEL.json");
1917 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1918
1919 if let StreamMessage::OrderChange(ocm) = msg {
1920 let oc = ocm.oc.as_ref().unwrap();
1921 let omc = &oc[0];
1922 let orc = &omc.orc.as_ref().unwrap()[0];
1923 let uo = &orc.uo.as_ref().unwrap()[0];
1924
1925 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1926 let report = parse_order_status_report(
1927 uo,
1928 instrument_id,
1929 AccountId::from("BETFAIR-001"),
1930 parse_millis_timestamp(ocm.pt),
1931 parse_millis_timestamp(ocm.pt),
1932 )
1933 .unwrap();
1934
1935 assert_eq!(report.order_status, OrderStatus::Canceled);
1936 assert_eq!(report.order_side, Some(OrderSide::Sell)); assert_eq!(report.filled_qty.as_f64(), 0.0);
1938 assert_eq!(report.quantity.as_f64(), 10.0);
1939 } else {
1940 panic!("expected OrderChange");
1941 }
1942 }
1943
1944 #[rstest]
1945 fn test_parse_order_status_report_duplicate_execution() {
1946 let data = load_test_json("stream/ocm_DUPLICATE_EXECUTION.json");
1947 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
1948
1949 if let StreamMessage::OrderChange(ocm) = &msgs[0] {
1950 let oc = ocm.oc.as_ref().unwrap();
1951 let omc = &oc[0];
1952 let orc = &omc.orc.as_ref().unwrap()[0];
1953 let uo = &orc.uo.as_ref().unwrap()[0];
1954
1955 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1956 let report = parse_order_status_report(
1957 uo,
1958 instrument_id,
1959 AccountId::from("BETFAIR-001"),
1960 parse_millis_timestamp(ocm.pt),
1961 parse_millis_timestamp(ocm.pt),
1962 )
1963 .unwrap();
1964
1965 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1967 assert_eq!(report.order_side, Some(OrderSide::Buy)); assert_eq!(report.filled_qty.as_f64(), 1.12);
1969 } else {
1970 panic!("expected OrderChange");
1971 }
1972 }
1973
1974 #[rstest]
1975 fn test_parse_order_status_report_sp_resting() {
1976 let data = load_test_json("stream/ocm_SP_RESTING.json");
1977 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
1978
1979 if let StreamMessage::OrderChange(ocm) = msg {
1980 let oc = ocm.oc.as_ref().unwrap();
1981 let omc = &oc[0];
1982 let orc = &omc.orc.as_ref().unwrap()[0];
1983 let uo = &orc.uo.as_ref().unwrap()[0];
1984
1985 assert!(is_resting_sp_bet(uo));
1986
1987 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
1988 let report = parse_order_status_report(
1989 uo,
1990 instrument_id,
1991 AccountId::from("BETFAIR-001"),
1992 parse_millis_timestamp(ocm.pt),
1993 parse_millis_timestamp(ocm.pt),
1994 )
1995 .unwrap();
1996
1997 assert_eq!(report.order_status, OrderStatus::Accepted);
2001 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
2002 assert_eq!(report.order_side, Some(OrderSide::Sell)); assert_eq!(report.filled_qty.as_f64(), 0.0);
2004 assert_eq!(report.quantity.as_f64(), 2.0);
2005 } else {
2006 panic!("expected OrderChange");
2007 }
2008 }
2009
2010 fn sp_unmatched_order(size_matched: Decimal, size_lapsed: Decimal) -> UnmatchedOrder {
2011 UnmatchedOrder {
2012 id: "442849719274".to_string(),
2013 p: Decimal::new(10000, 1),
2014 s: Decimal::ZERO,
2015 side: StreamingSide::Back,
2016 status: StreamingOrderStatus::ExecutionComplete,
2017 pt: Some(StreamingPersistenceType::MarketOnClose),
2018 ot: StreamingOrderType::MarketOnClose,
2019 pd: 1789423573000,
2020 bsp: Some(Decimal::new(2, 0)),
2021 rfo: None,
2022 rfs: None,
2023 rc: None,
2024 rac: None,
2025 md: None,
2026 cd: None,
2027 ld: None,
2028 avp: None,
2029 sm: Some(size_matched),
2030 sr: Some(Decimal::ZERO),
2031 sl: Some(size_lapsed),
2032 sc: Some(Decimal::ZERO),
2033 sv: Some(Decimal::ZERO),
2034 lsrc: None,
2035 }
2036 }
2037
2038 #[rstest]
2039 fn test_parse_order_status_report_sp_reconciled() {
2040 let matched = sp_unmatched_order(Decimal::new(2, 0), Decimal::ZERO);
2043 assert!(!is_resting_sp_bet(&matched));
2044
2045 let report = parse_order_status_report(
2046 &matched,
2047 InstrumentId::from("1.262362241-6532924-0.BETFAIR"),
2048 AccountId::from("BETFAIR-001"),
2049 UnixNanos::default(),
2050 UnixNanos::default(),
2051 )
2052 .unwrap();
2053
2054 assert_eq!(report.order_status, OrderStatus::Filled);
2055 assert_eq!(report.filled_qty.as_f64(), 2.0);
2056 assert_eq!(report.quantity.as_f64(), 2.0);
2057
2058 let lapsed = sp_unmatched_order(Decimal::ZERO, Decimal::new(2, 0));
2059 assert!(!is_resting_sp_bet(&lapsed));
2060
2061 let report = parse_order_status_report(
2062 &lapsed,
2063 InstrumentId::from("1.262362241-6532924-0.BETFAIR"),
2064 AccountId::from("BETFAIR-001"),
2065 UnixNanos::default(),
2066 UnixNanos::default(),
2067 )
2068 .unwrap();
2069
2070 assert_eq!(report.order_status, OrderStatus::Canceled);
2071 }
2072
2073 #[rstest]
2074 fn test_parse_order_status_report_multiple_fills() {
2075 let data = load_test_json("stream/ocm_multiple_fills.json");
2076 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2077
2078 if let StreamMessage::OrderChange(ocm) = &msgs[0] {
2079 let oc = ocm.oc.as_ref().unwrap();
2080 let omc = &oc[0];
2081 let orc = &omc.orc.as_ref().unwrap()[0];
2082 let uo = &orc.uo.as_ref().unwrap()[0];
2083
2084 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2085 let report = parse_order_status_report(
2086 uo,
2087 instrument_id,
2088 AccountId::from("BETFAIR-001"),
2089 parse_millis_timestamp(ocm.pt),
2090 parse_millis_timestamp(ocm.pt),
2091 )
2092 .unwrap();
2093
2094 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2096 assert_eq!(report.order_side, Some(OrderSide::Sell)); assert_eq!(report.filled_qty.as_f64(), 16.19);
2098 assert!(report.client_order_id.is_some());
2099 assert!(report.avg_px.is_some());
2100 } else {
2101 panic!("expected OrderChange");
2102 }
2103 }
2104
2105 #[rstest]
2106 fn test_parse_runner_book_snapshot_empty_book() {
2107 let rc = RunnerChange {
2109 id: 12345,
2110 hc: None,
2111 atb: Some(vec![]),
2112 atl: Some(vec![]),
2113 batb: None,
2114 batl: None,
2115 bdatb: None,
2116 bdatl: None,
2117 spb: None,
2118 spl: None,
2119 spn: None,
2120 spf: None,
2121 trd: None,
2122 ltp: None,
2123 tv: None,
2124 };
2125
2126 let result = parse_runner_book_deltas(
2127 make_instrument_id("1.123", 12345, Decimal::ZERO),
2128 &rc,
2129 true,
2130 1000,
2131 UnixNanos::default(),
2132 UnixNanos::default(),
2133 )
2134 .unwrap()
2135 .expect("should produce snapshot deltas");
2136
2137 assert_eq!(result.deltas.len(), 1);
2139 assert_eq!(result.deltas[0].action, BookAction::Clear);
2140 assert!(RecordFlag::F_LAST.matches(result.deltas[0].flags));
2141 assert!(RecordFlag::F_SNAPSHOT.matches(result.deltas[0].flags));
2142 }
2143
2144 #[rstest]
2145 fn test_make_fill_report() {
2146 let instrument_id = make_instrument_id("1.180604981", 1209555, Decimal::ZERO);
2147 let fill = make_fill_report(
2148 AccountId::from("BETFAIR-001"),
2149 instrument_id,
2150 VenueOrderId::from("229430281339"),
2151 TradeId::from("229430281339-0"),
2152 OrderSide::Buy,
2153 Quantity::new(10.0, BETFAIR_QUANTITY_PRECISION),
2154 Price::new(1.1, BETFAIR_PRICE_PRECISION),
2155 Currency::GBP(),
2156 None,
2157 UnixNanos::default(),
2158 UnixNanos::default(),
2159 );
2160
2161 assert_eq!(fill.instrument_id, instrument_id);
2162 assert_eq!(fill.order_side, OrderSide::Buy);
2163 assert_eq!(fill.last_qty.as_f64(), 10.0);
2164 assert_eq!(fill.last_px.as_f64(), 1.1);
2165 assert_eq!(fill.commission.as_f64(), 0.0);
2166 assert_eq!(fill.liquidity_side, LiquiditySide::NoLiquiditySide);
2167 }
2168
2169 #[rstest]
2170 fn test_fill_tracker_single_full_fill() {
2171 let data = load_test_json("stream/ocm_FILLED.json");
2172 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2173
2174 if let StreamMessage::OrderChange(ocm) = msg {
2175 let oc = ocm.oc.as_ref().unwrap();
2176 let omc = &oc[0];
2177 let orc = &omc.orc.as_ref().unwrap()[0];
2178 let uo = &orc.uo.as_ref().unwrap()[0];
2179 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2180 let ts = parse_millis_timestamp(ocm.pt);
2181
2182 let mut tracker = FillTracker::new();
2183 let fill = tracker
2184 .maybe_fill_report(
2185 uo,
2186 uo.s,
2187 instrument_id,
2188 AccountId::from("BETFAIR-001"),
2189 Currency::GBP(),
2190 ts,
2191 ts,
2192 )
2193 .expect("should produce fill");
2194
2195 assert_eq!(fill.last_qty.as_f64(), 10.0);
2196 assert_eq!(fill.last_px.as_f64(), 1.1);
2197 assert_eq!(fill.order_side, OrderSide::Buy);
2198 assert!(fill.client_order_id.is_some());
2199 } else {
2200 panic!("expected OrderChange");
2201 }
2202 }
2203
2204 #[rstest]
2205 fn test_fill_tracker_incremental_fills() {
2206 let data = load_test_json("stream/ocm_multiple_fills.json");
2207 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2208 let instrument_id = make_instrument_id("1.179082386", 50210, Decimal::ZERO);
2209
2210 let mut tracker = FillTracker::new();
2211 let account_id = AccountId::from("BETFAIR-001");
2212 let currency = Currency::GBP();
2213
2214 let uo1 = extract_uo(&msgs[0]);
2216 let ts1 = extract_ts(&msgs[0]);
2217 let fill1 = tracker
2218 .maybe_fill_report(uo1, uo1.s, instrument_id, account_id, currency, ts1, ts1)
2219 .expect("should produce first fill");
2220 assert_eq!(fill1.last_qty.as_f64(), 16.19);
2221 assert_eq!(fill1.last_px.as_f64(), 5.8);
2222
2223 let uo2 = extract_uo(&msgs[1]);
2225 let ts2 = extract_ts(&msgs[1]);
2226 let fill2 = tracker
2227 .maybe_fill_report(uo2, uo2.s, instrument_id, account_id, currency, ts2, ts2)
2228 .expect("should produce second fill");
2229 assert_eq!(fill2.last_qty.as_f64(), 0.77);
2230 assert_eq!(fill2.last_px.as_f64(), 5.8);
2231
2232 let uo3 = extract_uo(&msgs[2]);
2234 let ts3 = extract_ts(&msgs[2]);
2235 let fill3 = tracker
2236 .maybe_fill_report(uo3, uo3.s, instrument_id, account_id, currency, ts3, ts3)
2237 .expect("should produce third fill");
2238 assert_eq!(fill3.last_qty.as_f64(), 0.77);
2239 }
2240
2241 #[rstest]
2242 fn test_fill_tracker_different_price() {
2243 let data = load_test_json("stream/ocm_filled_different_price.json");
2244 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2245
2246 if let StreamMessage::OrderChange(ocm) = msg {
2247 let oc = ocm.oc.as_ref().unwrap();
2248 let omc = &oc[0];
2249 let orc = &omc.orc.as_ref().unwrap()[0];
2250 let uo = &orc.uo.as_ref().unwrap()[0];
2251 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2252 let ts = parse_millis_timestamp(ocm.pt);
2253
2254 let mut tracker = FillTracker::new();
2255 let fill = tracker
2256 .maybe_fill_report(
2257 uo,
2258 uo.s,
2259 instrument_id,
2260 AccountId::from("BETFAIR-001"),
2261 Currency::GBP(),
2262 ts,
2263 ts,
2264 )
2265 .expect("should produce fill");
2266
2267 assert_eq!(fill.last_qty.as_f64(), 20.0);
2269 assert_eq!(fill.last_px.as_f64(), 1.2);
2270 } else {
2271 panic!("expected OrderChange");
2272 }
2273 }
2274
2275 #[rstest]
2276 fn test_fill_tracker_cancel_no_fill() {
2277 let data = load_test_json("stream/ocm_CANCEL.json");
2278 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2279
2280 if let StreamMessage::OrderChange(ocm) = msg {
2281 let oc = ocm.oc.as_ref().unwrap();
2282 let omc = &oc[0];
2283 let orc = &omc.orc.as_ref().unwrap()[0];
2284 let uo = &orc.uo.as_ref().unwrap()[0];
2285 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2286 let ts = parse_millis_timestamp(ocm.pt);
2287
2288 let mut tracker = FillTracker::new();
2289 let result = tracker.maybe_fill_report(
2290 uo,
2291 uo.s,
2292 instrument_id,
2293 AccountId::from("BETFAIR-001"),
2294 Currency::GBP(),
2295 ts,
2296 ts,
2297 );
2298 assert!(result.is_none(), "cancelled order should not produce fill");
2299 } else {
2300 panic!("expected OrderChange");
2301 }
2302 }
2303
2304 #[rstest]
2305 fn test_fill_tracker_lapsed_no_fill() {
2306 let data = load_test_json("stream/ocm_error_fill.json");
2307 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2308
2309 if let StreamMessage::OrderChange(ocm) = msg {
2310 let oc = ocm.oc.as_ref().unwrap();
2311 let omc = &oc[0];
2312 let orc = &omc.orc.as_ref().unwrap()[0];
2313 let uo = &orc.uo.as_ref().unwrap()[0];
2314 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2315 let ts = parse_millis_timestamp(ocm.pt);
2316
2317 let mut tracker = FillTracker::new();
2318 let result = tracker.maybe_fill_report(
2319 uo,
2320 uo.s,
2321 instrument_id,
2322 AccountId::from("BETFAIR-001"),
2323 Currency::GBP(),
2324 ts,
2325 ts,
2326 );
2327 assert!(result.is_none(), "lapsed order should not produce fill");
2328 } else {
2329 panic!("expected OrderChange");
2330 }
2331 }
2332
2333 #[rstest]
2334 fn test_fill_tracker_duplicate_dedup() {
2335 let data = load_test_json("stream/ocm_FILLED.json");
2336 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2337
2338 if let StreamMessage::OrderChange(ocm) = msg {
2339 let oc = ocm.oc.as_ref().unwrap();
2340 let omc = &oc[0];
2341 let orc = &omc.orc.as_ref().unwrap()[0];
2342 let uo = &orc.uo.as_ref().unwrap()[0];
2343 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
2344 let ts = parse_millis_timestamp(ocm.pt);
2345 let account_id = AccountId::from("BETFAIR-001");
2346 let currency = Currency::GBP();
2347
2348 let mut tracker = FillTracker::new();
2349
2350 let fill1 =
2352 tracker.maybe_fill_report(uo, uo.s, instrument_id, account_id, currency, ts, ts);
2353 assert!(fill1.is_some());
2354
2355 let fill2 =
2357 tracker.maybe_fill_report(uo, uo.s, instrument_id, account_id, currency, ts, ts);
2358 assert!(fill2.is_none(), "duplicate fill should be suppressed");
2359 } else {
2360 panic!("expected OrderChange");
2361 }
2362 }
2363
2364 #[rstest]
2365 fn test_fill_tracker_price_back_calculation() {
2366 let data = load_test_json("stream/ocm_multiple_fills.json");
2367 let msgs: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2368 let instrument_id = make_instrument_id("1.179082386", 50210, Decimal::ZERO);
2369 let account_id = AccountId::from("BETFAIR-001");
2370 let currency = Currency::GBP();
2371 let mut tracker = FillTracker::new();
2372
2373 let uo1 = extract_uo(&msgs[0]);
2375 let ts1 = extract_ts(&msgs[0]);
2376 let fill1 = tracker
2377 .maybe_fill_report(uo1, uo1.s, instrument_id, account_id, currency, ts1, ts1)
2378 .unwrap();
2379 assert_eq!(fill1.last_px.as_f64(), 5.8);
2380
2381 let uo2 = extract_uo(&msgs[1]);
2383 let ts2 = extract_ts(&msgs[1]);
2384 let fill2 = tracker
2385 .maybe_fill_report(uo2, uo2.s, instrument_id, account_id, currency, ts2, ts2)
2386 .unwrap();
2387 assert_eq!(fill2.last_px.as_f64(), 5.8);
2388 }
2389
2390 #[rstest]
2391 fn test_has_cancel_quantity() {
2392 let data = load_test_json("stream/ocm_CANCEL.json");
2393 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2394
2395 if let StreamMessage::OrderChange(ocm) = msg {
2396 let uo = &ocm.oc.as_ref().unwrap()[0].orc.as_ref().unwrap()[0]
2397 .uo
2398 .as_ref()
2399 .unwrap()[0];
2400 assert!(has_cancel_quantity(uo));
2401 } else {
2402 panic!("expected OrderChange");
2403 }
2404 }
2405
2406 #[rstest]
2407 fn test_has_cancel_quantity_filled_order() {
2408 let data = load_test_json("stream/ocm_FILLED.json");
2409 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2410
2411 if let StreamMessage::OrderChange(ocm) = msg {
2412 let uo = &ocm.oc.as_ref().unwrap()[0].orc.as_ref().unwrap()[0]
2413 .uo
2414 .as_ref()
2415 .unwrap()[0];
2416 assert!(!has_cancel_quantity(uo));
2417 } else {
2418 panic!("expected OrderChange");
2419 }
2420 }
2421
2422 fn extract_uo(msg: &StreamMessage) -> &UnmatchedOrder {
2423 if let StreamMessage::OrderChange(ocm) = msg {
2424 &ocm.oc.as_ref().unwrap()[0].orc.as_ref().unwrap()[0]
2425 .uo
2426 .as_ref()
2427 .unwrap()[0]
2428 } else {
2429 panic!("expected OrderChange")
2430 }
2431 }
2432
2433 fn extract_ts(msg: &StreamMessage) -> UnixNanos {
2434 if let StreamMessage::OrderChange(ocm) = msg {
2435 parse_millis_timestamp(ocm.pt)
2436 } else {
2437 panic!("expected OrderChange")
2438 }
2439 }
2440
2441 #[rstest]
2442 fn test_parse_race_runner_data_from_fixture() {
2443 let data = load_test_json("stream/rcm_single.json");
2444 let msg = stream_decode(data.as_bytes()).unwrap();
2445
2446 let StreamMessage::RaceChange(rcm) = msg else {
2447 panic!("expected RaceChange");
2448 };
2449
2450 let race = &rcm.rc.as_ref().unwrap()[0];
2451 let rrc = &race.rrc.as_ref().unwrap()[0];
2452 let ts = parse_millis_timestamp(rcm.pt);
2453
2454 let runner = parse_race_runner_data(
2455 race.id.as_deref().unwrap(),
2456 race.mid.as_deref().unwrap(),
2457 rrc,
2458 ts,
2459 ts,
2460 )
2461 .unwrap();
2462
2463 assert_eq!(runner.race_id, "28587288.1650");
2464 assert_eq!(runner.market_id, "1.1234567");
2465 assert_eq!(runner.selection_id, 7390417);
2466 assert!((runner.latitude - 51.4189543).abs() < 1e-6);
2467 assert!((runner.longitude - (-0.4058491)).abs() < 1e-6);
2468 assert!((runner.speed - 17.8).abs() < 1e-6);
2469 assert!((runner.progress - 2051.0).abs() < 1e-6);
2470 assert!((runner.stride_frequency - 2.07).abs() < 1e-6);
2471 }
2472
2473 #[rstest]
2474 fn test_parse_race_progress_from_fixture() {
2475 let data = load_test_json("stream/rcm_single.json");
2476 let msg = stream_decode(data.as_bytes()).unwrap();
2477
2478 let StreamMessage::RaceChange(rcm) = msg else {
2479 panic!("expected RaceChange");
2480 };
2481
2482 let race = &rcm.rc.as_ref().unwrap()[0];
2483 let rpc = race.rpc.as_ref().unwrap();
2484 let ts = parse_millis_timestamp(rcm.pt);
2485
2486 let progress = parse_race_progress(
2487 race.id.as_deref().unwrap(),
2488 race.mid.as_deref().unwrap(),
2489 rpc,
2490 ts,
2491 ts,
2492 );
2493
2494 assert_eq!(progress.race_id, "28587288.1650");
2495 assert_eq!(progress.market_id, "1.1234567");
2496 assert_eq!(progress.gate_name, "1f");
2497 assert!((progress.sectional_time - 10.6).abs() < 1e-6);
2498 assert!((progress.running_time - 46.7).abs() < 1e-6);
2499 assert!((progress.speed - 17.8).abs() < 1e-6);
2500 assert!((progress.progress - 87.5).abs() < 1e-6);
2501
2502 let order: Vec<i64> = serde_json::from_str(&progress.order).unwrap();
2503 assert_eq!(order, vec![7390417, 5600338, 11527189, 6395118, 8706072]);
2504
2505 let jumps: Vec<serde_json::Value> = serde_json::from_str(&progress.jumps).unwrap();
2506 assert_eq!(jumps.len(), 2);
2507 assert_eq!(jumps[0]["J"], 2);
2508 }
2509
2510 #[rstest]
2511 fn test_parse_cricket_match_from_fixture() {
2512 let data = load_test_json("stream/ccm_single.json");
2513 let msg = stream_decode(data.as_bytes()).unwrap();
2514
2515 let StreamMessage::CricketChange(ccm) = msg else {
2516 panic!("expected CricketChange");
2517 };
2518
2519 let change = &ccm.cc.as_ref().unwrap()[0];
2520 let ts = parse_millis_timestamp(ccm.pt);
2521 let cricket = parse_cricket_match(change, ts, ts).unwrap();
2522
2523 assert_eq!(cricket.event_id, "35741575");
2524 assert_eq!(cricket.market_id, "1.259334639");
2525 let stats: serde_json::Value = serde_json::from_str(&cricket.match_stats).unwrap();
2526 assert_eq!(stats["inningsStats"][0]["inningsRuns"], 100);
2527 }
2528
2529 #[rstest]
2530 #[case(None, Some("1.259334639".to_string()))]
2531 #[case(Some("35741575".to_string()), None)]
2532 fn test_parse_cricket_match_missing_required_id_returns_none(
2533 #[case] event_id: Option<String>,
2534 #[case] market_id: Option<String>,
2535 ) {
2536 let change = CricketChange {
2537 event_id,
2538 market_id,
2539 fixture_info: None,
2540 home_team: None,
2541 away_team: None,
2542 match_stats: Some(serde_json::json!({"inningsStats": []})),
2543 incident_list_wrapper: None,
2544 };
2545 let ts = UnixNanos::from(1_000_000_000u64);
2546 let result = parse_cricket_match(&change, ts, ts);
2547
2548 assert!(result.is_none());
2549 }
2550
2551 #[rstest]
2552 fn test_parse_race_runner_data_multi_runner() {
2553 let data = load_test_json("stream/rcm_multi_runner.json");
2554 let msg = stream_decode(data.as_bytes()).unwrap();
2555
2556 let StreamMessage::RaceChange(rcm) = msg else {
2557 panic!("expected RaceChange");
2558 };
2559
2560 let race = &rcm.rc.as_ref().unwrap()[0];
2561 let ts = parse_millis_timestamp(rcm.pt);
2562 let race_id = race.id.as_deref().unwrap();
2563 let market_id = race.mid.as_deref().unwrap();
2564
2565 let runners: Vec<_> = race
2566 .rrc
2567 .as_ref()
2568 .unwrap()
2569 .iter()
2570 .filter_map(|rrc| parse_race_runner_data(race_id, market_id, rrc, ts, ts))
2571 .collect();
2572
2573 assert_eq!(runners.len(), 5);
2574 assert_eq!(runners[0].selection_id, 35467839);
2575 assert_eq!(runners[4].selection_id, 41694785);
2576 assert!((runners[0].speed - 16.33).abs() < 1e-6);
2577 assert!((runners[4].speed - 17.11).abs() < 1e-6);
2578 }
2579
2580 #[rstest]
2581 fn test_parse_race_runner_data_missing_id_returns_none() {
2582 let rrc = RaceRunnerChange {
2583 ft: Some(1000),
2584 id: None,
2585 lat: Some(51.0),
2586 lng: Some(-0.4),
2587 spd: Some(15.0),
2588 prg: Some(500.0),
2589 sfq: Some(2.0),
2590 };
2591 let ts = UnixNanos::from(1_000_000_000u64);
2592 let result = parse_race_runner_data("race1", "market1", &rrc, ts, ts);
2593 assert!(result.is_none());
2594 }
2595
2596 #[rstest]
2597 fn test_parse_race_runner_data_absent_fields_are_nan() {
2598 let rrc = RaceRunnerChange {
2599 ft: None,
2600 id: Some(12345),
2601 lat: None,
2602 lng: None,
2603 spd: None,
2604 prg: None,
2605 sfq: None,
2606 };
2607 let ts = UnixNanos::from(1_000_000_000u64);
2608 let runner = parse_race_runner_data("race1", "market1", &rrc, ts, ts).unwrap();
2609 assert!(runner.latitude.is_nan());
2610 assert!(runner.longitude.is_nan());
2611 assert!(runner.speed.is_nan());
2612 assert!(runner.progress.is_nan());
2613 assert!(runner.stride_frequency.is_nan());
2614 }
2615
2616 #[rstest]
2617 fn test_parse_race_progress_absent_fields() {
2618 let rpc = RaceProgressChange {
2619 ft: None,
2620 g: None,
2621 st: None,
2622 rt: None,
2623 spd: None,
2624 prg: None,
2625 ord: None,
2626 jumps: None,
2627 };
2628 let ts = UnixNanos::from(1_000_000_000u64);
2629 let progress = parse_race_progress("race1", "market1", &rpc, ts, ts);
2630 assert_eq!(progress.gate_name, "");
2631 assert!(progress.sectional_time.is_nan());
2632 assert!(progress.running_time.is_nan());
2633 assert_eq!(progress.order, "");
2634 assert_eq!(progress.jumps, "");
2635 }
2636
2637 fn runner_change_with_ticker(
2638 id: u64,
2639 ltp: Option<Decimal>,
2640 tv: Option<Decimal>,
2641 spn: Option<Decimal>,
2642 spf: Option<Decimal>,
2643 ) -> RunnerChange {
2644 RunnerChange {
2645 id,
2646 hc: None,
2647 atb: None,
2648 atl: None,
2649 batb: None,
2650 batl: None,
2651 bdatb: None,
2652 bdatl: None,
2653 spb: None,
2654 spl: None,
2655 spn,
2656 spf,
2657 trd: None,
2658 ltp,
2659 tv,
2660 }
2661 }
2662
2663 #[rstest]
2664 fn test_parse_betfair_ticker_all_fields() {
2665 let rc = runner_change_with_ticker(
2666 9249757,
2667 Some(Decimal::new(55, 1)),
2668 Some(Decimal::new(189032, 2)),
2669 Some(Decimal::new(568, 2)),
2670 Some(Decimal::new(573, 2)),
2671 );
2672 let ts = UnixNanos::from(1_000_000_000u64);
2673 let instrument_id = make_instrument_id("1.185781465", 9249757, Decimal::ZERO);
2674
2675 let ticker = parse_betfair_ticker(instrument_id, &rc, ts, ts).unwrap();
2676
2677 assert_eq!(ticker.instrument_id, instrument_id);
2678 assert_decimal_option_eq(ticker.last_traded_price, Decimal::new(55, 1));
2679 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(189032, 2));
2680 assert_decimal_option_eq(ticker.starting_price_near, Decimal::new(568, 2));
2681 assert_decimal_option_eq(ticker.starting_price_far, Decimal::new(573, 2));
2682 }
2683
2684 #[rstest]
2685 fn test_parse_betfair_ticker_partial_fields() {
2686 let rc = runner_change_with_ticker(
2687 9249757,
2688 Some(Decimal::new(55, 1)),
2689 Some(Decimal::new(189032, 2)),
2690 None,
2691 None,
2692 );
2693 let ts = UnixNanos::from(1_000_000_000u64);
2694 let instrument_id = make_instrument_id("1.185781465", 9249757, Decimal::ZERO);
2695
2696 let ticker = parse_betfair_ticker(instrument_id, &rc, ts, ts).unwrap();
2697
2698 assert_decimal_option_eq(ticker.last_traded_price, Decimal::new(55, 1));
2699 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(189032, 2));
2700 assert!(ticker.starting_price_near.is_none());
2701 assert!(ticker.starting_price_far.is_none());
2702 }
2703
2704 #[rstest]
2705 fn test_parse_betfair_ticker_no_fields_returns_none() {
2706 let rc = runner_change_with_ticker(9249757, None, None, None, None);
2707 let ts = UnixNanos::from(1_000_000_000u64);
2708 let instrument_id = make_instrument_id("1.185781465", 9249757, Decimal::ZERO);
2709
2710 assert!(parse_betfair_ticker(instrument_id, &rc, ts, ts).is_none());
2711 }
2712
2713 #[rstest]
2714 fn test_parse_betfair_ticker_only_tv() {
2715 let rc =
2716 runner_change_with_ticker(40273293, None, Some(Decimal::new(320115, 2)), None, None);
2717 let ts = UnixNanos::from(1_000_000_000u64);
2718 let instrument_id = make_instrument_id("1.185781465", 40273293, Decimal::ZERO);
2719
2720 let ticker = parse_betfair_ticker(instrument_id, &rc, ts, ts).unwrap();
2721
2722 assert!(ticker.last_traded_price.is_none());
2723 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(320115, 2));
2724 assert!(ticker.starting_price_near.is_none());
2725 assert!(ticker.starting_price_far.is_none());
2726 }
2727
2728 #[rstest]
2729 fn test_parse_betfair_ticker_from_fixture() {
2730 let data = load_test_json("stream/mcm_BSP_settled.json");
2731 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2732
2733 if let StreamMessage::MarketChange(mcm) = msg {
2734 let mc = mcm.mc.as_ref().unwrap();
2735 let change = &mc[0];
2736 let rc_list = change.rc.as_ref().unwrap();
2737
2738 let rc = rc_list.iter().find(|r| r.id == 9249757).unwrap();
2740 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
2741 let ts = parse_millis_timestamp(mcm.pt);
2742
2743 let ticker = parse_betfair_ticker(instrument_id, rc, ts, ts).unwrap();
2744
2745 assert_decimal_option_eq(ticker.last_traded_price, Decimal::new(55, 1));
2746 assert_decimal_option_eq(ticker.traded_volume, Decimal::new(189032, 2));
2747 assert_decimal_option_eq(ticker.starting_price_near, Decimal::new(568, 2));
2748 assert_decimal_option_eq(ticker.starting_price_far, Decimal::new(573, 2));
2749
2750 let rc2 = rc_list.iter().find(|r| r.id == 40273293).unwrap();
2752 let instrument_id2 = make_instrument_id(&change.id, rc2.id, Decimal::ZERO);
2753 let ticker2 = parse_betfair_ticker(instrument_id2, rc2, ts, ts).unwrap();
2754
2755 assert_decimal_option_eq(ticker2.last_traded_price, Decimal::new(21, 1));
2756 assert_decimal_option_eq(ticker2.traded_volume, Decimal::new(320115, 2));
2757 assert!(ticker2.starting_price_near.is_none());
2758 assert!(ticker2.starting_price_far.is_none());
2759
2760 let rc3 = rc_list.iter().find(|r| r.id == 23678734).unwrap();
2762 let instrument_id3 = make_instrument_id(&change.id, rc3.id, Decimal::ZERO);
2763 let ticker3 = parse_betfair_ticker(instrument_id3, rc3, ts, ts).unwrap();
2764
2765 assert!(ticker3.last_traded_price.is_none());
2766 assert_decimal_option_eq(ticker3.traded_volume, Decimal::ZERO);
2767 } else {
2768 panic!("Expected MarketChange");
2769 }
2770 }
2771
2772 #[rstest]
2773 fn test_parse_betfair_starting_prices_from_fixture() {
2774 let data = load_test_json("stream/mcm_BSP_settled.json");
2775 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2776
2777 if let StreamMessage::MarketChange(mcm) = msg {
2778 let mc = mcm.mc.as_ref().unwrap();
2779 let def = mc[0].market_definition.as_ref().unwrap();
2780 let ts = parse_millis_timestamp(mcm.pt);
2781
2782 let prices = parse_betfair_starting_prices(&mc[0].id, def, ts, ts);
2783
2784 assert_eq!(prices.len(), 3);
2786
2787 let bsp_map: std::collections::HashMap<String, Decimal> = prices
2788 .iter()
2789 .map(|p| (p.instrument_id.to_string(), p.bsp))
2790 .collect();
2791
2792 let id_winner = make_instrument_id("1.185781465", 9249757, Decimal::ZERO).to_string();
2793 let id_placed = make_instrument_id("1.185781465", 40273293, Decimal::ZERO).to_string();
2794 let id_loser = make_instrument_id("1.185781465", 11120000, Decimal::ZERO).to_string();
2795
2796 assert_eq!(bsp_map[&id_winner], Decimal::new(573, 2));
2797 assert_eq!(bsp_map[&id_placed], Decimal::new(214, 2));
2798 assert_eq!(bsp_map[&id_loser], Decimal::new(2856, 2));
2799 } else {
2800 panic!("Expected MarketChange");
2801 }
2802 }
2803
2804 #[rstest]
2805 fn test_parse_betfair_starting_prices_no_runners() {
2806 let def = MarketDefinition {
2807 runners: None,
2808 bet_delay: None,
2809 betting_type: None,
2810 bsp_market: None,
2811 bsp_reconciled: None,
2812 competition_id: None,
2813 competition_name: None,
2814 complete: None,
2815 country_code: None,
2816 cross_matching: None,
2817 discount_allowed: None,
2818 each_way_divisor: None,
2819 event_id: None,
2820 event_name: None,
2821 event_type_id: None,
2822 event_type_name: None,
2823 in_play: None,
2824 line_interval: None,
2825 line_max_unit: None,
2826 line_min_unit: None,
2827 market_base_rate: None,
2828 market_id: None,
2829 market_name: None,
2830 market_time: None,
2831 market_type: None,
2832 number_of_active_runners: None,
2833 number_of_winners: None,
2834 open_date: None,
2835 persistence_enabled: None,
2836 price_ladder_definition: None,
2837 race_type: None,
2838 regulators: None,
2839 runners_voidable: None,
2840 settled_time: None,
2841 status: None,
2842 suspend_time: None,
2843 timezone: None,
2844 turn_in_play_enabled: None,
2845 venue: None,
2846 version: None,
2847 };
2848 let ts = UnixNanos::from(1_000_000_000u64);
2849
2850 let prices = parse_betfair_starting_prices("1.12345", &def, ts, ts);
2851
2852 assert!(prices.is_empty());
2853 }
2854
2855 #[rstest]
2856 fn test_parse_bsp_book_deltas_from_fixture() {
2857 let data = load_test_json("stream/mcm_BSP.json");
2858 let messages: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2859
2860 let mcm = messages
2862 .iter()
2863 .find_map(|m| match m {
2864 StreamMessage::MarketChange(mcm) => {
2865 let mc = mcm.mc.as_ref()?;
2866 let has_spb = mc.iter().any(|c| {
2867 c.rc.as_ref()
2868 .is_some_and(|rcs| rcs.iter().any(|r| r.spb.is_some()))
2869 });
2870
2871 if has_spb { Some(mcm) } else { None }
2872 }
2873 _ => None,
2874 })
2875 .expect("fixture should contain MCM with spb data");
2876
2877 let mc = mcm.mc.as_ref().unwrap();
2878 let change = &mc[0];
2879 let rc_list = change.rc.as_ref().unwrap();
2880
2881 let rc = rc_list.iter().find(|r| r.id == 9249757).unwrap();
2883 let instrument_id = make_instrument_id(&change.id, rc.id, Decimal::ZERO);
2884 let ts = parse_millis_timestamp(mcm.pt);
2885
2886 let deltas = parse_bsp_book_deltas(instrument_id, rc, ts, ts);
2887
2888 let spb_count = rc.spb.as_ref().unwrap().len();
2889 let spl_count = rc.spl.as_ref().unwrap().len();
2890 assert_eq!(deltas.len(), spb_count + spl_count);
2891
2892 assert_eq!(deltas[0].side, OrderSide::Sell);
2894 assert_eq!(deltas[0].price, Decimal::new(1000, 0));
2895 assert_eq!(deltas[0].size, Decimal::new(3338, 2));
2896 assert_eq!(deltas[0].action, BookAction::Update);
2897
2898 let spl_start = spb_count;
2900 assert_eq!(deltas[spl_start].side, OrderSide::Buy);
2901 assert_eq!(deltas[spl_start].price, Decimal::new(7, 0));
2902 assert_eq!(deltas[spl_start].size, Decimal::new(10, 0));
2903 }
2904
2905 #[rstest]
2906 fn test_parse_bsp_book_deltas_zero_volume_is_delete() {
2907 let rc = RunnerChange {
2908 id: 12345,
2909 hc: None,
2910 atb: None,
2911 atl: None,
2912 batb: None,
2913 batl: None,
2914 bdatb: None,
2915 bdatl: None,
2916 spb: Some(vec![PV {
2917 price: Decimal::new(50, 1),
2918 volume: Decimal::ZERO,
2919 }]),
2920 spl: None,
2921 spn: None,
2922 spf: None,
2923 trd: None,
2924 ltp: None,
2925 tv: None,
2926 };
2927 let ts = UnixNanos::from(1_000_000_000u64);
2928 let instrument_id = make_instrument_id("1.12345", 12345, Decimal::ZERO);
2929
2930 let deltas = parse_bsp_book_deltas(instrument_id, &rc, ts, ts);
2931
2932 assert_eq!(deltas.len(), 1);
2933 assert_eq!(deltas[0].action, BookAction::Delete);
2934 assert_eq!(deltas[0].price, Decimal::new(5, 0));
2935 assert_eq!(deltas[0].size, Decimal::ZERO);
2936 }
2937
2938 #[rstest]
2939 fn test_parse_bsp_book_deltas_no_spb_spl_returns_empty() {
2940 let rc = runner_change_with_ticker(12345, None, None, None, None);
2941 let ts = UnixNanos::from(1_000_000_000u64);
2942 let instrument_id = make_instrument_id("1.12345", 12345, Decimal::ZERO);
2943
2944 let deltas = parse_bsp_book_deltas(instrument_id, &rc, ts, ts);
2945
2946 assert!(deltas.is_empty());
2947 }
2948
2949 #[rstest]
2950 fn test_parse_instrument_closes_from_fixture() {
2951 let data = load_test_json("stream/mcm_BSP_settled.json");
2952 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
2953
2954 if let StreamMessage::MarketChange(mcm) = msg {
2955 let mc = mcm.mc.as_ref().unwrap();
2956 let def = mc[0].market_definition.as_ref().unwrap();
2957 let ts = parse_millis_timestamp(mcm.pt);
2958
2959 let closes = parse_instrument_closes(&mc[0].id, def, ts, ts);
2960
2961 assert_eq!(closes.len(), 4);
2963
2964 let close_map: std::collections::HashMap<String, Price> = closes
2965 .iter()
2966 .map(|c| (c.instrument_id.to_string(), c.close_price))
2967 .collect();
2968
2969 let id_winner = make_instrument_id("1.185781465", 9249757, Decimal::ZERO).to_string();
2970 let id_placed = make_instrument_id("1.185781465", 40273293, Decimal::ZERO).to_string();
2971 let id_loser = make_instrument_id("1.185781465", 11120000, Decimal::ZERO).to_string();
2972 let id_removed = make_instrument_id("1.185781465", 37433527, Decimal::ZERO).to_string();
2973
2974 assert_eq!(close_map[&id_winner], Price::from("1.00"));
2975 assert_eq!(close_map[&id_placed], Price::from("1.00"));
2976 assert_eq!(close_map[&id_loser], Price::from("0.00"));
2977 assert_eq!(close_map[&id_removed], Price::from("0.00"));
2978 } else {
2979 panic!("Expected MarketChange");
2980 }
2981 }
2982
2983 #[rstest]
2984 fn test_parse_instrument_closes_active_runners_excluded() {
2985 let data = load_test_json("stream/mcm_BSP.json");
2986 let messages: Vec<StreamMessage> = serde_json::from_str(&data).unwrap();
2987
2988 let mcm = messages
2990 .iter()
2991 .find_map(|m| match m {
2992 StreamMessage::MarketChange(mcm) => {
2993 let mc = mcm.mc.as_ref()?;
2994 mc.iter()
2995 .find(|c| c.market_definition.is_some())
2996 .map(|_| mcm)
2997 }
2998 _ => None,
2999 })
3000 .expect("fixture should contain MCM with market definition");
3001
3002 let mc = mcm.mc.as_ref().unwrap();
3003 let change = mc.iter().find(|c| c.market_definition.is_some()).unwrap();
3004 let def = change.market_definition.as_ref().unwrap();
3005 let ts = parse_millis_timestamp(mcm.pt);
3006
3007 let closes = parse_instrument_closes(&change.id, def, ts, ts);
3008
3009 assert!(
3010 closes.is_empty(),
3011 "Active runners should not produce close events, found {}",
3012 closes.len()
3013 );
3014 }
3015
3016 fn make_test_uo(
3017 bet_id: &str,
3018 size: Decimal,
3019 sm: Option<Decimal>,
3020 avp: Option<Decimal>,
3021 ) -> UnmatchedOrder {
3022 UnmatchedOrder {
3023 id: bet_id.to_string(),
3024 p: Decimal::new(25, 1),
3025 s: size,
3026 side: StreamingSide::Back,
3027 status: StreamingOrderStatus::Executable,
3028 pt: Some(StreamingPersistenceType::Lapse),
3029 ot: StreamingOrderType::Limit,
3030 pd: 1616568581000,
3031 bsp: None,
3032 rfo: None,
3033 rfs: None,
3034 rc: None,
3035 rac: None,
3036 md: None,
3037 cd: None,
3038 ld: None,
3039 avp,
3040 sm,
3041 sr: None,
3042 sl: None,
3043 sc: None,
3044 sv: None,
3045 lsrc: None,
3046 }
3047 }
3048
3049 #[rstest]
3050 fn test_make_trade_id_normalizes_float_tail_sm() {
3051 let uo = make_test_uo(
3052 "430069890490",
3053 Decimal::new(4287, 2),
3054 Some(Decimal::new(4_287_000_000_000_001, 14)),
3055 Some(Decimal::new(25, 1)),
3056 );
3057
3058 let trade_id = make_trade_id(&uo);
3059
3060 assert_eq!(trade_id.as_str(), "430069890490-42.87");
3061 }
3062
3063 #[rstest]
3064 fn test_fill_tracker_sync_order_prevents_duplicate_fill() {
3065 let mut tracker = FillTracker::new();
3066
3067 tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
3069
3070 let uo = make_test_uo(
3071 "123456",
3072 Decimal::new(20, 0),
3073 Some(Decimal::new(10, 0)),
3074 Some(Decimal::new(25, 1)),
3075 );
3076
3077 let instrument_id = InstrumentId::from("1.234567-123456-0.0.BETFAIR");
3078 let account_id = AccountId::from("BETFAIR-001");
3079 let currency = Currency::from("GBP");
3080 let ts = UnixNanos::default();
3081
3082 let result =
3084 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
3085 assert!(
3086 result.is_none(),
3087 "should not emit fill for already-synced qty"
3088 );
3089 }
3090
3091 #[rstest]
3092 fn test_fill_tracker_sync_order_is_monotonic() {
3093 let mut tracker = FillTracker::new();
3094
3095 tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
3097
3098 tracker.sync_order("123456", Decimal::new(5, 0), Decimal::new(20, 1));
3100
3101 let uo = make_test_uo(
3103 "123456",
3104 Decimal::new(20, 0),
3105 Some(Decimal::new(15, 0)),
3106 Some(Decimal::new(25, 1)),
3107 );
3108 let result = tracker.maybe_fill_report(
3109 &uo,
3110 uo.s,
3111 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3112 AccountId::from("BETFAIR-001"),
3113 Currency::from("GBP"),
3114 UnixNanos::default(),
3115 UnixNanos::default(),
3116 );
3117 let fill = result.expect("should emit incremental fill");
3118 assert_eq!(fill.last_qty, Quantity::from("5.00"));
3119 }
3120
3121 #[rstest]
3122 fn test_fill_tracker_sync_order_allows_incremental_fill() {
3123 let mut tracker = FillTracker::new();
3124
3125 tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
3127
3128 let uo = make_test_uo(
3129 "123456",
3130 Decimal::new(20, 0),
3131 Some(Decimal::new(15, 0)),
3132 Some(Decimal::new(26, 1)),
3133 );
3134
3135 let instrument_id = InstrumentId::from("1.234567-123456-0.0.BETFAIR");
3136 let account_id = AccountId::from("BETFAIR-001");
3137 let currency = Currency::from("GBP");
3138 let ts = UnixNanos::default();
3139
3140 let result =
3142 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
3143 assert!(result.is_some(), "should emit fill for new matched qty");
3144 let fill = result.unwrap();
3145 assert_eq!(fill.last_qty, Quantity::from("5.00"));
3146 }
3147
3148 #[rstest]
3149 fn test_fill_tracker_seed_published_trade_ids_blocks_replay() {
3150 let mut tracker = FillTracker::new();
3151
3152 let uo_for_id = make_test_uo(
3153 "123456",
3154 Decimal::new(20, 0),
3155 Some(Decimal::new(10, 0)),
3156 Some(Decimal::new(25, 1)),
3157 );
3158 let trade_id = make_trade_id(&uo_for_id);
3159
3160 tracker.seed_published_trade_ids([trade_id.to_string()]);
3163
3164 let result = tracker.maybe_fill_report(
3165 &uo_for_id,
3166 uo_for_id.s,
3167 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3168 AccountId::from("BETFAIR-001"),
3169 Currency::from("GBP"),
3170 UnixNanos::default(),
3171 UnixNanos::default(),
3172 );
3173
3174 assert!(
3175 result.is_none(),
3176 "seeded trade-id should suppress duplicate fill",
3177 );
3178
3179 let next_update = make_test_uo(
3180 "123456",
3181 Decimal::new(20, 0),
3182 Some(Decimal::new(15, 0)),
3183 Some(Decimal::new(25, 1)),
3184 );
3185 let next_fill = tracker
3186 .maybe_fill_report(
3187 &next_update,
3188 next_update.s,
3189 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3190 AccountId::from("BETFAIR-001"),
3191 Currency::from("GBP"),
3192 UnixNanos::default(),
3193 UnixNanos::default(),
3194 )
3195 .expect("later cumulative state should emit only the new delta");
3196 assert_eq!(next_fill.last_qty, Quantity::from("5.00"));
3197 }
3198
3199 #[rstest]
3200 fn test_fill_tracker_accepts_sm_float_tail_at_order_quantity() {
3201 let mut tracker = FillTracker::new();
3202 let uo = make_test_uo(
3203 "430069890490",
3204 Decimal::new(4287, 2),
3205 Some(Decimal::new(4_287_000_000_000_001, 14)),
3206 Some(Decimal::new(25, 1)),
3207 );
3208
3209 let result = tracker.maybe_fill_report(
3210 &uo,
3211 uo.s,
3212 InstrumentId::from("1.234567-430069890490-0.0.BETFAIR"),
3213 AccountId::from("BETFAIR-001"),
3214 Currency::from("GBP"),
3215 UnixNanos::default(),
3216 UnixNanos::default(),
3217 );
3218
3219 let fill = result.expect("float tail at order quantity should emit fill");
3220 assert_eq!(fill.last_qty, Quantity::from("42.87"));
3221 assert_eq!(fill.trade_id.as_str(), "430069890490-42.87");
3222
3223 let duplicate = tracker.maybe_fill_report(
3224 &uo,
3225 uo.s,
3226 InstrumentId::from("1.234567-430069890490-0.0.BETFAIR"),
3227 AccountId::from("BETFAIR-001"),
3228 Currency::from("GBP"),
3229 UnixNanos::default(),
3230 UnixNanos::default(),
3231 );
3232 assert!(
3233 duplicate.is_none(),
3234 "same normalized sm should not emit a duplicate fill"
3235 );
3236 }
3237
3238 #[rstest]
3239 fn test_fill_tracker_normalized_duplicate_updates_average_price_anchor() {
3240 let mut tracker = FillTracker::new();
3241
3242 let first = tracker.advance_cumulative_fill(
3243 "123456",
3244 Decimal::new(10, 0),
3245 Some(Decimal::new(20, 1)),
3246 Decimal::new(20, 1),
3247 );
3248 let normalized_duplicate = tracker.advance_cumulative_fill(
3249 "123456",
3250 Decimal::new(10001, 3),
3251 Some(Decimal::new(30, 1)),
3252 Decimal::new(30, 1),
3253 );
3254 let next = tracker
3255 .advance_cumulative_fill(
3256 "123456",
3257 Decimal::new(11, 0),
3258 Some(Decimal::new(30, 1)),
3259 Decimal::new(30, 1),
3260 )
3261 .expect("new cumulative quantity should emit a fill");
3262
3263 let first = first.expect("first fill");
3264 let restatement = normalized_duplicate.expect("changed average restates the surviving lot");
3265 assert_eq!(restatement.0, first.0);
3266 assert_eq!(restatement.1, Quantity::from("10.00"));
3267 assert_eq!(restatement.2, Price::from("3.00"));
3268 assert_eq!(next.1, Quantity::from("1.00"));
3269 assert_eq!(next.2, Price::from("3.00"));
3270 }
3271
3272 #[rstest]
3273 fn test_fill_tracker_changed_average_with_two_lots_emits_nothing() {
3274 let mut tracker = FillTracker::new();
3275 let first = tracker
3276 .advance_cumulative_fill(
3277 "bet",
3278 Decimal::from(2),
3279 Some(Decimal::from(12)),
3280 Decimal::from(12),
3281 )
3282 .expect("first lot");
3283 let second = tracker
3284 .advance_cumulative_fill(
3285 "bet",
3286 Decimal::from(4),
3287 Some(Decimal::from(10)),
3288 Decimal::from(10),
3289 )
3290 .expect("second lot");
3291 let restatement = tracker.advance_cumulative_fill(
3292 "bet",
3293 Decimal::from(4),
3294 Some(Decimal::new(900, 2)),
3295 Decimal::from(12),
3296 );
3297 let later = tracker
3298 .advance_cumulative_fill(
3299 "bet",
3300 Decimal::from(6),
3301 Some(Decimal::from(8)),
3302 Decimal::from(12),
3303 )
3304 .expect("later fill uses the corrected average");
3305
3306 assert_ne!(first.0, second.0);
3307 assert!(restatement.is_none());
3308 assert_eq!(later.1, Quantity::from("2.00"));
3310 assert_eq!(later.2, Price::from("6.00"));
3311 }
3312
3313 #[rstest]
3314 fn test_fill_tracker_void_with_new_average_does_not_restate_price() {
3315 let mut tracker = FillTracker::new();
3316 tracker
3317 .advance_cumulative_fill(
3318 "bet",
3319 Decimal::from(2),
3320 Some(Decimal::from(12)),
3321 Decimal::from(12),
3322 )
3323 .expect("fill");
3324 let restatement = tracker.advance_cumulative_fill_with_voids(
3325 "bet",
3326 Decimal::from(2),
3327 Decimal::from(1),
3328 Some(Decimal::new(947, 2)),
3329 Decimal::from(12),
3330 );
3331
3332 assert!(restatement.is_none());
3333 }
3334
3335 #[rstest]
3336 fn test_fill_tracker_post_void_fill_uses_restated_accounting_price() {
3337 let mut tracker = FillTracker::new();
3338 let bet_id = "bet-void-restate";
3339 tracker
3340 .advance_cumulative_fill(
3341 bet_id,
3342 Decimal::from(2),
3343 Some(Decimal::from(12)),
3344 Decimal::from(12),
3345 )
3346 .expect("fill");
3347
3348 let voided = tracker.maybe_fill_voids(&UnmatchedOrder {
3349 id: bet_id.to_string(),
3350 sv: Some(Decimal::from(1)),
3351 ..make_test_uo(bet_id, Decimal::from(2), Some(Decimal::from(1)), None)
3352 });
3353
3354 let restatement = tracker
3355 .advance_cumulative_fill_with_voids(
3356 bet_id,
3357 Decimal::from(2),
3358 Decimal::from(1),
3359 Some(Decimal::new(947, 2)),
3360 Decimal::from(12),
3361 )
3362 .expect("one surviving lot restates");
3363 let later = tracker
3364 .advance_cumulative_fill_with_voids(
3365 bet_id,
3366 Decimal::from(3),
3367 Decimal::from(1),
3368 Some(Decimal::from(10)),
3369 Decimal::from(12),
3370 )
3371 .expect("later fill");
3372
3373 assert_eq!(voided[0].last_px, Price::from("12.00"));
3374 assert_eq!(restatement.2, Price::from("9.47"));
3375 assert_eq!(later.2, Price::from("10.53"));
3376 }
3377
3378 #[rstest]
3379 fn test_fill_tracker_overfill_rejected() {
3380 let mut tracker = FillTracker::new();
3381
3382 let uo = make_test_uo(
3384 "999001",
3385 Decimal::new(20, 0),
3386 Some(Decimal::new(30, 0)),
3387 Some(Decimal::new(25, 1)),
3388 );
3389
3390 let instrument_id = InstrumentId::from("1.234567-999001-0.0.BETFAIR");
3391 let account_id = AccountId::from("BETFAIR-001");
3392 let currency = Currency::from("GBP");
3393 let ts = UnixNanos::default();
3394
3395 let result =
3396 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
3397 assert!(
3398 result.is_none(),
3399 "overfill (sm > order_qty) should be rejected"
3400 );
3401 }
3402
3403 #[rstest]
3404 fn test_fill_tracker_zero_sm_returns_none() {
3405 let mut tracker = FillTracker::new();
3406
3407 let uo = make_test_uo("999002", Decimal::new(10, 0), Some(Decimal::ZERO), None);
3408
3409 let instrument_id = InstrumentId::from("1.234567-999002-0.0.BETFAIR");
3410 let result = tracker.maybe_fill_report(
3411 &uo,
3412 uo.s,
3413 instrument_id,
3414 AccountId::from("BETFAIR-001"),
3415 Currency::from("GBP"),
3416 UnixNanos::default(),
3417 UnixNanos::default(),
3418 );
3419 assert!(result.is_none(), "zero sm should not produce a fill");
3420 }
3421
3422 #[rstest]
3423 fn test_fill_tracker_no_avp_uses_order_price() {
3424 let data = load_test_json("stream/ocm_FILLED_no_avp.json");
3425 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
3426
3427 if let StreamMessage::OrderChange(ocm) = msg {
3428 let oc = ocm.oc.as_ref().unwrap();
3429 let omc = &oc[0];
3430 let orc = &omc.orc.as_ref().unwrap()[0];
3431 let uo = &orc.uo.as_ref().unwrap()[0];
3432 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
3433 let ts = parse_millis_timestamp(ocm.pt);
3434
3435 let mut tracker = FillTracker::new();
3436 let fill = tracker
3437 .maybe_fill_report(
3438 uo,
3439 uo.s,
3440 instrument_id,
3441 AccountId::from("BETFAIR-001"),
3442 Currency::GBP(),
3443 ts,
3444 ts,
3445 )
3446 .expect("should produce fill even without avp");
3447
3448 assert_eq!(fill.last_qty.as_f64(), 25.0);
3450 assert_eq!(fill.last_px.as_f64(), 3.5);
3451 } else {
3452 panic!("expected OrderChange");
3453 }
3454 }
3455
3456 #[rstest]
3457 fn test_fill_tracker_weighted_avg_back_calculation() {
3458 let mut tracker = FillTracker::new();
3459 let instrument_id = InstrumentId::from("1.234567-999003-0.0.BETFAIR");
3460 let account_id = AccountId::from("BETFAIR-001");
3461 let currency = Currency::from("GBP");
3462 let ts = UnixNanos::default();
3463
3464 let uo1 = make_test_uo(
3466 "999003",
3467 Decimal::new(30, 0),
3468 Some(Decimal::new(10, 0)),
3469 Some(Decimal::new(20, 1)),
3470 );
3471 let fill1 = tracker
3472 .maybe_fill_report(&uo1, uo1.s, instrument_id, account_id, currency, ts, ts)
3473 .expect("first fill");
3474 assert_eq!(fill1.last_px.as_f64(), 2.0);
3475 assert_eq!(fill1.last_qty.as_f64(), 10.0);
3476
3477 let uo2 = make_test_uo(
3480 "999003",
3481 Decimal::new(30, 0),
3482 Some(Decimal::new(20, 0)),
3483 Some(Decimal::new(25, 1)),
3484 );
3485 let fill2 = tracker
3486 .maybe_fill_report(&uo2, uo2.s, instrument_id, account_id, currency, ts, ts)
3487 .expect("second fill");
3488 assert_eq!(fill2.last_qty.as_f64(), 10.0);
3489 assert_eq!(fill2.last_px.as_f64(), 3.0);
3490 }
3491
3492 #[rstest]
3493 fn test_fill_tracker_negative_fill_price_falls_back_to_avp() {
3494 let mut tracker = FillTracker::new();
3495 let instrument_id = InstrumentId::from("1.234567-999004-0.0.BETFAIR");
3496 let account_id = AccountId::from("BETFAIR-001");
3497 let currency = Currency::from("GBP");
3498 let ts = UnixNanos::default();
3499
3500 let uo1 = make_test_uo(
3502 "999004",
3503 Decimal::new(20, 0),
3504 Some(Decimal::new(10, 0)),
3505 Some(Decimal::new(50, 1)),
3506 );
3507 tracker
3508 .maybe_fill_report(&uo1, uo1.s, instrument_id, account_id, currency, ts, ts)
3509 .expect("first fill");
3510
3511 let uo2 = make_test_uo(
3515 "999004",
3516 Decimal::new(20, 0),
3517 Some(Decimal::new(15, 0)),
3518 Some(Decimal::new(10, 1)),
3519 );
3520 let fill2 = tracker
3521 .maybe_fill_report(&uo2, uo2.s, instrument_id, account_id, currency, ts, ts)
3522 .expect("second fill should use avp fallback");
3523 assert_eq!(fill2.last_qty.as_f64(), 5.0);
3524 assert_eq!(fill2.last_px.as_f64(), 1.0);
3525 }
3526
3527 #[rstest]
3528 fn test_fill_tracker_prune_clears_state() {
3529 let mut tracker = FillTracker::new();
3530 let instrument_id = InstrumentId::from("1.234567-999005-0.0.BETFAIR");
3531 let account_id = AccountId::from("BETFAIR-001");
3532 let currency = Currency::from("GBP");
3533 let ts = UnixNanos::default();
3534
3535 let uo = make_test_uo(
3537 "999005",
3538 Decimal::new(10, 0),
3539 Some(Decimal::new(10, 0)),
3540 Some(Decimal::new(25, 1)),
3541 );
3542 let fill1 =
3543 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
3544 assert!(fill1.is_some());
3545
3546 let fill2 =
3548 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
3549 assert!(fill2.is_none(), "should be deduplicated");
3550
3551 tracker.prune("999005");
3553
3554 let fill3 =
3556 tracker.maybe_fill_report(&uo, uo.s, instrument_id, account_id, currency, ts, ts);
3557 assert!(fill3.is_some(), "after prune, should produce fill again");
3558 }
3559
3560 #[rstest]
3561 fn test_fill_tracker_sm_none_returns_none() {
3562 let mut tracker = FillTracker::new();
3563
3564 let uo = make_test_uo("999006", Decimal::new(10, 0), None, None);
3566
3567 let instrument_id = InstrumentId::from("1.234567-999006-0.0.BETFAIR");
3568 let result = tracker.maybe_fill_report(
3569 &uo,
3570 uo.s,
3571 instrument_id,
3572 AccountId::from("BETFAIR-001"),
3573 Currency::from("GBP"),
3574 UnixNanos::default(),
3575 UnixNanos::default(),
3576 );
3577 assert!(result.is_none(), "None sm should not produce a fill");
3578 }
3579
3580 #[rstest]
3581 fn test_parse_order_status_report_missing_persistence_type_for_market_on_close() {
3582 let uo = UnmatchedOrder {
3583 s: Decimal::ZERO,
3584 pt: None,
3585 ot: StreamingOrderType::MarketOnClose,
3586 sr: Some(Decimal::new(10, 0)),
3587 ..make_test_uo("999007", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3588 };
3589
3590 let report = parse_order_status_report(
3591 &uo,
3592 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3593 AccountId::from("BETFAIR-001"),
3594 UnixNanos::default(),
3595 UnixNanos::default(),
3596 )
3597 .unwrap();
3598
3599 assert_eq!(report.quantity, Quantity::from("10.00"));
3600 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
3601 }
3602
3603 #[rstest]
3604 fn test_parse_order_status_report_missing_persistence_type_for_limit_on_close() {
3605 let uo = UnmatchedOrder {
3606 s: Decimal::ZERO,
3607 pt: None,
3608 ot: StreamingOrderType::LimitOnClose,
3609 sr: Some(Decimal::new(10, 0)),
3610 ..make_test_uo("999013", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3611 };
3612
3613 let report = parse_order_status_report(
3614 &uo,
3615 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3616 AccountId::from("BETFAIR-001"),
3617 UnixNanos::default(),
3618 UnixNanos::default(),
3619 )
3620 .unwrap();
3621
3622 assert_eq!(report.quantity, Quantity::from("10.00"));
3623 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
3624 }
3625
3626 #[rstest]
3627 fn test_parse_order_status_report_market_on_close_uses_bsp_liability() {
3628 let uo = UnmatchedOrder {
3629 s: Decimal::ZERO,
3630 bsp: Some(Decimal::new(20, 1)),
3631 pt: None,
3632 ot: StreamingOrderType::MarketOnClose,
3633 ..make_test_uo("999010", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3634 };
3635
3636 let report = parse_order_status_report(
3637 &uo,
3638 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3639 AccountId::from("BETFAIR-001"),
3640 UnixNanos::default(),
3641 UnixNanos::default(),
3642 )
3643 .unwrap();
3644
3645 assert_eq!(report.quantity, Quantity::from("2.00"));
3646 assert_eq!(report.time_in_force, TimeInForce::AtTheClose);
3647 }
3648
3649 #[rstest]
3650 fn test_parse_order_status_report_fails_for_non_positive_quantity() {
3651 let uo = UnmatchedOrder {
3652 s: Decimal::ZERO,
3653 sm: Some(Decimal::ZERO),
3654 sr: Some(Decimal::ZERO),
3655 sc: Some(Decimal::ZERO),
3656 sl: Some(Decimal::ZERO),
3657 sv: Some(Decimal::ZERO),
3658 ..make_test_uo("999014", Decimal::ZERO, Some(Decimal::ZERO), None)
3659 };
3660
3661 let result = parse_order_status_report(
3662 &uo,
3663 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3664 AccountId::from("BETFAIR-001"),
3665 UnixNanos::default(),
3666 UnixNanos::default(),
3667 );
3668
3669 assert!(result.is_err());
3670 assert!(
3671 result
3672 .unwrap_err()
3673 .to_string()
3674 .contains("failed to resolve positive quantity for stream order update 999014")
3675 );
3676 }
3677
3678 #[rstest]
3679 fn test_parse_order_status_report_includes_lapse_reason() {
3680 let uo = UnmatchedOrder {
3681 status: StreamingOrderStatus::ExecutionComplete,
3682 sl: Some(Decimal::ONE),
3683 lsrc: Some(crate::common::enums::LapseStatusReasonCode::SpInPlay),
3684 ..make_test_uo("999012", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3685 };
3686
3687 let report = parse_order_status_report(
3688 &uo,
3689 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3690 AccountId::from("BETFAIR-001"),
3691 UnixNanos::default(),
3692 UnixNanos::default(),
3693 )
3694 .unwrap();
3695
3696 assert_eq!(report.order_status, OrderStatus::Canceled);
3697 assert_eq!(report.cancel_reason.as_deref(), Some("SP_IN_PLAY"));
3698 }
3699
3700 #[rstest]
3701 fn test_parse_order_status_report_blank_rfo_normalizes_to_none() {
3702 let uo = UnmatchedOrder {
3705 rfo: Some(String::new()),
3706 ..make_test_uo("999013", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3707 };
3708
3709 let report = parse_order_status_report(
3710 &uo,
3711 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3712 AccountId::from("BETFAIR-001"),
3713 UnixNanos::default(),
3714 UnixNanos::default(),
3715 )
3716 .unwrap();
3717
3718 assert!(report.client_order_id.is_none());
3719 }
3720
3721 #[rstest]
3722 fn test_fill_tracker_blank_rfo_normalizes_to_none() {
3723 let mut tracker = FillTracker::new();
3724 let uo = UnmatchedOrder {
3725 rfo: Some(String::new()),
3726 sm: Some(Decimal::new(5, 0)),
3727 avp: Some(Decimal::new(25, 1)),
3728 ..make_test_uo("999015", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3729 };
3730
3731 let fill = tracker
3732 .maybe_fill_report(
3733 &uo,
3734 uo.s,
3735 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3736 AccountId::from("BETFAIR-001"),
3737 Currency::from("GBP"),
3738 UnixNanos::default(),
3739 UnixNanos::default(),
3740 )
3741 .expect("blank rfo should still produce a fill");
3742
3743 assert!(fill.client_order_id.is_none());
3744 }
3745
3746 #[rstest]
3747 fn test_fill_tracker_first_seen_void_records_without_correction() {
3748 let mut tracker = FillTracker::new();
3749 let uo = UnmatchedOrder {
3750 status: StreamingOrderStatus::ExecutionComplete,
3751 sm: Some(Decimal::new(50, 0)),
3752 sv: Some(Decimal::new(50, 0)),
3753 ..make_test_uo("999016", Decimal::new(50, 0), None, None)
3754 };
3755
3756 let first = tracker.maybe_fill_voids(&uo);
3757 let duplicate = tracker.maybe_fill_voids(&uo);
3758
3759 assert!(first.is_empty());
3760 assert!(duplicate.is_empty());
3761 assert!(!tracker.has_unseen_fill_void(&uo));
3762 }
3763
3764 #[rstest]
3765 fn test_fill_tracker_emits_only_increased_cumulative_void() {
3766 let mut tracker = FillTracker::new();
3767 let instrument_id = InstrumentId::from("1.234567-123456-0.0.BETFAIR");
3768 let account_id = AccountId::from("BETFAIR-001");
3769 let mut uo = make_test_uo(
3770 "999017",
3771 Decimal::new(60, 0),
3772 Some(Decimal::new(60, 0)),
3773 Some(Decimal::new(25, 1)),
3774 );
3775 tracker
3776 .maybe_fill_report(
3777 &uo,
3778 uo.s,
3779 instrument_id,
3780 account_id,
3781 Currency::GBP(),
3782 UnixNanos::default(),
3783 UnixNanos::default(),
3784 )
3785 .expect("matched size should establish a fill lot");
3786
3787 uo.sv = Some(Decimal::new(40, 0));
3788 let first = tracker.maybe_fill_voids(&uo);
3789 let duplicate = tracker.maybe_fill_voids(&uo);
3790 uo.sv = Some(Decimal::new(50, 0));
3791 let unseen_before_update = tracker.has_unseen_fill_void(&uo);
3792 let increased = tracker.maybe_fill_voids(&uo);
3793
3794 assert_eq!(first.len(), 1);
3795 assert_eq!(first[0].voided_qty, Quantity::from("40.00"));
3796 assert!(duplicate.is_empty());
3797 assert!(unseen_before_update);
3798 assert_eq!(increased.len(), 1);
3799 assert_eq!(increased[0].trade_id, first[0].trade_id);
3800 assert_eq!(increased[0].voided_qty, Quantity::from("50.00"));
3801 assert!(!tracker.has_unseen_fill_void(&uo));
3802 }
3803
3804 #[rstest]
3805 fn test_fill_tracker_allocates_cumulative_void_to_newest_fill_lot_first() {
3806 let mut tracker = FillTracker::new();
3807 let bet_id = "999019";
3808 let older_trade_id = TradeId::from("999019-30.00");
3809 let newer_trade_id = TradeId::from("999019-100.00");
3810 tracker.sync_fill_lot(
3811 bet_id,
3812 older_trade_id,
3813 Decimal::new(30, 0),
3814 Price::from("2.00"),
3815 Decimal::ZERO,
3816 );
3817 tracker.sync_fill_lot(
3818 bet_id,
3819 newer_trade_id,
3820 Decimal::new(70, 0),
3821 Price::from("3.00"),
3822 Decimal::ZERO,
3823 );
3824 let uo = UnmatchedOrder {
3825 sv: Some(Decimal::new(50, 0)),
3826 ..make_test_uo(bet_id, Decimal::new(100, 0), None, None)
3827 };
3828
3829 let corrections = tracker.maybe_fill_voids(&uo);
3830
3831 assert_eq!(corrections.len(), 1);
3832 assert_eq!(corrections[0].trade_id, newer_trade_id);
3833 assert_eq!(corrections[0].voided_qty, Quantity::from("50.00"));
3834 assert_eq!(corrections[0].last_px, Price::from("3.00"));
3835 }
3836
3837 #[rstest]
3838 fn test_fill_tracker_prices_new_fill_from_post_void_average() {
3839 let mut tracker = FillTracker::new();
3840 let bet_id = "999021";
3841 let older_trade_id = TradeId::from("999021-50.00");
3842 tracker.sync_fill_lot(
3843 bet_id,
3844 older_trade_id,
3845 Decimal::new(50, 0),
3846 Price::from("2.00"),
3847 Decimal::ZERO,
3848 );
3849 let (_, last_qty, last_px) = tracker
3850 .advance_cumulative_fill_with_voids(
3851 bet_id,
3852 Decimal::new(100, 0),
3853 Decimal::new(40, 0),
3854 Some(Decimal::new(25, 1)),
3855 Decimal::new(20, 1),
3856 )
3857 .expect("gross lifecycle increase should produce a fill");
3858
3859 let uo = UnmatchedOrder {
3860 sm: Some(Decimal::new(60, 0)),
3861 sv: Some(Decimal::new(40, 0)),
3862 ..make_test_uo(bet_id, Decimal::new(100, 0), None, None)
3863 };
3864 let corrections = tracker.maybe_fill_voids(&uo);
3865
3866 assert_eq!(last_qty, Quantity::from("50.00"));
3867 assert_eq!(last_px, Price::from("5.00"));
3868 assert_eq!(corrections.len(), 1);
3869 assert_eq!(corrections[0].voided_qty, Quantity::from("40.00"));
3870 assert_eq!(corrections[0].last_px, Price::from("5.00"));
3871 }
3872
3873 #[rstest]
3874 fn test_fill_tracker_preserves_prior_void_allocation_when_new_fill_arrives() {
3875 let mut tracker = FillTracker::new();
3876 let bet_id = "999020";
3877 let older_trade_id = TradeId::from("999020-60.00");
3878 let newer_trade_id = TradeId::from("999020-80.00");
3879 tracker.sync_fill_lot(
3880 bet_id,
3881 older_trade_id,
3882 Decimal::new(60, 0),
3883 Price::from("2.00"),
3884 Decimal::ZERO,
3885 );
3886 let mut uo = UnmatchedOrder {
3887 sv: Some(Decimal::new(40, 0)),
3888 ..make_test_uo(bet_id, Decimal::new(80, 0), None, None)
3889 };
3890
3891 let first = tracker.maybe_fill_voids(&uo);
3892 tracker.sync_fill_lot(
3893 bet_id,
3894 newer_trade_id,
3895 Decimal::new(20, 0),
3896 Price::from("3.00"),
3897 Decimal::ZERO,
3898 );
3899 uo.sv = Some(Decimal::new(50, 0));
3900 let increased = tracker.maybe_fill_voids(&uo);
3901
3902 assert_eq!(first.len(), 1);
3903 assert_eq!(first[0].trade_id, older_trade_id);
3904 assert_eq!(first[0].voided_qty, Quantity::from("40.00"));
3905 assert_eq!(increased.len(), 1);
3906 assert_eq!(increased[0].trade_id, newer_trade_id);
3907 assert_eq!(increased[0].voided_qty, Quantity::from("10.00"));
3908 }
3909
3910 #[rstest]
3911 fn test_fill_tracker_reconnect_increased_void_does_not_replay_gross_fill() {
3912 let mut tracker = FillTracker::new();
3913 let instrument_id = InstrumentId::from("1.234567-123456-0.0.BETFAIR");
3914 let account_id = AccountId::from("BETFAIR-001");
3915 let mut uo = make_test_uo(
3916 "999018",
3917 Decimal::new(60, 0),
3918 Some(Decimal::new(60, 0)),
3919 Some(Decimal::new(25, 1)),
3920 );
3921 let trade_id = make_trade_id(&uo);
3922 tracker.sync_fill_lot(
3923 &uo.id,
3924 trade_id,
3925 Decimal::new(60, 0),
3926 Price::from("2.50"),
3927 Decimal::new(40, 0),
3928 );
3929 tracker.sync_voided_qty(&uo.id, Decimal::new(40, 0));
3930 uo.sv = Some(Decimal::new(50, 0));
3931
3932 let fill = tracker.maybe_fill_report(
3933 &uo,
3934 uo.s,
3935 instrument_id,
3936 account_id,
3937 Currency::GBP(),
3938 UnixNanos::default(),
3939 UnixNanos::default(),
3940 );
3941 let corrections = tracker.maybe_fill_voids(&uo);
3942
3943 assert!(fill.is_none());
3944 assert_eq!(corrections.len(), 1);
3945 assert_eq!(corrections[0].trade_id, trade_id);
3946 assert_eq!(corrections[0].voided_qty, Quantity::from("50.00"));
3947 }
3948
3949 #[rstest]
3950 fn test_parse_order_status_report_missing_persistence_type_fails_for_limit_order() {
3951 let uo = UnmatchedOrder {
3952 pt: None,
3953 ..make_test_uo("999008", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3954 };
3955
3956 let result = parse_order_status_report(
3957 &uo,
3958 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3959 AccountId::from("BETFAIR-001"),
3960 UnixNanos::default(),
3961 UnixNanos::default(),
3962 );
3963
3964 assert!(result.is_err());
3965 assert_eq!(
3966 result.unwrap_err().to_string(),
3967 "missing persistence type for order update 999008"
3968 );
3969 }
3970
3971 #[rstest]
3972 fn test_fill_tracker_uses_lifecycle_quantity_when_stream_size_is_zero() {
3973 let mut tracker = FillTracker::new();
3974 let uo = UnmatchedOrder {
3975 s: Decimal::ZERO,
3976 sr: Some(Decimal::new(10, 0)),
3977 sm: Some(Decimal::new(5, 0)),
3978 avp: Some(Decimal::new(20, 1)),
3979 ..make_test_uo("999009", Decimal::new(10, 0), Some(Decimal::ZERO), None)
3980 };
3981
3982 let fill = tracker
3983 .maybe_fill_report(
3984 &uo,
3985 uo.s,
3986 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
3987 AccountId::from("BETFAIR-001"),
3988 Currency::from("GBP"),
3989 UnixNanos::default(),
3990 UnixNanos::default(),
3991 )
3992 .expect("zero stream size should fall back to lifecycle quantities");
3993
3994 assert_eq!(fill.last_qty, Quantity::from("5.00"));
3995 }
3996
3997 #[rstest]
3998 fn test_fill_tracker_uses_bsp_liability_when_stream_size_is_zero() {
3999 let mut tracker = FillTracker::new();
4000 let uo = UnmatchedOrder {
4001 s: Decimal::ZERO,
4002 bsp: Some(Decimal::new(20, 1)),
4003 pt: None,
4004 ot: StreamingOrderType::MarketOnClose,
4005 sm: Some(Decimal::new(10, 1)),
4006 avp: Some(Decimal::new(20, 1)),
4007 ..make_test_uo("999011", Decimal::new(10, 0), Some(Decimal::ZERO), None)
4008 };
4009
4010 let fill = tracker
4011 .maybe_fill_report(
4012 &uo,
4013 uo.s,
4014 InstrumentId::from("1.234567-123456-0.0.BETFAIR"),
4015 AccountId::from("BETFAIR-001"),
4016 Currency::from("GBP"),
4017 UnixNanos::default(),
4018 UnixNanos::default(),
4019 )
4020 .expect("zero stream size should fall back to bsp liability");
4021
4022 assert_eq!(fill.last_qty, Quantity::from("1.00"));
4023 }
4024
4025 #[rstest]
4026 fn test_fill_tracker_partial_void_still_emits_fill() {
4027 let data = load_test_json("stream/ocm_VOIDED_partial.json");
4028 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
4029
4030 if let StreamMessage::OrderChange(ocm) = msg {
4031 let oc = ocm.oc.as_ref().unwrap();
4032 let omc = &oc[0];
4033 let orc = &omc.orc.as_ref().unwrap()[0];
4034 let uo = &orc.uo.as_ref().unwrap()[0];
4035 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
4036 let ts = parse_millis_timestamp(ocm.pt);
4037
4038 let mut tracker = FillTracker::new();
4039 let fill = tracker
4040 .maybe_fill_report(
4041 uo,
4042 uo.s,
4043 instrument_id,
4044 AccountId::from("BETFAIR-001"),
4045 Currency::GBP(),
4046 ts,
4047 ts,
4048 )
4049 .expect("should produce fill for matched portion");
4050
4051 assert_eq!(fill.last_qty.as_f64(), 60.0);
4053 assert_eq!(fill.last_px.as_f64(), 1.5);
4054 assert_eq!(fill.order_side, OrderSide::Sell);
4055
4056 let report = parse_order_status_report(
4057 uo,
4058 instrument_id,
4059 AccountId::from("BETFAIR-001"),
4060 ts,
4061 ts,
4062 )
4063 .unwrap();
4064 assert_eq!(report.filled_qty, Quantity::from("60.00"));
4065 } else {
4066 panic!("expected OrderChange");
4067 }
4068 }
4069
4070 #[rstest]
4071 fn test_fill_tracker_no_fill_when_sv_zero_and_fully_filled() {
4072 let data = load_test_json("stream/ocm_FILLED_sv_zero.json");
4073 let msg: StreamMessage = serde_json::from_str(&data).unwrap();
4074
4075 if let StreamMessage::OrderChange(ocm) = msg {
4076 let oc = ocm.oc.as_ref().unwrap();
4077 let omc = &oc[0];
4078 let orc = &omc.orc.as_ref().unwrap()[0];
4079 let uo = &orc.uo.as_ref().unwrap()[0];
4080 let instrument_id = make_instrument_id(&omc.id, orc.id, Decimal::ZERO);
4081 let ts = parse_millis_timestamp(ocm.pt);
4082
4083 let mut tracker = FillTracker::new();
4084 let fill = tracker
4085 .maybe_fill_report(
4086 uo,
4087 uo.s,
4088 instrument_id,
4089 AccountId::from("BETFAIR-001"),
4090 Currency::GBP(),
4091 ts,
4092 ts,
4093 )
4094 .expect("fully filled order should produce fill");
4095
4096 assert_eq!(fill.last_qty.as_f64(), 50.0);
4097 assert_eq!(fill.last_px.as_f64(), 2.0);
4098
4099 assert_eq!(uo.sv, Some(Decimal::ZERO));
4101 } else {
4102 panic!("expected OrderChange");
4103 }
4104 }
4105}