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