Skip to main content

nautilus_betfair/stream/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities that convert Betfair stream messages into Nautilus domain models.
17//!
18//! MCM (Market Change Messages) are parsed into order book deltas, trade ticks,
19//! and instrument status updates. OCM (Order Change Messages) are parsed into
20//! order status reports and fill reports.
21
22use 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
59/// Parses a single runner's book data into [`OrderBookDeltas`].
60///
61/// Handles both full image snapshots (`is_snapshot = true`) and delta updates.
62///
63/// Only processes price-keyed fields (`atb`/`atl`). Level-indexed fields
64/// (`batb`/`batl`/`bdatb`/`bdatl`) are ignored because correct delta
65/// processing requires stateful level-to-price tracking. The stream
66/// subscription should use `EX_ALL_OFFERS` which populates `atb`/`atl`.
67///
68/// Book side mapping (Betfair exchange convention):
69/// - `atb` (available to back) -> [`OrderSide::Buy`] (bid side)
70/// - `atl` (available to lay) -> [`OrderSide::Sell`] (ask side)
71///
72/// For snapshots: emits a Clear delta followed by Add deltas for each level.
73/// For updates: emits Update or Delete (when volume is zero) deltas.
74///
75/// Returns `Ok(None)` if the runner change contains no processable book data.
76///
77/// # Errors
78///
79/// Returns an error if price or quantity values cannot be converted.
80pub 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    // Set F_LAST on the final delta
147    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/// Creates a [`TradeTick`] from stream data.
155///
156/// Betfair does not identify the aggressor side in its stream, so
157/// [`AggressorSide::NoAggressor`] is always used.
158#[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/// Produces per-runner [`InstrumentStatus`] events from a market definition.
179///
180/// Iterates `def.runners` and maps each runner's lifecycle to a Nautilus status.
181/// Scratched runners (`Removed`, `RemovedVacant`) close immediately regardless
182/// of market-level state. The `in_play` flag distinguishes pre-open (Open + not
183/// in play) from active trading (Open + in play).
184///
185/// Returns an empty vector when `def.status` or `def.runners` is missing.
186#[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                    // Unreachable: unmodeled market status skips the whole definition above.
226                    (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
245/// Generates a deterministic [`TradeId`] for a Betfair fill.
246///
247/// Uses `bet_id` and cumulative `sm` (size matched) which together uniquely
248/// identify each fill state, since `sm` increases monotonically with each fill.
249pub 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/// Tracks cumulative fill state per bet to compute incremental fills from the
255/// Betfair OCM stream.
256///
257/// Betfair provides cumulative `sm` (size matched) and `avp` (average price
258/// matched) on each order update. This tracker maintains per-bet state to
259/// derive individual fill quantities and prices for each update.
260#[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/// One cumulative per-fill allocation derived from Betfair's cumulative `sv`.
279#[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    /// Creates a new [`FillTracker`] instance.
288    #[must_use]
289    pub fn new() -> Self {
290        Self::default()
291    }
292
293    /// Computes a [`FillReport`] for an unmatched order update.
294    ///
295    /// A new fill is incremental. A price restatement repeats the surviving lot's trade ID
296    /// when matched and voided quantities are unchanged, the average changed, and exactly one
297    /// lot survives. Returns `None` when those conditions are not met, including a duplicate
298    /// trade ID or an overfill.
299    #[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    /// Allocates cumulative Betfair `sv` to known applied fill lots, newest first.
474    ///
475    /// First-seen voids without known fill lots are recorded but emit no correction.
476    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    /// Returns whether a cumulative Betfair void update has not yet been applied.
543    #[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    /// Returns whether an unchanged matched and voided quantity carries a new average while
555    /// exactly one fill lot survives, without recording that average.
556    #[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    /// Returns whether an update with unchanged matched and voided quantities carries a new
597    /// average price while exactly one fill lot survives.
598    #[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    /// Returns whether a cumulative Betfair fill update has not yet been applied.
609    #[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    /// Back-calculates the individual fill price from Betfair's cumulative
808    /// average price matched (`avp`).
809    ///
810    /// For the first fill, the average price IS the fill price. For subsequent
811    /// fills, the individual price is derived from:
812    /// `fill_price = (avp * sm - prev_avp * prev_sm) / fill_size`
813    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    /// Pre-populates state for a bet from existing order data.
917    ///
918    /// Monotonic in `filled_qty`: keeps the larger of the cached and
919    /// in-memory values so a cache that lags the tracker (engine has
920    /// not yet processed an emitted fill) cannot regress it.
921    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    /// Seeds the trade-id dedup set so fills already published via another
939    /// channel are not re-emitted when the post-reconnect image arrives.
940    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    /// Removes state for a completed bet to prevent unbounded growth.
951    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/// Returns `true` if the unmatched order has cancel, lapse, or void quantities.
969#[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/// Returns `true` if the order is execution-complete and has lapsed.
978#[must_use]
979pub fn is_lapsed(uo: &UnmatchedOrder) -> bool {
980    uo.status == StreamingOrderStatus::ExecutionComplete && uo.lsrc.is_some()
981}
982
983/// Returns `true` if the unmatched order is an SP (on-close) bet resting until
984/// BSP reconciliation: execution-complete with no matched, cancelled, lapsed,
985/// or voided quantity and no lapse reason.
986///
987/// SP bets cannot be cancelled once placed, so this state is open, not
988/// terminal.
989#[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
1001/// Parses a streaming [`UnmatchedOrder`] into a Nautilus [`OrderStatusReport`].
1002///
1003/// Resolves the Nautilus order status from the Betfair streaming status
1004/// plus matched/cancelled quantities.
1005///
1006/// # Errors
1007///
1008/// Returns an error if price or quantity values cannot be converted.
1009pub 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    // Include lapsed/voided in the closed quantity for status resolution
1026    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    // Use the latest lifecycle timestamp, falling back to OCM publish time
1062    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        // The stream can carry a non-BSP persistence type on SP bets; the
1109        // on-close instruction defines the time in force
1110        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/// Creates a [`FillReport`] for a Betfair order fill.
1158///
1159/// Betfair charges commission on net winnings, not per-fill, so commission
1160/// is set to zero. The `liquidity_side` is unknown from the stream.
1161#[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/// Extracts a [`BetfairTicker`] from a runner change if any ticker fields are present.
1195///
1196/// Returns `None` when the runner change contains no ltp, tv, spn, or spf data.
1197#[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/// Extracts [`BetfairStartingPrice`] values from a market definition's runners.
1220///
1221/// Returns one entry per runner that has a non-None BSP value.
1222#[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/// Extracts BSP order book deltas from a runner change's `spb`/`spl` fields.
1250///
1251/// Returns an empty vec when neither `spb` nor `spl` data is present.
1252/// The `side` field uses `OrderSide::Sell` for `spb` (back) and
1253/// `OrderSide::Buy` for `spl` (lay), following Betfair's inverted convention.
1254#[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/// Produces [`InstrumentClose`] events from a market definition's runner statuses.
1297///
1298/// Winners and placed runners get close price 1.0; losers and removed runners
1299/// get close price 0.0. Active runners produce no close event.
1300#[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/// Parses a single [`RaceRunnerChange`] into a [`BetfairRaceRunnerData`].
1338///
1339/// Returns `None` if the runner change has no selection ID.
1340#[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/// Parses a [`RaceProgressChange`] into a [`BetfairRaceProgress`].
1365#[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            // Clear + atb levels + atl levels
1464            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            // First delta is Clear with F_SNAPSHOT
1469            assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1470            assert!(RecordFlag::F_SNAPSHOT.matches(deltas.deltas[0].flags));
1471
1472            // Subsequent deltas are Add with F_SNAPSHOT
1473            for delta in &deltas.deltas[1..] {
1474                assert_eq!(delta.action, BookAction::Add);
1475                assert!(RecordFlag::F_SNAPSHOT.matches(delta.flags));
1476            }
1477
1478            // Last delta has F_LAST
1479            let last = deltas.deltas.last().unwrap();
1480            assert!(RecordFlag::F_LAST.matches(last.flags));
1481
1482            // Verify buy/sell sides
1483            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            // No Clear delta for updates
1557            assert!(deltas.deltas.iter().all(|d| d.action != BookAction::Clear));
1558
1559            // Last delta has F_LAST
1560            let last = deltas.deltas.last().unwrap();
1561            assert!(RecordFlag::F_LAST.matches(last.flags));
1562
1563            // No snapshot flags
1564            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            // atl has [[4.7, 0]] which should be Delete
1595            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        // An unmodeled market status must not publish a fabricated non-trading
1738        // state; the whole definition is skipped.
1739        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        // An unmodeled runner status must not be emitted as tradable, even in an
1749        // open in-play market; we skip it rather than fabricate tradability.
1750        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        // Even with Open + in_play the runner must close when scratched
1762        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        // Three runners in one market definition: an Active runner should follow the
1791        // market-level mapping while Removed/RemovedVacant override to Close. Verify
1792        // that selection id and handicap propagate into the emitted instrument id.
1793        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)), // 2.5 handicap
1808                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        // Each runner produces a distinct instrument id (selection id + handicap)
1842        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            // Partially filled: sm=4.75, sr=0.25, status=E
1869            assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1870            assert_eq!(report.order_side, Some(OrderSide::Sell)); // Back → Sell
1871            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)); // Lay → Buy
1904            assert_eq!(report.filled_qty.as_f64(), 10.0);
1905            assert_eq!(report.quantity.as_f64(), 10.0);
1906
1907            // Has client_order_id from rfo field
1908            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)); // Back → Sell
1937            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            // Partially filled: sm=1.12, status=E
1966            assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1967            assert_eq!(report.order_side, Some(OrderSide::Buy)); // Lay → Buy
1968            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            // A resting SP bet reports execution-complete with zero size
1998            // fields, but cannot be cancelled and is still open until BSP
1999            // reconciliation
2000            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)); // Back → Sell
2003            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        // At BSP reconciliation an SP bet is no longer resting: a matched bet
2041        // resolves Filled and a lapsed bet resolves Canceled
2042        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            // Partially filled: sm=16.19, status=E, has rfo
2095            assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2096            assert_eq!(report.order_side, Some(OrderSide::Sell)); // Back → Sell
2097            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        // A snapshot with no levels should still produce a clear delta
2108        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        // Just the Clear delta with F_LAST
2138        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        // First fill: sm=16.19 (from zero)
2215        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        // Second fill: sm=16.96 (delta=0.77)
2224        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        // Third fill: sm=17.73 (delta=0.77)
2233        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            // Order placed at 1.3 but average fill price is 1.2
2268            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            // First call produces fill
2351            let fill1 =
2352                tracker.maybe_fill_report(uo, uo.s, instrument_id, account_id, currency, ts, ts);
2353            assert!(fill1.is_some());
2354
2355            // Second call with same data produces nothing (dedup)
2356            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        // Process first fill at avp=5.8
2374        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        // Second fill also at avp=5.8 (same price, avg unchanged)
2382        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            // Runner 9249757 has ltp=5.5, tv=1890.32, spn=5.68, spf=5.73
2739            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            // Runner 40273293 has ltp=2.1, tv=3201.15 but no spn/spf
2751            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            // Runner 23678734 has only tv=0, no ltp
2761            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            // 3 runners have bsp values, 1 (REMOVED) does not
2785            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        // Find the MCM with runner changes containing spb/spl data
2861        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        // Runner 9249757 has spb and spl arrays
2882        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        // SPB entries are Sell side
2893        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        // SPL entries are Buy side
2899        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            // 4 runners: WINNER, PLACED, LOSER, REMOVED - all produce close events
2962            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        // Find the MCM with a market definition containing ACTIVE runners
2989        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        // Sync existing fill state: 10 matched at 2.5
3068        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        // Same sm=10 as synced, should not emit a fill
3083        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 already has 10 from prior stream activity
3096        tracker.sync_order("123456", Decimal::new(10, 0), Decimal::new(25, 1));
3097
3098        // Cache lags (engine hasn't processed the last fill yet) and reports 5
3099        tracker.sync_order("123456", Decimal::new(5, 0), Decimal::new(20, 1));
3100
3101        // Next OCM with sm=15 must emit incremental fill of 5, not 10
3102        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        // Sync: 10 matched at 2.5
3126        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        // sm=15 vs synced 10, should emit incremental fill of 5
3141        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        // Seeded trade-id must block even with an empty FillTracker
3161        // (cumulative-size gate would otherwise let this fill through)
3162        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        // (8 * 6 - 9 * 4) / 2 = 6, not (8 * 6 - 10 * 4) / 2 = 4
3309        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        // sm=30 exceeds order size s=20
3383        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            // No avp field, so fill price falls back to order price (p=3.5)
3449            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        // First fill: 10 @ avp=2.0
3465        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        // Second fill: sm=20, avp=2.5
3478        // Back-calc: (2.5*20 - 2.0*10) / 10 = (50-20)/10 = 3.0
3479        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        // First fill: 10 @ avp=5.0
3501        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        // Second fill: sm=15, avp=1.0
3512        // Back-calc: (1.0*15 - 5.0*10) / 5 = (15-50)/5 = -7.0
3513        // Negative price should fall back to avp=1.0
3514        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        // Fill order fully
3536        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        // Same data again - deduplicated
3547        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        // Prune the bet
3552        tracker.prune("999005");
3553
3554        // After prune, same data can produce a fill again (simulates re-processing)
3555        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        // sm=None (no matched quantity field at all)
3565        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        // Some venues send rfo:"" instead of omitting it. ClientOrderId rejects
3703        // empty strings, so the parser must treat blank refs as missing.
3704        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            // Order: s=100, sm=60, sv=40 -> fill qty should be 60
4052            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            // sv=0, so no void event should be generated (tested separately)
4100            assert_eq!(uo.sv, Some(Decimal::ZERO));
4101        } else {
4102            panic!("expected OrderChange");
4103        }
4104    }
4105}