Skip to main content

nautilus_betfair/
execution.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//! Live execution client for the Betfair adapter.
17//!
18//! # Submission gate
19//!
20//! The client halts new-order submissions whenever the execution stream is unavailable or
21//! recovering. `submit_order` and `submit_order_list` emit `OrderDenied` with
22//! `STREAM_RECONCILING`, while cancel and modify commands remain available. Transport availability
23//! alone does not reopen the gate: an active replacement socket remains halted until the order
24//! subscription is current and the matching recovery task dispatches mass status.
25//!
26//! # Reconnect recovery
27//!
28//! A transport loss or server `connectionClosed` status advances the reconciliation generation
29//! immediately. A replacement `Connection` raises `pending_resync`; a complete `SUB_IMAGE` or
30//! `RESUB_DELTA` queues the current generation once. Subsequent OCMs remain buffered until
31//! `process_pending_resync` runs on the engine thread. Connectivity polling and command or report
32//! entry points invoke it to synchronize OCM state from the cache and drain the buffer.
33//!
34//! The recovery task attempts to refresh the session, requests account state on a best-effort basis,
35//! and builds a mass status from `list_current_orders`. Recovery uses match-time ordering and
36//! bounded retries to include fills that completed and rolled off the unmatched book during the
37//! gap.
38//!
39//! Mass-status dispatch, fill-deduplication commit, and gate reopening share one generation check;
40//! the task does not wait for a separate cache-application acknowledgement. A newer transport loss
41//! or replacement connection cancels older work. Failed authentication, exhausted recovery, or a
42//! failed report dispatch leaves the gate closed until a later reconnect succeeds or the client
43//! disconnects. A keep-alive failure other than explicit `LoginFailed` continues recovery with the
44//! retained session.
45//!
46//! # Modify reconciliation
47//!
48//! An ambiguous replace or quantity reduction remains pending until OCM or `listCurrentOrders`
49//! confirms it; only a fully paginated response can prove the original order non-actionable.
50//! Reconciliation emits the resulting `OrderUpdated` directly and withholds active reports that
51//! would reapply or contradict that update. A terminal replacement report follows its
52//! `OrderUpdated` through the normal report path and enters terminal retention. A terminal reduction
53//! needs no preceding update; its confirmed quantity overrides Betfair's original stake in that
54//! report and later reports.
55//!
56//! Owned report tasks collect HTTP evidence without the live cache. Their core-thread continuation
57//! resolves identities and pending modifications against current OCM state. Ambiguous-replace
58//! rejections cover only the pending replacements captured during collection. The continuation
59//! queues modification events; only the execution engine applies those events.
60
61use std::{
62    fmt,
63    future::Future,
64    sync::{
65        Arc,
66        atomic::{AtomicBool, AtomicU64, Ordering},
67    },
68    time::Duration,
69};
70
71use ahash::{AHashMap, AHashSet};
72use async_trait::async_trait;
73use nautilus_common::{
74    clients::{ExecutionClient, ExecutionReportTask},
75    live::{
76        runner::{get_data_event_sender, get_exec_event_sender},
77        sender::EventSender,
78    },
79    messages::{
80        DataEvent, ExecutionReport,
81        execution::{
82            BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
83            GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
84            ModifyOrder, QueryOrder, SubmitOrder, SubmitOrderList,
85        },
86    },
87};
88use nautilus_core::{
89    DurationNanos, Params, UUID4, UnixNanos,
90    time::{AtomicTime, get_atomic_clock_realtime},
91};
92use nautilus_live::{
93    ExecutionClientCore, ExecutionEventEmitter, SocketControl,
94    execution::{failure::CommandFailure, reports::retain_order_status_reports},
95    task::{TaskGroup, TaskGroupGuard},
96};
97use nautilus_model::{
98    accounts::AccountAny,
99    data::Data,
100    enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, TimeInForce},
101    events::{
102        AccountState, OrderAccepted, OrderCanceled, OrderDeniedReason, OrderEventAny,
103        OrderFillVoided, OrderFilled, OrderModifyRejected, OrderUpdated,
104    },
105    identifiers::{
106        AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, Venue, VenueOrderId,
107    },
108    instruments::InstrumentAny,
109    orders::{Order, OrderAny},
110    reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
111    types::{AccountBalance, Currency, MarginBalance, Price, Quantity},
112};
113use nautilus_network::{SocketState, SocketStateSink};
114use parking_lot::Mutex;
115use rust_decimal::Decimal;
116use ustr::Ustr;
117
118use crate::{
119    common::{
120        consts::{
121            BETFAIR_VENUE, METHOD_CANCEL_ORDERS, METHOD_GET_ACCOUNT_FUNDS,
122            METHOD_LIST_CURRENT_ORDERS, METHOD_LIST_EVENTS, METHOD_LIST_MARKET_CATALOGUE,
123            METHOD_PLACE_ORDERS, METHOD_REPLACE_ORDERS,
124        },
125        credential::BetfairCredential,
126        enums::{
127            BetfairOrderStatus, BetfairOrderType, BetfairSide, BetfairTimeInForce, ChangeType,
128            ExecutionReportErrorCode, ExecutionReportStatus, InstructionReportErrorCode,
129            InstructionReportStatus, OrderBy, OrderProjection, PersistenceType, SegmentType,
130            SortDir, StreamingOrderStatus, StreamingSide,
131        },
132        parse::{
133            extract_market_id, extract_selection_id, make_customer_order_ref,
134            make_customer_order_ref_legacy, make_instrument_id, parse_account_state,
135            parse_betfair_price, parse_betfair_quantity, parse_betfair_timestamp,
136            parse_millis_timestamp,
137        },
138        types::{BetId, OrderSyncEntry, SelectionId},
139    },
140    config::BetfairExecutionClientConfig,
141    data::custom_data_with_instrument,
142    data_types::{BetfairOrderVoided, register_betfair_custom_data},
143    http::{
144        client::BetfairHttpClient,
145        error::BetfairHttpError,
146        models::{
147            AccountFundsResponse, CancelExecutionReport, CancelInstruction, CancelOrdersParams,
148            CurrentOrderSummary, CurrentOrderSummaryReport, Event, LimitOnCloseOrder, LimitOrder,
149            ListCurrentOrdersParams, ListMarketCatalogueParams, MarketCatalogue, MarketFilter,
150            MarketOnCloseOrder, MarketVersion, PlaceExecutionReport, PlaceInstruction,
151            PlaceInstructionReport, PlaceOrdersParams, ReplaceExecutionReport, ReplaceInstruction,
152            ReplaceInstructionReport, ReplaceOrdersParams, TimeRange,
153        },
154        parse::{parse_current_order_fill_report, parse_current_order_report},
155    },
156    stream::{
157        USER_STREAMS_ENDPOINT,
158        client::{BetfairStreamClient, HeartbeatTimeoutSource, StreamMessageHandler},
159        config::BetfairStreamConfig,
160        messages::{OCM, OrderMarketChange, OrderRunnerChange, StreamMessage, UnmatchedOrder},
161        ocm::{CustomerOrderRefResolution, OcmState},
162        parse::{
163            FillTracker, FillVoidAllocation, has_cancel_quantity, is_resting_sp_bet,
164            parse_order_status_report,
165        },
166    },
167};
168
169/// Betfair live execution client.
170#[derive(Debug)]
171pub struct BetfairExecutionClient {
172    core: ExecutionClientCore,
173    clock: &'static AtomicTime,
174    emitter: ExecutionEventEmitter,
175    http_client: Arc<BetfairHttpClient>,
176    stream_client: Option<Arc<BetfairStreamClient>>,
177    socket_control: Option<SocketControl>,
178    credential: BetfairCredential,
179    stream_config: BetfairStreamConfig,
180    config: BetfairExecutionClientConfig,
181    currency: Currency,
182    ocm_state: Arc<Mutex<OcmState>>,
183    pending_resync: Arc<AtomicBool>,
184    reconciliation_gate: Arc<ReconciliationGate>,
185    replay_buffer: Arc<Mutex<Vec<ReceivedOcm>>>,
186    session_tasks: TaskGroup,
187    pending_tasks: TaskGroup,
188    shutdown_errors: Vec<String>,
189    account_refresh_tx: Option<tokio::sync::mpsc::UnboundedSender<()>>,
190}
191
192impl BetfairExecutionClient {
193    /// Creates a new [`BetfairExecutionClient`] instance.
194    #[must_use]
195    pub fn new(
196        core: ExecutionClientCore,
197        http_client: BetfairHttpClient,
198        credential: BetfairCredential,
199        stream_config: BetfairStreamConfig,
200        config: BetfairExecutionClientConfig,
201        currency: Currency,
202    ) -> Self {
203        let clock = get_atomic_clock_realtime();
204        let emitter = ExecutionEventEmitter::new(
205            clock,
206            core.trader_id,
207            core.account_id,
208            AccountType::Betting,
209            None,
210        );
211        let socket_control = Some(SocketControl::new(
212            core.client_id,
213            Some(*BETFAIR_VENUE),
214            USER_STREAMS_ENDPOINT,
215        ));
216
217        let session_tasks = TaskGroup::new();
218        let pending_tasks = TaskGroup::new();
219
220        Self {
221            core,
222            clock,
223            emitter,
224            http_client: Arc::new(http_client),
225            stream_client: None,
226            socket_control,
227            credential,
228            stream_config,
229            config,
230            currency,
231            ocm_state: Arc::new(Mutex::new(OcmState::default())),
232            pending_resync: Arc::new(AtomicBool::new(false)),
233            reconciliation_gate: Arc::new(ReconciliationGate::default()),
234            replay_buffer: Arc::new(Mutex::new(Vec::new())),
235            session_tasks,
236            pending_tasks,
237            shutdown_errors: Vec::new(),
238            account_refresh_tx: None,
239        }
240    }
241
242    /// Returns true while new-order submissions are halted for stream recovery.
243    #[must_use]
244    pub fn is_reconciling(&self) -> bool {
245        self.reconciliation_gate.is_halted()
246    }
247
248    /// Waits for the reconciliation halt state to equal `expected`.
249    ///
250    /// Returns immediately if the gate is already in the expected state; otherwise
251    /// waits for a later transition. A transient expected state that is replaced
252    /// before this task observes it can be missed because transitions are not
253    /// recorded as history. This method has no internal timeout; callers wanting a
254    /// bound should wrap it in [`tokio::time::timeout`].
255    pub async fn wait_for_reconciliation_state(&self, expected: bool) {
256        wait_for_reconciliation_state(&self.reconciliation_gate, expected).await;
257    }
258
259    fn submissions_halted(&self) -> bool {
260        !self.core.is_connected()
261            || self.reconciliation_gate.is_halted()
262            || self
263                .stream_client
264                .as_ref()
265                .is_none_or(|client| !client.is_order_ready())
266    }
267
268    fn spawn_task<F>(&self, description: &'static str, fut: F)
269    where
270        F: Future<Output = anyhow::Result<()>> + Send + 'static,
271    {
272        let future = async move {
273            if let Err(e) = fut.await {
274                log::warn!("{description} failed: {e:?}");
275            }
276        };
277
278        if let Err(e) = self.pending_tasks.spawn(future) {
279            log::warn!("Skipping Betfair {description} after shutdown began: {e}");
280        }
281    }
282
283    fn reconcile_market_ids(&self) -> Option<Vec<String>> {
284        if self.config.reconcile_market_ids_only
285            && let Some(ids) = &self.config.reconcile_market_ids
286        {
287            return Some(ids.clone());
288        }
289        self.config.stream_market_ids_filter.clone()
290    }
291
292    /// Returns the market version for price protection on order placement.
293    ///
294    /// When `use_market_version` is enabled, reads the `version` field from
295    /// the instrument's `info` metadata. Betfair lapses orders submitted with
296    /// a stale version rather than matching against a moved book.
297    fn get_market_version(&self, instrument_id: &InstrumentId) -> Option<MarketVersion> {
298        if !self.config.use_market_version {
299            return None;
300        }
301
302        let cache = self.core.cache();
303        let instrument = cache.instrument(instrument_id)?;
304
305        if let InstrumentAny::Betting(betting) = instrument {
306            let version = betting.info.as_ref()?.get_i64("version")?;
307            return Some(MarketVersion {
308                version: Some(version),
309            });
310        }
311
312        None
313    }
314
315    /// Pre-populates OCM state from cached orders to prevent duplicate fills
316    /// and terminal events after reconnect.
317    fn sync_ocm_state_from_cache(&self) {
318        let cache = self.core.cache();
319        let venue = *BETFAIR_VENUE;
320        let orders = cache.orders(Some(&venue), None, None, None, None);
321        let (active_orders, mut closed_orders): (Vec<_>, Vec<_>) =
322            orders.into_iter().partition(|order| !order.is_closed());
323        closed_orders.retain(|order| order.venue_order_id().is_some());
324        closed_orders.sort_by_key(|order| (order.ts_last(), order.client_order_id()));
325        let retained_closed_start = closed_orders
326            .len()
327            .saturating_sub(OcmState::DEDUP_RETENTION);
328        let retained_orders = active_orders
329            .into_iter()
330            .chain(closed_orders.into_iter().skip(retained_closed_start))
331            .collect::<Vec<_>>();
332
333        let order_data = retained_orders
334            .iter()
335            .filter_map(|order| Self::order_sync_entry(order))
336            .collect::<Vec<_>>();
337
338        let mut state = self.ocm_state.lock();
339        state.sync_from_orders(&order_data);
340        Self::sync_cached_fills(
341            &mut state,
342            retained_orders
343                .iter()
344                .filter(|order| order.venue_order_id().is_some())
345                .map(|order| &**order),
346        );
347
348        log::debug!("Synced OCM state from {} cached orders", order_data.len());
349    }
350
351    fn order_sync_entry(order: &OrderAny) -> Option<OrderSyncEntry> {
352        let venue_order_id = order.venue_order_id()?;
353        let (filled_qty, avg_px) = Self::current_order_fill_state(order, venue_order_id);
354        let mut venue_order_ids = order
355            .venue_order_ids()
356            .into_iter()
357            .map(ToString::to_string)
358            .collect::<AHashSet<_>>();
359        venue_order_ids.extend(
360            order
361                .events()
362                .iter()
363                .filter_map(|event| event.venue_order_id())
364                .map(|venue_order_id| venue_order_id.to_string()),
365        );
366        let mut venue_order_ids = venue_order_ids.into_iter().collect::<Vec<_>>();
367        venue_order_ids.sort_unstable();
368
369        let trade_ids = order
370            .trade_ids()
371            .iter()
372            .map(|trade_id| trade_id.to_string())
373            .collect();
374
375        Some(OrderSyncEntry {
376            bet_id: venue_order_id.to_string(),
377            venue_order_ids,
378            client_order_id: order.client_order_id(),
379            strategy_id: order.strategy_id(),
380            filled_qty,
381            avg_px,
382            is_closed: order.is_closed(),
383            trade_ids,
384        })
385    }
386
387    fn current_order_fill_state(
388        order: &OrderAny,
389        venue_order_id: VenueOrderId,
390    ) -> (Decimal, Decimal) {
391        let events = order.events();
392        let mut latest_px = AHashMap::new();
393        let mut quantities = Vec::new();
394
395        for event in &events {
396            let OrderEventAny::Filled(fill) = event else {
397                continue;
398            };
399
400            if fill.venue_order_id != venue_order_id {
401                continue;
402            }
403
404            let price = fill.last_px.as_decimal();
405            if latest_px.insert(fill.trade_id, price).is_some() {
406                continue;
407            }
408
409            quantities.push((fill.trade_id, fill.last_qty.as_decimal()));
410        }
411
412        let mut voided_qty = AHashMap::new();
413
414        for event in &events {
415            let OrderEventAny::FillVoided(voided) = event else {
416                continue;
417            };
418
419            voided_qty.insert(voided.trade_id, voided.voided_qty.as_decimal());
420        }
421
422        let surviving: Vec<(TradeId, Decimal)> = quantities
423            .into_iter()
424            .filter_map(|(trade_id, quantity)| {
425                let remaining = quantity - voided_qty.get(&trade_id).copied().unwrap_or_default();
426                (remaining > Decimal::ZERO).then_some((trade_id, remaining))
427            })
428            .collect();
429
430        let filled_qty: Decimal = surviving.iter().map(|(_, quantity)| *quantity).sum();
431        let notional: Decimal = surviving
432            .iter()
433            .map(|(trade_id, quantity)| *quantity * latest_px[trade_id])
434            .sum();
435
436        if filled_qty > Decimal::ZERO {
437            return (filled_qty, notional / filled_qty);
438        }
439
440        if events
441            .iter()
442            .any(|event| matches!(event, OrderEventAny::Filled(_)))
443        {
444            return (Decimal::ZERO, Decimal::ZERO);
445        }
446
447        (
448            order.filled_qty().as_decimal(),
449            order.avg_px().unwrap_or(Decimal::ZERO),
450        )
451    }
452
453    fn sync_cached_fills<'a>(state: &mut OcmState, orders: impl IntoIterator<Item = &'a OrderAny>) {
454        let mut replay_fills = Vec::new();
455        let mut latest_px = AHashMap::new();
456        let mut voided_by_trade = AHashMap::new();
457
458        for order in orders {
459            for event in order.events() {
460                match event {
461                    OrderEventAny::Filled(fill) => {
462                        let key = (fill.venue_order_id.to_string(), fill.trade_id);
463                        if !latest_px.contains_key(&key) {
464                            replay_fills.push((
465                                key.0.clone(),
466                                fill.trade_id,
467                                fill.last_qty.as_decimal(),
468                                fill.last_px,
469                            ));
470                        }
471
472                        latest_px.insert(key, fill.last_px);
473                    }
474                    OrderEventAny::FillVoided(voided) => {
475                        voided_by_trade.insert(
476                            (voided.venue_order_id.to_string(), voided.trade_id),
477                            voided.voided_qty.as_decimal(),
478                        );
479                    }
480                    _ => {}
481                }
482            }
483        }
484
485        for (bet_id, trade_id, quantity, price) in replay_fills {
486            let voided_qty = voided_by_trade
487                .get(&(bet_id.clone(), trade_id))
488                .copied()
489                .unwrap_or(Decimal::ZERO);
490            state
491                .fill_tracker
492                .sync_fill_lot(&bet_id, trade_id, quantity, price, voided_qty);
493            if let Some(accounting_px) = latest_px.get(&(bet_id.clone(), trade_id))
494                && *accounting_px != price
495            {
496                state
497                    .fill_tracker
498                    .set_accounting_px(&bet_id, trade_id, *accounting_px);
499            }
500        }
501
502        state.fill_tracker.seed_missing_averages();
503        let mut voided_by_bet = AHashMap::<String, Decimal>::new();
504
505        for ((bet_id, _), voided_qty) in voided_by_trade {
506            *voided_by_bet.entry(bet_id).or_default() += voided_qty;
507        }
508
509        for (bet_id, voided_qty) in voided_by_bet {
510            state.fill_tracker.sync_voided_qty(&bet_id, voided_qty);
511        }
512    }
513
514    /// Resyncs OCM state from cache and drains any OCMs the network handler
515    /// buffered while waiting (cache is `!Send` so this must run on the
516    /// engine thread).
517    fn process_pending_resync(&self) {
518        if !self.core.is_connected() || self.reconciliation_gate.is_halted() {
519            return;
520        }
521
522        if !self.pending_resync.load(Ordering::Acquire) {
523            return;
524        }
525
526        let data_sender = get_data_event_sender();
527        let market_ids_filter = self
528            .config
529            .stream_market_ids_filter
530            .as_ref()
531            .map(|ids| ids.iter().cloned().collect::<ahash::AHashSet<String>>());
532
533        // Sync only at the start: per-iteration re-sync would clobber
534        // tracker updates `process_ocm` just made with a staler cache view.
535        self.sync_ocm_state_from_cache();
536
537        loop {
538            let mut buf = self.replay_buffer.lock();
539            if buf.is_empty() {
540                self.pending_resync.store(false, Ordering::Release);
541                return;
542            }
543
544            let drained: Vec<ReceivedOcm> = std::mem::take(&mut *buf);
545            drop(buf);
546
547            for received in drained {
548                Self::process_ocm(
549                    &received,
550                    self.core.account_id,
551                    self.currency,
552                    &self.emitter,
553                    &self.ocm_state,
554                    &data_sender,
555                    market_ids_filter.as_ref(),
556                    self.config.ignore_external_orders,
557                    self.account_refresh_tx.as_ref(),
558                );
559            }
560        }
561    }
562
563    /// Clears the resync flag and replay buffer so post-shutdown
564    /// `is_connected()` polls don't dispatch buffered OCMs.
565    fn clear_resync_state(&self) {
566        let mut buf = self.replay_buffer.lock();
567        buf.clear();
568        self.pending_resync.store(false, Ordering::Release);
569
570        self.reconciliation_gate.clear();
571    }
572
573    fn abort_pending_tasks(&self) {
574        self.pending_tasks.begin_shutdown();
575    }
576
577    fn abort_session_tasks(&mut self) {
578        self.session_tasks.begin_shutdown();
579        self.account_refresh_tx = None;
580    }
581
582    async fn await_pending_tasks(&self) -> anyhow::Result<()> {
583        self.pending_tasks.begin_shutdown();
584        self.pending_tasks
585            .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
586            .await
587            .map_err(|e| anyhow::anyhow!("Failed to terminate Betfair execution tasks: {e}"))?;
588        Ok(())
589    }
590
591    async fn await_session_tasks(&self) -> anyhow::Result<()> {
592        self.session_tasks.begin_shutdown();
593        self.session_tasks
594            .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
595            .await
596            .map_err(|e| anyhow::anyhow!("Failed to terminate Betfair session tasks: {e}"))?;
597        Ok(())
598    }
599
600    async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
601        self.abort_session_tasks();
602        self.abort_pending_tasks();
603
604        if let Some(control) = &self.socket_control {
605            control.deregister();
606        }
607
608        if let Some(client) = self.stream_client.as_ref() {
609            match client.close().await {
610                Ok(()) => self.stream_client = None,
611                Err(e) => self
612                    .shutdown_errors
613                    .push(format!("stream shutdown failed: {e}")),
614            }
615        }
616
617        self.http_client.disconnect().await;
618        let (session_result, pending_result) =
619            tokio::join!(self.await_session_tasks(), self.await_pending_tasks());
620        self.core.set_disconnected();
621        self.clear_resync_state();
622
623        if let Err(e) = session_result {
624            self.shutdown_errors.push(e.to_string());
625        }
626
627        if let Err(e) = pending_result {
628            self.shutdown_errors.push(e.to_string());
629        }
630
631        if self.shutdown_errors.is_empty() {
632            Ok(())
633        } else {
634            let errors = std::mem::take(&mut self.shutdown_errors);
635            anyhow::bail!("Betfair execution shutdown failed: {}", errors.join("; "))
636        }
637    }
638
639    #[expect(clippy::too_many_arguments)]
640    fn create_ocm_handler(
641        emitter: ExecutionEventEmitter,
642        account_id: AccountId,
643        currency: Currency,
644        ocm_state: Arc<Mutex<OcmState>>,
645        data_sender: EventSender<DataEvent>,
646        market_ids_filter: Option<ahash::AHashSet<String>>,
647        ignore_external_orders: bool,
648        reconnect_tx: tokio::sync::mpsc::UnboundedSender<u64>,
649        queued_generation: Arc<AtomicU64>,
650        pending_resync: Arc<AtomicBool>,
651        reconciliation_gate: Arc<ReconciliationGate>,
652        replay_buffer: Arc<Mutex<Vec<ReceivedOcm>>>,
653        account_refresh_tx: tokio::sync::mpsc::UnboundedSender<()>,
654        clock: &'static AtomicTime,
655    ) -> StreamMessageHandler {
656        let has_initial_connection = Arc::new(AtomicBool::new(false));
657        let order_degraded = AtomicBool::new(false);
658
659        Arc::new(move |msg: StreamMessage| {
660            let ts_init = clock.get_time_ns();
661
662            match msg {
663                StreamMessage::OrderChange(ocm) => {
664                    let complete =
665                        ocm.segment_type.is_none() || ocm.segment_type == Some(SegmentType::SegEnd);
666                    let current_image = ocm.status.is_none()
667                        && matches!(ocm.ct, Some(ChangeType::SubImage | ChangeType::ResubDelta))
668                        && complete;
669                    let recovered_from_degradation = ocm.status.is_none()
670                        && complete
671                        && order_degraded.swap(false, Ordering::AcqRel);
672                    if ocm.status == Some(503) && !order_degraded.swap(true, Ordering::AcqRel) {
673                        reconciliation_gate.halt();
674                        pending_resync.store(true, Ordering::Release);
675                    }
676
677                    if pending_resync.load(Ordering::Acquire)
678                        && (current_image || recovered_from_degradation)
679                    {
680                        let generation = reconciliation_gate.current_generation();
681                        if queued_generation.swap(generation, Ordering::AcqRel) != generation
682                            && reconnect_tx.send(generation).is_err()
683                        {
684                            log::warn!("Failed to schedule Betfair reconnect reconciliation");
685                        }
686                    }
687
688                    if ocm.status == Some(503) {
689                        return;
690                    }
691
692                    if ocm.is_heartbeat() {
693                        return;
694                    }
695
696                    let received = ReceivedOcm {
697                        message: ocm,
698                        ts_init,
699                    };
700
701                    // Lock spans the flag check so the drainer's clear-flag
702                    // step cannot race a producer push
703                    let mut buf = replay_buffer.lock();
704                    if pending_resync.load(Ordering::Acquire) {
705                        buf.push(received);
706                        return;
707                    }
708                    drop(buf);
709
710                    Self::process_ocm(
711                        &received,
712                        account_id,
713                        currency,
714                        &emitter,
715                        &ocm_state,
716                        &data_sender,
717                        market_ids_filter.as_ref(),
718                        ignore_external_orders,
719                        Some(&account_refresh_tx),
720                    );
721                }
722                StreamMessage::Connection(_) => {
723                    order_degraded.store(false, Ordering::Release);
724                    let initial = !has_initial_connection.swap(true, Ordering::SeqCst)
725                        && !reconciliation_gate.is_halted();
726
727                    if initial {
728                        log::debug!("Betfair execution stream connected");
729                    } else {
730                        log::info!("Betfair execution stream reconnected");
731
732                        if !reconciliation_gate.is_halted() {
733                            reconciliation_gate.halt();
734                        }
735                        pending_resync.store(true, Ordering::Release);
736                    }
737                }
738                StreamMessage::Status(status) => {
739                    if status.connection_closed {
740                        reconciliation_gate.halt();
741                        pending_resync.store(true, Ordering::Release);
742                        log::warn!(
743                            "Betfair execution stream closed: {:?} - {:?}",
744                            status.error_code,
745                            status.error_message,
746                        );
747                    }
748                }
749                StreamMessage::MarketChange(_)
750                | StreamMessage::RaceChange(_)
751                | StreamMessage::CricketChange(_) => {}
752            }
753        })
754    }
755
756    #[expect(clippy::too_many_arguments)]
757    fn process_ocm(
758        received: &ReceivedOcm,
759        account_id: AccountId,
760        currency: Currency,
761        emitter: &ExecutionEventEmitter,
762        ocm_state: &Arc<Mutex<OcmState>>,
763        data_sender: &EventSender<DataEvent>,
764        market_ids_filter: Option<&ahash::AHashSet<String>>,
765        ignore_external_orders: bool,
766        account_refresh_tx: Option<&tokio::sync::mpsc::UnboundedSender<()>>,
767    ) {
768        let ocm = &received.message;
769        let Some(order_changes) = &ocm.oc else {
770            return;
771        };
772
773        let ts_event = parse_millis_timestamp(ocm.pt);
774        let ts_init = received.ts_init;
775
776        let context = OcmProcessingContext {
777            account_id,
778            currency,
779            emitter,
780            ocm_state,
781            data_sender,
782            ignore_external_orders,
783            account_refresh_tx,
784            ts_event,
785            ts_init,
786        };
787
788        for omc in order_changes {
789            Self::process_order_market_change(omc, market_ids_filter, &context);
790        }
791    }
792
793    fn process_order_market_change(
794        omc: &OrderMarketChange,
795        market_ids_filter: Option<&ahash::AHashSet<String>>,
796        context: &OcmProcessingContext<'_>,
797    ) {
798        if market_ids_filter.is_some_and(|filter| !filter.contains(&omc.id)) {
799            return;
800        }
801
802        for runner_change in omc.orc.as_deref().unwrap_or_default() {
803            Self::process_order_runner_change(&omc.id, runner_change, context);
804        }
805    }
806
807    fn process_order_runner_change(
808        market_id: &str,
809        runner_change: &OrderRunnerChange,
810        context: &OcmProcessingContext<'_>,
811    ) {
812        let handicap = runner_change.hc.unwrap_or(Decimal::ZERO);
813        let instrument_id = make_instrument_id(market_id, runner_change.id, handicap);
814
815        for order in runner_change.uo.as_deref().unwrap_or_default() {
816            let has_customer_ref = order
817                .rfo
818                .as_deref()
819                .is_some_and(|customer_ref| !customer_ref.is_empty());
820
821            if context.ignore_external_orders && !has_customer_ref {
822                continue;
823            }
824
825            let processed = Self::process_unmatched_order(
826                order,
827                instrument_id,
828                context.account_id,
829                context.currency,
830                context.emitter,
831                context.ocm_state,
832                context.ts_event,
833                context.ts_init,
834            );
835
836            if processed
837                && order.sv.is_some_and(|size| size > Decimal::ZERO)
838                && let Some(tx) = context.account_refresh_tx
839            {
840                let _ = tx.send(());
841            }
842
843            Self::publish_legacy_order_voided(order, instrument_id, context);
844        }
845    }
846
847    fn publish_legacy_order_voided(
848        order: &UnmatchedOrder,
849        instrument_id: InstrumentId,
850        context: &OcmProcessingContext<'_>,
851    ) {
852        if order.status != StreamingOrderStatus::ExecutionComplete {
853            return;
854        }
855
856        let Some(size_voided) = order.sv.filter(|size| *size > Decimal::ZERO) else {
857            return;
858        };
859
860        let side = match order.side {
861            StreamingSide::Back => "BACK",
862            StreamingSide::Lay => "LAY",
863        };
864
865        let voided = BetfairOrderVoided::new(
866            instrument_id,
867            order.rfo.as_deref().unwrap_or("").to_string(),
868            order.id.clone(),
869            size_voided,
870            order.p,
871            order.s,
872            side.to_string(),
873            order.avp,
874            order.sm,
875            String::new(),
876            context.ts_event,
877            context.ts_init,
878        );
879
880        log::debug!(
881            "Order voided: bet_id={}, size_voided={size_voided}",
882            order.id
883        );
884
885        let custom = custom_data_with_instrument(Arc::new(voided), instrument_id);
886
887        if let Err(e) = context
888            .data_sender
889            .send(DataEvent::Data(Data::Custom(custom)))
890        {
891            log::warn!("Failed to send voided event: {e}");
892        }
893    }
894
895    #[expect(clippy::too_many_arguments)]
896    fn process_unmatched_order(
897        uo: &UnmatchedOrder,
898        instrument_id: InstrumentId,
899        account_id: AccountId,
900        currency: Currency,
901        emitter: &ExecutionEventEmitter,
902        ocm_state: &Arc<Mutex<OcmState>>,
903        ts_event: UnixNanos,
904        ts_init: UnixNanos,
905    ) -> bool {
906        let context = UnmatchedOrderContext {
907            order: uo,
908            instrument_id,
909            account_id,
910            currency,
911            emitter,
912            ts_event,
913            ts_init,
914        };
915        let mut report =
916            match parse_order_status_report(uo, instrument_id, account_id, ts_event, ts_init) {
917                Ok(report) => report,
918                Err(e) => {
919                    log::warn!("Failed to parse order status report for {instrument_id}: {e}");
920                    return false;
921                }
922            };
923
924        let mut state = ocm_state.lock();
925
926        if state.is_redundant_terminal_update(uo) {
927            return false;
928        }
929
930        let owner = state.resolve_order_owner(uo.rfo.as_deref(), &uo.id);
931        let resolved_client_order_id = owner.and_then(CustomerOrderRefResolution::client_order_id);
932
933        // Ambiguity clears the parser-derived client ID so routing falls back to Bet ID
934        if owner.is_some() {
935            report.client_order_id = resolved_client_order_id;
936        }
937
938        let cancel_action = resolve_cancel_action(&state, uo, resolved_client_order_id.as_ref());
939
940        if cancel_action == CancelAction::Suppress {
941            log::debug!(
942                "Suppressing cancel for bet_id={} (pending replace or already replaced)",
943                uo.id,
944            );
945
946            if let Some(client_order_id) = resolved_client_order_id {
947                state.retain_terminal_order(client_order_id, &uo.id);
948            }
949        }
950
951        // Tracked orders take the direct-event path, not reports (routing contract,
952        // developer_guide/adapters.md): direct terminal events stay authoritative instead
953        // of being deferred by reconciliation while the order is locally `PendingCancel`.
954        let tracked = resolved_client_order_id.and_then(|client_oid| {
955            state
956                .order_strategy_id(&client_oid)
957                .map(|strategy_id| (client_oid, strategy_id))
958        });
959
960        if cancel_action == CancelAction::Suppress && tracked.is_none() {
961            return false;
962        }
963
964        if let Some((client_order_id, strategy_id)) = tracked
965            && let Some((total_quantity, _)) =
966                state.promote_pending_replace(&client_order_id, &uo.id, report.quantity)
967        {
968            let updated = OrderUpdated::new(
969                context.emitter.trader_id(),
970                strategy_id,
971                context.instrument_id,
972                client_order_id,
973                total_quantity,
974                UUID4::new(),
975                report.ts_last,
976                context.ts_init,
977                false,
978                Some(report.venue_order_id),
979                Some(context.account_id),
980                report.price,
981                None,
982                None,
983                false,
984            );
985            context
986                .emitter
987                .send_order_event(OrderEventAny::Updated(updated));
988        }
989
990        if let Some((client_order_id, strategy_id, quantity)) =
991            Self::resolve_pending_reduction_from_stream(&mut state, tracked, uo)
992        {
993            let updated = OrderUpdated::new(
994                context.emitter.trader_id(),
995                strategy_id,
996                context.instrument_id,
997                client_order_id,
998                quantity,
999                UUID4::new(),
1000                report.ts_last,
1001                context.ts_init,
1002                false,
1003                Some(report.venue_order_id),
1004                Some(context.account_id),
1005                None,
1006                None,
1007                None,
1008                false,
1009            );
1010            context
1011                .emitter
1012                .send_order_event(OrderEventAny::Updated(updated));
1013        }
1014
1015        let (fill, fill_voids, price_restatement) = Self::derive_fill_changes(&context, &mut state);
1016
1017        if report.order_status == OrderStatus::Canceled
1018            && let Some(reason) = report.cancel_reason.as_deref()
1019        {
1020            log::debug!(
1021                "Betfair order {} ({}) canceled: reason={}, matched={}, canceled={}, lapsed={}, voided={}",
1022                report
1023                    .client_order_id
1024                    .unwrap_or_else(|| ClientOrderId::from(uo.id.as_str())),
1025                uo.id,
1026                reason,
1027                uo.sm.unwrap_or(Decimal::ZERO),
1028                uo.sc.unwrap_or(Decimal::ZERO),
1029                uo.sl.unwrap_or(Decimal::ZERO),
1030                uo.sv.unwrap_or(Decimal::ZERO),
1031            );
1032        }
1033
1034        let emitted = if let Some((client_order_id, strategy_id)) = tracked {
1035            Self::emit_tracked_order_events(
1036                &context,
1037                &mut state,
1038                &report,
1039                client_order_id,
1040                strategy_id,
1041                fill,
1042                fill_voids,
1043                cancel_action,
1044                price_restatement,
1045            )
1046        } else {
1047            Self::emit_untracked_order_reports(&context, report, fill);
1048            true
1049        };
1050
1051        if !emitted {
1052            return false;
1053        }
1054
1055        if uo.status == StreamingOrderStatus::ExecutionComplete
1056            && !is_resting_sp_bet(uo)
1057            && cancel_action != CancelAction::Suppress
1058        {
1059            if let Some(client_order_id) =
1060                resolved_client_order_id.or_else(|| state.client_order_id_by_venue_order_id(&uo.id))
1061            {
1062                state.retain_terminal_order(client_order_id, &uo.id);
1063            } else {
1064                state.mark_terminal_order(uo.id.clone());
1065            }
1066            state.clear_canceled_replace(&uo.id);
1067        }
1068
1069        true
1070    }
1071
1072    fn resolve_pending_reduction_from_stream(
1073        state: &mut OcmState,
1074        tracked: Option<(ClientOrderId, StrategyId)>,
1075        order: &UnmatchedOrder,
1076    ) -> Option<(ClientOrderId, StrategyId, Quantity)> {
1077        let (client_order_id, strategy_id) = tracked?;
1078        let active_quantity = parse_betfair_quantity(
1079            stream_active_quantity(order)?.as_decimal()
1080                + state.replaced_matched_quantity(&client_order_id)
1081                + state.replaced_voided_quantity(&client_order_id),
1082        )
1083        .ok()?;
1084
1085        let quantity =
1086            state.confirm_pending_reduction(&client_order_id, &order.id, active_quantity)?;
1087        Some((client_order_id, strategy_id, quantity))
1088    }
1089
1090    fn derive_fill_changes(
1091        context: &UnmatchedOrderContext<'_>,
1092        state: &mut OcmState,
1093    ) -> (Option<FillReport>, Vec<FillVoidAllocation>, bool) {
1094        let order = context.order;
1095        let price_restatement = state.fill_tracker.has_unseen_price_restatement(order);
1096        let has_applied_fill_lots = state.fill_tracker.has_fill_lots(&order.id);
1097        let size_matched = order.sm.unwrap_or(Decimal::ZERO);
1098        let size_voided = order.sv.unwrap_or(Decimal::ZERO);
1099        let gross_matched = size_matched + size_voided;
1100        let mut cumulative = order.clone();
1101
1102        cumulative.sm = Some(if has_applied_fill_lots {
1103            gross_matched
1104        } else {
1105            size_matched
1106        });
1107
1108        let fill = state.fill_tracker.maybe_fill_report(
1109            &cumulative,
1110            order.s,
1111            context.instrument_id,
1112            context.account_id,
1113            context.currency,
1114            context.ts_event,
1115            context.ts_init,
1116        );
1117
1118        if has_applied_fill_lots {
1119            return (
1120                fill,
1121                state.fill_tracker.maybe_fill_voids(order),
1122                price_restatement,
1123            );
1124        }
1125
1126        // A first-seen snapshot has no proof that Nautilus applied the voided portion.
1127        // Only anchor gross lifecycle quantity after emitting a surviving fill lot.
1128        if fill.is_some() {
1129            state.fill_tracker.sync_order(
1130                &order.id,
1131                gross_matched,
1132                order.avp.unwrap_or(Decimal::ZERO),
1133            );
1134        }
1135        state.fill_tracker.sync_voided_qty(&order.id, size_voided);
1136        (fill, Vec::new(), price_restatement)
1137    }
1138
1139    #[allow(
1140        clippy::too_many_arguments,
1141        reason = "tracked OCM routing needs resolved order, fill, and replace state"
1142    )]
1143    fn emit_tracked_order_events(
1144        context: &UnmatchedOrderContext<'_>,
1145        state: &mut OcmState,
1146        report: &OrderStatusReport,
1147        client_order_id: ClientOrderId,
1148        strategy_id: StrategyId,
1149        fill: Option<FillReport>,
1150        fill_voids: Vec<FillVoidAllocation>,
1151        cancel_action: CancelAction,
1152        price_restatement: bool,
1153    ) -> bool {
1154        if state.claim_acceptance(client_order_id, report.venue_order_id) {
1155            let accepted = OrderAccepted::new(
1156                context.emitter.trader_id(),
1157                strategy_id,
1158                context.instrument_id,
1159                client_order_id,
1160                report.venue_order_id,
1161                context.account_id,
1162                UUID4::new(),
1163                report.ts_accepted,
1164                context.ts_init,
1165                false,
1166            );
1167            context
1168                .emitter
1169                .send_order_event(OrderEventAny::Accepted(accepted));
1170        }
1171
1172        let has_fill = fill.is_some() && !price_restatement;
1173
1174        let causation_id = fill.map(|fill_report| {
1175            Self::emit_tracked_fill(context, report, client_order_id, strategy_id, &fill_report)
1176        });
1177
1178        let should_reclose = cancel_action.should_reclose_after_fill(
1179            report.order_status,
1180            has_fill,
1181            state.is_canceled_replace(&context.order.id),
1182        );
1183
1184        let reclose_before_void = should_reclose
1185            && !fill_voids.is_empty()
1186            && report.order_status == OrderStatus::Canceled;
1187
1188        if reclose_before_void {
1189            Self::emit_tracked_cancel(context, report, client_order_id, strategy_id);
1190        }
1191
1192        if !Self::emit_fill_voids(
1193            context,
1194            report,
1195            client_order_id,
1196            strategy_id,
1197            fill_voids,
1198            causation_id,
1199        ) {
1200            return false;
1201        }
1202
1203        let emit_cancel = match cancel_action {
1204            CancelAction::Emit => report.order_status == OrderStatus::Canceled,
1205            CancelAction::Suppress => false,
1206            CancelAction::RecloseAfterFill => should_reclose && !reclose_before_void,
1207        };
1208
1209        if emit_cancel {
1210            Self::emit_tracked_cancel(context, report, client_order_id, strategy_id);
1211        }
1212
1213        true
1214    }
1215
1216    fn emit_tracked_cancel(
1217        context: &UnmatchedOrderContext<'_>,
1218        report: &OrderStatusReport,
1219        client_order_id: ClientOrderId,
1220        strategy_id: StrategyId,
1221    ) {
1222        let canceled = OrderCanceled::new(
1223            context.emitter.trader_id(),
1224            strategy_id,
1225            context.instrument_id,
1226            client_order_id,
1227            UUID4::new(),
1228            report.ts_last,
1229            context.ts_init,
1230            false,
1231            Some(report.venue_order_id),
1232            Some(context.account_id),
1233            report.cancel_reason.as_deref().map(Ustr::from),
1234        );
1235        context
1236            .emitter
1237            .send_order_event(OrderEventAny::Canceled(canceled));
1238    }
1239
1240    fn emit_tracked_fill(
1241        context: &UnmatchedOrderContext<'_>,
1242        report: &OrderStatusReport,
1243        client_order_id: ClientOrderId,
1244        strategy_id: StrategyId,
1245        fill_report: &FillReport,
1246    ) -> UUID4 {
1247        log::debug!(
1248            "Fill: bet_id={}, last_qty={}, last_px={}",
1249            context.order.id,
1250            fill_report.last_qty,
1251            fill_report.last_px,
1252        );
1253        let filled = OrderFilled::new(
1254            context.emitter.trader_id(),
1255            strategy_id,
1256            context.instrument_id,
1257            client_order_id,
1258            fill_report.venue_order_id,
1259            context.account_id,
1260            fill_report.trade_id,
1261            fill_report.order_side,
1262            report.order_type,
1263            fill_report.last_qty,
1264            fill_report.last_px,
1265            context.currency,
1266            fill_report.liquidity_side,
1267            UUID4::new(),
1268            fill_report.ts_event,
1269            context.ts_init,
1270            false,
1271            fill_report.venue_position_id,
1272            Some(fill_report.commission),
1273            None,
1274        );
1275        let event_id = filled.event_id;
1276        context
1277            .emitter
1278            .send_order_event(OrderEventAny::Filled(filled));
1279        event_id
1280    }
1281
1282    fn emit_fill_voids(
1283        context: &UnmatchedOrderContext<'_>,
1284        report: &OrderStatusReport,
1285        client_order_id: ClientOrderId,
1286        strategy_id: StrategyId,
1287        fill_voids: Vec<FillVoidAllocation>,
1288        mut causation_id: Option<UUID4>,
1289    ) -> bool {
1290        if fill_voids.is_empty() {
1291            return Self::emit_terminal_fill_void(
1292                context,
1293                report,
1294                client_order_id,
1295                strategy_id,
1296                causation_id,
1297            );
1298        }
1299
1300        for allocation in fill_voids {
1301            let correction_id = Ustr::from(&format!(
1302                "{}-sv-{}-{}",
1303                context.order.id,
1304                context.order.sv.unwrap_or(Decimal::ZERO).normalize(),
1305                allocation.trade_id,
1306            ));
1307
1308            let mut fill_voided = OrderFillVoided::new(
1309                context.emitter.trader_id(),
1310                strategy_id,
1311                context.instrument_id,
1312                client_order_id,
1313                report.venue_order_id,
1314                context.account_id,
1315                correction_id,
1316                allocation.trade_id,
1317                allocation.voided_qty,
1318                None,
1319                OrderSide::from(context.order.side),
1320                report.order_type,
1321                allocation.last_px,
1322                context.currency,
1323                LiquiditySide::NoLiquiditySide,
1324                None,
1325                context.order.rc.as_deref().map(Ustr::from),
1326                None,
1327                UUID4::new(),
1328                report.ts_last,
1329                context.ts_init,
1330                false,
1331                false,
1332            );
1333
1334            fill_voided.causation_id = causation_id;
1335            causation_id = Some(fill_voided.event_id);
1336            context
1337                .emitter
1338                .send_order_event(OrderEventAny::FillVoided(fill_voided));
1339        }
1340
1341        true
1342    }
1343
1344    fn emit_terminal_fill_void(
1345        context: &UnmatchedOrderContext<'_>,
1346        report: &OrderStatusReport,
1347        client_order_id: ClientOrderId,
1348        strategy_id: StrategyId,
1349        causation_id: Option<UUID4>,
1350    ) -> bool {
1351        if report.order_status != OrderStatus::Voided {
1352            return true;
1353        }
1354        let order = context.order;
1355        let Ok(voided_qty) = parse_betfair_quantity(order.sv.unwrap_or(Decimal::ZERO)) else {
1356            log::warn!("Cannot parse voided quantity for bet_id={}", order.id);
1357            return false;
1358        };
1359        let Ok(last_px) = parse_betfair_price(order.avp.unwrap_or(order.p)) else {
1360            log::warn!("Cannot parse voided price for bet_id={}", order.id);
1361            return false;
1362        };
1363
1364        let mut voided = OrderFillVoided::new(
1365            context.emitter.trader_id(),
1366            strategy_id,
1367            context.instrument_id,
1368            client_order_id,
1369            report.venue_order_id,
1370            context.account_id,
1371            Ustr::from(&format!("{}-sv", order.id)),
1372            TradeId::new(format!("VOID-{}", order.id)),
1373            voided_qty,
1374            None,
1375            OrderSide::from(order.side),
1376            report.order_type,
1377            last_px,
1378            context.currency,
1379            LiquiditySide::NoLiquiditySide,
1380            None,
1381            order.rc.as_deref().map(Ustr::from),
1382            None,
1383            UUID4::new(),
1384            report.ts_last,
1385            context.ts_init,
1386            false,
1387            false,
1388        );
1389
1390        voided.causation_id = causation_id;
1391        context
1392            .emitter
1393            .send_order_event(OrderEventAny::FillVoided(voided));
1394        true
1395    }
1396
1397    fn emit_untracked_order_reports(
1398        context: &UnmatchedOrderContext<'_>,
1399        report: OrderStatusReport,
1400        fill: Option<FillReport>,
1401    ) {
1402        // The fill must precede the cumulative status report to avoid an inferred duplicate.
1403        if let Some(mut fill_report) = fill {
1404            fill_report.client_order_id = report.client_order_id;
1405
1406            log::debug!(
1407                "Fill: bet_id={}, last_qty={}, last_px={}",
1408                context.order.id,
1409                fill_report.last_qty,
1410                fill_report.last_px,
1411            );
1412            context.emitter.send_fill_report(fill_report);
1413        }
1414
1415        context.emitter.send_order_status_report(report);
1416    }
1417}
1418
1419#[async_trait(?Send)]
1420impl ExecutionClient for BetfairExecutionClient {
1421    fn is_connected(&self) -> bool {
1422        let connected = self.core.is_connected()
1423            && !self.reconciliation_gate.is_halted()
1424            && self
1425                .stream_client
1426                .as_ref()
1427                .is_some_and(|client| client.is_order_ready());
1428        if !connected {
1429            return false;
1430        }
1431
1432        // Drain any OCMs the network handler buffered during reconnect
1433        self.process_pending_resync();
1434        true
1435    }
1436
1437    fn client_id(&self) -> ClientId {
1438        self.core.client_id
1439    }
1440
1441    fn account_id(&self) -> AccountId {
1442        self.core.account_id
1443    }
1444
1445    fn venue(&self) -> Venue {
1446        *BETFAIR_VENUE
1447    }
1448
1449    fn oms_type(&self) -> OmsType {
1450        self.core.oms_type
1451    }
1452
1453    fn get_account(&self) -> Option<AccountAny> {
1454        self.core.cache().account_owned(&self.core.account_id)
1455    }
1456
1457    fn provides_bulk_position_coverage(&self, _instrument_id: InstrumentId) -> bool {
1458        false
1459    }
1460
1461    fn generate_account_state(
1462        &self,
1463        balances: Vec<AccountBalance>,
1464        margins: Vec<MarginBalance>,
1465        reported: bool,
1466        ts_event: UnixNanos,
1467        info: Option<Params>,
1468    ) -> anyhow::Result<()> {
1469        self.emitter
1470            .emit_account_state(balances, margins, reported, ts_event, info);
1471        Ok(())
1472    }
1473
1474    fn start(&mut self) -> anyhow::Result<()> {
1475        if self.core.is_started() {
1476            return Ok(());
1477        }
1478
1479        let sender = get_exec_event_sender();
1480        self.emitter.set_sender(sender);
1481        self.core.set_started();
1482
1483        log::info!(
1484            "Started: client_id={}, account_id={}",
1485            self.core.client_id,
1486            self.core.account_id,
1487        );
1488        Ok(())
1489    }
1490
1491    fn stop(&mut self) -> anyhow::Result<()> {
1492        if self.core.is_stopped() {
1493            return Ok(());
1494        }
1495
1496        self.core.set_stopped();
1497        self.core.set_disconnected();
1498        self.abort_session_tasks();
1499        self.abort_pending_tasks();
1500
1501        if let Some(control) = &self.socket_control {
1502            control.deregister();
1503        }
1504
1505        if let Some(client) = self.stream_client.as_ref() {
1506            client.begin_shutdown();
1507        }
1508
1509        self.clear_resync_state();
1510        log::info!("Stopped: client_id={}", self.core.client_id);
1511        Ok(())
1512    }
1513
1514    async fn connect(&mut self) -> anyhow::Result<()> {
1515        if self.core.is_connected() && self.session_tasks.is_open() && self.pending_tasks.is_open()
1516        {
1517            return Ok(());
1518        }
1519
1520        if !self.session_tasks.is_open() || !self.pending_tasks.is_open() {
1521            self.teardown_partial_connect().await?;
1522            self.session_tasks
1523                .start_generation()
1524                .map_err(|e| anyhow::anyhow!("Failed to start Betfair session generation: {e}"))?;
1525            self.pending_tasks
1526                .start_generation()
1527                .map_err(|e| anyhow::anyhow!("Failed to start Betfair task generation: {e}"))?;
1528        }
1529
1530        let http_cancellation = self.http_client.cancellation_token();
1531        let setup_guard =
1532            TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
1533                http_cancellation.cancel();
1534            });
1535
1536        register_betfair_custom_data();
1537
1538        let session_token_result = async {
1539            self.http_client
1540                .connect()
1541                .await
1542                .map_err(|e| anyhow::anyhow!("{e}"))?;
1543
1544            let funds: AccountFundsResponse = self
1545                .http_client
1546                .send_accounts(METHOD_GET_ACCOUNT_FUNDS, serde_json::json!({}))
1547                .await
1548                .map_err(|e| anyhow::anyhow!("{e}"))?;
1549
1550            let ts_init = self.clock.get_time_ns();
1551            let account_state = parse_account_state(
1552                &funds,
1553                self.core.account_id,
1554                self.currency,
1555                ts_init,
1556                ts_init,
1557            )?;
1558            self.emitter.send_account_state(account_state);
1559
1560            self.http_client
1561                .session_token()
1562                .await
1563                .ok_or_else(|| anyhow::anyhow!("No session token after login"))
1564        }
1565        .await;
1566        let session_token = match session_token_result {
1567            Ok(session_token) => session_token,
1568            Err(e) => {
1569                if let Err(teardown_error) = self.teardown_partial_connect().await {
1570                    return Err(e.context(format!(
1571                        "Betfair execution startup teardown failed: {teardown_error}"
1572                    )));
1573                }
1574                return Err(e);
1575            }
1576        };
1577
1578        // Sync OCM state from cached orders before stream connects
1579        self.sync_ocm_state_from_cache();
1580
1581        let market_ids_filter = self
1582            .config
1583            .stream_market_ids_filter
1584            .as_ref()
1585            .map(|ids| ids.iter().cloned().collect::<ahash::AHashSet<String>>());
1586
1587        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
1588        let queued_generation = Arc::new(AtomicU64::new(0));
1589        let (account_refresh_tx, mut account_refresh_rx) = tokio::sync::mpsc::unbounded_channel();
1590        self.account_refresh_tx = Some(account_refresh_tx.clone());
1591
1592        let handler = Self::create_ocm_handler(
1593            self.emitter.clone(),
1594            self.core.account_id,
1595            self.currency,
1596            Arc::clone(&self.ocm_state),
1597            get_data_event_sender(),
1598            market_ids_filter,
1599            self.config.ignore_external_orders,
1600            reconnect_tx,
1601            queued_generation,
1602            Arc::clone(&self.pending_resync),
1603            Arc::clone(&self.reconciliation_gate),
1604            Arc::clone(&self.replay_buffer),
1605            account_refresh_tx,
1606            self.clock,
1607        );
1608
1609        let transport_gate = Arc::clone(&self.reconciliation_gate);
1610        let transport_pending_resync = Arc::clone(&self.pending_resync);
1611        let transport_was_connected = Arc::new(AtomicBool::new(false));
1612        let halt_on_disconnect = move |state| match state {
1613            SocketState::Connected => {
1614                transport_was_connected.store(true, Ordering::Release);
1615            }
1616            SocketState::Disconnected => {
1617                if transport_was_connected.swap(false, Ordering::AcqRel) {
1618                    transport_gate.halt();
1619                    transport_pending_resync.store(true, Ordering::Release);
1620                }
1621            }
1622        };
1623        let state_sink = match self.socket_control.as_ref() {
1624            Some(control) => control.sink_with(halt_on_disconnect),
1625            None => SocketStateSink::new(halt_on_disconnect),
1626        };
1627
1628        let stream_client_result = BetfairStreamClient::connect_with_state_sink(
1629            &self.credential,
1630            session_token,
1631            handler,
1632            self.stream_config.clone(),
1633            HeartbeatTimeoutSource::Server,
1634            Some(state_sink),
1635        )
1636        .await;
1637        let stream_client = match stream_client_result {
1638            Ok(stream_client) => stream_client,
1639            Err(e) => {
1640                let e = anyhow::Error::new(e);
1641                if let Err(teardown_error) = self.teardown_partial_connect().await {
1642                    return Err(e.context(format!(
1643                        "Betfair execution startup teardown failed: {teardown_error}"
1644                    )));
1645                }
1646                return Err(e);
1647            }
1648        };
1649
1650        let stream_client = Arc::new(stream_client);
1651
1652        if let Err(e) = stream_client.subscribe_orders(None, None).await {
1653            if let Err(close_error) = stream_client.close().await {
1654                log::warn!(
1655                    "Failed to close Betfair order stream after subscribe failure: {close_error}"
1656                );
1657            }
1658            let e = anyhow::Error::new(e);
1659            if let Err(teardown_error) = self.teardown_partial_connect().await {
1660                return Err(e.context(format!(
1661                    "Betfair execution startup teardown failed: {teardown_error}"
1662                )));
1663            }
1664            return Err(e);
1665        }
1666
1667        self.stream_client = Some(Arc::clone(&stream_client));
1668        setup_guard.disarm();
1669        let http_cancellation = self.http_client.cancellation_token();
1670        let shutdown_stream = Arc::clone(&stream_client);
1671        let stream_guard =
1672            TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
1673                http_cancellation.cancel();
1674                shutdown_stream.begin_shutdown();
1675            });
1676
1677        let session_result = async {
1678
1679        // Spawn periodic keep-alive to prevent session expiry
1680        let keep_alive_client = Arc::clone(&self.http_client);
1681        let keep_alive_stream = Arc::clone(&stream_client);
1682        let keep_alive_app_key = self.credential.app_key().to_string();
1683
1684        self.session_tasks.spawn(async move {
1685            const KEEP_ALIVE_INTERVAL_SECS: u64 = 36_000;
1686            let interval = tokio::time::Duration::from_secs(KEEP_ALIVE_INTERVAL_SECS);
1687            loop {
1688                tokio::time::sleep(interval).await;
1689
1690                let (_, session_replaced) = match keep_alive_client.keep_alive_with_token().await {
1691                    Ok(token) => (token, false),
1692                    Err(ref e) if e.is_login_failed() => {
1693                        log::warn!("Betfair execution session expired, attempting re-login: {e}");
1694
1695                        match keep_alive_client.reconnect_with_token().await {
1696                            Ok(token) => (token, true),
1697                            Err(e) => {
1698                                log::warn!("Betfair execution re-login failed: {e}");
1699                                continue;
1700                            }
1701                        }
1702                    }
1703                    Err(e) => {
1704                        log::warn!("Betfair execution keep-alive failed (transient): {e}");
1705                        continue;
1706                    }
1707                };
1708                apply_stream_session_refresh(
1709                    keep_alive_client.as_ref(),
1710                    Some(&keep_alive_stream),
1711                    &keep_alive_app_key,
1712                    SessionRefresh {
1713                        refreshed: true,
1714                        replaced: session_replaced,
1715                    },
1716                )
1717                .await;
1718                log::debug!("Betfair execution session keep-alive sent");
1719            }
1720        })?;
1721
1722        let acct_client = Arc::clone(&self.http_client);
1723        let acct_emitter = self.emitter.clone();
1724        let acct_id = self.core.account_id;
1725        let acct_currency = self.currency;
1726        let acct_clock = self.clock;
1727        let periodic_interval = (self.config.calculate_account_state
1728            && self.config.request_account_state_secs > 0)
1729            .then(|| tokio::time::Duration::from_secs(self.config.request_account_state_secs));
1730
1731        self.session_tasks.spawn(async move {
1732            loop {
1733                let should_refresh = if let Some(interval) = periodic_interval {
1734                    tokio::select! {
1735                        request = account_refresh_rx.recv() => request.is_some(),
1736                        () = tokio::time::sleep(interval) => true,
1737                    }
1738                } else {
1739                    account_refresh_rx.recv().await.is_some()
1740                };
1741
1742                if !should_refresh {
1743                    return;
1744                }
1745
1746                while account_refresh_rx.try_recv().is_ok() {}
1747
1748                match acct_client
1749                    .send_accounts::<AccountFundsResponse, _>(
1750                        METHOD_GET_ACCOUNT_FUNDS,
1751                        serde_json::json!({}),
1752                    )
1753                    .await
1754                {
1755                    Ok(funds) => {
1756                        let ts_init = acct_clock.get_time_ns();
1757
1758                        match parse_account_state(&funds, acct_id, acct_currency, ts_init, ts_init)
1759                        {
1760                            Ok(state) => acct_emitter.send_account_state(state),
1761                            Err(e) => log::warn!("Failed to parse account state: {e}"),
1762                        }
1763                    }
1764                    Err(e) => log::warn!("Failed to fetch account state: {e}"),
1765                }
1766            }
1767        })?;
1768
1769        let reconnect_http = Arc::clone(&self.http_client);
1770        let reconnect_stream = Arc::clone(&stream_client);
1771        let reconnect_emitter = self.emitter.clone();
1772        let reconnect_app_key = self.credential.app_key().to_string();
1773        let reconnect_clock = self.clock;
1774        let reconnect_client_id = self.core.client_id;
1775        let reconnect_acct_id = self.core.account_id;
1776        let reconnect_currency = self.currency;
1777        let reconnect_market_ids = self.reconcile_market_ids();
1778        let reconnect_lookback_mins = self.config.stream_gap_recovery_lookback_mins;
1779        let reconnect_ocm_state = Arc::clone(&self.ocm_state);
1780        let reconnect_gate = Arc::clone(&self.reconciliation_gate);
1781
1782        self.session_tasks.spawn(async move {
1783            const RECOVERY_ATTEMPTS: usize = 4;
1784
1785            while let Some(generation) = reconnect_rx.recv().await {
1786                log::info!("Handling execution stream reconnection");
1787                let mut state_rx = reconnect_gate.subscribe();
1788
1789                for attempt in 0..RECOVERY_ATTEMPTS {
1790                    if !reconnect_gate.is_current(generation) {
1791                        break;
1792                    }
1793
1794                    let recovery = tokio::select! {
1795                        result = attempt_post_reconnect_recovery(
1796                        &reconnect_http,
1797                        &reconnect_stream,
1798                        &reconnect_app_key,
1799                        reconnect_client_id,
1800                        reconnect_acct_id,
1801                        reconnect_currency,
1802                        reconnect_clock,
1803                        reconnect_market_ids.clone(),
1804                        reconnect_lookback_mins,
1805                        &reconnect_ocm_state,
1806                        ) => Some(result),
1807                        () = wait_for_generation_change(&mut state_rx, generation) => None,
1808                    };
1809                    let Some(recovery) = recovery else {
1810                        break;
1811                    };
1812
1813                    match recovery {
1814                        Ok(recovery) => {
1815                            let committed = commit_post_reconnect_mass_status(
1816                                &reconnect_gate,
1817                                generation,
1818                                &reconnect_ocm_state,
1819                                &reconnect_emitter,
1820                                recovery,
1821                            );
1822
1823                            match committed {
1824                                Ok(Some((order_count, fill_count, account_state))) => {
1825                                    if let Some(account_state) = account_state {
1826                                        reconnect_emitter.send_account_state(account_state);
1827                                    }
1828                                    log::info!(
1829                                        "Post-reconnect reconciliation submitted: \
1830                                        orders={order_count}, fills={fill_count}",
1831                                    );
1832                                }
1833                                Ok(None) => log::info!(
1834                                    "A newer execution stream reconnect remains unreconciled",
1835                                ),
1836                                Err(e) => log::warn!(
1837                                    "Post-reconnect reconciliation publication failed: {e}",
1838                                ),
1839                            }
1840                            break;
1841                        }
1842                        Err(e) => {
1843                            let attempt_number = attempt + 1;
1844                            log::warn!(
1845                                "Post-reconnect reconciliation attempt \
1846                                 {attempt_number}/{RECOVERY_ATTEMPTS} failed: {e}",
1847                            );
1848
1849                            if attempt_number == RECOVERY_ATTEMPTS {
1850                                log::warn!(
1851                                    "Post-reconnect reconciliation exhausted retries; \
1852                                     submissions remain halted",
1853                                );
1854                                break;
1855                            }
1856
1857                            let delay_ms = 250_u64.saturating_mul(1_u64 << attempt);
1858                            tokio::select! {
1859                                () = tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)) => {}
1860                                () = wait_for_generation_change(&mut state_rx, generation) => break,
1861                            }
1862                        }
1863                    }
1864                }
1865            }
1866        })?;
1867
1868        Ok::<(), anyhow::Error>(())
1869        }
1870        .await;
1871
1872        if let Err(e) = session_result {
1873            if let Err(teardown_error) = self.teardown_partial_connect().await {
1874                return Err(e.context(format!(
1875                    "Betfair execution startup teardown failed: {teardown_error}"
1876                )));
1877            }
1878            return Err(e);
1879        }
1880
1881        if let Some(control) = &self.socket_control {
1882            let reconnect_stream = Arc::clone(&stream_client);
1883            control.register(move || reconnect_stream.request_reconnect_outcome());
1884        }
1885
1886        self.core.set_connected();
1887        stream_guard.disarm();
1888
1889        log::info!("Connected: client_id={}", self.core.client_id);
1890        Ok(())
1891    }
1892
1893    async fn disconnect(&mut self) -> anyhow::Result<()> {
1894        self.teardown_partial_connect().await?;
1895
1896        log::info!("Disconnected: client_id={}", self.core.client_id);
1897        Ok(())
1898    }
1899
1900    fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
1901        self.process_pending_resync();
1902
1903        let http_client = Arc::clone(&self.http_client);
1904        let emitter = self.emitter.clone();
1905        let account_id = self.core.account_id;
1906        let ocm_state = Arc::clone(&self.ocm_state);
1907        let clock = self.clock;
1908        let client_order_id = cmd.client_order_id;
1909        let venue_order_id = cmd.venue_order_id;
1910        let instrument_id = cmd.instrument_id;
1911        let stream_client = self.stream_client.as_ref().map(Arc::clone);
1912        let app_key = self.credential.app_key().to_string();
1913
1914        self.spawn_task("query_order", async move {
1915            let mut session_refresh = SessionRefresh::default();
1916            let stream_session = StreamSession {
1917                client: stream_client.as_ref(),
1918                app_key: &app_key,
1919            };
1920            let mut candidates: Vec<CurrentOrderSummary> = Vec::new();
1921            let mut seen_bet_ids: AHashSet<String> = AHashSet::new();
1922
1923            // Customer_order_ref lookup: Betfair reuses the ref across a
1924            // replace (old bet cancelled + new bet live), so this returns the
1925            // live replacement even when the cached bet_id is stale.
1926            let rfo = make_customer_order_ref(client_order_id.as_str());
1927            let rfo_params = list_current_orders_filter_ref(rfo.clone());
1928
1929            match list_current_orders_with_retry(
1930                &http_client,
1931                &rfo_params,
1932                stream_session,
1933                &mut session_refresh,
1934            )
1935            .await
1936            {
1937                Ok(r) => extend_unique(&mut candidates, &mut seen_bet_ids, r.current_orders),
1938                Err(e) => log::warn!("Betfair query_order ref lookup failed: {e}"),
1939            }
1940
1941            if candidates.is_empty() {
1942                let rfo_legacy = make_customer_order_ref_legacy(client_order_id.as_str());
1943                if rfo_legacy != rfo {
1944                    let legacy_params = list_current_orders_filter_ref(rfo_legacy);
1945
1946                    match list_current_orders_with_retry(
1947                        &http_client,
1948                        &legacy_params,
1949                        stream_session,
1950                        &mut session_refresh,
1951                    )
1952                    .await
1953                    {
1954                        Ok(r) => {
1955                            extend_unique(&mut candidates, &mut seen_bet_ids, r.current_orders);
1956                        }
1957                        Err(e) => log::warn!("Betfair query_order legacy lookup failed: {e}"),
1958                    }
1959                }
1960            }
1961
1962            // Always also query by bet_id when known. This rescues
1963            // pre-existing orders without a recognizable ref and orders whose
1964            // ref-based results came back as foreign-market collisions only.
1965            if let Some(ref bet_id) = venue_order_id {
1966                let params = list_current_orders_filter_bet_id(bet_id.to_string());
1967
1968                match list_current_orders_with_retry(
1969                    &http_client,
1970                    &params,
1971                    stream_session,
1972                    &mut session_refresh,
1973                )
1974                .await
1975                {
1976                    Ok(r) => extend_unique(&mut candidates, &mut seen_bet_ids, r.current_orders),
1977                    Err(e) => log::warn!("Betfair query_order bet_id lookup failed: {e}"),
1978                }
1979            }
1980
1981            let order = if candidates.is_empty() {
1982                log::warn!(
1983                    "Betfair query_order found no order for client_order_id={client_order_id}, venue_order_id={venue_order_id:?}",
1984                );
1985                None
1986            } else {
1987                select_order_for_query(
1988                    &candidates,
1989                    instrument_id,
1990                    client_order_id,
1991                    venue_order_id,
1992                )
1993            };
1994
1995            if let Some(order) = order {
1996                let ts_init = clock.get_time_ns();
1997                match parse_current_order_report(order, account_id, ts_init) {
1998                    Ok(mut report) => {
1999                        let update = {
2000                            let mut state = ocm_state.lock();
2001                            let update = resolve_query_order_report(
2002                                order,
2003                                &mut report,
2004                                client_order_id,
2005                                &mut state,
2006                                &emitter,
2007                            );
2008
2009                            let replaced_fills =
2010                                replaced_leg_fills_from_candidates(&candidates, order, &state);
2011                            let reduced_quantity = state.reduced_quantity(&order.bet_id);
2012                            if let Some(quantity) = reduced_quantity {
2013                                report.quantity = quantity;
2014                            } else if let Some(OrderEventAny::Updated(updated)) = &update {
2015                                report.quantity = updated.quantity;
2016                            }
2017
2018                            replaced_fills.fold(
2019                                &mut report,
2020                                reduced_quantity.is_some() || update.is_some(),
2021                            );
2022
2023                            update
2024                        };
2025
2026                        if let Some(update) = update {
2027                            emitter.send_order_event(update);
2028                        }
2029                        emitter.send_order_status_report(report);
2030                    }
2031                    Err(e) => {
2032                        log::warn!("Failed to parse order report for {}: {e}", order.bet_id);
2033                    }
2034                }
2035            }
2036
2037            apply_stream_session_refresh(
2038                http_client.as_ref(),
2039                stream_client.as_ref(),
2040                &app_key,
2041                session_refresh,
2042            )
2043            .await;
2044            Ok(())
2045        });
2046
2047        Ok(())
2048    }
2049
2050    async fn generate_mass_status(
2051        &self,
2052        lookback_mins: Option<u64>,
2053    ) -> anyhow::Result<Option<ExecutionMassStatus>> {
2054        self.process_pending_resync();
2055
2056        log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
2057
2058        let ts_now = self.clock.get_time_ns();
2059        let start = lookback_mins
2060            .map(DurationNanos::try_from_mins)
2061            .transpose()?
2062            .map(|lookback| ts_now.saturating_sub(lookback));
2063
2064        let mut session_refresh = SessionRefresh::default();
2065
2066        let fetched = fetch_order_status_reports_snapshot_http(
2067            &self.http_client,
2068            self.core.account_id,
2069            ts_now,
2070            self.reconcile_market_ids(),
2071            false,
2072            &self.ocm_state,
2073            StreamSession {
2074                client: self.stream_client.as_ref(),
2075                app_key: self.credential.app_key(),
2076            },
2077            &mut session_refresh,
2078        )
2079        .await;
2080
2081        apply_stream_session_refresh(
2082            self.http_client.as_ref(),
2083            self.stream_client.as_ref(),
2084            self.credential.app_key(),
2085            session_refresh,
2086        )
2087        .await;
2088
2089        let mut fetched = fetched?;
2090        assign_report_owners(&mut fetched.reports, &self.ocm_state.lock());
2091        retain_snapshot_fill_orders(&mut fetched.orders, start, None)?;
2092
2093        let snapshot = MassStatusSnapshot {
2094            client_id: self.core.client_id,
2095            account_id: self.core.account_id,
2096            currency: self.currency,
2097            ts_init: ts_now,
2098            order_reports: fetched.reports,
2099            active_quantities: fetched.active_quantities,
2100            voided_quantities: fetched.voided_quantities,
2101            fill_orders: fetched.orders,
2102            account_state: None,
2103        };
2104
2105        let mut state = self.ocm_state.lock();
2106        let mut staged_state = state.clone();
2107        let (mass_status, updates) = snapshot.build(&mut staged_state, &self.emitter)?;
2108
2109        log::info!(
2110            "Received {} OrderStatusReports",
2111            mass_status.order_reports().len()
2112        );
2113        log::info!(
2114            "Received {} FillReports",
2115            mass_status
2116                .fill_reports()
2117                .values()
2118                .map(Vec::len)
2119                .sum::<usize>(),
2120        );
2121
2122        for update in updates {
2123            self.emitter.try_send_order_event(update)?;
2124        }
2125
2126        // No await after commit: cancellation must not consume undelivered fills
2127        *state = staged_state;
2128        Ok(Some(mass_status))
2129    }
2130
2131    fn generate_order_status_reports_task(
2132        &self,
2133        cmd: &GenerateOrderStatusReports,
2134    ) -> Option<ExecutionReportTask<Vec<OrderStatusReport>>> {
2135        self.process_pending_resync();
2136
2137        let http_client = Arc::clone(&self.http_client);
2138        let stream_client = self.stream_client.clone();
2139        let app_key = self.credential.app_key().to_string();
2140        let account_id = self.core.account_id;
2141        let ts_init = self.clock.get_time_ns();
2142        let market_ids = self.reconcile_market_ids();
2143        let collection_state = Arc::clone(&self.ocm_state);
2144        let state = Arc::clone(&self.ocm_state);
2145        let emitter = self.emitter.clone();
2146        let command = cmd.clone();
2147        let filter = cmd.clone();
2148
2149        Some(ExecutionReportTask::new(
2150            async move {
2151                let mut session_refresh = SessionRefresh::default();
2152
2153                let result = collect_order_status_reports_http(
2154                    &http_client,
2155                    account_id,
2156                    ts_init,
2157                    market_ids,
2158                    Some(&command),
2159                    &collection_state,
2160                    StreamSession {
2161                        client: stream_client.as_ref(),
2162                        app_key: &app_key,
2163                    },
2164                    &mut session_refresh,
2165                )
2166                .await;
2167
2168                apply_stream_session_refresh(
2169                    &http_client,
2170                    stream_client.as_ref(),
2171                    &app_key,
2172                    session_refresh,
2173                )
2174                .await;
2175                result
2176            },
2177            move |fetched| {
2178                let reports = finish_order_status_reports(
2179                    fetched,
2180                    Some(&filter),
2181                    &state,
2182                    Some(&emitter),
2183                    true,
2184                );
2185                log::debug!("Generated {} order status reports", reports.len());
2186                Ok(reports)
2187            },
2188        ))
2189    }
2190
2191    fn generate_fill_reports_task(
2192        &self,
2193        cmd: &GenerateFillReports,
2194    ) -> Option<ExecutionReportTask<Vec<FillReport>>> {
2195        self.process_pending_resync();
2196
2197        let http_client = Arc::clone(&self.http_client);
2198        let stream_client = self.stream_client.clone();
2199        let app_key = self.credential.app_key().to_string();
2200        let account_id = self.core.account_id;
2201        let currency = self.currency;
2202        let ts_init = self.clock.get_time_ns();
2203        let market_ids = self.reconcile_market_ids();
2204        let state = Arc::clone(&self.ocm_state);
2205        let command = cmd.clone();
2206
2207        Some(ExecutionReportTask::new(
2208            async move {
2209                let date_range = fill_report_date_range(&command);
2210                let mut session_refresh = SessionRefresh::default();
2211
2212                let result = fetch_fill_orders_http(
2213                    &http_client,
2214                    market_ids,
2215                    date_range,
2216                    StreamSession {
2217                        client: stream_client.as_ref(),
2218                        app_key: &app_key,
2219                    },
2220                    &mut session_refresh,
2221                )
2222                .await;
2223
2224                apply_stream_session_refresh(
2225                    &http_client,
2226                    stream_client.as_ref(),
2227                    &app_key,
2228                    session_refresh,
2229                )
2230                .await;
2231                let mut orders = result?;
2232                retain_fill_orders(&mut orders, &command);
2233                Ok(orders)
2234            },
2235            move |orders| {
2236                let reports = finish_fill_reports(&orders, &state, account_id, currency, ts_init)?;
2237                log::debug!("Generated {} fill reports", reports.len());
2238                Ok(reports)
2239            },
2240        ))
2241    }
2242
2243    async fn generate_order_status_report(
2244        &self,
2245        _cmd: &GenerateOrderStatusReport,
2246    ) -> anyhow::Result<Option<OrderStatusReport>> {
2247        anyhow::bail!("Betfair single-order status reports are not supported")
2248    }
2249
2250    async fn generate_position_status_reports(
2251        &self,
2252        _cmd: &GeneratePositionStatusReports,
2253    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2254        anyhow::bail!("Betfair position status reports are not supported")
2255    }
2256
2257    async fn generate_order_status_reports(
2258        &self,
2259        cmd: &GenerateOrderStatusReports,
2260    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2261        self.process_pending_resync();
2262
2263        let mut session_refresh = SessionRefresh::default();
2264        let stream_session = StreamSession {
2265            client: self.stream_client.as_ref(),
2266            app_key: self.credential.app_key(),
2267        };
2268        let result = fetch_order_status_reports_http(
2269            &self.http_client,
2270            self.core.account_id,
2271            self.clock.get_time_ns(),
2272            self.reconcile_market_ids(),
2273            Some(cmd),
2274            &self.ocm_state,
2275            Some(&self.emitter),
2276            stream_session,
2277            &mut session_refresh,
2278        )
2279        .await;
2280
2281        apply_stream_session_refresh(
2282            self.http_client.as_ref(),
2283            self.stream_client.as_ref(),
2284            self.credential.app_key(),
2285            session_refresh,
2286        )
2287        .await;
2288        let reports = result?;
2289
2290        log::debug!("Generated {} order status reports", reports.len());
2291        Ok(reports)
2292    }
2293
2294    async fn generate_fill_reports(
2295        &self,
2296        cmd: GenerateFillReports,
2297    ) -> anyhow::Result<Vec<FillReport>> {
2298        self.process_pending_resync();
2299
2300        let date_range = fill_report_date_range(&cmd);
2301
2302        let mut session_refresh = SessionRefresh::default();
2303        let stream_session = StreamSession {
2304            client: self.stream_client.as_ref(),
2305            app_key: self.credential.app_key(),
2306        };
2307        let result = fetch_fill_reports_http(
2308            &self.http_client,
2309            self.core.account_id,
2310            self.currency,
2311            self.clock.get_time_ns(),
2312            self.reconcile_market_ids(),
2313            date_range,
2314            Some(&cmd),
2315            &self.ocm_state,
2316            stream_session,
2317            &mut session_refresh,
2318        )
2319        .await;
2320
2321        apply_stream_session_refresh(
2322            self.http_client.as_ref(),
2323            self.stream_client.as_ref(),
2324            self.credential.app_key(),
2325            session_refresh,
2326        )
2327        .await;
2328        let reports = result?;
2329
2330        log::debug!("Generated {} fill reports", reports.len());
2331        Ok(reports)
2332    }
2333
2334    fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
2335        self.process_pending_resync();
2336
2337        let order = self.core.get_order(&cmd.client_order_id)?;
2338
2339        if let Err(reason) = validate_order(&order) {
2340            self.emitter.emit_order_denied(&order, &reason.to_string());
2341            return Ok(());
2342        }
2343
2344        if self.submissions_halted() {
2345            log::warn!(
2346                "Halting submit for {} while the execution stream is unavailable or reconciling",
2347                order.client_order_id(),
2348            );
2349            self.emitter
2350                .emit_order_denied(&order, &OrderDeniedReason::StreamReconciling.to_string());
2351            return Ok(());
2352        }
2353
2354        if order.is_closed() {
2355            log::warn!("Cannot submit closed order {}", order.client_order_id());
2356            return Ok(());
2357        }
2358
2359        let instrument_id = order.instrument_id();
2360        let market_id = extract_market_id(&instrument_id)?;
2361        let (selection_id, handicap) = extract_selection_id(&instrument_id)?;
2362
2363        let instruction = create_place_instruction(&order, selection_id, handicap)?;
2364        let collision = self
2365            .ocm_state
2366            .lock()
2367            .register_submission(order.client_order_id(), order.strategy_id())
2368            .err();
2369
2370        if let Some(customer_order_ref) = collision {
2371            let reason = customer_order_ref_collision_reason(&customer_order_ref);
2372            log::warn!("Denying submit for {}: {reason}", order.client_order_id(),);
2373            self.emitter.emit_order_denied(&order, &reason);
2374            return Ok(());
2375        }
2376
2377        let market_version = self.get_market_version(&instrument_id);
2378
2379        let params = PlaceOrdersParams {
2380            market_id,
2381            instructions: vec![instruction],
2382            customer_ref: Some(order_customer_ref()),
2383            market_version,
2384            customer_strategy_ref: None,
2385        };
2386
2387        let client_order_id = order.client_order_id();
2388        let strategy_id = order.strategy_id();
2389
2390        log::debug!("OrderSubmitted client_order_id={client_order_id}");
2391        self.emitter.emit_order_submitted(&order);
2392
2393        let http_client = Arc::clone(&self.http_client);
2394        let emitter = self.emitter.clone();
2395        let clock = self.clock;
2396        let ocm_state = Arc::clone(&self.ocm_state);
2397
2398        self.spawn_task("submit-order", async move {
2399            let report: PlaceExecutionReport = match http_client
2400                .send_betting_order(METHOD_PLACE_ORDERS, &params)
2401                .await
2402            {
2403                Ok(r) => r,
2404                Err(e) => {
2405                    match classify_http_error(&e) {
2406                        CommandFailure::Ambiguous(_) => log::warn!(
2407                            "Ambiguous submit response for {client_order_id}: {e}. \
2408                             Order may be live, awaiting OCM reconciliation",
2409                        ),
2410                        CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {
2411                            let reason = format!("submit-order error: {e}");
2412                            emit_http_reject_if_unreported(
2413                                &ocm_state,
2414                                &client_order_id,
2415                                &reason,
2416                                || {
2417                                    let ts_event = clock.get_time_ns();
2418                                    emitter.emit_order_rejected_event(
2419                                        strategy_id,
2420                                        instrument_id,
2421                                        client_order_id,
2422                                        &reason,
2423                                        ts_event,
2424                                        false,
2425                                    );
2426                                },
2427                            );
2428                        }
2429                    }
2430                    return Ok(());
2431                }
2432            };
2433
2434            let instruction_report =
2435                single_instruction_report(report.instruction_reports.as_deref());
2436            let instruction_result = instruction_report.map(|ir| {
2437                classify_instruction_report(ir.status, ir.error_code, false, || {
2438                    format_place_instruction_reason(ir, &report)
2439                })
2440            });
2441            let result = classify_execution_report(
2442                report.status,
2443                report.error_code,
2444                instruction_result,
2445                || format_betfair_reason(report.error_code, None, "unknown error"),
2446            );
2447
2448            match result {
2449                Err(CommandFailure::Ambiguous(_)) => {
2450                    log::warn!(
2451                        "Ambiguous submit report for {client_order_id}. \
2452                     Order may be live, awaiting OCM reconciliation",
2453                    );
2454                }
2455                Err(CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason)) => {
2456                    emit_http_reject_if_unreported(&ocm_state, &client_order_id, &reason, || {
2457                        let ts_event = clock.get_time_ns();
2458                        emitter.emit_order_rejected_event(
2459                            strategy_id,
2460                            instrument_id,
2461                            client_order_id,
2462                            &reason,
2463                            ts_event,
2464                            false,
2465                        );
2466                    });
2467                }
2468                Ok(()) => {
2469                    if let Some(bet_id) = instruction_report.and_then(|ir| ir.bet_id.as_ref()) {
2470                        let venue_order_id = VenueOrderId::from(bet_id.as_str());
2471                        let ts_event = clock.get_time_ns();
2472
2473                        emit_http_accept_if_claimed(
2474                            &ocm_state,
2475                            &client_order_id,
2476                            venue_order_id,
2477                            || emitter.emit_order_accepted(&order, venue_order_id, ts_event),
2478                        );
2479                    } else {
2480                        log::warn!(
2481                            "Submit succeeded without a bet ID for {client_order_id}; \
2482                             awaiting OCM reconciliation",
2483                        );
2484                    }
2485                }
2486            }
2487
2488            Ok(())
2489        });
2490
2491        Ok(())
2492    }
2493
2494    fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
2495        self.process_pending_resync();
2496
2497        let instrument_id = cmd.instrument_id;
2498        let venue_order_id = match cmd.venue_order_id {
2499            Some(venue_order_id) => venue_order_id,
2500            None => {
2501                log::warn!(
2502                    "Cannot cancel order {}: no venue_order_id",
2503                    cmd.client_order_id
2504                );
2505                return Ok(());
2506            }
2507        };
2508        let market_id = match extract_market_id(&instrument_id) {
2509            Ok(market_id) => market_id,
2510            Err(e) => {
2511                log::warn!("Cannot cancel order {}: {e}", cmd.client_order_id);
2512                return Ok(());
2513            }
2514        };
2515        let bet_id: BetId = venue_order_id.to_string();
2516
2517        let params = CancelOrdersParams {
2518            market_id: Some(market_id),
2519            instructions: Some(vec![CancelInstruction {
2520                bet_id,
2521                size_reduction: None,
2522            }]),
2523            customer_ref: Some(order_customer_ref()),
2524        };
2525
2526        let client_order_id = cmd.client_order_id;
2527        let strategy_id = cmd.strategy_id;
2528        let http_client = Arc::clone(&self.http_client);
2529        let emitter = self.emitter.clone();
2530        let ocm_state = Arc::clone(&self.ocm_state);
2531        let clock = self.clock;
2532        let sp_bet = self
2533            .core
2534            .cache()
2535            .order(&client_order_id)
2536            .is_some_and(|order| is_sp_order(&order));
2537
2538        self.spawn_task("cancel-order", async move {
2539            let result: Result<CancelExecutionReport, _> = http_client
2540                .send_betting_order(METHOD_CANCEL_ORDERS, &params)
2541                .await;
2542
2543            let report = match result {
2544                Ok(r) => r,
2545                Err(e) => {
2546                    match classify_http_error(&e) {
2547                        CommandFailure::Ambiguous(_) => log::warn!(
2548                            "Ambiguous cancel response for {client_order_id}, awaiting OCM reconciliation: {e}",
2549                        ),
2550                        CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {
2551                            let reason = format!("cancel-order error: {e}");
2552                            let ts_event = clock.get_time_ns();
2553                            emitter.emit_order_cancel_rejected_event(
2554                                strategy_id,
2555                                instrument_id,
2556                                client_order_id,
2557                                Some(venue_order_id),
2558                                &reason,
2559                                ts_event,
2560                            );
2561                        }
2562                    }
2563                    return Ok(());
2564                }
2565            };
2566
2567            let instruction_report =
2568                single_instruction_report(report.instruction_reports.as_deref());
2569            let instruction_result = instruction_report.map(|ir| {
2570                classify_instruction_report(ir.status, ir.error_code, !sp_bet, || {
2571                    format_cancel_instruction_reason(ir.error_code, report.error_code)
2572                })
2573            });
2574            let result = classify_execution_report(
2575                report.status,
2576                report.error_code,
2577                instruction_result,
2578                || {
2579                    format_betfair_reason(report.error_code, None, "unknown error")
2580                },
2581            );
2582
2583            match result {
2584                Ok(()) => {
2585                    let bet_taken_or_lapsed = instruction_report.is_some_and(|ir| {
2586                        ir.error_code == Some(InstructionReportErrorCode::BetTakenOrLapsed)
2587                    });
2588
2589                    if bet_taken_or_lapsed {
2590                        log::debug!(
2591                            "Cancel {client_order_id}: BetTakenOrLapsed, treating as success",
2592                        );
2593                    }
2594
2595                    let old_terminal = {
2596                        let mut state = ocm_state.lock();
2597                        let old_terminal = state
2598                            .terminal_orders
2599                            .contains(venue_order_id.as_str());
2600                        let pending =
2601                            state.take_pending_replace(client_order_id, venue_order_id.as_str());
2602                        if pending.is_some() && old_terminal {
2603                            state.retain_terminal_order(
2604                                client_order_id,
2605                                venue_order_id.as_str(),
2606                            );
2607                        }
2608                        pending.is_some_and(|_| old_terminal)
2609                    };
2610
2611                    if old_terminal {
2612                        let ts_event = clock.get_time_ns();
2613                        let canceled = OrderCanceled::new(
2614                            emitter.trader_id(),
2615                            strategy_id,
2616                            instrument_id,
2617                            client_order_id,
2618                            UUID4::new(),
2619                            ts_event,
2620                            ts_event,
2621                            false,
2622                            Some(venue_order_id),
2623                            Some(emitter.account_id()),
2624                            None,
2625                        );
2626                        emitter.send_order_event(OrderEventAny::Canceled(canceled));
2627                    }
2628                }
2629                Err(CommandFailure::Ambiguous(_)) => log::warn!(
2630                    "Ambiguous cancel report for {client_order_id}, awaiting OCM reconciliation",
2631                ),
2632                Err(
2633                    CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason),
2634                ) => {
2635                    let ts_event = clock.get_time_ns();
2636                    emitter.emit_order_cancel_rejected_event(
2637                        strategy_id,
2638                        instrument_id,
2639                        client_order_id,
2640                        Some(venue_order_id),
2641                        &reason,
2642                        ts_event,
2643                    );
2644                }
2645            }
2646
2647            Ok(())
2648        });
2649
2650        Ok(())
2651    }
2652
2653    fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
2654        self.process_pending_resync();
2655
2656        let instrument_id = cmd.instrument_id;
2657        let market_id = extract_market_id(&instrument_id)?;
2658
2659        let venue_order_id = cmd
2660            .venue_order_id
2661            .ok_or_else(|| anyhow::anyhow!("Cannot modify order without venue_order_id"))?;
2662        let bet_id: BetId = venue_order_id.to_string();
2663
2664        // Compare against existing order to determine actual changes
2665        let existing_order = self.core.get_order(&cmd.client_order_id);
2666        let has_price_change = match (&cmd.price, &existing_order) {
2667            (Some(new_price), Ok(order)) => order.price() != Some(*new_price),
2668            (Some(_), Err(_)) => true,
2669            (None, _) => false,
2670        };
2671        let has_quantity_change = match (&cmd.quantity, &existing_order) {
2672            (Some(new_qty), Ok(order)) => order.quantity() != *new_qty,
2673            (Some(_), Err(_)) => true,
2674            (None, _) => false,
2675        };
2676
2677        // Betfair does not support atomic price+quantity modification
2678        if has_price_change && has_quantity_change {
2679            let ts_event = self.clock.get_time_ns();
2680            self.emitter.emit_order_modify_rejected_event(
2681                cmd.strategy_id,
2682                instrument_id,
2683                cmd.client_order_id,
2684                Some(venue_order_id),
2685                "cannot modify price and quantity simultaneously on Betfair",
2686                ts_event,
2687            );
2688            return Ok(());
2689        }
2690
2691        let client_order_id = cmd.client_order_id;
2692        let strategy_id = cmd.strategy_id;
2693        let http_client = Arc::clone(&self.http_client);
2694        let emitter = self.emitter.clone();
2695        let clock = self.clock;
2696
2697        if has_price_change {
2698            let new_price = cmd.price.unwrap().as_decimal();
2699            let old_bet_id = bet_id.clone();
2700            let update_price = cmd.price;
2701            let update_qty = existing_order.as_ref().ok().map(|order| order.quantity());
2702
2703            // Track pending replace so the OCM handler suppresses the
2704            // cancel event for the old bet that Betfair emits as part
2705            // of the replace operation.
2706            self.ocm_state.lock().register_pending_replace(
2707                client_order_id,
2708                old_bet_id.clone(),
2709                update_qty,
2710            );
2711
2712            let market_version = self.get_market_version(&instrument_id);
2713
2714            let params = ReplaceOrdersParams {
2715                market_id,
2716                instructions: vec![ReplaceInstruction { bet_id, new_price }],
2717                customer_ref: Some(order_customer_ref()),
2718                market_version,
2719            };
2720
2721            let ocm_state = Arc::clone(&self.ocm_state);
2722
2723            self.spawn_task("modify-order-price", async move {
2724                let result: Result<ReplaceExecutionReport, _> = http_client
2725                    .send_betting_order(METHOD_REPLACE_ORDERS, &params)
2726                    .await;
2727
2728                match result {
2729                    Ok(report) => {
2730                        let instruction_report =
2731                            single_instruction_report(report.instruction_reports.as_deref());
2732                        let instruction_result = instruction_report.map(|ir| {
2733                            classify_replace_instruction(
2734                                ir,
2735                                format_replace_instruction_reason(ir, &report),
2736                            )
2737                        });
2738                        let result = classify_execution_report(
2739                            report.status,
2740                            report.error_code,
2741                            instruction_result,
2742                            || {
2743                                format_betfair_reason(report.error_code, None, "unknown error")
2744                            },
2745                        );
2746
2747                        match result {
2748                            Ok(()) => {
2749                                let new_bet_id = instruction_report
2750                                    .and_then(|ir| ir.place_instruction_report.as_ref())
2751                                    .and_then(|ir| ir.bet_id.as_ref());
2752                                let Some(new_bet_id) = new_bet_id else {
2753                                    ocm_state.lock().mark_pending_replace_ambiguous(
2754                                        client_order_id,
2755                                        &old_bet_id,
2756                                    );
2757                                    log::warn!(
2758                                        "Replace succeeded without a new bet ID for {client_order_id}; \
2759                                         awaiting reconciliation",
2760                                    );
2761                                    return Ok(());
2762                                };
2763
2764                                let new_venue_order_id = VenueOrderId::from(new_bet_id.as_str());
2765                                let replace_was_pending = ocm_state
2766                                    .lock()
2767                                    .complete_pending_replace(
2768                                        client_order_id,
2769                                        &old_bet_id,
2770                                        new_venue_order_id,
2771                                    )
2772                                    .is_some();
2773
2774                                if replace_was_pending
2775                                    && let Some(quantity) = update_qty
2776                                {
2777                                    let ts_event = clock.get_time_ns();
2778                                    let updated = OrderUpdated::new(
2779                                        emitter.trader_id(),
2780                                        strategy_id,
2781                                        instrument_id,
2782                                        client_order_id,
2783                                        quantity,
2784                                        UUID4::new(),
2785                                        ts_event,
2786                                        ts_event,
2787                                        false,
2788                                        Some(new_venue_order_id),
2789                                        Some(emitter.account_id()),
2790                                        update_price,
2791                                        None,
2792                                        None,
2793                                        false,
2794                                    );
2795                                    emitter.send_order_event(OrderEventAny::Updated(updated));
2796                                }
2797                            }
2798                            Err(CommandFailure::Ambiguous(_)) => {
2799                                ocm_state.lock().mark_pending_replace_ambiguous(
2800                                    client_order_id,
2801                                    &old_bet_id,
2802                                );
2803                                log::warn!(
2804                                    "Ambiguous replace report for {client_order_id}, awaiting reconciliation",
2805                                );
2806                            }
2807                            Err(CommandFailure::VenueRejected(reason))
2808                                if replace_cancelled_without_replacement(instruction_report) =>
2809                            {
2810                                let replace_was_pending = {
2811                                    let mut state = ocm_state.lock();
2812                                    let replace_was_pending = state
2813                                        .take_pending_replace(client_order_id, &old_bet_id)
2814                                        .is_some();
2815
2816                                    if replace_was_pending {
2817                                        state.mark_canceled_replace(
2818                                            client_order_id,
2819                                            &old_bet_id,
2820                                        );
2821                                    }
2822                                    replace_was_pending
2823                                };
2824
2825                                if !replace_was_pending {
2826                                    return Ok(());
2827                                }
2828                                log::warn!(
2829                                    "Replace canceled {client_order_id} without placing its replacement: {reason}",
2830                                );
2831                                let ts_event = clock.get_time_ns();
2832                                let canceled = OrderCanceled::new(
2833                                    emitter.trader_id(),
2834                                    strategy_id,
2835                                    instrument_id,
2836                                    client_order_id,
2837                                    UUID4::new(),
2838                                    ts_event,
2839                                    ts_event,
2840                                    false,
2841                                    Some(venue_order_id),
2842                                    Some(emitter.account_id()),
2843                                    None,
2844                                );
2845                                emitter.send_order_event(OrderEventAny::Canceled(canceled));
2846                            }
2847                            Err(
2848                                CommandFailure::NotSent(reason)
2849                                | CommandFailure::VenueRejected(reason),
2850                            ) => {
2851                                emit_replace_failure(
2852                                    &ocm_state,
2853                                    &emitter,
2854                                    clock,
2855                                    strategy_id,
2856                                    instrument_id,
2857                                    client_order_id,
2858                                    venue_order_id,
2859                                    &old_bet_id,
2860                                    &reason,
2861                                );
2862                            }
2863                        }
2864                    }
2865                    Err(e) => {
2866                        match classify_http_error(&e) {
2867                            CommandFailure::Ambiguous(_) => {
2868                                ocm_state.lock().mark_pending_replace_ambiguous(
2869                                    client_order_id,
2870                                    &old_bet_id,
2871                                );
2872                                log::warn!(
2873                                    "Ambiguous replace response for {client_order_id}, awaiting reconciliation: {e}",
2874                                );
2875                            }
2876                            CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {
2877                                emit_replace_failure(
2878                                    &ocm_state,
2879                                    &emitter,
2880                                    clock,
2881                                    strategy_id,
2882                                    instrument_id,
2883                                    client_order_id,
2884                                    venue_order_id,
2885                                    &old_bet_id,
2886                                    &format!("modify-order error: {e}"),
2887                                );
2888                            }
2889                        }
2890                    }
2891                }
2892
2893                Ok(())
2894            });
2895        } else if has_quantity_change {
2896            // Quantity reduction via partial cancel
2897            let order = self.core.get_order(&client_order_id)?;
2898            let original_quantity = order.quantity();
2899            let requested_quantity = cmd.quantity.unwrap();
2900            let existing_qty = original_quantity.as_decimal();
2901            let new_qty = requested_quantity.as_decimal();
2902
2903            if new_qty >= existing_qty {
2904                let ts_event = self.clock.get_time_ns();
2905                self.emitter.emit_order_modify_rejected_event(
2906                    strategy_id,
2907                    instrument_id,
2908                    client_order_id,
2909                    Some(venue_order_id),
2910                    "can only reduce quantity on Betfair",
2911                    ts_event,
2912                );
2913                return Ok(());
2914            }
2915
2916            let size_reduction = existing_qty - new_qty;
2917            let reduction_bet_id = bet_id.clone();
2918            let params = CancelOrdersParams {
2919                market_id: Some(market_id),
2920                instructions: Some(vec![CancelInstruction {
2921                    bet_id,
2922                    size_reduction: Some(size_reduction),
2923                }]),
2924                customer_ref: Some(order_customer_ref()),
2925            };
2926
2927            // Register before sending so OCM can resolve before REST
2928            self.ocm_state.lock().register_pending_reduction(
2929                client_order_id,
2930                reduction_bet_id.clone(),
2931                original_quantity,
2932                requested_quantity,
2933            );
2934
2935            let ocm_state = Arc::clone(&self.ocm_state);
2936
2937            self.spawn_task("modify-order-quantity", async move {
2938                let result: Result<CancelExecutionReport, _> = http_client
2939                    .send_betting_order(METHOD_CANCEL_ORDERS, &params)
2940                    .await;
2941
2942                match result {
2943                    Err(e) => {
2944                        match classify_http_error(&e) {
2945                            CommandFailure::Ambiguous(_) => log::warn!(
2946                                "Ambiguous quantity reduction for {client_order_id}, awaiting reconciliation: {e}",
2947                            ),
2948                            CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {
2949                                ocm_state
2950                                    .lock()
2951                                    .clear_pending_reduction(&client_order_id, &reduction_bet_id);
2952
2953                                let ts_event = clock.get_time_ns();
2954                                emitter.emit_order_modify_rejected_event(
2955                                    strategy_id,
2956                                    instrument_id,
2957                                    client_order_id,
2958                                    Some(venue_order_id),
2959                                    &format!("modify-order error: {e}"),
2960                                    ts_event,
2961                                );
2962                            }
2963                        }
2964                    }
2965                    Ok(report) => {
2966                        let instruction_report =
2967                            single_instruction_report(report.instruction_reports.as_deref());
2968                        let instruction_result = instruction_report.map(|ir| {
2969                            classify_instruction_report(ir.status, ir.error_code, false, || {
2970                                format_cancel_instruction_reason(ir.error_code, report.error_code)
2971                            })
2972                        });
2973                        let result = classify_execution_report(
2974                            report.status,
2975                            report.error_code,
2976                            instruction_result,
2977                            || {
2978                                format_betfair_reason(report.error_code, None, "unknown error")
2979                            },
2980                        );
2981
2982                        match result {
2983                            Err(CommandFailure::Ambiguous(_)) => log::warn!(
2984                                "Ambiguous quantity reduction report for {client_order_id}, awaiting reconciliation",
2985                            ),
2986                            Err(
2987                                CommandFailure::NotSent(reason)
2988                                | CommandFailure::VenueRejected(reason),
2989                            ) => {
2990                                ocm_state
2991                                    .lock()
2992                                    .clear_pending_reduction(&client_order_id, &reduction_bet_id);
2993
2994                                let ts_event = clock.get_time_ns();
2995                                emitter.emit_order_modify_rejected_event(
2996                                    strategy_id,
2997                                    instrument_id,
2998                                    client_order_id,
2999                                    Some(venue_order_id),
3000                                    &reason,
3001                                    ts_event,
3002                                );
3003                            }
3004                            Ok(()) => {
3005                                let Some(updated_quantity) = instruction_report
3006                                    .and_then(|ir| ir.size_cancelled)
3007                                    .and_then(|cancelled| {
3008                                        parse_betfair_quantity(existing_qty - cancelled).ok()
3009                                    })
3010                                else {
3011                                    log::warn!(
3012                                        "Quantity reduction succeeded without a valid cancelled size for {client_order_id}; \
3013                                         awaiting reconciliation",
3014                                    );
3015                                    return Ok(());
3016                                };
3017
3018                                let newly_resolved = ocm_state
3019                                    .lock()
3020                                    .complete_pending_reduction(
3021                                        &client_order_id,
3022                                        &reduction_bet_id,
3023                                        updated_quantity,
3024                                    );
3025
3026                                if !newly_resolved {
3027                                    log::debug!(
3028                                        "Suppressing late reduction update for {client_order_id}: \
3029                                         already resolved from another channel",
3030                                    );
3031                                    return Ok(());
3032                                }
3033
3034                                let ts_event = clock.get_time_ns();
3035                                let updated = OrderUpdated::new(
3036                                    emitter.trader_id(),
3037                                    strategy_id,
3038                                    instrument_id,
3039                                    client_order_id,
3040                                    updated_quantity,
3041                                    UUID4::new(),
3042                                    ts_event,
3043                                    ts_event,
3044                                    false,
3045                                    Some(venue_order_id),
3046                                    Some(emitter.account_id()),
3047                                    None,
3048                                    None,
3049                                    None,
3050                                    false,
3051                                );
3052                                emitter.send_order_event(OrderEventAny::Updated(updated));
3053                            }
3054                        }
3055                    }
3056                }
3057
3058                Ok(())
3059            });
3060        } else {
3061            let ts_event = self.clock.get_time_ns();
3062            self.emitter.emit_order_modify_rejected_event(
3063                strategy_id,
3064                instrument_id,
3065                client_order_id,
3066                Some(venue_order_id),
3067                "no effective change in price or quantity",
3068                ts_event,
3069            );
3070        }
3071
3072        Ok(())
3073    }
3074
3075    fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
3076        self.process_pending_resync();
3077
3078        let instrument_id = cmd.instrument_id;
3079        let market_id = match extract_market_id(&instrument_id) {
3080            Ok(market_id) => market_id,
3081            Err(e) => {
3082                log::warn!("Cannot cancel all orders for {instrument_id}: {e}");
3083                return Ok(());
3084            }
3085        };
3086
3087        let Some(order_side) = cmd.order_side else {
3088            let params = CancelOrdersParams {
3089                market_id: Some(market_id),
3090                instructions: None,
3091                customer_ref: Some(order_customer_ref()),
3092            };
3093
3094            let http_client = Arc::clone(&self.http_client);
3095
3096            self.spawn_task("cancel-all-orders", async move {
3097                let result = http_client
3098                    .send_betting_order::<serde_json::Value, _>(METHOD_CANCEL_ORDERS, &params)
3099                    .await;
3100
3101                if let Err(e) = result {
3102                    log::warn!("Failed to cancel all orders: {e}");
3103                }
3104
3105                Ok(())
3106            });
3107
3108            return Ok(());
3109        };
3110
3111        let cache = self.core.cache();
3112        let orders = cache.orders_open_refs(
3113            Some(&self.core.venue),
3114            Some(&instrument_id),
3115            None,
3116            Some(&self.core.account_id),
3117            Some(order_side),
3118        );
3119        let mut cancels = Vec::with_capacity(orders.len());
3120
3121        for order in orders {
3122            let client_order_id = order.client_order_id();
3123            if cache.client_id(&client_order_id) != Some(&self.core.client_id) {
3124                continue;
3125            }
3126
3127            let Some(venue_order_id) = order.venue_order_id() else {
3128                log::warn!(
3129                    "Cannot cancel all {order_side} orders for {instrument_id}: \
3130                     order {client_order_id} has no venue_order_id",
3131                );
3132                return Ok(());
3133            };
3134
3135            cancels.push(CancelOrderData {
3136                strategy_id: order.strategy_id(),
3137                instrument_id: order.instrument_id(),
3138                client_order_id,
3139                venue_order_id,
3140                sp_bet: is_sp_order(&order),
3141            });
3142        }
3143        drop(cache);
3144
3145        if cancels.is_empty() {
3146            return Ok(());
3147        }
3148
3149        self.spawn_cancel_orders("cancel-all-orders-by-side", market_id, cancels, false);
3150
3151        Ok(())
3152    }
3153
3154    fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
3155        self.process_pending_resync();
3156
3157        let instrument_id = cmd.instrument_id;
3158        let market_id = match extract_market_id(&instrument_id) {
3159            Ok(market_id) => market_id,
3160            Err(e) => {
3161                log::warn!("Cannot batch cancel orders for {instrument_id}: {e}");
3162                return Ok(());
3163            }
3164        };
3165
3166        let mut cancels = Vec::new();
3167
3168        let cache = self.core.cache();
3169
3170        for cancel in &cmd.cancels {
3171            match cancel.venue_order_id {
3172                Some(venue_order_id) => {
3173                    let sp_bet = cache
3174                        .order(&cancel.client_order_id)
3175                        .is_some_and(|order| is_sp_order(&order));
3176                    cancels.push(CancelOrderData {
3177                        strategy_id: cancel.strategy_id,
3178                        instrument_id: cancel.instrument_id,
3179                        client_order_id: cancel.client_order_id,
3180                        venue_order_id,
3181                        sp_bet,
3182                    });
3183                }
3184                None => {
3185                    log::warn!(
3186                        "Cannot batch cancel order {}: no venue_order_id",
3187                        cancel.client_order_id,
3188                    );
3189                }
3190            }
3191        }
3192
3193        if cancels.is_empty() {
3194            return Ok(());
3195        }
3196
3197        self.spawn_cancel_orders("batch-cancel-orders", market_id, cancels, true);
3198
3199        Ok(())
3200    }
3201
3202    fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
3203        self.process_pending_resync();
3204
3205        let orders = self.core.get_orders_for_list(&cmd.order_list)?;
3206
3207        if let Some(reason) = orders.iter().find_map(|order| validate_order(order).err()) {
3208            let reason = reason.to_string();
3209
3210            for order in &orders {
3211                self.emitter.emit_order_denied(order, &reason);
3212            }
3213
3214            return Ok(());
3215        }
3216
3217        if self.submissions_halted() {
3218            log::warn!(
3219                "Halting submit_order_list ({} orders) while the execution stream is \
3220                 unavailable or reconciling",
3221                cmd.order_list.client_order_ids.len(),
3222            );
3223
3224            let denied = OrderDeniedReason::StreamReconciling.to_string();
3225
3226            for client_order_id in &cmd.order_list.client_order_ids {
3227                if let Ok(order) = self.core.get_order(client_order_id) {
3228                    self.emitter.emit_order_denied(&order, &denied);
3229                }
3230            }
3231            return Ok(());
3232        }
3233
3234        let instrument_id = cmd.instrument_id;
3235        let market_id = extract_market_id(&instrument_id)?;
3236        let (selection_id, handicap) = extract_selection_id(&instrument_id)?;
3237
3238        let mut candidates = Vec::new();
3239
3240        for order in orders {
3241            if order.is_closed() {
3242                log::warn!("Skipping closed order {}", order.client_order_id());
3243                continue;
3244            }
3245
3246            let instruction = create_place_instruction(&order, selection_id, handicap)?;
3247            candidates.push((instruction, order));
3248        }
3249
3250        let mut instructions = Vec::new();
3251        let mut order_snapshots = Vec::new();
3252        let mut collisions = Vec::new();
3253        {
3254            let mut state = self.ocm_state.lock();
3255
3256            for (instruction, order) in candidates {
3257                match state.register_submission(order.client_order_id(), order.strategy_id()) {
3258                    Ok(()) => {
3259                        instructions.push(instruction);
3260                        order_snapshots.push((order.client_order_id(), order.strategy_id(), order));
3261                    }
3262                    Err(customer_order_ref) => {
3263                        collisions.push((order, customer_order_ref));
3264                    }
3265                }
3266            }
3267        }
3268
3269        for (order, customer_order_ref) in collisions {
3270            let reason = customer_order_ref_collision_reason(&customer_order_ref);
3271            log::warn!(
3272                "Denying order list leg {}: {reason}",
3273                order.client_order_id(),
3274            );
3275            self.emitter.emit_order_denied(&order, &reason);
3276        }
3277
3278        if instructions.is_empty() {
3279            return Ok(());
3280        }
3281
3282        let market_version = self.get_market_version(&instrument_id);
3283
3284        let params = PlaceOrdersParams {
3285            market_id,
3286            instructions,
3287            customer_ref: Some(order_customer_ref()),
3288            market_version,
3289            customer_strategy_ref: None,
3290        };
3291
3292        for (_, _, order) in &order_snapshots {
3293            log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
3294            self.emitter.emit_order_submitted(order);
3295        }
3296
3297        let http_client = Arc::clone(&self.http_client);
3298        let emitter = self.emitter.clone();
3299        let clock = self.clock;
3300        let ocm_state = Arc::clone(&self.ocm_state);
3301
3302        self.spawn_task("submit-order-list", async move {
3303            let report: PlaceExecutionReport = match http_client
3304                .send_betting_order(METHOD_PLACE_ORDERS, &params)
3305                .await
3306            {
3307                Ok(r) => r,
3308                Err(e) => {
3309                    match classify_http_error(&e) {
3310                        CommandFailure::Ambiguous(_) => log::warn!(
3311                            "Ambiguous submit response for order list: {e}. \
3312                             Orders may be live, awaiting OCM reconciliation",
3313                        ),
3314                        CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {
3315                            let ts_event = clock.get_time_ns();
3316                            let reason = format!("submit-order-list error: {e}");
3317
3318                            for (client_oid, strategy_id, _) in &order_snapshots {
3319                                emit_http_reject_if_unreported(
3320                                    &ocm_state,
3321                                    client_oid,
3322                                    &reason,
3323                                    || {
3324                                        emitter.emit_order_rejected_event(
3325                                            *strategy_id,
3326                                            instrument_id,
3327                                            *client_oid,
3328                                            &reason,
3329                                            ts_event,
3330                                            false,
3331                                        );
3332                                    },
3333                                );
3334                            }
3335                        }
3336                    }
3337                    return Ok(());
3338                }
3339            };
3340
3341            let instruction_reports = report.instruction_reports.as_deref().unwrap_or_default();
3342            if instruction_reports.len() > order_snapshots.len() {
3343                log::warn!(
3344                    "Order list returned {} reports for {} instructions; ignoring unmatched reports",
3345                    instruction_reports.len(),
3346                    order_snapshots.len(),
3347                );
3348            }
3349
3350            for (index, (client_oid, strategy_id, order)) in
3351                order_snapshots.iter().enumerate()
3352            {
3353                let instruction_report = instruction_reports.get(index);
3354                let instruction_result = instruction_report.map(|ir| {
3355                    classify_instruction_report(ir.status, ir.error_code, false, || {
3356                        format_place_instruction_reason(ir, &report)
3357                    })
3358                });
3359                let result = classify_execution_report(
3360                    report.status,
3361                    report.error_code,
3362                    instruction_result,
3363                    || {
3364                        format_betfair_reason(report.error_code, None, "unknown error")
3365                    },
3366                );
3367
3368                match result {
3369                    Ok(()) => {
3370                        if let Some(bet_id) = instruction_report.and_then(|ir| ir.bet_id.as_ref()) {
3371                            let venue_order_id = VenueOrderId::from(bet_id.as_str());
3372                            let ts_event = clock.get_time_ns();
3373                            emit_http_accept_if_claimed(
3374                                &ocm_state,
3375                                client_oid,
3376                                venue_order_id,
3377                                || emitter.emit_order_accepted(order, venue_order_id, ts_event),
3378                            );
3379                        } else {
3380                            log::warn!(
3381                                "Submit succeeded without a bet ID for {client_oid}; \
3382                                 awaiting OCM reconciliation",
3383                            );
3384                        }
3385                    }
3386                    Err(CommandFailure::Ambiguous(_)) => log::warn!(
3387                        "Ambiguous submit result for {client_oid}, awaiting OCM reconciliation",
3388                    ),
3389                    Err(
3390                        CommandFailure::NotSent(reason)
3391                        | CommandFailure::VenueRejected(reason),
3392                    ) => {
3393                        emit_http_reject_if_unreported(
3394                            &ocm_state,
3395                            client_oid,
3396                            &reason,
3397                            || {
3398                                let ts_event = clock.get_time_ns();
3399                                emitter.emit_order_rejected_event(
3400                                    *strategy_id,
3401                                    instrument_id,
3402                                    *client_oid,
3403                                    &reason,
3404                                    ts_event,
3405                                    false,
3406                                );
3407                            },
3408                        );
3409                    }
3410                }
3411            }
3412
3413            Ok(())
3414        });
3415
3416        Ok(())
3417    }
3418}
3419
3420const MAX_CANCEL_ORDERS_INSTRUCTIONS: usize = 60;
3421
3422#[derive(Clone, Copy, Debug)]
3423struct CancelOrderData {
3424    strategy_id: StrategyId,
3425    instrument_id: InstrumentId,
3426    client_order_id: ClientOrderId,
3427    venue_order_id: VenueOrderId,
3428    sp_bet: bool,
3429}
3430
3431impl BetfairExecutionClient {
3432    fn spawn_cancel_orders(
3433        &self,
3434        task_name: &'static str,
3435        market_id: String,
3436        cancels: Vec<CancelOrderData>,
3437        emit_rejections: bool,
3438    ) {
3439        let http_client = Arc::clone(&self.http_client);
3440        let emitter = self.emitter.clone();
3441        let clock = self.clock;
3442
3443        self.spawn_task(task_name, async move {
3444            for cancels in cancels.chunks(MAX_CANCEL_ORDERS_INSTRUCTIONS) {
3445                let params = CancelOrdersParams {
3446                    market_id: Some(market_id.clone()),
3447                    instructions: Some(
3448                        cancels
3449                            .iter()
3450                            .map(|cancel| CancelInstruction {
3451                                bet_id: cancel.venue_order_id.to_string(),
3452                                size_reduction: None,
3453                            })
3454                            .collect(),
3455                    ),
3456                    customer_ref: Some(order_customer_ref()),
3457                };
3458                let report: CancelExecutionReport = match http_client
3459                    .send_betting_order(METHOD_CANCEL_ORDERS, &params)
3460                    .await
3461                {
3462                    Ok(report) => report,
3463                    Err(e) => {
3464                        match classify_http_error(&e) {
3465                            CommandFailure::Ambiguous(_) => log::warn!(
3466                                "Ambiguous {task_name} response for {} orders, awaiting OCM reconciliation: {e}",
3467                                cancels.len(),
3468                            ),
3469                            CommandFailure::NotSent(_) | CommandFailure::VenueRejected(_) => {
3470                                let reason = format!("{task_name} error: {e}");
3471
3472                                if emit_rejections {
3473                                    let ts_event = clock.get_time_ns();
3474
3475                                    for cancel in cancels {
3476                                        emitter.emit_order_cancel_rejected_event(
3477                                            cancel.strategy_id,
3478                                            cancel.instrument_id,
3479                                            cancel.client_order_id,
3480                                            Some(cancel.venue_order_id),
3481                                            &reason,
3482                                            ts_event,
3483                                        );
3484                                    }
3485                                } else {
3486                                    for cancel in cancels {
3487                                        log::warn!(
3488                                            "Cancel {} was rejected: {reason}",
3489                                            cancel.client_order_id,
3490                                        );
3491                                    }
3492                                }
3493                            }
3494                        }
3495                        continue;
3496                    }
3497                };
3498
3499                let instruction_reports = report.instruction_reports.as_deref().unwrap_or_default();
3500                if instruction_reports.len() > cancels.len() {
3501                    log::warn!(
3502                        "{task_name} returned {} reports for {} instructions; ignoring unmatched reports",
3503                        instruction_reports.len(),
3504                        cancels.len(),
3505                    );
3506                }
3507
3508                for (index, cancel) in cancels.iter().enumerate() {
3509                    let instruction_report = instruction_reports.get(index);
3510                    let instruction_result = instruction_report.map(|ir| {
3511                        classify_instruction_report(ir.status, ir.error_code, !cancel.sp_bet, || {
3512                            format_cancel_instruction_reason(ir.error_code, report.error_code)
3513                        })
3514                    });
3515                    let result = classify_execution_report(
3516                        report.status,
3517                        report.error_code,
3518                        instruction_result,
3519                        || format_betfair_reason(report.error_code, None, "unknown error"),
3520                    );
3521
3522                    match result {
3523                        Ok(()) => {
3524                            if instruction_report.is_some_and(|ir| {
3525                                ir.error_code == Some(InstructionReportErrorCode::BetTakenOrLapsed)
3526                            }) {
3527                                log::debug!(
3528                                    "Cancel {}: BetTakenOrLapsed, treating as success",
3529                                    cancel.client_order_id,
3530                                );
3531                            }
3532                        }
3533                        Err(CommandFailure::Ambiguous(_)) => log::warn!(
3534                            "Ambiguous cancel result for {}, awaiting OCM reconciliation",
3535                            cancel.client_order_id,
3536                        ),
3537                        Err(
3538                            CommandFailure::NotSent(reason)
3539                            | CommandFailure::VenueRejected(reason),
3540                        ) => {
3541                            if emit_rejections {
3542                                emitter.emit_order_cancel_rejected_event(
3543                                    cancel.strategy_id,
3544                                    cancel.instrument_id,
3545                                    cancel.client_order_id,
3546                                    Some(cancel.venue_order_id),
3547                                    &reason,
3548                                    clock.get_time_ns(),
3549                                );
3550                            } else {
3551                                log::warn!(
3552                                    "Cancel {} was rejected: {reason}",
3553                                    cancel.client_order_id,
3554                                );
3555                            }
3556                        }
3557                    }
3558                }
3559            }
3560
3561            Ok(())
3562        });
3563    }
3564}
3565
3566fn validate_order(order: &impl Order) -> Result<(), OrderDeniedReason> {
3567    if order.is_reduce_only() {
3568        return Err(OrderDeniedReason::UnsupportedReduceOnly);
3569    }
3570
3571    match order.order_type() {
3572        OrderType::Limit => Ok(()),
3573        OrderType::Market if order.time_in_force() != TimeInForce::AtTheClose => Err(
3574            OrderDeniedReason::UnsupportedTimeInForce(order.time_in_force()),
3575        ),
3576        OrderType::Market => Ok(()),
3577        order_type => Err(OrderDeniedReason::UnsupportedOrderType { order_type }),
3578    }
3579}
3580
3581// SP bets cannot be cancelled once placed.
3582fn is_sp_order(order: &OrderAny) -> bool {
3583    matches!(
3584        order.time_in_force(),
3585        TimeInForce::AtTheClose | TimeInForce::AtTheOpen
3586    )
3587}
3588
3589fn create_place_instruction(
3590    order: &impl Order,
3591    selection_id: SelectionId,
3592    handicap: Decimal,
3593) -> anyhow::Result<PlaceInstruction> {
3594    let side = BetfairSide::from(order.order_side());
3595    let size = order.quantity().as_decimal();
3596    let handicap = (handicap != Decimal::ZERO).then_some(handicap);
3597    let customer_order_ref = Some(make_customer_order_ref(order.client_order_id().as_str()));
3598
3599    match order.order_type() {
3600        OrderType::Limit => {
3601            let price = order
3602                .price()
3603                .ok_or_else(|| anyhow::anyhow!("Limit order missing price"))?
3604                .as_decimal();
3605
3606            if matches!(
3607                order.time_in_force(),
3608                TimeInForce::AtTheClose | TimeInForce::AtTheOpen
3609            ) {
3610                return Ok(PlaceInstruction {
3611                    order_type: BetfairOrderType::LimitOnClose,
3612                    selection_id,
3613                    handicap,
3614                    side,
3615                    limit_order: None,
3616                    limit_on_close_order: Some(LimitOnCloseOrder {
3617                        liability: size,
3618                        price,
3619                    }),
3620                    market_on_close_order: None,
3621                    customer_order_ref,
3622                });
3623            }
3624
3625            let (persistence_type, time_in_force, min_fill_size) = match order.time_in_force() {
3626                TimeInForce::Ioc => (
3627                    None,
3628                    Some(BetfairTimeInForce::FillOrKill),
3629                    Some(Decimal::ZERO),
3630                ),
3631                TimeInForce::Fok => (None, Some(BetfairTimeInForce::FillOrKill), None),
3632                TimeInForce::Gtc => (Some(PersistenceType::Persist), None, None),
3633                _ => (Some(PersistenceType::Lapse), None, None),
3634            };
3635
3636            Ok(PlaceInstruction {
3637                order_type: BetfairOrderType::Limit,
3638                selection_id,
3639                handicap,
3640                side,
3641                limit_order: Some(LimitOrder {
3642                    size,
3643                    price,
3644                    persistence_type,
3645                    time_in_force,
3646                    min_fill_size,
3647                    bet_target_type: None,
3648                    bet_target_size: None,
3649                }),
3650                limit_on_close_order: None,
3651                market_on_close_order: None,
3652                customer_order_ref,
3653            })
3654        }
3655        OrderType::Market => {
3656            if order.time_in_force() != TimeInForce::AtTheClose {
3657                anyhow::bail!(
3658                    "Market orders on Betfair are only supported with AtTheClose \
3659                     time in force (BSP MarketOnClose)"
3660                );
3661            }
3662
3663            Ok(PlaceInstruction {
3664                order_type: BetfairOrderType::MarketOnClose,
3665                selection_id,
3666                handicap,
3667                side,
3668                limit_order: None,
3669                limit_on_close_order: None,
3670                market_on_close_order: Some(MarketOnCloseOrder { liability: size }),
3671                customer_order_ref,
3672            })
3673        }
3674        other => anyhow::bail!("Unsupported order type for Betfair: {other:?}"),
3675    }
3676}
3677
3678fn customer_order_ref_collision_reason(customer_order_ref: &str) -> String {
3679    OrderDeniedReason::ValidationFailed {
3680        detail: format!(
3681            "customerOrderRef {customer_order_ref} collides with another tracked order"
3682        ),
3683    }
3684    .to_string()
3685}
3686
3687// Even states admit submissions; each halt advances to a distinct odd generation.
3688// The commit lock makes generation validation, publication, and reopening one boundary.
3689#[derive(Debug)]
3690struct ReconciliationGate {
3691    state: AtomicU64,
3692    commit_lock: Mutex<()>,
3693    state_tx: tokio::sync::watch::Sender<u64>,
3694}
3695
3696impl Default for ReconciliationGate {
3697    fn default() -> Self {
3698        let (state_tx, _) = tokio::sync::watch::channel(0);
3699        Self {
3700            state: AtomicU64::new(0),
3701            commit_lock: Mutex::new(()),
3702            state_tx,
3703        }
3704    }
3705}
3706
3707impl ReconciliationGate {
3708    fn is_halted(&self) -> bool {
3709        self.state.load(Ordering::Acquire) & 1 == 1
3710    }
3711
3712    fn halt(&self) -> u64 {
3713        let _commit = self.commit_lock.lock();
3714        let mut current = self.state.load(Ordering::Acquire);
3715
3716        loop {
3717            let next = if current == u64::MAX {
3718                current
3719            } else if current & 1 == 0 {
3720                current + 1
3721            } else {
3722                current.saturating_add(2)
3723            };
3724
3725            if next == current {
3726                return current;
3727            }
3728
3729            match self.state.compare_exchange_weak(
3730                current,
3731                next,
3732                Ordering::AcqRel,
3733                Ordering::Acquire,
3734            ) {
3735                Ok(_) => {
3736                    self.state_tx.send_replace(next);
3737                    return next;
3738                }
3739                Err(actual) => current = actual,
3740            }
3741        }
3742    }
3743
3744    #[cfg(test)]
3745    fn try_resume(&self, generation: u64) -> bool {
3746        let _commit = self.commit_lock.lock();
3747        self.try_resume_locked(generation)
3748    }
3749
3750    fn try_resume_locked(&self, generation: u64) -> bool {
3751        generation != u64::MAX
3752            && generation & 1 == 1
3753            && self
3754                .state
3755                .compare_exchange(
3756                    generation,
3757                    generation + 1,
3758                    Ordering::AcqRel,
3759                    Ordering::Acquire,
3760                )
3761                .is_ok_and(|_| {
3762                    self.state_tx.send_replace(generation + 1);
3763                    true
3764                })
3765    }
3766
3767    fn is_current(&self, generation: u64) -> bool {
3768        self.state.load(Ordering::Acquire) == generation
3769    }
3770
3771    fn current_generation(&self) -> u64 {
3772        self.state.load(Ordering::Acquire)
3773    }
3774
3775    fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
3776        self.state_tx.subscribe()
3777    }
3778
3779    fn commit<F>(&self, generation: u64, publish: F) -> anyhow::Result<bool>
3780    where
3781        F: FnOnce() -> anyhow::Result<()>,
3782    {
3783        let _commit = self.commit_lock.lock();
3784
3785        if !self.is_current(generation) {
3786            return Ok(false);
3787        }
3788
3789        publish()?;
3790        Ok(self.try_resume_locked(generation))
3791    }
3792
3793    fn clear(&self) {
3794        let _commit = self.commit_lock.lock();
3795        let mut current = self.state.load(Ordering::Acquire);
3796
3797        while current & 1 == 1 {
3798            match self.state.compare_exchange_weak(
3799                current,
3800                current.wrapping_add(1),
3801                Ordering::AcqRel,
3802                Ordering::Acquire,
3803            ) {
3804                Ok(_) => {
3805                    self.state_tx.send_replace(current.wrapping_add(1));
3806                    return;
3807                }
3808                Err(actual) => current = actual,
3809            }
3810        }
3811    }
3812}
3813
3814fn commit_post_reconnect_mass_status(
3815    gate: &ReconciliationGate,
3816    generation: u64,
3817    ocm_state: &Arc<Mutex<OcmState>>,
3818    emitter: &ExecutionEventEmitter,
3819    mut recovery: MassStatusSnapshot,
3820) -> anyhow::Result<Option<(usize, usize, Option<AccountState>)>> {
3821    let account_state = recovery.account_state.take();
3822    let mut committed = None;
3823    let published = gate.commit(generation, || {
3824        let mut state = ocm_state.lock();
3825        let mut staged_state = state.clone();
3826        let (mass_status, updates) = recovery.build(&mut staged_state, emitter)?;
3827        let order_count = mass_status.order_reports().len();
3828        let fill_count = mass_status
3829            .fill_reports()
3830            .values()
3831            .map(Vec::len)
3832            .sum::<usize>();
3833
3834        for update in updates {
3835            emitter.try_send_order_event(update)?;
3836        }
3837        emitter.try_send_execution_report(ExecutionReport::MassStatus(Box::new(mass_status)))?;
3838        *state = staged_state;
3839        committed = Some((order_count, fill_count, account_state));
3840        Ok(())
3841    })?;
3842    Ok(published.then(|| committed.expect("published recovery result must be present")))
3843}
3844
3845#[derive(Debug)]
3846struct ReceivedOcm {
3847    message: OCM,
3848    ts_init: UnixNanos,
3849}
3850
3851struct OcmProcessingContext<'a> {
3852    account_id: AccountId,
3853    currency: Currency,
3854    emitter: &'a ExecutionEventEmitter,
3855    ocm_state: &'a Arc<Mutex<OcmState>>,
3856    data_sender: &'a EventSender<DataEvent>,
3857    ignore_external_orders: bool,
3858    account_refresh_tx: Option<&'a tokio::sync::mpsc::UnboundedSender<()>>,
3859    ts_event: UnixNanos,
3860    ts_init: UnixNanos,
3861}
3862
3863#[derive(Clone, Copy, PartialEq, Eq)]
3864enum CancelAction {
3865    Emit,
3866    Suppress,
3867    RecloseAfterFill,
3868}
3869
3870impl CancelAction {
3871    fn should_reclose_after_fill(
3872        self,
3873        order_status: OrderStatus,
3874        has_fill: bool,
3875        is_canceled_replace: bool,
3876    ) -> bool {
3877        if self != Self::RecloseAfterFill || !has_fill {
3878            return false;
3879        }
3880
3881        match order_status {
3882            OrderStatus::Canceled => true,
3883            OrderStatus::PartiallyFilled => is_canceled_replace,
3884            _ => false,
3885        }
3886    }
3887}
3888
3889fn resolve_cancel_action(
3890    state: &OcmState,
3891    order: &UnmatchedOrder,
3892    client_order_id: Option<&ClientOrderId>,
3893) -> CancelAction {
3894    let Some(client_order_id) = client_order_id else {
3895        return CancelAction::Emit;
3896    };
3897
3898    if state.is_canceled_replace(&order.id) {
3899        return CancelAction::RecloseAfterFill;
3900    }
3901
3902    if !is_terminal_cancel(order) {
3903        return CancelAction::Emit;
3904    }
3905
3906    if state.is_retained_terminal_order(client_order_id) {
3907        CancelAction::RecloseAfterFill
3908    } else if state.should_suppress_cancel(client_order_id, &order.id) {
3909        CancelAction::Suppress
3910    } else {
3911        CancelAction::Emit
3912    }
3913}
3914
3915fn is_terminal_cancel(order: &UnmatchedOrder) -> bool {
3916    order.status == StreamingOrderStatus::ExecutionComplete && has_cancel_quantity(order)
3917}
3918
3919struct UnmatchedOrderContext<'a> {
3920    order: &'a UnmatchedOrder,
3921    instrument_id: InstrumentId,
3922    account_id: AccountId,
3923    currency: Currency,
3924    emitter: &'a ExecutionEventEmitter,
3925    ts_event: UnixNanos,
3926    ts_init: UnixNanos,
3927}
3928
3929#[derive(Default)]
3930struct FetchedOrderStatusReports {
3931    orders: Vec<CurrentOrderSummary>,
3932    reports: Vec<OrderStatusReport>,
3933    active_quantities: AHashMap<String, Quantity>,
3934    voided_quantities: AHashMap<String, Quantity>,
3935    ambiguous_replaces: AHashMap<(ClientOrderId, String), UUID4>,
3936}
3937
3938/// Paginates `list_current_orders` into `OrderStatusReport`s without touching
3939/// the engine cache, so it is callable from any tokio task.
3940#[expect(
3941    clippy::too_many_arguments,
3942    reason = "report context and stream session state remain explicit at the HTTP boundary"
3943)]
3944async fn fetch_order_status_reports_http(
3945    http_client: &Arc<BetfairHttpClient>,
3946    account_id: AccountId,
3947    ts_init: UnixNanos,
3948    market_ids: Option<Vec<String>>,
3949    filter: Option<&GenerateOrderStatusReports>,
3950    ocm_state: &Arc<Mutex<OcmState>>,
3951    emitter: Option<&ExecutionEventEmitter>,
3952    stream_session: StreamSession<'_>,
3953    session_refresh: &mut SessionRefresh,
3954) -> anyhow::Result<Vec<OrderStatusReport>> {
3955    let fetched = collect_order_status_reports_http(
3956        http_client,
3957        account_id,
3958        ts_init,
3959        market_ids,
3960        filter,
3961        ocm_state,
3962        stream_session,
3963        session_refresh,
3964    )
3965    .await?;
3966    Ok(finish_order_status_reports(
3967        fetched, filter, ocm_state, emitter, false,
3968    ))
3969}
3970
3971#[expect(
3972    clippy::too_many_arguments,
3973    reason = "report context and stream session state remain explicit at the HTTP boundary"
3974)]
3975async fn collect_order_status_reports_http(
3976    http_client: &Arc<BetfairHttpClient>,
3977    account_id: AccountId,
3978    ts_init: UnixNanos,
3979    market_ids: Option<Vec<String>>,
3980    filter: Option<&GenerateOrderStatusReports>,
3981    ocm_state: &Arc<Mutex<OcmState>>,
3982    stream_session: StreamSession<'_>,
3983    session_refresh: &mut SessionRefresh,
3984) -> anyhow::Result<FetchedOrderStatusReports> {
3985    let market_ids = if let Some(instrument_id) = filter
3986        .filter(|filter| filter.open_only)
3987        .and_then(|filter| filter.instrument_id)
3988    {
3989        let market_id = extract_market_id(&instrument_id)?;
3990
3991        if market_ids
3992            .as_ref()
3993            .is_some_and(|market_ids| !market_ids.contains(&market_id))
3994        {
3995            return Ok(FetchedOrderStatusReports::default());
3996        }
3997
3998        Some(vec![market_id])
3999    } else {
4000        market_ids
4001    };
4002
4003    let mut fetched = fetch_order_status_reports_snapshot_http(
4004        http_client,
4005        account_id,
4006        ts_init,
4007        market_ids,
4008        filter.is_some_and(|filter| filter.open_only),
4009        ocm_state,
4010        stream_session,
4011        session_refresh,
4012    )
4013    .await?;
4014
4015    if let Some(filter) = filter {
4016        fetched.reports.retain(|report| {
4017            filter
4018                .instrument_id
4019                .is_none_or(|instrument_id| report.instrument_id == instrument_id)
4020        });
4021    }
4022
4023    Ok(fetched)
4024}
4025
4026fn finish_order_status_reports(
4027    mut fetched: FetchedOrderStatusReports,
4028    filter: Option<&GenerateOrderStatusReports>,
4029    ocm_state: &Arc<Mutex<OcmState>>,
4030    emitter: Option<&ExecutionEventEmitter>,
4031    guard_pending_replaces: bool,
4032) -> Vec<OrderStatusReport> {
4033    let mut state = ocm_state.lock();
4034    assign_report_owners(&mut fetched.reports, &state);
4035
4036    if let Some(emitter) = emitter {
4037        let updates = resolve_pending_modifies_in_state(
4038            &mut fetched.reports,
4039            &fetched.active_quantities,
4040            &fetched.voided_quantities,
4041            &mut state,
4042            emitter,
4043            guard_pending_replaces.then_some(&fetched.ambiguous_replaces),
4044        );
4045
4046        for update in updates {
4047            emitter.send_order_event(update);
4048        }
4049    }
4050
4051    drop(state);
4052
4053    if let Some(filter) = filter {
4054        retain_order_status_reports(&mut fetched.reports, filter);
4055    }
4056
4057    fetched.reports
4058}
4059
4060fn assign_report_owners(reports: &mut [OrderStatusReport], state: &OcmState) {
4061    for report in reports {
4062        if let Some(resolution) = state.resolve_order_owner(
4063            report.client_order_id.as_ref().map(ClientOrderId::as_str),
4064            report.venue_order_id.as_str(),
4065        ) {
4066            report.client_order_id = resolution.client_order_id();
4067        }
4068    }
4069}
4070
4071#[expect(
4072    clippy::too_many_arguments,
4073    reason = "report context and stream session state remain explicit at the HTTP boundary"
4074)]
4075async fn fetch_order_status_reports_snapshot_http(
4076    http_client: &Arc<BetfairHttpClient>,
4077    account_id: AccountId,
4078    ts_init: UnixNanos,
4079    market_ids: Option<Vec<String>>,
4080    open_only: bool,
4081    ocm_state: &Arc<Mutex<OcmState>>,
4082    stream_session: StreamSession<'_>,
4083    session_refresh: &mut SessionRefresh,
4084) -> anyhow::Result<FetchedOrderStatusReports> {
4085    let order_projection = if open_only {
4086        Some(OrderProjection::Executable)
4087    } else {
4088        Some(OrderProjection::All)
4089    };
4090
4091    let market_id_batches = list_current_orders_market_id_batches(market_ids.clone());
4092    let merge_batches = market_id_batches.len() > 1;
4093
4094    let queries = market_id_batches
4095        .into_iter()
4096        .map(|market_ids| list_current_orders_filter_markets(market_ids, order_projection))
4097        .collect();
4098
4099    let mut orders = Vec::new();
4100    let mut indexes = AHashMap::new();
4101    fetch_order_status_pages_http(
4102        http_client,
4103        queries,
4104        &mut orders,
4105        &mut indexes,
4106        stream_session,
4107        session_refresh,
4108    )
4109    .await?;
4110
4111    let mut ambiguous_replaces = ocm_state.lock().pending_replace_reconciliation_ids();
4112
4113    if open_only {
4114        let bsp_market_ids = fetch_bsp_market_ids_http(
4115            http_client,
4116            market_ids.clone(),
4117            stream_session,
4118            session_refresh,
4119        )
4120        .await?;
4121
4122        let queries = bsp_market_ids
4123            .chunks(MAX_LIST_CURRENT_ORDERS_MARKET_IDS)
4124            .map(|market_ids| {
4125                list_current_orders_filter_markets(
4126                    Some(market_ids.to_vec()),
4127                    Some(OrderProjection::All),
4128                )
4129            })
4130            .collect();
4131
4132        fetch_order_status_pages_http(
4133            http_client,
4134            queries,
4135            &mut orders,
4136            &mut indexes,
4137            stream_session,
4138            session_refresh,
4139        )
4140        .await?;
4141
4142        let discovered_bet_ids: AHashSet<_> = indexes.keys().cloned().collect();
4143        let (bet_ids, customer_order_refs) =
4144            order_status_supplemental_ids(&orders, &ocm_state.lock());
4145        let mut queried_customer_order_refs = customer_order_refs.clone();
4146        let queries = list_current_orders_supplemental_queries(
4147            bet_ids.difference(&discovered_bet_ids).cloned().collect(),
4148            customer_order_refs.into_keys().collect(),
4149        );
4150        fetch_order_status_pages_http(
4151            http_client,
4152            queries,
4153            &mut orders,
4154            &mut indexes,
4155            stream_session,
4156            session_refresh,
4157        )
4158        .await?;
4159
4160        loop {
4161            let (required_bet_ids, pending_customer_order_refs) = {
4162                let state = ocm_state.lock();
4163                ambiguous_replaces = state.pending_replace_reconciliation_ids();
4164                order_status_supplemental_ids(&orders, &state)
4165            };
4166
4167            let missing: AHashSet<String> = required_bet_ids
4168                .into_iter()
4169                .filter(|bet_id| !indexes.contains_key(bet_id))
4170                .collect();
4171
4172            let customer_order_refs: AHashSet<_> = pending_customer_order_refs
4173                .iter()
4174                .filter(|&(customer_order_ref, bet_ids)| {
4175                    queried_customer_order_refs.get(customer_order_ref) != Some(bet_ids)
4176                })
4177                .map(|(customer_order_ref, _)| customer_order_ref.clone())
4178                .collect();
4179
4180            if missing.is_empty() && customer_order_refs.is_empty() {
4181                break;
4182            }
4183
4184            let before = indexes.len();
4185            queried_customer_order_refs.extend(pending_customer_order_refs);
4186            let queries = list_current_orders_supplemental_queries(missing, customer_order_refs);
4187            fetch_order_status_pages_http(
4188                http_client,
4189                queries,
4190                &mut orders,
4191                &mut indexes,
4192                stream_session,
4193                session_refresh,
4194            )
4195            .await?;
4196
4197            if indexes.len() == before {
4198                let (required_bet_ids, _) =
4199                    order_status_supplemental_ids(&orders, &ocm_state.lock());
4200
4201                if let Some(bet_id) = required_bet_ids
4202                    .iter()
4203                    .find(|bet_id| !indexes.contains_key(*bet_id))
4204                {
4205                    anyhow::bail!("Missing Betfair order history for tracked Bet ID {bet_id}");
4206                }
4207            }
4208        }
4209
4210        if let Some(market_ids) = market_ids {
4211            orders.retain(|order| {
4212                discovered_bet_ids.contains(&order.bet_id) || market_ids.contains(&order.market_id)
4213            });
4214        }
4215    }
4216
4217    let mut reports = Vec::with_capacity(orders.len());
4218    let mut active_quantities = AHashMap::new();
4219    let mut voided_quantities = AHashMap::new();
4220
4221    for order in &orders {
4222        let report = parse_current_order_report(order, account_id, ts_init).map_err(|e| {
4223            anyhow::anyhow!("Failed to parse order report for {}: {e}", order.bet_id)
4224        })?;
4225
4226        let active_quantity = current_order_active_quantity(order).map_err(|e| {
4227            anyhow::anyhow!("Failed to parse active quantity for {}: {e}", order.bet_id)
4228        })?;
4229        let voided_quantity = parse_betfair_quantity(order.size_voided.unwrap_or(Decimal::ZERO))
4230            .map_err(|e| {
4231                anyhow::anyhow!("Failed to parse voided quantity for {}: {e}", order.bet_id)
4232            })?;
4233
4234        active_quantities.insert(order.bet_id.clone(), active_quantity);
4235        voided_quantities.insert(order.bet_id.clone(), voided_quantity);
4236        reports.push(report);
4237    }
4238
4239    if merge_batches {
4240        reports.sort_by_key(|report| (report.ts_accepted, report.venue_order_id));
4241    }
4242
4243    Ok(FetchedOrderStatusReports {
4244        orders,
4245        reports,
4246        active_quantities,
4247        voided_quantities,
4248        ambiguous_replaces,
4249    })
4250}
4251
4252fn order_status_supplemental_ids(
4253    orders: &[CurrentOrderSummary],
4254    state: &OcmState,
4255) -> (AHashSet<String>, AHashMap<String, AHashSet<String>>) {
4256    let client_order_ids = orders
4257        .iter()
4258        .filter_map(|order| {
4259            state
4260                .resolve_order_owner(order.customer_order_ref.as_deref(), &order.bet_id)
4261                .and_then(CustomerOrderRefResolution::client_order_id)
4262                .or_else(|| state.client_order_id_by_venue_order_id(&order.bet_id))
4263        })
4264        .collect();
4265
4266    state.order_status_supplemental_ids(client_order_ids)
4267}
4268
4269fn list_current_orders_supplemental_queries(
4270    bet_ids: AHashSet<String>,
4271    customer_order_refs: AHashSet<String>,
4272) -> Vec<ListCurrentOrdersParams> {
4273    let mut bet_ids: Vec<_> = bet_ids.into_iter().collect();
4274    bet_ids.sort();
4275    let mut customer_order_refs: Vec<_> = customer_order_refs.into_iter().collect();
4276    customer_order_refs.sort();
4277    let mut queries = Vec::new();
4278
4279    for batch in bet_ids.chunks(MAX_LIST_CURRENT_ORDERS_MARKET_IDS) {
4280        let mut params = list_current_orders_filter_bet_id(batch[0].clone());
4281        params.bet_ids = Some(batch.to_vec());
4282        params.order_projection = Some(OrderProjection::All);
4283        queries.push(params);
4284    }
4285
4286    for customer_order_ref in customer_order_refs {
4287        let mut params = list_current_orders_filter_ref(customer_order_ref);
4288        params.order_projection = Some(OrderProjection::All);
4289        queries.push(params);
4290    }
4291
4292    queries
4293}
4294
4295#[derive(serde::Deserialize)]
4296struct BspEvent {
4297    event: Event,
4298}
4299
4300async fn fetch_bsp_market_ids_http(
4301    http_client: &Arc<BetfairHttpClient>,
4302    market_ids: Option<Vec<String>>,
4303    stream_session: StreamSession<'_>,
4304    session_refresh: &mut SessionRefresh,
4305) -> anyhow::Result<Vec<String>> {
4306    const CATALOGUE_LIMIT: u32 = 1000;
4307
4308    let filter = MarketFilter {
4309        bsp_only: Some(true),
4310        with_orders: Some(vec![BetfairOrderStatus::ExecutionComplete.to_string()]),
4311        ..Default::default()
4312    };
4313
4314    let filters = if let Some(market_ids) = market_ids {
4315        market_ids
4316            .chunks(MAX_LIST_CURRENT_ORDERS_MARKET_IDS)
4317            .map(|market_ids| MarketFilter {
4318                market_ids: Some(market_ids.to_vec()),
4319                ..filter.clone()
4320            })
4321            .collect::<Vec<_>>()
4322    } else {
4323        let events: Vec<BspEvent> = send_betting_with_retry(
4324            http_client,
4325            METHOD_LIST_EVENTS,
4326            &serde_json::json!({"filter": filter}),
4327            stream_session,
4328            session_refresh,
4329        )
4330        .await?;
4331        events
4332            .into_iter()
4333            .map(|event| {
4334                let event_id = event.event.id.ok_or_else(|| {
4335                    anyhow::anyhow!("Betfair BSP event discovery returned an event without an ID")
4336                })?;
4337
4338                Ok(MarketFilter {
4339                    event_ids: Some(vec![event_id]),
4340                    ..filter.clone()
4341                })
4342            })
4343            .collect::<anyhow::Result<Vec<_>>>()?
4344    };
4345
4346    let mut bsp_market_ids = AHashSet::new();
4347
4348    for filter in filters {
4349        let market_scoped = filter.market_ids.is_some();
4350
4351        let params = ListMarketCatalogueParams {
4352            filter,
4353            market_projection: None,
4354            sort: None,
4355            max_results: Some(CATALOGUE_LIMIT),
4356            locale: None,
4357        };
4358
4359        let catalogues: Vec<MarketCatalogue> = send_betting_with_retry(
4360            http_client,
4361            METHOD_LIST_MARKET_CATALOGUE,
4362            &params,
4363            stream_session,
4364            session_refresh,
4365        )
4366        .await?;
4367        anyhow::ensure!(
4368            market_scoped || catalogues.len() < CATALOGUE_LIMIT as usize,
4369            "Betfair BSP market discovery reached the catalogue limit for event {:?}",
4370            params.filter.event_ids,
4371        );
4372        bsp_market_ids.extend(catalogues.into_iter().map(|market| market.market_id));
4373    }
4374
4375    let mut bsp_market_ids: Vec<_> = bsp_market_ids.into_iter().collect();
4376    bsp_market_ids.sort_unstable();
4377    Ok(bsp_market_ids)
4378}
4379
4380async fn fetch_order_status_pages_http(
4381    http_client: &Arc<BetfairHttpClient>,
4382    queries: Vec<ListCurrentOrdersParams>,
4383    orders: &mut Vec<CurrentOrderSummary>,
4384    indexes: &mut AHashMap<String, usize>,
4385    stream_session: StreamSession<'_>,
4386    session_refresh: &mut SessionRefresh,
4387) -> anyhow::Result<()> {
4388    for mut params in queries {
4389        let mut from_record = 0;
4390
4391        loop {
4392            params.from_record = (from_record > 0).then_some(from_record);
4393            let response = list_current_orders_with_retry(
4394                http_client,
4395                &params,
4396                stream_session,
4397                session_refresh,
4398            )
4399            .await?;
4400            let page_size = response.current_orders.len() as u32;
4401            if response.more_available && page_size == 0 {
4402                anyhow::bail!("listCurrentOrders returned an empty page with moreAvailable=true");
4403            }
4404
4405            for order in response.current_orders {
4406                if let Some(&index) = indexes.get(&order.bet_id) {
4407                    orders[index] = order;
4408                } else {
4409                    indexes.insert(order.bet_id.clone(), orders.len());
4410                    orders.push(order);
4411                }
4412            }
4413
4414            if !response.more_available {
4415                break;
4416            }
4417
4418            from_record += page_size;
4419        }
4420    }
4421
4422    Ok(())
4423}
4424
4425fn resolve_pending_modifies_in_state(
4426    reports: &mut Vec<OrderStatusReport>,
4427    active_quantities: &AHashMap<String, Quantity>,
4428    voided_quantities: &AHashMap<String, Quantity>,
4429    state: &mut OcmState,
4430    emitter: &ExecutionEventEmitter,
4431    eligible_replaces: Option<&AHashMap<(ClientOrderId, String), UUID4>>,
4432) -> Vec<OrderEventAny> {
4433    let mut updates = Vec::new();
4434    let replaced_fills = replaced_leg_fills_from_reports(reports, voided_quantities, state);
4435
4436    let mut resolved_bet_ids = AHashSet::new();
4437
4438    for report in reports.iter_mut() {
4439        let bet_id = report.venue_order_id.to_string();
4440
4441        if let Some(quantity) = state.reduced_quantity(&bet_id) {
4442            report.quantity = quantity;
4443        }
4444
4445        let Some((client_order_id, strategy_id)) = report_correlation(state, report) else {
4446            continue;
4447        };
4448
4449        if let Some((update, old_bet_id)) =
4450            resolve_pending_replace_report(report, state, emitter, client_order_id, strategy_id)
4451        {
4452            updates.push(update);
4453
4454            if report.order_status.is_closed() {
4455                resolved_bet_ids.insert(old_bet_id);
4456            } else {
4457                resolved_bet_ids.insert(bet_id);
4458            }
4459            continue;
4460        }
4461
4462        if let Some(quantity) = resolve_pending_reduction_from_reconciliation(
4463            state,
4464            active_quantities,
4465            &client_order_id,
4466            &bet_id,
4467            replaced_fills
4468                .get(&client_order_id)
4469                .map_or(Quantity::default(), |fills| fills.matched),
4470            replaced_fills
4471                .get(&client_order_id)
4472                .map_or(Quantity::default(), |fills| fills.voided),
4473        ) {
4474            report.quantity = quantity;
4475            if report.order_status.is_closed() {
4476                state.retain_terminal_order(client_order_id, &bet_id);
4477            } else {
4478                updates.push(make_reconciled_update(
4479                    emitter,
4480                    report,
4481                    client_order_id,
4482                    strategy_id,
4483                    quantity,
4484                    None,
4485                ));
4486                resolved_bet_ids.insert(bet_id);
4487            }
4488            continue;
4489        }
4490
4491        if report.order_status.is_closed() {
4492            state.retain_terminal_order(client_order_id, &bet_id);
4493        }
4494    }
4495
4496    updates.extend(resolve_ambiguous_replaces(
4497        reports,
4498        state,
4499        emitter,
4500        eligible_replaces,
4501    ));
4502
4503    accumulate_replaced_leg_fills(reports, voided_quantities, state);
4504
4505    reports.retain(|report| {
4506        let bet_id = report.venue_order_id.as_str();
4507        !state.should_suppress_replaced_report(bet_id) && !resolved_bet_ids.contains(bet_id)
4508    });
4509
4510    updates
4511}
4512
4513fn resolve_ambiguous_replaces(
4514    reports: &[OrderStatusReport],
4515    state: &mut OcmState,
4516    emitter: &ExecutionEventEmitter,
4517    eligible_replaces: Option<&AHashMap<(ClientOrderId, String), UUID4>>,
4518) -> Vec<OrderEventAny> {
4519    let mut updates = Vec::new();
4520    let current_replace_ids = eligible_replaces.map(|_| state.pending_replace_reconciliation_ids());
4521
4522    let non_actionable_replaces = reports
4523        .iter()
4524        .filter_map(|report| {
4525            let bet_id = report.venue_order_id.to_string();
4526            let (client_order_id, strategy_id) = report_correlation(state, report)?;
4527
4528            if let Some(eligible_replaces) = eligible_replaces {
4529                let key = (client_order_id, bet_id.clone());
4530                let current_id = current_replace_ids
4531                    .as_ref()
4532                    .and_then(|ids| ids.get(&key))
4533                    .copied();
4534
4535                if current_id.is_none() || current_id != eligible_replaces.get(&key).copied() {
4536                    return None;
4537                }
4538            }
4539
4540            state
4541                .pending_replace_awaits_reconciliation(&client_order_id, &bet_id)
4542                .then_some((client_order_id, strategy_id, bet_id, report))
4543        })
4544        .collect::<Vec<_>>();
4545
4546    for (client_order_id, strategy_id, bet_id, report) in non_actionable_replaces {
4547        if report.order_status.is_closed() {
4548            if report.client_order_id == Some(client_order_id) {
4549                state.take_pending_replace(client_order_id, &bet_id);
4550            }
4551
4552            state.retain_terminal_order(client_order_id, &bet_id);
4553            continue;
4554        }
4555
4556        state.take_pending_replace(client_order_id, &bet_id);
4557        state.mark_order_active(&client_order_id, &bet_id);
4558        updates.push(OrderEventAny::ModifyRejected(OrderModifyRejected::new(
4559            emitter.trader_id(),
4560            strategy_id,
4561            report.instrument_id,
4562            client_order_id,
4563            Ustr::from("Original bet remained executable after ambiguous replace"),
4564            UUID4::new(),
4565            report.ts_last,
4566            report.ts_init,
4567            true,
4568            Some(report.venue_order_id),
4569            Some(emitter.account_id()),
4570        )));
4571    }
4572
4573    updates
4574}
4575
4576fn report_correlation(
4577    state: &OcmState,
4578    report: &OrderStatusReport,
4579) -> Option<(ClientOrderId, StrategyId)> {
4580    let client_order_id = report
4581        .client_order_id
4582        .or_else(|| state.client_order_id_by_venue_order_id(report.venue_order_id.as_str()))?;
4583    let strategy_id = state.order_strategy_id(&client_order_id)?;
4584    Some((client_order_id, strategy_id))
4585}
4586
4587#[derive(Default)]
4588struct ReplacedLegFills {
4589    matched: Quantity,
4590    voided: Quantity,
4591    notional: Option<Decimal>,
4592}
4593
4594impl ReplacedLegFills {
4595    fn accumulate(&mut self, matched: Quantity, avg_px: Option<Decimal>) {
4596        if matched.is_zero() {
4597            return;
4598        }
4599
4600        let notional = match (self.notional, avg_px) {
4601            (Some(notional), Some(avg_px)) => Some(notional + matched.as_decimal() * avg_px),
4602            // A leg with no average price leaves the weighted average unknown for
4603            // the whole history; only a first leg can initialize the notional.
4604            (None, Some(avg_px)) if self.matched.is_zero() => Some(matched.as_decimal() * avg_px),
4605            _ => None,
4606        };
4607
4608        self.matched = self.matched + matched;
4609        self.notional = notional;
4610    }
4611
4612    fn accumulate_voided(&mut self, voided: Quantity) {
4613        self.voided = self.voided + voided;
4614    }
4615
4616    fn fold(self, report: &mut OrderStatusReport, quantity_already_logical: bool) {
4617        if self.matched.is_zero() && self.voided.is_zero() {
4618            return;
4619        }
4620
4621        // A confirmed reduction on the successor already stores the logical
4622        // quantity, which includes every replaced leg's matched and voided size.
4623        if !quantity_already_logical {
4624            report.quantity = report.quantity + self.matched + self.voided;
4625        }
4626
4627        let mut totals = self;
4628        totals.accumulate(report.filled_qty, report.avg_px);
4629        report.filled_qty = totals.matched;
4630        report.avg_px = totals
4631            .notional
4632            .map(|notional| notional / totals.matched.as_decimal());
4633        // An executable bet with cumulative matched quantity is partially
4634        // filled; the engine's Accepted branch applies no fills.
4635        if report.order_status == OrderStatus::Accepted && !report.filled_qty.is_zero() {
4636            report.order_status = OrderStatus::PartiallyFilled;
4637        }
4638    }
4639}
4640
4641/// Accumulates matched and voided quantity from replaced Bet legs into their successor's report.
4642///
4643/// A price replacement reports each leg's matched quantity separately, so the
4644/// successor's per-bet totals would read as a fill decrease at the engine. Folding
4645/// the replaced legs into the current Bet's report reconciles the full history.
4646fn accumulate_replaced_leg_fills(
4647    reports: &mut [OrderStatusReport],
4648    voided_quantities: &AHashMap<String, Quantity>,
4649    state: &OcmState,
4650) {
4651    let mut successor_indexes: AHashMap<ClientOrderId, usize> = AHashMap::new();
4652
4653    for (index, report) in reports.iter().enumerate() {
4654        let Some((client_order_id, _)) = report_correlation(state, report) else {
4655            continue;
4656        };
4657
4658        if state.is_current_venue_order_id(&client_order_id, report.venue_order_id.as_str()) {
4659            successor_indexes.insert(client_order_id, index);
4660        }
4661    }
4662
4663    let replaced_fills = replaced_leg_fills_from_reports(reports, voided_quantities, state);
4664
4665    for (client_order_id, fills) in replaced_fills {
4666        let Some(&index) = successor_indexes.get(&client_order_id) else {
4667            continue;
4668        };
4669
4670        let bet_id = reports[index].venue_order_id.as_str().to_string();
4671        let quantity_already_logical = state.reduced_quantity(&bet_id).is_some();
4672        fills.fold(&mut reports[index], quantity_already_logical);
4673    }
4674}
4675
4676fn replaced_leg_fills_from_reports(
4677    reports: &[OrderStatusReport],
4678    voided_quantities: &AHashMap<String, Quantity>,
4679    state: &OcmState,
4680) -> AHashMap<ClientOrderId, ReplacedLegFills> {
4681    let mut fills: AHashMap<ClientOrderId, ReplacedLegFills> = AHashMap::new();
4682
4683    for report in reports {
4684        let bet_id = report.venue_order_id.as_str();
4685        if !state.replaced_venue_order_ids.contains(bet_id) {
4686            continue;
4687        }
4688
4689        if let Some((client_order_id, _)) = report_correlation(state, report) {
4690            let entry = fills.entry(client_order_id).or_default();
4691            entry.accumulate(report.filled_qty, report.avg_px);
4692            if let Some(leg_voided) = voided_quantities.get(bet_id).copied() {
4693                entry.accumulate_voided(leg_voided);
4694            }
4695        }
4696    }
4697
4698    fills
4699}
4700
4701fn replaced_leg_fills_from_candidates(
4702    candidates: &[CurrentOrderSummary],
4703    successor: &CurrentOrderSummary,
4704    state: &OcmState,
4705) -> ReplacedLegFills {
4706    let mut fills = ReplacedLegFills::default();
4707
4708    // An ambiguous customer order ref cannot attribute legs to this order.
4709    let Some(owner_client_order_id) = state
4710        .resolve_order_owner(successor.customer_order_ref.as_deref(), &successor.bet_id)
4711        .and_then(CustomerOrderRefResolution::client_order_id)
4712    else {
4713        return fills;
4714    };
4715
4716    for candidate in candidates {
4717        if candidate.bet_id == successor.bet_id
4718            || !state.replaced_venue_order_ids.contains(&candidate.bet_id)
4719        {
4720            continue;
4721        }
4722
4723        let resolves_to_owner = state
4724            .resolve_order_owner(candidate.customer_order_ref.as_deref(), &candidate.bet_id)
4725            .and_then(CustomerOrderRefResolution::client_order_id)
4726            .is_some_and(|client_order_id| client_order_id == owner_client_order_id);
4727        if !resolves_to_owner {
4728            continue;
4729        }
4730
4731        let matched = candidate.size_matched.unwrap_or(Decimal::ZERO);
4732        match parse_betfair_quantity(matched) {
4733            Ok(matched) => fills.accumulate(matched, candidate.average_price_matched),
4734            Err(e) => {
4735                log::warn!(
4736                    "Skipping replaced leg {} with unparsable matched quantity {matched}: {e}",
4737                    candidate.bet_id,
4738                );
4739            }
4740        }
4741
4742        let voided = candidate.size_voided.unwrap_or(Decimal::ZERO);
4743        match parse_betfair_quantity(voided) {
4744            Ok(voided) => fills.accumulate_voided(voided),
4745            Err(e) => {
4746                log::warn!(
4747                    "Skipping replaced leg {} with unparsable voided quantity {voided}: {e}",
4748                    candidate.bet_id,
4749                );
4750            }
4751        }
4752    }
4753
4754    fills
4755}
4756
4757fn resolve_query_order_report(
4758    order: &CurrentOrderSummary,
4759    report: &mut OrderStatusReport,
4760    fallback_client_order_id: ClientOrderId,
4761    state: &mut OcmState,
4762    emitter: &ExecutionEventEmitter,
4763) -> Option<OrderEventAny> {
4764    let owner = state.resolve_order_owner(
4765        order.customer_order_ref.as_deref(),
4766        report.venue_order_id.as_str(),
4767    );
4768
4769    if let Some(owner) = owner {
4770        report.client_order_id = owner.client_order_id();
4771    }
4772
4773    let update = report_correlation(state, report).and_then(|(client_order_id, strategy_id)| {
4774        resolve_pending_replace_report(report, state, emitter, client_order_id, strategy_id)
4775            .map(|(update, _)| update)
4776    });
4777
4778    if report.client_order_id.is_none() && owner != Some(CustomerOrderRefResolution::Ambiguous) {
4779        report.client_order_id = Some(fallback_client_order_id);
4780    }
4781    update
4782}
4783
4784fn resolve_pending_replace_report(
4785    report: &OrderStatusReport,
4786    state: &mut OcmState,
4787    emitter: &ExecutionEventEmitter,
4788    client_order_id: ClientOrderId,
4789    strategy_id: StrategyId,
4790) -> Option<(OrderEventAny, String)> {
4791    let bet_id = report.venue_order_id.to_string();
4792    let (quantity, old_bet_id) =
4793        state.promote_pending_replace(&client_order_id, &bet_id, report.quantity)?;
4794
4795    // The report keeps its per-bet quantity; `accumulate_replaced_leg_fills`
4796    // folds the replacement history into the totals the engine reconciles.
4797    if report.order_status.is_closed() {
4798        state.retain_terminal_order(client_order_id, &bet_id);
4799    }
4800
4801    let update = make_reconciled_update(
4802        emitter,
4803        report,
4804        client_order_id,
4805        strategy_id,
4806        quantity,
4807        report.price,
4808    );
4809    Some((update, old_bet_id))
4810}
4811
4812fn resolve_pending_reduction_from_reconciliation(
4813    state: &mut OcmState,
4814    active_quantities: &AHashMap<String, Quantity>,
4815    client_order_id: &ClientOrderId,
4816    bet_id: &str,
4817    replaced_matched: Quantity,
4818    replaced_voided: Quantity,
4819) -> Option<Quantity> {
4820    let active_quantity =
4821        active_quantities.get(bet_id).copied()? + replaced_matched + replaced_voided;
4822    state.confirm_pending_reduction(client_order_id, bet_id, active_quantity)
4823}
4824
4825fn make_reconciled_update(
4826    emitter: &ExecutionEventEmitter,
4827    report: &OrderStatusReport,
4828    client_order_id: ClientOrderId,
4829    strategy_id: StrategyId,
4830    quantity: Quantity,
4831    price: Option<Price>,
4832) -> OrderEventAny {
4833    let updated = OrderUpdated::new(
4834        emitter.trader_id(),
4835        strategy_id,
4836        report.instrument_id,
4837        client_order_id,
4838        quantity,
4839        UUID4::new(),
4840        report.ts_last,
4841        report.ts_init,
4842        true,
4843        Some(report.venue_order_id),
4844        Some(report.account_id),
4845        price,
4846        None,
4847        None,
4848        false,
4849    );
4850    OrderEventAny::Updated(updated)
4851}
4852
4853fn current_order_active_quantity(order: &CurrentOrderSummary) -> anyhow::Result<Quantity> {
4854    let active = order.size_matched.unwrap_or(Decimal::ZERO)
4855        + order.size_remaining.unwrap_or(Decimal::ZERO)
4856        + order.size_voided.unwrap_or(Decimal::ZERO);
4857    parse_betfair_quantity(active)
4858}
4859
4860/// Paginates `list_current_orders` into `FillReport`s without touching the
4861/// engine cache, so it is callable from any tokio task.
4862#[expect(
4863    clippy::too_many_arguments,
4864    reason = "report context and session refresh state remain explicit at the HTTP boundary"
4865)]
4866async fn fetch_fill_reports_http(
4867    http_client: &Arc<BetfairHttpClient>,
4868    account_id: AccountId,
4869    currency: Currency,
4870    ts_init: UnixNanos,
4871    market_ids: Option<Vec<String>>,
4872    date_range: Option<TimeRange>,
4873    filter: Option<&GenerateFillReports>,
4874    ocm_state: &Arc<Mutex<OcmState>>,
4875    stream_session: StreamSession<'_>,
4876    session_refresh: &mut SessionRefresh,
4877) -> anyhow::Result<Vec<FillReport>> {
4878    let mut orders = fetch_fill_orders_http(
4879        http_client,
4880        market_ids,
4881        date_range,
4882        stream_session,
4883        session_refresh,
4884    )
4885    .await?;
4886
4887    if let Some(filter) = filter {
4888        retain_fill_orders(&mut orders, filter);
4889    }
4890
4891    finish_fill_reports(&orders, ocm_state, account_id, currency, ts_init)
4892}
4893
4894fn fill_report_date_range(filter: &GenerateFillReports) -> Option<TimeRange> {
4895    (filter.start.is_some() || filter.end.is_some()).then(|| TimeRange {
4896        from: filter.start.map(|start| start.to_rfc3339()),
4897        to: filter.end.map(|end| end.to_rfc3339()),
4898    })
4899}
4900
4901fn retain_fill_orders(orders: &mut Vec<CurrentOrderSummary>, filter: &GenerateFillReports) {
4902    orders.retain(|order| {
4903        filter
4904            .venue_order_id
4905            .is_none_or(|venue_order_id| venue_order_id.as_str() == order.bet_id)
4906            && filter.instrument_id.is_none_or(|instrument_id| {
4907                make_instrument_id(&order.market_id, order.selection_id, order.handicap)
4908                    == instrument_id
4909            })
4910    });
4911}
4912
4913fn finish_fill_reports(
4914    orders: &[CurrentOrderSummary],
4915    ocm_state: &Arc<Mutex<OcmState>>,
4916    account_id: AccountId,
4917    currency: Currency,
4918    ts_init: UnixNanos,
4919) -> anyhow::Result<Vec<FillReport>> {
4920    let mut state = ocm_state.lock();
4921    let customer_order_refs = state.customer_order_refs.clone();
4922    let mut fill_tracker = state.fill_tracker.clone();
4923    let reports = build_incremental_fill_reports(
4924        orders,
4925        &mut fill_tracker,
4926        &customer_order_refs,
4927        account_id,
4928        currency,
4929        ts_init,
4930    )?;
4931    state.fill_tracker = fill_tracker;
4932    Ok(reports)
4933}
4934
4935async fn fetch_fill_orders_http(
4936    http_client: &Arc<BetfairHttpClient>,
4937    market_ids: Option<Vec<String>>,
4938    date_range: Option<TimeRange>,
4939    stream_session: StreamSession<'_>,
4940    session_refresh: &mut SessionRefresh,
4941) -> anyhow::Result<Vec<CurrentOrderSummary>> {
4942    let mut orders = Vec::new();
4943    let market_id_batches = list_current_orders_market_id_batches(market_ids);
4944    let merge_batches = market_id_batches.len() > 1;
4945
4946    for market_ids in market_id_batches {
4947        let mut from_record: u32 = 0;
4948
4949        loop {
4950            let params = ListCurrentOrdersParams {
4951                bet_ids: None,
4952                market_ids: market_ids.clone(),
4953                order_projection: Some(OrderProjection::All),
4954                customer_order_refs: None,
4955                customer_strategy_refs: None,
4956                date_range: date_range.clone(),
4957                order_by: Some(OrderBy::ByMatchTime),
4958                sort_dir: Some(SortDir::EarliestToLatest),
4959                from_record: (from_record > 0).then_some(from_record),
4960                record_count: None,
4961            };
4962
4963            let response = list_current_orders_with_retry(
4964                http_client,
4965                &params,
4966                stream_session,
4967                session_refresh,
4968            )
4969            .await?;
4970            let page_size = response.current_orders.len() as u32;
4971
4972            if response.more_available && page_size == 0 {
4973                anyhow::bail!("listCurrentOrders returned an empty page with moreAvailable=true");
4974            }
4975
4976            orders.extend(response.current_orders);
4977
4978            if !response.more_available {
4979                break;
4980            }
4981
4982            from_record += page_size;
4983        }
4984    }
4985
4986    if merge_batches {
4987        orders.sort_by_cached_key(current_order_match_sort_key);
4988    }
4989
4990    Ok(orders)
4991}
4992
4993const MAX_LIST_CURRENT_ORDERS_MARKET_IDS: usize = 250;
4994
4995fn list_current_orders_filter_markets(
4996    market_ids: Option<Vec<String>>,
4997    order_projection: Option<OrderProjection>,
4998) -> ListCurrentOrdersParams {
4999    ListCurrentOrdersParams {
5000        bet_ids: None,
5001        market_ids,
5002        order_projection,
5003        customer_order_refs: None,
5004        customer_strategy_refs: None,
5005        date_range: None,
5006        order_by: None,
5007        sort_dir: None,
5008        from_record: None,
5009        record_count: None,
5010    }
5011}
5012
5013fn list_current_orders_market_id_batches(
5014    market_ids: Option<Vec<String>>,
5015) -> Vec<Option<Vec<String>>> {
5016    match market_ids {
5017        Some(ids) if ids.len() > MAX_LIST_CURRENT_ORDERS_MARKET_IDS => ids
5018            .chunks(MAX_LIST_CURRENT_ORDERS_MARKET_IDS)
5019            .map(|chunk| Some(chunk.to_vec()))
5020            .collect(),
5021        ids => vec![ids],
5022    }
5023}
5024
5025fn current_order_match_sort_key(
5026    order: &CurrentOrderSummary,
5027) -> (Option<UnixNanos>, Option<UnixNanos>, String) {
5028    let matched = order
5029        .matched_date
5030        .as_deref()
5031        .and_then(|date| parse_betfair_timestamp(date).ok());
5032    let placed = parse_betfair_timestamp(&order.placed_date).ok();
5033    (matched, placed, order.bet_id.clone())
5034}
5035
5036fn build_incremental_fill_reports(
5037    orders: &[CurrentOrderSummary],
5038    fill_tracker: &mut FillTracker,
5039    customer_order_refs: &AHashMap<String, CustomerOrderRefResolution>,
5040    account_id: AccountId,
5041    currency: Currency,
5042    ts_init: UnixNanos,
5043) -> anyhow::Result<Vec<FillReport>> {
5044    let mut reports = Vec::new();
5045
5046    for order in orders {
5047        let size_matched = order.size_matched.unwrap_or(Decimal::ZERO);
5048        let size_voided = order.size_voided.unwrap_or(Decimal::ZERO);
5049        let gross_matched = size_matched + size_voided;
5050        if gross_matched == Decimal::ZERO {
5051            continue;
5052        }
5053
5054        parse_betfair_timestamp(&order.placed_date).map_err(|e| {
5055            anyhow::anyhow!("Failed to parse fill report for {}: {e}", order.bet_id)
5056        })?;
5057
5058        if fill_tracker.has_pending_price_restatement(
5059            &order.bet_id,
5060            size_matched,
5061            size_voided,
5062            order.average_price_matched,
5063        ) {
5064            continue;
5065        }
5066
5067        let has_applied_fill_lots = fill_tracker.has_fill_lots(&order.bet_id);
5068        let cumulative = if has_applied_fill_lots {
5069            gross_matched
5070        } else {
5071            size_matched
5072        };
5073        let incremental_fill = if has_applied_fill_lots && size_voided > Decimal::ZERO {
5074            fill_tracker.advance_cumulative_fill_with_voids(
5075                &order.bet_id,
5076                cumulative,
5077                size_voided,
5078                order.average_price_matched,
5079                order.price_size.price,
5080            )
5081        } else {
5082            fill_tracker.advance_cumulative_fill(
5083                &order.bet_id,
5084                cumulative,
5085                order.average_price_matched,
5086                order.price_size.price,
5087            )
5088        };
5089
5090        if !has_applied_fill_lots {
5091            if incremental_fill.is_some() {
5092                fill_tracker.sync_order(
5093                    &order.bet_id,
5094                    gross_matched,
5095                    order.average_price_matched.unwrap_or(Decimal::ZERO),
5096                );
5097            }
5098
5099            fill_tracker.sync_voided_qty(&order.bet_id, size_voided);
5100        }
5101
5102        let Some((trade_id, last_qty, last_px)) = incremental_fill else {
5103            continue;
5104        };
5105
5106        let mut report = parse_current_order_fill_report(
5107            order, account_id, currency, trade_id, last_qty, last_px, ts_init,
5108        )
5109        .map_err(|e| anyhow::anyhow!("Failed to parse fill report for {}: {e}", order.bet_id))?;
5110        if let Some(ref customer_order_ref) = order.customer_order_ref
5111            && let Some(resolution) = customer_order_refs.get(customer_order_ref).copied()
5112        {
5113            report.client_order_id = resolution.client_order_id();
5114        }
5115        reports.push(report);
5116    }
5117
5118    Ok(reports)
5119}
5120
5121struct MassStatusSnapshot {
5122    client_id: ClientId,
5123    account_id: AccountId,
5124    currency: Currency,
5125    ts_init: UnixNanos,
5126    order_reports: Vec<OrderStatusReport>,
5127    active_quantities: AHashMap<String, Quantity>,
5128    voided_quantities: AHashMap<String, Quantity>,
5129    fill_orders: Vec<CurrentOrderSummary>,
5130    account_state: Option<AccountState>,
5131}
5132
5133impl MassStatusSnapshot {
5134    fn build(
5135        self,
5136        state: &mut OcmState,
5137        emitter: &ExecutionEventEmitter,
5138    ) -> anyhow::Result<(ExecutionMassStatus, Vec<OrderEventAny>)> {
5139        let mut order_reports = self.order_reports;
5140
5141        let updates = resolve_pending_modifies_in_state(
5142            &mut order_reports,
5143            &self.active_quantities,
5144            &self.voided_quantities,
5145            state,
5146            emitter,
5147            None,
5148        );
5149        let fill_reports = build_incremental_fill_reports(
5150            &self.fill_orders,
5151            &mut state.fill_tracker,
5152            &state.customer_order_refs,
5153            self.account_id,
5154            self.currency,
5155            self.ts_init,
5156        )?;
5157
5158        let mut mass_status = ExecutionMassStatus::new(
5159            self.client_id,
5160            self.account_id,
5161            *BETFAIR_VENUE,
5162            self.ts_init,
5163            None,
5164        );
5165        mass_status.add_order_reports(order_reports);
5166        mass_status.add_fill_reports(fill_reports);
5167        Ok((mass_status, updates))
5168    }
5169}
5170
5171fn retain_snapshot_fill_orders(
5172    orders: &mut Vec<CurrentOrderSummary>,
5173    start: Option<UnixNanos>,
5174    end: Option<UnixNanos>,
5175) -> anyhow::Result<()> {
5176    let mut eligible = Vec::with_capacity(orders.len());
5177    for order in orders.drain(..) {
5178        let Some(date) = &order.matched_date else {
5179            continue;
5180        };
5181
5182        let matched = parse_betfair_timestamp(date)
5183            .map_err(|e| anyhow::anyhow!("Failed to parse match time for {}: {e}", order.bet_id))?;
5184        if start.is_none_or(|start| matched >= start) && end.is_none_or(|end| matched <= end) {
5185            eligible.push(order);
5186        }
5187    }
5188
5189    eligible.sort_by_cached_key(current_order_match_sort_key);
5190    *orders = eligible;
5191    Ok(())
5192}
5193
5194async fn wait_for_generation_change(
5195    state_rx: &mut tokio::sync::watch::Receiver<u64>,
5196    generation: u64,
5197) {
5198    while *state_rx.borrow_and_update() == generation {
5199        if state_rx.changed().await.is_err() {
5200            return;
5201        }
5202    }
5203}
5204
5205/// Waits until the gate's halt state equals `expected`.
5206///
5207/// Subscribes before the first read so a transition landing between the two is
5208/// observed rather than missed. The generation channel is used only as an edge
5209/// notification; `is_halted` remains the authoritative read.
5210async fn wait_for_reconciliation_state(gate: &ReconciliationGate, expected: bool) {
5211    let mut state_rx = gate.subscribe();
5212
5213    while gate.is_halted() != expected {
5214        if state_rx.changed().await.is_err() {
5215            return;
5216        }
5217    }
5218}
5219
5220#[expect(
5221    clippy::too_many_arguments,
5222    reason = "recovery keeps venue clients, account context, and generation inputs explicit"
5223)]
5224async fn attempt_post_reconnect_recovery(
5225    http_client: &Arc<BetfairHttpClient>,
5226    stream_client: &Arc<BetfairStreamClient>,
5227    app_key: &str,
5228    client_id: ClientId,
5229    account_id: AccountId,
5230    currency: Currency,
5231    clock: &'static AtomicTime,
5232    market_ids: Option<Vec<String>>,
5233    lookback_mins: u64,
5234    ocm_state: &Arc<Mutex<OcmState>>,
5235) -> anyhow::Result<MassStatusSnapshot> {
5236    let mut session_refresh = SessionRefresh::default();
5237    match http_client.keep_alive_with_token().await {
5238        Ok(_) => session_refresh.refreshed = true,
5239        Err(ref e) if e.is_login_failed() => {
5240            log::warn!("Session expired on reconnect, attempting re-login: {e}");
5241            http_client.reconnect_with_token().await?;
5242            session_refresh.refreshed = true;
5243            session_refresh.replaced = true;
5244        }
5245        Err(e) => log::warn!(
5246            "Keep-alive failed on reconnect; continuing recovery with the retained session: {e}",
5247        ),
5248    }
5249
5250    let stream_session = StreamSession {
5251        client: Some(stream_client),
5252        app_key,
5253    };
5254
5255    if session_refresh.refreshed {
5256        stream_session.publish(http_client).await;
5257    }
5258
5259    let account_state = match http_client
5260        .send_accounts::<AccountFundsResponse, _>(METHOD_GET_ACCOUNT_FUNDS, serde_json::json!({}))
5261        .await
5262    {
5263        Ok(funds) => {
5264            let ts_init = clock.get_time_ns();
5265            match parse_account_state(&funds, account_id, currency, ts_init, ts_init) {
5266                Ok(state) => Some(state),
5267                Err(e) => {
5268                    log::warn!("Failed to parse account state on reconnect: {e}");
5269                    None
5270                }
5271            }
5272        }
5273        Err(e) => {
5274            log::warn!("Failed to fetch account state on reconnect: {e}");
5275            None
5276        }
5277    };
5278
5279    let result = fetch_post_reconnect_mass_status(
5280        http_client,
5281        client_id,
5282        account_id,
5283        currency,
5284        clock,
5285        market_ids,
5286        lookback_mins,
5287        ocm_state,
5288        stream_session,
5289        &mut session_refresh,
5290    )
5291    .await;
5292    apply_stream_session_refresh(
5293        http_client.as_ref(),
5294        Some(stream_client),
5295        app_key,
5296        session_refresh,
5297    )
5298    .await;
5299    result.map(|mut result| {
5300        result.account_state = account_state;
5301        result
5302    })
5303}
5304
5305/// Fetches the REST inputs for an [`ExecutionMassStatus`] over `lookback_mins` of history.
5306#[expect(clippy::too_many_arguments)]
5307async fn fetch_post_reconnect_mass_status(
5308    http_client: &Arc<BetfairHttpClient>,
5309    client_id: ClientId,
5310    account_id: AccountId,
5311    currency: Currency,
5312    clock: &'static AtomicTime,
5313    market_ids: Option<Vec<String>>,
5314    lookback_mins: u64,
5315    ocm_state: &Arc<Mutex<OcmState>>,
5316    stream_session: StreamSession<'_>,
5317    session_refresh: &mut SessionRefresh,
5318) -> anyhow::Result<MassStatusSnapshot> {
5319    let ts_now = clock.get_time_ns();
5320    let start = ts_now.saturating_sub(DurationNanos::try_from_mins(lookback_mins)?);
5321
5322    let mut fetched_orders = fetch_order_status_reports_snapshot_http(
5323        http_client,
5324        account_id,
5325        ts_now,
5326        market_ids,
5327        false,
5328        ocm_state,
5329        stream_session,
5330        session_refresh,
5331    )
5332    .await?;
5333    assign_report_owners(&mut fetched_orders.reports, &ocm_state.lock());
5334    retain_snapshot_fill_orders(&mut fetched_orders.orders, Some(start), Some(ts_now))?;
5335    Ok(MassStatusSnapshot {
5336        client_id,
5337        account_id,
5338        currency,
5339        ts_init: ts_now,
5340        order_reports: fetched_orders.reports,
5341        active_quantities: fetched_orders.active_quantities,
5342        voided_quantities: fetched_orders.voided_quantities,
5343        fill_orders: fetched_orders.orders,
5344        account_state: None,
5345    })
5346}
5347
5348fn list_current_orders_filter_bet_id(bet_id: String) -> ListCurrentOrdersParams {
5349    ListCurrentOrdersParams {
5350        bet_ids: Some(vec![bet_id]),
5351        market_ids: None,
5352        order_projection: None,
5353        customer_order_refs: None,
5354        customer_strategy_refs: None,
5355        date_range: None,
5356        order_by: None,
5357        sort_dir: None,
5358        from_record: None,
5359        record_count: None,
5360    }
5361}
5362
5363fn list_current_orders_filter_ref(customer_order_ref: String) -> ListCurrentOrdersParams {
5364    ListCurrentOrdersParams {
5365        bet_ids: None,
5366        market_ids: None,
5367        order_projection: None,
5368        customer_order_refs: Some(vec![customer_order_ref]),
5369        customer_strategy_refs: None,
5370        date_range: None,
5371        order_by: None,
5372        sort_dir: None,
5373        from_record: None,
5374        record_count: None,
5375    }
5376}
5377
5378fn extend_unique(
5379    candidates: &mut Vec<CurrentOrderSummary>,
5380    seen: &mut AHashSet<String>,
5381    orders: Vec<CurrentOrderSummary>,
5382) {
5383    for order in orders {
5384        if seen.insert(order.bet_id.clone()) {
5385            candidates.push(order);
5386        }
5387    }
5388}
5389
5390fn select_order_for_query(
5391    orders: &[CurrentOrderSummary],
5392    expected_instrument_id: InstrumentId,
5393    expected_client_order_id: ClientOrderId,
5394    expected_venue_order_id: Option<VenueOrderId>,
5395) -> Option<&CurrentOrderSummary> {
5396    let matching: Vec<&CurrentOrderSummary> = orders
5397        .iter()
5398        .filter(|o| {
5399            make_instrument_id(&o.market_id, o.selection_id, o.handicap) == expected_instrument_id
5400        })
5401        .collect();
5402
5403    let candidates: Vec<&CurrentOrderSummary> = if matching.is_empty() {
5404        // No instrument match: accept only an exact venue_order_id hit
5405        // (pre-existing orders without a recognizable customer_order_ref).
5406        // A lone foreign-instrument candidate is not enough, since a 32-char
5407        // customer_order_ref collision can surface a single unrelated bet.
5408        if let Some(vid) = expected_venue_order_id
5409            && let Some(order) = orders.iter().find(|o| o.bet_id == vid.as_str())
5410        {
5411            return Some(order);
5412        }
5413        log::warn!(
5414            "Betfair query_order returned {} orders for client_order_id={expected_client_order_id}, none matching instrument {expected_instrument_id}; skipping to avoid cross-instrument reconciliation",
5415            orders.len(),
5416        );
5417        return None;
5418    } else {
5419        matching
5420    };
5421
5422    // Prefer EXECUTABLE so a live replacement wins over a cancelled
5423    // predecessor sharing the same customer_order_ref.
5424    let executable: Vec<&CurrentOrderSummary> = candidates
5425        .iter()
5426        .copied()
5427        .filter(|o| o.status == BetfairOrderStatus::Executable)
5428        .collect();
5429
5430    let pool = if executable.is_empty() {
5431        candidates
5432    } else {
5433        executable
5434    };
5435
5436    // Tiebreaker: most recently placed bet. Picks the replacement over the
5437    // predecessor even when both are already terminal by poll time.
5438    pool.into_iter()
5439        .max_by(|a, b| a.placed_date.cmp(&b.placed_date))
5440}
5441
5442async fn list_current_orders_with_retry(
5443    http_client: &Arc<BetfairHttpClient>,
5444    params: &ListCurrentOrdersParams,
5445    stream_session: StreamSession<'_>,
5446    session_refresh: &mut SessionRefresh,
5447) -> anyhow::Result<CurrentOrderSummaryReport> {
5448    send_betting_with_retry(
5449        http_client,
5450        METHOD_LIST_CURRENT_ORDERS,
5451        params,
5452        stream_session,
5453        session_refresh,
5454    )
5455    .await
5456}
5457
5458async fn send_betting_with_retry<T: serde::de::DeserializeOwned, P: serde::Serialize>(
5459    http_client: &Arc<BetfairHttpClient>,
5460    method: &str,
5461    params: &P,
5462    stream_session: StreamSession<'_>,
5463    session_refresh: &mut SessionRefresh,
5464) -> anyhow::Result<T> {
5465    const RATE_LIMIT_RETRY_DELAY_SECS: u64 = 5;
5466
5467    match http_client.send_betting(method, params).await {
5468        Ok(r) => Ok(r),
5469        Err(e) if e.is_session_error() || e.is_rate_limit_error() => {
5470            if e.is_rate_limit_error() {
5471                log::warn!("Rate limited, retrying in {RATE_LIMIT_RETRY_DELAY_SECS}s");
5472                tokio::time::sleep(tokio::time::Duration::from_secs(
5473                    RATE_LIMIT_RETRY_DELAY_SECS,
5474                ))
5475                .await;
5476            } else {
5477                log::warn!("Session error, refreshing session");
5478
5479                let refreshed = match http_client.keep_alive_with_token().await {
5480                    Ok(_) => Some(false),
5481                    Err(_) => http_client.reconnect_with_token().await.ok().map(|_| true),
5482                };
5483
5484                if let Some(session_replaced) = refreshed {
5485                    session_refresh.refreshed = true;
5486                    session_refresh.replaced |= session_replaced;
5487                    stream_session.publish(http_client).await;
5488                }
5489            }
5490            http_client
5491                .send_betting(method, params)
5492                .await
5493                .map_err(|e| anyhow::anyhow!("{e}"))
5494        }
5495        Err(e) => Err(anyhow::anyhow!("{e}")),
5496    }
5497}
5498
5499#[derive(Default)]
5500struct SessionRefresh {
5501    refreshed: bool,
5502    replaced: bool,
5503}
5504
5505#[derive(Clone, Copy)]
5506struct StreamSession<'a> {
5507    client: Option<&'a Arc<BetfairStreamClient>>,
5508    app_key: &'a str,
5509}
5510
5511impl StreamSession<'_> {
5512    async fn publish(self, http_client: &BetfairHttpClient) {
5513        let Some(client) = self.client else {
5514            return;
5515        };
5516
5517        let _ = http_client
5518            .with_session_token(|token| {
5519                client.update_auth(self.app_key, token.clone());
5520            })
5521            .await;
5522    }
5523}
5524
5525async fn apply_stream_session_refresh(
5526    http_client: &BetfairHttpClient,
5527    stream_client: Option<&Arc<BetfairStreamClient>>,
5528    app_key: &str,
5529    session_refresh: SessionRefresh,
5530) {
5531    if !session_refresh.refreshed {
5532        return;
5533    }
5534    let Some(stream_client) = stream_client else {
5535        return;
5536    };
5537
5538    let _ = http_client
5539        .with_session_token(|token| {
5540            stream_client.update_auth(app_key, token.clone());
5541            if session_refresh.replaced {
5542                let _ = stream_client.request_reconnect();
5543            }
5544        })
5545        .await;
5546}
5547
5548// Claims and emits the HTTP place acceptance while holding the `OcmState` lock. The OCM
5549// handler emits under the same lock, so a racing OCM fill cannot enqueue ahead of this
5550// acceptance. No-op if acceptance was already claimed.
5551fn emit_http_accept_if_claimed(
5552    ocm_state: &Arc<Mutex<OcmState>>,
5553    client_order_id: &ClientOrderId,
5554    venue_order_id: VenueOrderId,
5555    emit: impl FnOnce(),
5556) {
5557    let mut state = ocm_state.lock();
5558
5559    if state.claim_acceptance(*client_order_id, venue_order_id) {
5560        emit();
5561    }
5562}
5563
5564#[expect(
5565    clippy::too_many_arguments,
5566    reason = "order identity and reconciliation state remain explicit at the replace failure boundary"
5567)]
5568fn emit_replace_failure(
5569    ocm_state: &Arc<Mutex<OcmState>>,
5570    emitter: &ExecutionEventEmitter,
5571    clock: &AtomicTime,
5572    strategy_id: StrategyId,
5573    instrument_id: InstrumentId,
5574    client_order_id: ClientOrderId,
5575    venue_order_id: VenueOrderId,
5576    old_bet_id: &str,
5577    reason: &str,
5578) {
5579    let ts_event = clock.get_time_ns();
5580    let old_terminal = {
5581        let mut state = ocm_state.lock();
5582        let old_terminal = state.terminal_orders.contains(old_bet_id);
5583        let Some(_) = state.take_pending_replace(client_order_id, old_bet_id) else {
5584            return;
5585        };
5586
5587        if old_terminal {
5588            state.retain_terminal_order(client_order_id, old_bet_id);
5589        }
5590        old_terminal
5591    };
5592
5593    if old_terminal {
5594        let canceled = OrderCanceled::new(
5595            emitter.trader_id(),
5596            strategy_id,
5597            instrument_id,
5598            client_order_id,
5599            UUID4::new(),
5600            ts_event,
5601            ts_event,
5602            false,
5603            Some(venue_order_id),
5604            Some(emitter.account_id()),
5605            None,
5606        );
5607        emitter.send_order_event(OrderEventAny::Canceled(canceled));
5608    } else {
5609        emitter.emit_order_modify_rejected_event(
5610            strategy_id,
5611            instrument_id,
5612            client_order_id,
5613            Some(venue_order_id),
5614            reason,
5615            ts_event,
5616        );
5617    }
5618}
5619
5620// Claims and emits a definitive HTTP rejection while holding the same state lock as OCM.
5621fn emit_http_reject_if_unreported(
5622    ocm_state: &Arc<Mutex<OcmState>>,
5623    client_order_id: &ClientOrderId,
5624    reason: &str,
5625    emit: impl FnOnce(),
5626) {
5627    let mut state = ocm_state.lock();
5628
5629    if state.is_accepted(client_order_id) {
5630        log::debug!(
5631            "Suppressing late HTTP rejection for {client_order_id}: OCM already reported order state ({reason})"
5632        );
5633        return;
5634    }
5635
5636    emit();
5637    state.remove_order_correlation(client_order_id);
5638}
5639
5640fn order_customer_ref() -> String {
5641    UUID4::new().to_string().replace('-', "")
5642}
5643
5644fn classify_http_error(error: &BetfairHttpError) -> CommandFailure {
5645    let reason = error.to_string();
5646
5647    if error.is_order_ambiguous() {
5648        CommandFailure::ambiguous(reason)
5649    } else if matches!(
5650        error,
5651        BetfairHttpError::MissingCredentials
5652            | BetfairHttpError::LoginFailed { .. }
5653            | BetfairHttpError::JsonError(_)
5654            | BetfairHttpError::InvalidConfiguration(_)
5655    ) {
5656        CommandFailure::not_sent(reason)
5657    } else {
5658        CommandFailure::venue_rejected(reason)
5659    }
5660}
5661
5662fn single_instruction_report<T>(reports: Option<&[T]>) -> Option<&T> {
5663    match reports? {
5664        [report] => Some(report),
5665        _ => None,
5666    }
5667}
5668
5669fn stream_active_quantity(uo: &UnmatchedOrder) -> Option<Quantity> {
5670    let active = uo.sm.unwrap_or(Decimal::ZERO)
5671        + uo.sr.unwrap_or(Decimal::ZERO)
5672        + uo.sv.unwrap_or(Decimal::ZERO);
5673    parse_betfair_quantity(active).ok()
5674}
5675
5676fn classify_execution_report<F>(
5677    status: ExecutionReportStatus,
5678    error_code: Option<ExecutionReportErrorCode>,
5679    instruction_result: Option<Result<(), CommandFailure>>,
5680    report_reason: F,
5681) -> Result<(), CommandFailure>
5682where
5683    F: FnOnce() -> String,
5684{
5685    match (status, instruction_result) {
5686        (ExecutionReportStatus::Timeout, _) => Err(CommandFailure::ambiguous(report_reason())),
5687        (ExecutionReportStatus::Failure, Some(Ok(()))) => {
5688            Err(CommandFailure::ambiguous(report_reason()))
5689        }
5690        (_, Some(result)) => result,
5691        (ExecutionReportStatus::Failure, None)
5692            if execution_error_is_venue_rejection(error_code) =>
5693        {
5694            Err(CommandFailure::venue_rejected(report_reason()))
5695        }
5696        (
5697            ExecutionReportStatus::Success
5698            | ExecutionReportStatus::Failure
5699            | ExecutionReportStatus::ProcessedWithErrors,
5700            None,
5701        ) => Err(CommandFailure::ambiguous(report_reason())),
5702    }
5703}
5704
5705fn execution_error_is_venue_rejection(error_code: Option<ExecutionReportErrorCode>) -> bool {
5706    matches!(
5707        error_code,
5708        Some(
5709            ExecutionReportErrorCode::InvalidAccountState
5710                | ExecutionReportErrorCode::InvalidWalletStatus
5711                | ExecutionReportErrorCode::InsufficientFunds
5712                | ExecutionReportErrorCode::LossLimitExceeded
5713                | ExecutionReportErrorCode::MarketSuspended
5714                | ExecutionReportErrorCode::MarketNotOpenForBetting
5715                | ExecutionReportErrorCode::InvalidOrder
5716                | ExecutionReportErrorCode::InvalidMarketId
5717                | ExecutionReportErrorCode::PermissionDenied
5718                | ExecutionReportErrorCode::DuplicateBetids
5719                | ExecutionReportErrorCode::NoActionRequired
5720                | ExecutionReportErrorCode::RejectedByRegulator
5721                | ExecutionReportErrorCode::NoChasing
5722                | ExecutionReportErrorCode::RegulatorIsNotAvailable
5723                | ExecutionReportErrorCode::TooManyInstructions
5724                | ExecutionReportErrorCode::InvalidMarketVersion
5725                | ExecutionReportErrorCode::InvalidProfitRatio
5726                | ExecutionReportErrorCode::EventExposureLimitExceeded
5727                | ExecutionReportErrorCode::EventMatchedExposureLimitExceeded
5728                | ExecutionReportErrorCode::EventBlocked
5729        )
5730    )
5731}
5732
5733fn classify_replace_instruction(
5734    report: &ReplaceInstructionReport,
5735    reason: String,
5736) -> Result<(), CommandFailure> {
5737    classify_instruction_report(report.status, report.error_code, false, || reason.clone())?;
5738
5739    let Some(cancel) = &report.cancel_instruction_report else {
5740        return Err(CommandFailure::ambiguous(reason));
5741    };
5742    classify_instruction_report(cancel.status, cancel.error_code, false, || reason.clone())?;
5743
5744    let Some(place) = &report.place_instruction_report else {
5745        return Err(CommandFailure::ambiguous(reason));
5746    };
5747    classify_instruction_report(place.status, place.error_code, false, || reason)
5748}
5749
5750fn classify_instruction_report<F>(
5751    status: InstructionReportStatus,
5752    error_code: Option<InstructionReportErrorCode>,
5753    bet_taken_is_success: bool,
5754    reason: F,
5755) -> Result<(), CommandFailure>
5756where
5757    F: FnOnce() -> String,
5758{
5759    match status {
5760        InstructionReportStatus::Success => Ok(()),
5761        InstructionReportStatus::Timeout => Err(CommandFailure::ambiguous(reason())),
5762        InstructionReportStatus::Failure
5763            if error_code == Some(InstructionReportErrorCode::BetInProgress) =>
5764        {
5765            Err(CommandFailure::ambiguous(reason()))
5766        }
5767        InstructionReportStatus::Failure
5768            if bet_taken_is_success
5769                && error_code == Some(InstructionReportErrorCode::BetTakenOrLapsed) =>
5770        {
5771            Ok(())
5772        }
5773        InstructionReportStatus::Failure => Err(CommandFailure::venue_rejected(reason())),
5774    }
5775}
5776
5777fn replace_cancelled_without_replacement(report: Option<&ReplaceInstructionReport>) -> bool {
5778    report.is_some_and(|report| {
5779        report.error_code == Some(InstructionReportErrorCode::CancelledNotPlaced)
5780            && report
5781                .cancel_instruction_report
5782                .as_ref()
5783                .is_some_and(|cancel| cancel.status == InstructionReportStatus::Success)
5784            && report
5785                .place_instruction_report
5786                .as_ref()
5787                .is_some_and(|place| place.status == InstructionReportStatus::Failure)
5788    })
5789}
5790
5791fn format_place_instruction_reason(
5792    instruction_report: &PlaceInstructionReport,
5793    report: &PlaceExecutionReport,
5794) -> String {
5795    format_betfair_reason(
5796        instruction_report.error_code,
5797        report_fallback(report.error_code),
5798        "unknown error",
5799    )
5800}
5801
5802fn format_cancel_instruction_reason(
5803    error_code: Option<InstructionReportErrorCode>,
5804    report_error_code: Option<ExecutionReportErrorCode>,
5805) -> String {
5806    format_betfair_reason(
5807        error_code,
5808        report_fallback(report_error_code),
5809        "unknown instruction error",
5810    )
5811}
5812
5813fn format_replace_instruction_reason(
5814    instruction_report: &ReplaceInstructionReport,
5815    report: &ReplaceExecutionReport,
5816) -> String {
5817    let nested_reason = instruction_report
5818        .place_instruction_report
5819        .as_ref()
5820        .and_then(|ir| instruction_fallback(ir.error_code))
5821        .or_else(|| {
5822            instruction_report
5823                .cancel_instruction_report
5824                .as_ref()
5825                .and_then(|ir| instruction_fallback(ir.error_code))
5826        });
5827
5828    format_betfair_reason(
5829        instruction_report.error_code,
5830        nested_reason.or_else(|| report_fallback(report.error_code)),
5831        "unknown instruction error",
5832    )
5833}
5834
5835fn format_betfair_reason(
5836    error_code: Option<impl fmt::Debug>,
5837    fallback: Option<String>,
5838    unknown: &str,
5839) -> String {
5840    error_code
5841        .map(|code| format!("{code:?}"))
5842        .or(fallback)
5843        .unwrap_or_else(|| unknown.to_string())
5844}
5845
5846fn report_fallback(error_code: Option<ExecutionReportErrorCode>) -> Option<String> {
5847    error_code.map(|code| format!("{code:?}"))
5848}
5849
5850fn instruction_fallback(error_code: Option<InstructionReportErrorCode>) -> Option<String> {
5851    error_code.map(|code| format!("{code:?}"))
5852}
5853
5854#[cfg(test)]
5855mod tests {
5856    use std::{cell::RefCell, rc::Rc};
5857
5858    use nautilus_common::{
5859        cache::Cache,
5860        messages::{ExecutionEvent, ExecutionReport},
5861    };
5862    use nautilus_model::{
5863        events::{OrderDenied, OrderSubmitted},
5864        identifiers::{PositionId, StrategyId, TraderId},
5865        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
5866        orders::builder::OrderTestBuilder,
5867        position::Position,
5868        types::{Money, Price, Quantity},
5869    };
5870    use rstest::rstest;
5871    use rust_decimal::Decimal;
5872    use rust_decimal_macros::dec;
5873
5874    use super::*;
5875    use crate::{
5876        common::{
5877            enums::SegmentType,
5878            testing::{load_test_json, parse_jsonrpc},
5879        },
5880        http::models::CancelInstructionReport,
5881        stream::messages::stream_decode,
5882    };
5883
5884    fn validation_order_builder(order_type: OrderType) -> OrderTestBuilder {
5885        let mut order = OrderTestBuilder::new(order_type);
5886        order
5887            .instrument_id(InstrumentId::from("1.234567-12345-0.0.BETFAIR"))
5888            .quantity(Quantity::from(10));
5889
5890        match order_type {
5891            OrderType::Limit => {
5892                order.price(Price::from("2.0"));
5893            }
5894            OrderType::StopMarket => {
5895                order.trigger_price(Price::from("2.0"));
5896            }
5897            _ => {}
5898        }
5899
5900        order
5901    }
5902
5903    #[rstest]
5904    #[case(OrderType::Limit, TimeInForce::Gtc)]
5905    #[case(OrderType::Market, TimeInForce::AtTheClose)]
5906    fn test_validate_order_accepts_supported_orders(
5907        #[case] order_type: OrderType,
5908        #[case] time_in_force: TimeInForce,
5909    ) {
5910        let order = validation_order_builder(order_type)
5911            .time_in_force(time_in_force)
5912            .build();
5913
5914        assert_eq!(validate_order(&order), Ok(()));
5915    }
5916
5917    #[rstest]
5918    #[case(OrderType::Market, TimeInForce::AtTheClose, true)]
5919    #[case(OrderType::Limit, TimeInForce::AtTheClose, true)]
5920    #[case(OrderType::Limit, TimeInForce::AtTheOpen, true)]
5921    #[case(OrderType::Limit, TimeInForce::Gtc, false)]
5922    fn test_is_sp_order_matches_on_close_time_in_force(
5923        #[case] order_type: OrderType,
5924        #[case] time_in_force: TimeInForce,
5925        #[case] expected: bool,
5926    ) {
5927        let order = validation_order_builder(order_type)
5928            .time_in_force(time_in_force)
5929            .build();
5930
5931        assert_eq!(is_sp_order(&order), expected);
5932    }
5933
5934    #[rstest]
5935    fn test_validate_order_denies_reduce_only() {
5936        let order = validation_order_builder(OrderType::Limit)
5937            .reduce_only(true)
5938            .build();
5939
5940        assert_eq!(
5941            validate_order(&order),
5942            Err(OrderDeniedReason::UnsupportedReduceOnly),
5943        );
5944    }
5945
5946    #[rstest]
5947    fn test_validate_order_denies_unsupported_order_type() {
5948        let order = validation_order_builder(OrderType::StopMarket).build();
5949
5950        assert_eq!(
5951            validate_order(&order),
5952            Err(OrderDeniedReason::UnsupportedOrderType {
5953                order_type: OrderType::StopMarket,
5954            }),
5955        );
5956    }
5957
5958    #[rstest]
5959    fn test_validate_order_denies_unsupported_market_time_in_force() {
5960        let order = validation_order_builder(OrderType::Market)
5961            .time_in_force(TimeInForce::Gtc)
5962            .build();
5963
5964        assert_eq!(
5965            validate_order(&order),
5966            Err(OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Gtc)),
5967        );
5968    }
5969
5970    #[rstest]
5971    #[case(
5972        ExecutionReportStatus::Success,
5973        None,
5974        Some(Ok(())),
5975        Ok(())
5976    )]
5977    #[case(
5978        ExecutionReportStatus::Success,
5979        None,
5980        None,
5981        Err(CommandFailure::Ambiguous("report reason".to_string()))
5982    )]
5983    #[case(
5984        ExecutionReportStatus::Timeout,
5985        None,
5986        Some(Err(CommandFailure::VenueRejected("instruction reason".to_string()))),
5987        Err(CommandFailure::Ambiguous("report reason".to_string()))
5988    )]
5989    #[case(
5990        ExecutionReportStatus::Failure,
5991        Some(ExecutionReportErrorCode::DuplicateTransaction),
5992        None,
5993        Err(CommandFailure::Ambiguous("report reason".to_string()))
5994    )]
5995    #[case(
5996        ExecutionReportStatus::Failure,
5997        Some(ExecutionReportErrorCode::MarketSuspended),
5998        Some(Ok(())),
5999        Err(CommandFailure::Ambiguous("report reason".to_string()))
6000    )]
6001    #[case(
6002        ExecutionReportStatus::Failure,
6003        Some(ExecutionReportErrorCode::MarketSuspended),
6004        None,
6005        Err(CommandFailure::VenueRejected("report reason".to_string()))
6006    )]
6007    #[case(
6008        ExecutionReportStatus::ProcessedWithErrors,
6009        None,
6010        None,
6011        Err(CommandFailure::Ambiguous("report reason".to_string()))
6012    )]
6013    #[case(
6014        ExecutionReportStatus::ProcessedWithErrors,
6015        None,
6016        Some(Err(CommandFailure::VenueRejected("instruction reason".to_string()))),
6017        Err(CommandFailure::VenueRejected("instruction reason".to_string()))
6018    )]
6019    fn test_classify_execution_report(
6020        #[case] status: ExecutionReportStatus,
6021        #[case] error_code: Option<ExecutionReportErrorCode>,
6022        #[case] instruction_result: Option<Result<(), CommandFailure>>,
6023        #[case] expected: Result<(), CommandFailure>,
6024    ) {
6025        assert_eq!(
6026            classify_execution_report(status, error_code, instruction_result, || {
6027                "report reason".to_string()
6028            }),
6029            expected,
6030        );
6031    }
6032
6033    #[rstest]
6034    #[case(InstructionReportStatus::Success, None, false, Ok(()))]
6035    #[case(
6036        InstructionReportStatus::Timeout,
6037        None,
6038        false,
6039        Err(CommandFailure::Ambiguous("instruction reason".to_string()))
6040    )]
6041    #[case(
6042        InstructionReportStatus::Failure,
6043        Some(InstructionReportErrorCode::BetInProgress),
6044        false,
6045        Err(CommandFailure::Ambiguous("instruction reason".to_string()))
6046    )]
6047    #[case(
6048        InstructionReportStatus::Failure,
6049        Some(InstructionReportErrorCode::BetTakenOrLapsed),
6050        true,
6051        Ok(())
6052    )]
6053    #[case(
6054        InstructionReportStatus::Failure,
6055        Some(InstructionReportErrorCode::BetTakenOrLapsed),
6056        false,
6057        Err(CommandFailure::VenueRejected("instruction reason".to_string()))
6058    )]
6059    #[case(
6060        InstructionReportStatus::Failure,
6061        Some(InstructionReportErrorCode::ErrorInOrder),
6062        false,
6063        Err(CommandFailure::VenueRejected("instruction reason".to_string()))
6064    )]
6065    fn test_classify_instruction_report(
6066        #[case] status: InstructionReportStatus,
6067        #[case] error_code: Option<InstructionReportErrorCode>,
6068        #[case] bet_taken_is_success: bool,
6069        #[case] expected: Result<(), CommandFailure>,
6070    ) {
6071        assert_eq!(
6072            classify_instruction_report(status, error_code, bet_taken_is_success, || {
6073                "instruction reason".to_string()
6074            }),
6075            expected,
6076        );
6077    }
6078
6079    #[rstest]
6080    #[case(
6081        Some((InstructionReportStatus::Success, None)),
6082        Some((InstructionReportStatus::Success, None)),
6083        Ok(())
6084    )]
6085    #[case(
6086        None,
6087        Some((InstructionReportStatus::Success, None)),
6088        Err(CommandFailure::Ambiguous("replace reason".to_string()))
6089    )]
6090    #[case(
6091        Some((InstructionReportStatus::Success, None)),
6092        None,
6093        Err(CommandFailure::Ambiguous("replace reason".to_string()))
6094    )]
6095    #[case(
6096        Some((InstructionReportStatus::Success, None)),
6097        Some((InstructionReportStatus::Timeout, None)),
6098        Err(CommandFailure::Ambiguous("replace reason".to_string()))
6099    )]
6100    #[case(
6101        Some((
6102            InstructionReportStatus::Failure,
6103            Some(InstructionReportErrorCode::ErrorInOrder),
6104        )),
6105        Some((InstructionReportStatus::Success, None)),
6106        Err(CommandFailure::VenueRejected("replace reason".to_string()))
6107    )]
6108    fn test_classify_replace_instruction(
6109        #[case] cancel: Option<(InstructionReportStatus, Option<InstructionReportErrorCode>)>,
6110        #[case] place: Option<(InstructionReportStatus, Option<InstructionReportErrorCode>)>,
6111        #[case] expected: Result<(), CommandFailure>,
6112    ) {
6113        let report = ReplaceInstructionReport {
6114            status: InstructionReportStatus::Success,
6115            error_code: None,
6116            error_message: None,
6117            cancel_instruction_report: cancel.map(|(status, error_code)| CancelInstructionReport {
6118                status,
6119                error_code,
6120                error_message: None,
6121                instruction: None,
6122                size_cancelled: None,
6123                cancelled_date: None,
6124            }),
6125            place_instruction_report: place.map(|(status, error_code)| PlaceInstructionReport {
6126                status,
6127                error_code,
6128                error_message: None,
6129                order_status: None,
6130                instruction: None,
6131                bet_id: None,
6132                placed_date: None,
6133                average_price_matched: None,
6134                size_matched: None,
6135            }),
6136        };
6137
6138        assert_eq!(
6139            classify_replace_instruction(&report, "replace reason".to_string()),
6140            expected,
6141        );
6142    }
6143
6144    #[rstest]
6145    #[case(
6146        Some(InstructionReportErrorCode::CancelledNotPlaced),
6147        Some(InstructionReportStatus::Success),
6148        Some(InstructionReportStatus::Failure),
6149        true
6150    )]
6151    #[case(
6152        Some(InstructionReportErrorCode::ErrorInOrder),
6153        Some(InstructionReportStatus::Success),
6154        Some(InstructionReportStatus::Failure),
6155        false
6156    )]
6157    #[case(
6158        Some(InstructionReportErrorCode::CancelledNotPlaced),
6159        Some(InstructionReportStatus::Failure),
6160        Some(InstructionReportStatus::Failure),
6161        false
6162    )]
6163    #[case(
6164        Some(InstructionReportErrorCode::CancelledNotPlaced),
6165        Some(InstructionReportStatus::Success),
6166        Some(InstructionReportStatus::Success),
6167        false
6168    )]
6169    #[case(
6170        Some(InstructionReportErrorCode::CancelledNotPlaced),
6171        None,
6172        Some(InstructionReportStatus::Failure),
6173        false
6174    )]
6175    #[case(
6176        Some(InstructionReportErrorCode::CancelledNotPlaced),
6177        Some(InstructionReportStatus::Success),
6178        None,
6179        false
6180    )]
6181    fn test_replace_cancelled_without_replacement(
6182        #[case] error_code: Option<InstructionReportErrorCode>,
6183        #[case] cancel_status: Option<InstructionReportStatus>,
6184        #[case] place_status: Option<InstructionReportStatus>,
6185        #[case] expected: bool,
6186    ) {
6187        let report = ReplaceInstructionReport {
6188            status: InstructionReportStatus::Failure,
6189            error_code,
6190            error_message: None,
6191            cancel_instruction_report: cancel_status.map(|status| CancelInstructionReport {
6192                status,
6193                error_code: None,
6194                error_message: None,
6195                instruction: None,
6196                size_cancelled: None,
6197                cancelled_date: None,
6198            }),
6199            place_instruction_report: place_status.map(|status| PlaceInstructionReport {
6200                status,
6201                error_code: Some(InstructionReportErrorCode::InvalidOdds),
6202                error_message: None,
6203                order_status: None,
6204                instruction: None,
6205                bet_id: None,
6206                placed_date: None,
6207                average_price_matched: None,
6208                size_matched: None,
6209            }),
6210        };
6211
6212        assert_eq!(
6213            replace_cancelled_without_replacement(Some(&report)),
6214            expected
6215        );
6216        assert!(!replace_cancelled_without_replacement(None));
6217    }
6218
6219    #[rstest]
6220    #[case(
6221        BetfairHttpError::MissingCredentials,
6222        CommandFailure::NotSent("Missing API credentials".to_string())
6223    )]
6224    #[case(
6225        BetfairHttpError::Timeout("request timed out".to_string()),
6226        CommandFailure::Ambiguous("Timeout: request timed out".to_string())
6227    )]
6228    #[case(
6229        BetfairHttpError::OrderRequestAmbiguous("earlier attempt".to_string()),
6230        CommandFailure::Ambiguous("Ambiguous order request: earlier attempt".to_string())
6231    )]
6232    #[case(
6233        BetfairHttpError::JsonError("request encoding".to_string()),
6234        CommandFailure::NotSent("JSON error: request encoding".to_string())
6235    )]
6236    #[case(
6237        BetfairHttpError::ResponseError("truncated response".to_string()),
6238        CommandFailure::Ambiguous("Response error: truncated response".to_string())
6239    )]
6240    #[case(
6241        BetfairHttpError::UnexpectedStatus {
6242            status: 500,
6243            body: "server error".to_string(),
6244        },
6245        CommandFailure::Ambiguous("Unexpected status 500: server error".to_string())
6246    )]
6247    #[case(
6248        BetfairHttpError::UnexpectedStatus {
6249            status: 429,
6250            body: "too many requests".to_string(),
6251        },
6252        CommandFailure::VenueRejected(
6253            "Unexpected status 429: too many requests".to_string()
6254        )
6255    )]
6256    #[case(
6257        BetfairHttpError::BetfairError {
6258            code: -32099,
6259            message: "ANGX-0001".to_string(),
6260            api_error_code: Some("TOO_MUCH_DATA".to_string()),
6261            api_error_details: Some(
6262                "MaxResults must be less than or equal to 1000".to_string()
6263            ),
6264        },
6265        CommandFailure::VenueRejected(
6266            "Betfair error -32099: ANGX-0001 (TOO_MUCH_DATA: MaxResults must be less than or equal to 1000)".to_string()
6267        )
6268    )]
6269    fn test_classify_http_error(#[case] error: BetfairHttpError, #[case] expected: CommandFailure) {
6270        assert_eq!(classify_http_error(&error), expected);
6271    }
6272
6273    #[rstest]
6274    #[case(
6275        Some(InstructionReportErrorCode::ErrorInOrder),
6276        None,
6277        "unknown",
6278        "ErrorInOrder"
6279    )]
6280    #[case(None, Some("report error".to_string()), "unknown", "report error")]
6281    #[case(None, None, "unknown error", "unknown error")]
6282    fn test_format_betfair_reason(
6283        #[case] error_code: Option<InstructionReportErrorCode>,
6284        #[case] fallback: Option<String>,
6285        #[case] unknown: &str,
6286        #[case] expected: &str,
6287    ) {
6288        assert_eq!(
6289            format_betfair_reason(error_code, fallback, unknown),
6290            expected
6291        );
6292    }
6293
6294    #[rstest]
6295    fn test_ocm_state_register_and_resolve() {
6296        let mut state = OcmState::default();
6297        let client_oid = ClientOrderId::from("O-20240101-001");
6298
6299        state.register_order_ref(client_oid).unwrap();
6300
6301        let rfo = make_customer_order_ref(client_oid.as_str());
6302        let resolved = state.resolve_client_order_id(Some(&rfo));
6303        assert_eq!(resolved, Some(client_oid));
6304    }
6305
6306    #[rstest]
6307    fn test_ocm_state_resolve_none_for_unknown_rfo() {
6308        let state = OcmState::default();
6309        assert!(state.resolve_client_order_id(Some("unknown")).is_none());
6310        assert!(state.resolve_client_order_id(None).is_none());
6311    }
6312
6313    #[rstest]
6314    fn test_ocm_state_register_with_legacy() {
6315        let mut state = OcmState::default();
6316        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
6317        let client_oid = ClientOrderId::from(id);
6318
6319        state.restore_order(
6320            client_oid,
6321            StrategyId::from("S-001"),
6322            VenueOrderId::from("bet-1"),
6323        );
6324
6325        let rfo_current = make_customer_order_ref(id);
6326        let rfo_legacy = make_customer_order_ref_legacy(id);
6327        assert_ne!(rfo_current, rfo_legacy);
6328
6329        assert_eq!(
6330            state.resolve_client_order_id(Some(&rfo_current)),
6331            Some(client_oid)
6332        );
6333        assert_eq!(
6334            state.resolve_client_order_id(Some(&rfo_legacy)),
6335            Some(client_oid)
6336        );
6337    }
6338
6339    #[rstest]
6340    fn test_ocm_state_remove_order_correlation() {
6341        let mut state = OcmState::default();
6342        let id = "O-20240101-550e8400-e29b-41d4-a716-446655440000";
6343        let client_oid = ClientOrderId::from(id);
6344
6345        state.restore_order(
6346            client_oid,
6347            StrategyId::from("S-001"),
6348            VenueOrderId::from("bet-1"),
6349        );
6350        state.remove_order_correlation(&client_oid);
6351
6352        let rfo_current = make_customer_order_ref(id);
6353        let rfo_legacy = make_customer_order_ref_legacy(id);
6354        assert!(state.resolve_client_order_id(Some(&rfo_current)).is_none());
6355        assert!(state.resolve_client_order_id(Some(&rfo_legacy)).is_none());
6356    }
6357
6358    #[rstest]
6359    fn test_http_accept_emits_when_unclaimed() {
6360        let state = Arc::new(Mutex::new(OcmState::default()));
6361        let client_oid = ClientOrderId::from("O-001");
6362        state
6363            .lock()
6364            .register_submission(client_oid, StrategyId::from("S-001"))
6365            .unwrap();
6366
6367        let mut emitted = false;
6368        emit_http_accept_if_claimed(&state, &client_oid, VenueOrderId::from("bet-1"), || {
6369            emitted = true;
6370        });
6371        assert!(emitted, "first HTTP accept must emit");
6372
6373        // A second claim for the same order is suppressed (already accepted).
6374        let mut emitted_again = false;
6375        emit_http_accept_if_claimed(&state, &client_oid, VenueOrderId::from("bet-1"), || {
6376            emitted_again = true;
6377        });
6378        assert!(!emitted_again, "already-accepted order must not re-emit");
6379    }
6380
6381    #[rstest]
6382    fn test_http_accept_suppressed_after_stream_report() {
6383        let client_oid = ClientOrderId::from("O-001");
6384        let mut inner = OcmState::default();
6385        inner
6386            .register_submission(client_oid, StrategyId::from("S-001"))
6387            .unwrap();
6388        inner.mark_accepted(client_oid);
6389        let state = Arc::new(Mutex::new(inner));
6390
6391        let mut emitted = false;
6392        emit_http_accept_if_claimed(&state, &client_oid, VenueOrderId::from("bet-1"), || {
6393            emitted = true;
6394        });
6395        assert!(!emitted, "OCM-reported order must suppress the HTTP accept");
6396    }
6397
6398    #[rstest]
6399    fn test_http_reject_emits_and_removes_correlation_without_stream_report() {
6400        let state = Arc::new(Mutex::new(OcmState::default()));
6401        let client_oid = ClientOrderId::from("O-001");
6402        state
6403            .lock()
6404            .register_submission(client_oid, StrategyId::from("S-001"))
6405            .unwrap();
6406        let mut emitted = false;
6407
6408        emit_http_reject_if_unreported(
6409            &state,
6410            &client_oid,
6411            "BetLapsedPriceImprovementTooLarge",
6412            || emitted = true,
6413        );
6414
6415        assert!(emitted);
6416        let rfo = make_customer_order_ref(client_oid.as_str());
6417        assert_eq!(state.lock().resolve_client_order_id(Some(&rfo)), None);
6418    }
6419
6420    #[rstest]
6421    fn test_http_reject_suppressed_after_stream_report() {
6422        // OCM-first race: the stream has already moved the order through
6423        // a terminal state (e.g. lapsed). A late HTTP rejection would
6424        // hit InvalidStateTrigger and pollute the own book audit log.
6425        let client_oid = ClientOrderId::from("O-001");
6426        let mut inner = OcmState::default();
6427        inner
6428            .register_submission(client_oid, StrategyId::from("S-001"))
6429            .unwrap();
6430        inner.mark_accepted(client_oid);
6431        let state = Arc::new(Mutex::new(inner));
6432        let mut emitted = false;
6433
6434        emit_http_reject_if_unreported(
6435            &state,
6436            &client_oid,
6437            "BetLapsedPriceImprovementTooLarge",
6438            || emitted = true,
6439        );
6440
6441        assert!(!emitted);
6442        let rfo = make_customer_order_ref(client_oid.as_str());
6443        assert_eq!(
6444            state.lock().resolve_client_order_id(Some(&rfo)),
6445            Some(client_oid),
6446        );
6447    }
6448
6449    fn cancel_unmatched_order(
6450        bet_id: &str,
6451        rfo: Option<String>,
6452    ) -> crate::stream::messages::UnmatchedOrder {
6453        crate::stream::messages::UnmatchedOrder {
6454            id: bet_id.to_string(),
6455            p: Decimal::new(30, 1),
6456            s: Decimal::new(20, 0),
6457            side: crate::common::enums::StreamingSide::Back,
6458            status: crate::common::enums::StreamingOrderStatus::ExecutionComplete,
6459            pt: Some(crate::common::enums::StreamingPersistenceType::Lapse),
6460            ot: crate::common::enums::StreamingOrderType::Limit,
6461            pd: 1617863365000,
6462            bsp: None,
6463            rfo,
6464            rfs: None,
6465            rc: None,
6466            rac: None,
6467            md: None,
6468            cd: None,
6469            ld: None,
6470            avp: None,
6471            sm: None,
6472            sr: None,
6473            sl: None,
6474            sc: Some(Decimal::new(20, 0)),
6475            sv: None,
6476            lsrc: Some(crate::common::enums::LapseStatusReasonCode::SpInPlay),
6477        }
6478    }
6479
6480    fn resting_sp_unmatched_order(
6481        bet_id: &str,
6482        rfo: Option<String>,
6483    ) -> crate::stream::messages::UnmatchedOrder {
6484        crate::stream::messages::UnmatchedOrder {
6485            id: bet_id.to_string(),
6486            p: Decimal::new(10000, 1),
6487            s: Decimal::ZERO,
6488            side: crate::common::enums::StreamingSide::Back,
6489            status: crate::common::enums::StreamingOrderStatus::ExecutionComplete,
6490            pt: Some(crate::common::enums::StreamingPersistenceType::MarketOnClose),
6491            ot: crate::common::enums::StreamingOrderType::MarketOnClose,
6492            pd: 1789423573000,
6493            bsp: Some(Decimal::new(2, 0)),
6494            rfo,
6495            rfs: None,
6496            rc: None,
6497            rac: None,
6498            md: None,
6499            cd: None,
6500            ld: None,
6501            avp: None,
6502            sm: Some(Decimal::ZERO),
6503            sr: Some(Decimal::ZERO),
6504            sl: Some(Decimal::ZERO),
6505            sc: Some(Decimal::ZERO),
6506            sv: Some(Decimal::ZERO),
6507            lsrc: None,
6508        }
6509    }
6510
6511    #[rstest]
6512    fn test_stream_active_quantity_includes_voided() {
6513        let order = crate::stream::messages::UnmatchedOrder {
6514            sm: Some(Decimal::from(3)),
6515            sr: Some(Decimal::from(4)),
6516            sv: Some(Decimal::from(2)),
6517            ..resting_sp_unmatched_order("bet-1", None)
6518        };
6519
6520        assert_eq!(
6521            stream_active_quantity(&order),
6522            Some(Quantity::from(9)),
6523            "sm(3) + sr(4) + sv(2) must all contribute to the gross active quantity"
6524        );
6525    }
6526
6527    #[rstest]
6528    fn test_current_order_active_quantity_includes_voided() {
6529        let mut order = make_summary(
6530            "bet-1",
6531            "1.100",
6532            12345,
6533            Decimal::ZERO,
6534            BetfairOrderStatus::Executable,
6535            "2026-08-25T00:00:00Z",
6536        );
6537        order.size_matched = Some(Decimal::from(3));
6538        order.size_remaining = Some(Decimal::from(4));
6539        order.size_voided = Some(Decimal::from(2));
6540
6541        assert_eq!(
6542            current_order_active_quantity(&order).unwrap(),
6543            Quantity::from(9),
6544            "size_matched(3) + size_remaining(4) + size_voided(2) must all contribute, matching \
6545             the HTTP status-report path's gross lifecycle quantity"
6546        );
6547    }
6548
6549    #[rstest]
6550    fn test_http_stream_and_reconciliation_agree_on_gross_active_quantity() {
6551        let matched = Decimal::from(3);
6552        let remaining = Decimal::from(4);
6553        let voided = Decimal::from(2);
6554        let expected = Quantity::from(9);
6555
6556        let stream_order = crate::stream::messages::UnmatchedOrder {
6557            sm: Some(matched),
6558            sr: Some(remaining),
6559            sv: Some(voided),
6560            ..resting_sp_unmatched_order("bet-1", None)
6561        };
6562        assert_eq!(stream_active_quantity(&stream_order), Some(expected));
6563
6564        let mut reconciliation_order = make_summary(
6565            "bet-1",
6566            "1.100",
6567            12345,
6568            Decimal::ZERO,
6569            BetfairOrderStatus::Executable,
6570            "2026-08-25T00:00:00Z",
6571        );
6572        reconciliation_order.size_matched = Some(matched);
6573        reconciliation_order.size_remaining = Some(remaining);
6574        reconciliation_order.size_voided = Some(voided);
6575        assert_eq!(
6576            current_order_active_quantity(&reconciliation_order).unwrap(),
6577            expected
6578        );
6579
6580        let mut http_order = reconciliation_order;
6581        http_order.price_size.size = Decimal::ZERO;
6582        http_order.size_cancelled = Some(Decimal::ZERO);
6583        http_order.size_lapsed = Some(Decimal::ZERO);
6584        let report = parse_current_order_report(
6585            &http_order,
6586            AccountId::from("BETFAIR-001"),
6587            UnixNanos::default(),
6588        )
6589        .unwrap();
6590        assert_eq!(report.quantity, expected);
6591    }
6592
6593    #[rstest]
6594    #[case::reduction_confirms_then_void_arrives(false)]
6595    #[case::void_present_before_reduction_confirms(true)]
6596    fn test_same_bet_reduction_and_void_ordering_agree(#[case] void_first: bool) {
6597        let client_order_id = ClientOrderId::from("O-1");
6598        let strategy_id = StrategyId::from("S-001");
6599        let bet_id = "bet-1";
6600        let mut state = OcmState::default();
6601        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(bet_id));
6602        state.register_pending_reduction(
6603            client_order_id,
6604            bet_id.to_string(),
6605            Quantity::from(10),
6606            Quantity::from(6),
6607        );
6608
6609        let update = |sm: i64, sr: i64, sv: i64| crate::stream::messages::UnmatchedOrder {
6610            sm: Some(Decimal::from(sm)),
6611            sr: Some(Decimal::from(sr)),
6612            sv: Some(Decimal::from(sv)),
6613            ..resting_sp_unmatched_order(bet_id, None)
6614        };
6615
6616        if void_first {
6617            // The void lands before the reduction takes visible effect on the wire: gross
6618            // still equals the original size, so nothing confirms yet.
6619            let result = BetfairExecutionClient::resolve_pending_reduction_from_stream(
6620                &mut state,
6621                Some((client_order_id, strategy_id)),
6622                &update(2, 7, 1),
6623            );
6624            assert_eq!(result, None);
6625
6626            // The reduction now takes effect, with the earlier void still present
6627            let result = BetfairExecutionClient::resolve_pending_reduction_from_stream(
6628                &mut state,
6629                Some((client_order_id, strategy_id)),
6630                &update(2, 3, 1),
6631            );
6632            assert_eq!(
6633                result,
6634                Some((client_order_id, strategy_id, Quantity::from(6)))
6635            );
6636        } else {
6637            // The reduction confirms first, with no void yet
6638            let result = BetfairExecutionClient::resolve_pending_reduction_from_stream(
6639                &mut state,
6640                Some((client_order_id, strategy_id)),
6641                &update(2, 4, 0),
6642            );
6643            assert_eq!(
6644                result,
6645                Some((client_order_id, strategy_id, Quantity::from(6)))
6646            );
6647
6648            // A later void on the same, already-confirmed bet reshuffles sr -> sv without
6649            // changing the gross total; it must not re-trigger or corrupt the confirmation.
6650            let result = BetfairExecutionClient::resolve_pending_reduction_from_stream(
6651                &mut state,
6652                Some((client_order_id, strategy_id)),
6653                &update(2, 3, 1),
6654            );
6655            assert_eq!(result, None);
6656        }
6657
6658        assert_eq!(state.reduced_quantity(bet_id), Some(Quantity::from(6)));
6659    }
6660
6661    fn emitter_with_receiver(
6662        account_id: AccountId,
6663    ) -> (
6664        ExecutionEventEmitter,
6665        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
6666    ) {
6667        let clock = get_atomic_clock_realtime();
6668        let mut emitter = ExecutionEventEmitter::new(
6669            clock,
6670            TraderId::from("TESTER-001"),
6671            account_id,
6672            AccountType::Betting,
6673            None,
6674        );
6675        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
6676        emitter.set_sender(tx);
6677        (emitter, rx)
6678    }
6679
6680    #[expect(
6681        clippy::type_complexity,
6682        reason = "The tuple exposes each test channel and the replay buffer"
6683    )]
6684    fn ocm_handler_at(
6685        ts_init: UnixNanos,
6686        pending_resync: bool,
6687    ) -> (
6688        StreamMessageHandler,
6689        tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
6690        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
6691        Arc<Mutex<Vec<ReceivedOcm>>>,
6692    ) {
6693        let account_id = AccountId::from("BETFAIR-001");
6694        let (emitter, execution_rx) = emitter_with_receiver(account_id);
6695        let (data_tx, data_rx) = tokio::sync::mpsc::unbounded_channel();
6696        let (reconnect_tx, _reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
6697        let (account_refresh_tx, _account_refresh_rx) = tokio::sync::mpsc::unbounded_channel();
6698        let replay_buffer = Arc::new(Mutex::new(Vec::new()));
6699        let clock = Box::leak(Box::new(AtomicTime::new(false, ts_init)));
6700
6701        let handler = BetfairExecutionClient::create_ocm_handler(
6702            emitter,
6703            account_id,
6704            Currency::GBP(),
6705            Arc::new(Mutex::new(OcmState::default())),
6706            data_tx.into(),
6707            None,
6708            false,
6709            reconnect_tx,
6710            Arc::new(AtomicU64::new(0)),
6711            Arc::new(AtomicBool::new(pending_resync)),
6712            Arc::new(ReconciliationGate::default()),
6713            Arc::clone(&replay_buffer),
6714            account_refresh_tx,
6715            clock,
6716        );
6717
6718        (handler, data_rx, execution_rx, replay_buffer)
6719    }
6720
6721    #[rstest]
6722    fn test_ocm_handler_sets_init_from_clock() {
6723        let ts_init = UnixNanos::from(1_800_000_000_000_000_004);
6724
6725        let (handler, mut data_rx, _execution_rx, replay_buffer) = ocm_handler_at(ts_init, false);
6726        let data = load_test_json("stream/ocm_VOIDED.json");
6727
6728        handler(stream_decode(data.as_bytes()).unwrap());
6729
6730        let custom = std::iter::from_fn(|| data_rx.try_recv().ok())
6731            .find_map(|event| match event {
6732                DataEvent::Data(Data::Custom(custom))
6733                    if custom.data.as_any().is::<BetfairOrderVoided>() =>
6734                {
6735                    Some(custom.data)
6736                }
6737                _ => None,
6738            })
6739            .expect("expected BetfairOrderVoided custom data");
6740        let voided = custom
6741            .as_any()
6742            .downcast_ref::<BetfairOrderVoided>()
6743            .unwrap();
6744
6745        assert_eq!(voided.ts_event, UnixNanos::from(1_617_863_371_576_000_000));
6746        assert_eq!(voided.ts_init, ts_init);
6747        assert!(replay_buffer.lock().is_empty());
6748    }
6749
6750    #[rstest]
6751    fn test_ocm_handler_preserves_buffered_init() {
6752        let ts_init = UnixNanos::from(1_800_000_000_000_000_005);
6753
6754        let (handler, mut data_rx, mut execution_rx, replay_buffer) = ocm_handler_at(ts_init, true);
6755        let data = load_test_json("stream/ocm_VOIDED.json");
6756
6757        handler(stream_decode(data.as_bytes()).unwrap());
6758
6759        let received = replay_buffer.lock().pop().unwrap();
6760
6761        assert!(data_rx.try_recv().is_err());
6762        assert!(execution_rx.try_recv().is_err());
6763
6764        let account_id = AccountId::from("BETFAIR-001");
6765        let (emitter, _execution_rx) = emitter_with_receiver(account_id);
6766        let (data_tx, mut data_rx) = tokio::sync::mpsc::unbounded_channel();
6767        BetfairExecutionClient::process_ocm(
6768            &received,
6769            account_id,
6770            Currency::GBP(),
6771            &emitter,
6772            &Arc::new(Mutex::new(OcmState::default())),
6773            &data_tx.into(),
6774            None,
6775            false,
6776            None,
6777        );
6778
6779        let custom = std::iter::from_fn(|| data_rx.try_recv().ok())
6780            .find_map(|event| match event {
6781                DataEvent::Data(Data::Custom(custom))
6782                    if custom.data.as_any().is::<BetfairOrderVoided>() =>
6783                {
6784                    Some(custom.data)
6785                }
6786                _ => None,
6787            })
6788            .expect("expected buffered BetfairOrderVoided custom data");
6789        let voided = custom
6790            .as_any()
6791            .downcast_ref::<BetfairOrderVoided>()
6792            .unwrap();
6793
6794        assert_eq!(voided.ts_event, UnixNanos::from(1_617_863_371_576_000_000));
6795        assert_eq!(voided.ts_init, ts_init);
6796        assert!(replay_buffer.lock().is_empty());
6797    }
6798
6799    #[rstest]
6800    fn test_ocm_handler_buffers_each_segment_once() {
6801        let ts_init = UnixNanos::from(1_800_000_000_000_000_006);
6802        let (handler, mut data_rx, mut execution_rx, replay_buffer) = ocm_handler_at(ts_init, true);
6803        let data = load_test_json("stream/ocm_SEGMENTS.jsonl");
6804
6805        for line in data.lines() {
6806            handler(stream_decode(line.as_bytes()).unwrap());
6807        }
6808
6809        let received = replay_buffer.lock();
6810        assert_eq!(received.len(), 3);
6811        assert_eq!(
6812            received[0].message.segment_type,
6813            Some(SegmentType::SegStart)
6814        );
6815        assert_eq!(received[1].message.segment_type, Some(SegmentType::Seg));
6816        assert_eq!(received[2].message.segment_type, Some(SegmentType::SegEnd));
6817        assert!(received.iter().all(|message| message.ts_init == ts_init));
6818        assert_eq!(
6819            received
6820                .iter()
6821                .map(|message| message.message.oc.as_ref().unwrap()[0].id.as_str())
6822                .collect::<Vec<_>>(),
6823            vec!["1.100001", "1.100002", "1.100003"],
6824        );
6825        assert!(data_rx.try_recv().is_err());
6826        assert!(execution_rx.try_recv().is_err());
6827    }
6828
6829    #[rstest]
6830    fn test_ocm_handler_stress_buffers_segments_once_in_order() {
6831        const SEQUENCE_COUNT: usize = 1_024;
6832        const MAX_MIDDLE_SEGMENTS: usize = 15;
6833
6834        let ts_init = UnixNanos::from(1_800_000_000_000_000_007);
6835        let (handler, mut data_rx, mut execution_rx, replay_buffer) = ocm_handler_at(ts_init, true);
6836        let data = load_test_json("stream/ocm_SEGMENTS.jsonl");
6837        let segments = data.lines().collect::<Vec<_>>();
6838        let mut expected = Vec::new();
6839
6840        for sequence in 0..SEQUENCE_COUNT {
6841            handler(stream_decode(segments[0].as_bytes()).unwrap());
6842            expected.push((SegmentType::SegStart, "1.100001"));
6843
6844            for _ in 0..sequence % (MAX_MIDDLE_SEGMENTS + 1) {
6845                handler(stream_decode(segments[1].as_bytes()).unwrap());
6846                expected.push((SegmentType::Seg, "1.100002"));
6847            }
6848
6849            handler(stream_decode(segments[2].as_bytes()).unwrap());
6850            expected.push((SegmentType::SegEnd, "1.100003"));
6851        }
6852
6853        let received = replay_buffer.lock();
6854        assert_eq!(received.len(), expected.len());
6855        for (message, (segment_type, market_id)) in received.iter().zip(expected) {
6856            assert_eq!(message.message.segment_type, Some(segment_type));
6857            assert_eq!(message.ts_init, ts_init);
6858            assert_eq!(
6859                message.message.oc.as_ref().unwrap()[0].id.as_str(),
6860                market_id,
6861            );
6862        }
6863        assert!(data_rx.try_recv().is_err());
6864        assert!(execution_rx.try_recv().is_err());
6865    }
6866
6867    #[rstest]
6868    fn test_tracked_cancel_emits_direct_order_canceled() {
6869        // A tracked cancel must emit a direct OrderCanceled, not a deferrable report.
6870        let account_id = AccountId::from("BETFAIR-001");
6871        let client_order_id = ClientOrderId::from("O-CANCEL-001");
6872        let strategy_id = StrategyId::from("S-QUOTER");
6873
6874        let mut inner = OcmState::default();
6875        inner
6876            .register_submission(client_order_id, strategy_id)
6877            .unwrap();
6878        inner.mark_accepted(client_order_id); // accepted before cancel (no synthesized accept)
6879        let ocm_state = Arc::new(Mutex::new(inner));
6880
6881        let (emitter, mut rx) = emitter_with_receiver(account_id);
6882        let rfo = make_customer_order_ref(client_order_id.as_str());
6883        let uo = cancel_unmatched_order("bet_cancel", Some(rfo));
6884
6885        let processed = BetfairExecutionClient::process_unmatched_order(
6886            &uo,
6887            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
6888            account_id,
6889            Currency::from("GBP"),
6890            &emitter,
6891            &ocm_state,
6892            UnixNanos::default(),
6893            UnixNanos::default(),
6894        );
6895
6896        assert!(processed);
6897
6898        match rx.try_recv().expect("expected an execution event") {
6899            ExecutionEvent::Order(OrderEventAny::Canceled(canceled)) => {
6900                assert_eq!(canceled.client_order_id, client_order_id);
6901                assert_eq!(canceled.strategy_id, strategy_id);
6902                assert_eq!(
6903                    canceled.venue_order_id,
6904                    Some(VenueOrderId::from("bet_cancel"))
6905                );
6906            }
6907            other => panic!("expected a direct OrderCanceled event, was {other:?}"),
6908        }
6909
6910        assert!(
6911            rx.try_recv().is_err(),
6912            "tracked cancel must not also emit a status report",
6913        );
6914    }
6915
6916    #[rstest]
6917    fn test_untracked_cancel_emits_status_report() {
6918        // An order with no registered identity is external: its cancel takes the report path.
6919        let account_id = AccountId::from("BETFAIR-001");
6920        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
6921
6922        let (emitter, mut rx) = emitter_with_receiver(account_id);
6923        let uo = cancel_unmatched_order("bet_external", Some("EXTERNAL-REF".to_string()));
6924
6925        let processed = BetfairExecutionClient::process_unmatched_order(
6926            &uo,
6927            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
6928            account_id,
6929            Currency::from("GBP"),
6930            &emitter,
6931            &ocm_state,
6932            UnixNanos::default(),
6933            UnixNanos::default(),
6934        );
6935
6936        assert!(processed);
6937
6938        match rx.try_recv().expect("expected an execution event") {
6939            ExecutionEvent::Report(ExecutionReport::Order(report)) => {
6940                assert_eq!(report.order_status, OrderStatus::Canceled);
6941                assert_eq!(report.venue_order_id, VenueOrderId::from("bet_external"));
6942            }
6943            other => panic!("expected an OrderStatusReport, was {other:?}"),
6944        }
6945    }
6946
6947    #[rstest]
6948    fn test_tracked_resting_sp_bet_stays_open() {
6949        // A resting SP bet is execution-complete with zero sizes but cannot be
6950        // cancelled: it must not emit a terminal event or be marked terminal.
6951        let account_id = AccountId::from("BETFAIR-001");
6952        let client_order_id = ClientOrderId::from("O-SP-001");
6953        let strategy_id = StrategyId::from("S-QUOTER");
6954
6955        let mut inner = OcmState::default();
6956        inner
6957            .register_submission(client_order_id, strategy_id)
6958            .unwrap();
6959        inner.mark_accepted(client_order_id);
6960        let ocm_state = Arc::new(Mutex::new(inner));
6961
6962        let (emitter, mut rx) = emitter_with_receiver(account_id);
6963        let rfo = make_customer_order_ref(client_order_id.as_str());
6964        let uo = resting_sp_unmatched_order("bet_sp_resting", Some(rfo));
6965
6966        let processed = BetfairExecutionClient::process_unmatched_order(
6967            &uo,
6968            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
6969            account_id,
6970            Currency::from("GBP"),
6971            &emitter,
6972            &ocm_state,
6973            UnixNanos::default(),
6974            UnixNanos::default(),
6975        );
6976
6977        assert!(processed);
6978        assert!(
6979            rx.try_recv().is_err(),
6980            "resting SP bet must not emit a terminal event",
6981        );
6982
6983        // The bet was not marked terminal, so a repeat update still processes
6984        let processed_again = BetfairExecutionClient::process_unmatched_order(
6985            &uo,
6986            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
6987            account_id,
6988            Currency::from("GBP"),
6989            &emitter,
6990            &ocm_state,
6991            UnixNanos::default(),
6992            UnixNanos::default(),
6993        );
6994
6995        assert!(processed_again);
6996        assert!(rx.try_recv().is_err());
6997    }
6998
6999    #[rstest]
7000    #[case::current(
7001        "FIRST-12345678901234567890123456789012",
7002        "SECOND-12345678901234567890123456789012",
7003        "12345678901234567890123456789012"
7004    )]
7005    #[case::legacy(
7006        "12345678901234567890123456789012-FIRST",
7007        "12345678901234567890123456789012-SECOND",
7008        "12345678901234567890123456789012"
7009    )]
7010    fn test_ambiguous_customer_order_ref_does_not_route_status_or_fill(
7011        #[case] first_id: &str,
7012        #[case] second_id: &str,
7013        #[case] customer_order_ref: &str,
7014    ) {
7015        let account_id = AccountId::from("BETFAIR-001");
7016        let first = ClientOrderId::from(first_id);
7017        let second = ClientOrderId::from(second_id);
7018        let mut state = OcmState::default();
7019        state.restore_order(
7020            first,
7021            StrategyId::from("S-FIRST"),
7022            VenueOrderId::from("bet-first"),
7023        );
7024        state.restore_order(
7025            second,
7026            StrategyId::from("S-SECOND"),
7027            VenueOrderId::from("bet-second"),
7028        );
7029        let ocm_state = Arc::new(Mutex::new(state));
7030        let (emitter, mut rx) = emitter_with_receiver(account_id);
7031        let order = fill_unmatched_order(
7032            "bet-ambiguous",
7033            Some(customer_order_ref.to_string()),
7034            Decimal::new(10, 0),
7035        );
7036
7037        let processed = BetfairExecutionClient::process_unmatched_order(
7038            &order,
7039            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7040            account_id,
7041            Currency::from("GBP"),
7042            &emitter,
7043            &ocm_state,
7044            UnixNanos::default(),
7045            UnixNanos::default(),
7046        );
7047
7048        assert!(processed);
7049
7050        match rx.try_recv().expect("expected a fill report") {
7051            ExecutionEvent::Report(ExecutionReport::Fill(fill)) => {
7052                assert_eq!(fill.client_order_id, None);
7053                assert_eq!(fill.venue_order_id, VenueOrderId::from("bet-ambiguous"));
7054            }
7055            other => panic!("ambiguous reference must emit an unowned fill report: {other:?}"),
7056        }
7057
7058        match rx.try_recv().expect("expected a status report") {
7059            ExecutionEvent::Report(ExecutionReport::Order(report)) => {
7060                assert_eq!(report.client_order_id, None);
7061                assert_eq!(report.venue_order_id, VenueOrderId::from("bet-ambiguous"));
7062            }
7063            other => panic!("ambiguous reference must emit an unowned status report: {other:?}"),
7064        }
7065        assert!(rx.try_recv().is_err());
7066    }
7067
7068    #[rstest]
7069    fn test_ambiguous_customer_order_ref_does_not_evict_known_pending_identity() {
7070        let account_id = AccountId::from("BETFAIR-001");
7071        let suffix = "12345678901234567890123456789012";
7072        let first = ClientOrderId::from(format!("FIRST-{suffix}"));
7073        let second = ClientOrderId::from(format!("SECOND-{suffix}"));
7074        let bet_id = "bet-first";
7075        let mut state = OcmState::default();
7076        state.restore_order(
7077            first,
7078            StrategyId::from("S-FIRST"),
7079            VenueOrderId::from(bet_id),
7080        );
7081        state.restore_order(
7082            second,
7083            StrategyId::from("S-SECOND"),
7084            VenueOrderId::from("bet-second"),
7085        );
7086        state.register_pending_replace(first, bet_id.to_string(), Some(Quantity::from(20)));
7087        state.mark_pending_replace_ambiguous(first, bet_id);
7088        state.fill_tracker.advance_cumulative_fill(
7089            bet_id,
7090            Decimal::from(2),
7091            Some(Decimal::from(3)),
7092            Decimal::from(3),
7093        );
7094        let ocm_state = Arc::new(Mutex::new(state));
7095        let (emitter, mut rx) = emitter_with_receiver(account_id);
7096        let order = cancel_unmatched_order(bet_id, Some(suffix.to_string()));
7097
7098        let processed = BetfairExecutionClient::process_unmatched_order(
7099            &order,
7100            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7101            account_id,
7102            Currency::GBP(),
7103            &emitter,
7104            &ocm_state,
7105            UnixNanos::from(1),
7106            UnixNanos::from(1),
7107        );
7108        let report = match rx.try_recv().unwrap() {
7109            ExecutionEvent::Report(ExecutionReport::Order(report)) => *report,
7110            other => panic!("expected fail-closed order report, was {other:?}"),
7111        };
7112
7113        let mut state = ocm_state.lock();
7114        for index in 0..=OcmState::DEDUP_RETENTION {
7115            state.mark_terminal_order(format!("external-bet-{index}"));
7116        }
7117        let replay = state.fill_tracker.advance_cumulative_fill(
7118            bet_id,
7119            Decimal::from(2),
7120            Some(Decimal::from(3)),
7121            Decimal::from(3),
7122        );
7123        let mut reports = vec![report];
7124        let updates = resolve_pending_modifies_in_state(
7125            &mut reports,
7126            &AHashMap::new(),
7127            &AHashMap::new(),
7128            &mut state,
7129            &emitter,
7130            None,
7131        );
7132
7133        assert!(processed);
7134        assert!(updates.is_empty());
7135        assert_eq!(reports.len(), 1);
7136        assert_eq!(reports[0].client_order_id, None);
7137        assert_eq!(reports[0].venue_order_id, VenueOrderId::from(bet_id));
7138        assert_eq!(
7139            state.order_strategy_id(&first),
7140            Some(StrategyId::from("S-FIRST"))
7141        );
7142        assert!(state.should_suppress_cancel(&first, bet_id));
7143        assert!(state.pending_replace_awaits_reconciliation(&first, bet_id));
7144        assert!(!state.is_retained_terminal_order(&first));
7145        assert!(state.terminal_orders.contains(bet_id));
7146        assert!(replay.is_none());
7147        assert!(rx.try_recv().is_err());
7148    }
7149
7150    #[rstest]
7151    fn test_ambiguous_customer_order_ref_bounds_known_current_terminal_identity() {
7152        let account_id = AccountId::from("BETFAIR-001");
7153        let suffix = "12345678901234567890123456789012";
7154        let first = ClientOrderId::from(format!("FIRST-{suffix}"));
7155        let second = ClientOrderId::from(format!("SECOND-{suffix}"));
7156        let bet_id = "bet-first";
7157        let mut state = OcmState::default();
7158        state.restore_order(
7159            first,
7160            StrategyId::from("S-FIRST"),
7161            VenueOrderId::from(bet_id),
7162        );
7163        state.restore_order(
7164            second,
7165            StrategyId::from("S-SECOND"),
7166            VenueOrderId::from("bet-second"),
7167        );
7168        let ocm_state = Arc::new(Mutex::new(state));
7169        let (emitter, mut rx) = emitter_with_receiver(account_id);
7170        let order = cancel_unmatched_order(bet_id, Some(suffix.to_string()));
7171
7172        let processed = BetfairExecutionClient::process_unmatched_order(
7173            &order,
7174            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7175            account_id,
7176            Currency::GBP(),
7177            &emitter,
7178            &ocm_state,
7179            UnixNanos::from(1),
7180            UnixNanos::from(1),
7181        );
7182        let report = match rx.try_recv().unwrap() {
7183            ExecutionEvent::Report(ExecutionReport::Order(report)) => report,
7184            other => panic!("expected fail-closed order report, was {other:?}"),
7185        };
7186
7187        let mut state = ocm_state.lock();
7188        assert!(state.is_retained_terminal_order(&first));
7189        for index in 0..OcmState::DEDUP_RETENTION {
7190            state.mark_terminal_order(format!("external-bet-{index}"));
7191        }
7192
7193        assert!(processed);
7194        assert_eq!(report.client_order_id, None);
7195        assert_eq!(report.venue_order_id, VenueOrderId::from(bet_id));
7196        assert_eq!(state.order_strategy_id(&first), None);
7197        assert!(!state.terminal_orders.contains(bet_id));
7198        assert!(rx.try_recv().is_err());
7199    }
7200
7201    #[rstest]
7202    fn test_untracked_order_with_fill_reports_with_resolved_id() {
7203        // Resolved rfo but untracked: the fill is reported with the resolved
7204        // client_order_id patched on.
7205        let account_id = AccountId::from("BETFAIR-001");
7206        let client_order_id = ClientOrderId::from("O-EXT-FILL");
7207
7208        let mut inner = OcmState::default();
7209        inner.register_order_ref(client_order_id).unwrap();
7210        let ocm_state = Arc::new(Mutex::new(inner));
7211
7212        let (emitter, mut rx) = emitter_with_receiver(account_id);
7213        let rfo = make_customer_order_ref(client_order_id.as_str());
7214        let uo = fill_unmatched_order("bet_ext_fill", Some(rfo), Decimal::new(10, 0));
7215
7216        let processed = BetfairExecutionClient::process_unmatched_order(
7217            &uo,
7218            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7219            account_id,
7220            Currency::from("GBP"),
7221            &emitter,
7222            &ocm_state,
7223            UnixNanos::default(),
7224            UnixNanos::default(),
7225        );
7226
7227        assert!(processed);
7228
7229        match rx.try_recv().expect("expected a fill report") {
7230            ExecutionEvent::Report(ExecutionReport::Fill(fill)) => {
7231                assert_eq!(fill.client_order_id, Some(client_order_id));
7232                assert_eq!(fill.last_qty.as_decimal(), Decimal::new(10, 0));
7233            }
7234            other => panic!("expected a FillReport for untracked fill, was {other:?}"),
7235        }
7236
7237        match rx.try_recv().expect("expected a status report") {
7238            ExecutionEvent::Report(ExecutionReport::Order(_)) => {}
7239            other => panic!("expected an OrderStatusReport, was {other:?}"),
7240        }
7241    }
7242
7243    #[rstest]
7244    fn test_unparsable_order_returns_false_without_emitting() {
7245        let account_id = AccountId::from("BETFAIR-001");
7246        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
7247        let (emitter, mut rx) = emitter_with_receiver(account_id);
7248
7249        let mut uo = cancel_unmatched_order("bet_bad", None);
7250        uo.pt = None; // LIMIT without persistence type -> parse error
7251
7252        let processed = BetfairExecutionClient::process_unmatched_order(
7253            &uo,
7254            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7255            account_id,
7256            Currency::from("GBP"),
7257            &emitter,
7258            &ocm_state,
7259            UnixNanos::default(),
7260            UnixNanos::default(),
7261        );
7262
7263        assert!(!processed);
7264        assert!(rx.try_recv().is_err(), "unparsable order must emit nothing");
7265    }
7266
7267    #[rstest]
7268    fn test_process_ocm_voided_emits_custom_data() {
7269        // An ExecutionComplete order with size_voided > 0 publishes a
7270        // BetfairOrderVoided custom data event.
7271        let data = crate::common::testing::load_test_json("stream/ocm_VOIDED.json");
7272        let ocm = match crate::stream::messages::stream_decode(data.as_bytes()).unwrap() {
7273            crate::stream::messages::StreamMessage::OrderChange(ocm) => ocm,
7274            other => panic!("expected an OCM stream message, was {other:?}"),
7275        };
7276
7277        let account_id = AccountId::from("BETFAIR-001");
7278        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
7279        let (emitter, _rx) = emitter_with_receiver(account_id);
7280        let (data_tx, mut data_rx) = tokio::sync::mpsc::unbounded_channel();
7281        let (account_refresh_tx, mut account_refresh_rx) = tokio::sync::mpsc::unbounded_channel();
7282
7283        let received = ReceivedOcm {
7284            message: ocm,
7285            ts_init: UnixNanos::from(1_800_000_000_000_000_004),
7286        };
7287
7288        BetfairExecutionClient::process_ocm(
7289            &received,
7290            account_id,
7291            Currency::from("GBP"),
7292            &emitter,
7293            &ocm_state,
7294            &data_tx.into(),
7295            None,
7296            false,
7297            Some(&account_refresh_tx),
7298        );
7299
7300        let voided = std::iter::from_fn(|| data_rx.try_recv().ok())
7301            .any(|event| matches!(event, DataEvent::Data(Data::Custom(_))));
7302        assert!(voided, "voided OCM must publish a custom voided data event");
7303        assert!(account_refresh_rx.try_recv().is_ok());
7304    }
7305
7306    #[rstest]
7307    #[case("stream/ocm_VOIDED.json", Decimal::new(50, 0), Decimal::new(50, 0))]
7308    #[case(
7309        "stream/ocm_VOIDED_partial.json",
7310        Decimal::new(60, 0),
7311        Decimal::new(40, 0)
7312    )]
7313    fn test_tracked_void_fixture_emits_terminal_fill_correction(
7314        #[case] fixture: &str,
7315        #[case] matched: Decimal,
7316        #[case] voided: Decimal,
7317    ) {
7318        let data = crate::common::testing::load_test_json(fixture);
7319        let ocm = match crate::stream::messages::stream_decode(data.as_bytes()).unwrap() {
7320            crate::stream::messages::StreamMessage::OrderChange(ocm) => ocm,
7321            other => panic!("expected an OCM stream message, was {other:?}"),
7322        };
7323        let market = &ocm.oc.as_ref().unwrap()[0];
7324        let runner = &market.orc.as_ref().unwrap()[0];
7325        let uo = &runner.uo.as_ref().unwrap()[0];
7326        let client_order_id = ClientOrderId::from(uo.rfo.as_deref().unwrap());
7327        let strategy_id = StrategyId::from("S-VOID");
7328        let account_id = AccountId::from("BETFAIR-001");
7329        let mut inner = OcmState::default();
7330        inner
7331            .register_submission(client_order_id, strategy_id)
7332            .unwrap();
7333        inner.mark_accepted(client_order_id);
7334        let state = Arc::new(Mutex::new(inner));
7335        let (emitter, mut rx) = emitter_with_receiver(account_id);
7336
7337        let processed = BetfairExecutionClient::process_unmatched_order(
7338            uo,
7339            make_instrument_id(&market.id, runner.id, Decimal::ZERO),
7340            account_id,
7341            Currency::GBP(),
7342            &emitter,
7343            &state,
7344            parse_millis_timestamp(ocm.pt),
7345            parse_millis_timestamp(ocm.pt),
7346        );
7347
7348        let filled = match rx.try_recv().unwrap() {
7349            ExecutionEvent::Order(OrderEventAny::Filled(event)) => event,
7350            other => panic!("expected OrderFilled, was {other:?}"),
7351        };
7352        let fill_voided = match rx.try_recv().unwrap() {
7353            ExecutionEvent::Order(OrderEventAny::FillVoided(event)) => event,
7354            other => panic!("expected OrderFillVoided, was {other:?}"),
7355        };
7356        assert!(processed);
7357        assert_eq!(filled.last_qty.as_decimal(), matched);
7358        assert_ne!(fill_voided.trade_id, filled.trade_id);
7359        assert_eq!(fill_voided.voided_qty.as_decimal(), voided);
7360        assert_eq!(fill_voided.commission_voided, None);
7361        assert!(!fill_voided.is_reopened);
7362        assert_eq!(fill_voided.causation_id, Some(filled.event_id));
7363        assert!(rx.try_recv().is_err());
7364    }
7365
7366    #[rstest]
7367    fn test_terminal_bet_allows_increased_cumulative_void_report() {
7368        let data = crate::common::testing::load_test_json("stream/ocm_VOIDED_partial.json");
7369        let ocm = match crate::stream::messages::stream_decode(data.as_bytes()).unwrap() {
7370            crate::stream::messages::StreamMessage::OrderChange(ocm) => ocm,
7371            other => panic!("expected an OCM stream message, was {other:?}"),
7372        };
7373        let market = &ocm.oc.as_ref().unwrap()[0];
7374        let runner = &market.orc.as_ref().unwrap()[0];
7375        let mut uo = runner.uo.as_ref().unwrap()[0].clone();
7376        let client_order_id = ClientOrderId::from(uo.rfo.as_deref().unwrap());
7377        let account_id = AccountId::from("BETFAIR-001");
7378        let mut inner = OcmState::default();
7379        inner
7380            .register_submission(client_order_id, StrategyId::from("S-VOID"))
7381            .unwrap();
7382        inner.mark_accepted(client_order_id);
7383        let state = Arc::new(Mutex::new(inner));
7384        let (emitter, mut rx) = emitter_with_receiver(account_id);
7385        let instrument_id = make_instrument_id(&market.id, runner.id, Decimal::ZERO);
7386        let ts = parse_millis_timestamp(ocm.pt);
7387
7388        let first = BetfairExecutionClient::process_unmatched_order(
7389            &uo,
7390            instrument_id,
7391            account_id,
7392            Currency::GBP(),
7393            &emitter,
7394            &state,
7395            ts,
7396            ts,
7397        );
7398        let first_events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
7399        uo.sm = Some(Decimal::new(50, 0));
7400        uo.sv = Some(Decimal::new(50, 0));
7401        let increased = BetfairExecutionClient::process_unmatched_order(
7402            &uo,
7403            instrument_id,
7404            account_id,
7405            Currency::GBP(),
7406            &emitter,
7407            &state,
7408            ts,
7409            ts,
7410        );
7411        let fill_voided = match rx.try_recv().unwrap() {
7412            ExecutionEvent::Order(OrderEventAny::FillVoided(fill_voided)) => fill_voided,
7413            other => panic!("expected a direct fill void correction, was {other:?}"),
7414        };
7415        let duplicate = BetfairExecutionClient::process_unmatched_order(
7416            &uo,
7417            instrument_id,
7418            account_id,
7419            Currency::GBP(),
7420            &emitter,
7421            &state,
7422            ts,
7423            ts,
7424        );
7425
7426        assert!(first);
7427        assert_eq!(first_events.len(), 2);
7428        assert!(increased);
7429        assert_eq!(fill_voided.client_order_id, client_order_id);
7430        assert_eq!(fill_voided.strategy_id, StrategyId::from("S-VOID"));
7431        assert_eq!(
7432            fill_voided.venue_order_id,
7433            VenueOrderId::from(uo.id.as_str()),
7434        );
7435        assert_eq!(fill_voided.voided_qty, Quantity::from("10.00"));
7436        assert!(!duplicate);
7437        assert!(rx.try_recv().is_err());
7438    }
7439
7440    #[rstest]
7441    fn test_retained_terminal_order_routes_late_fill_and_void_by_bet_id() {
7442        let client_order_id = ClientOrderId::from("O-LATE-FILL");
7443        let strategy_id = StrategyId::from("S-001");
7444        let account_id = AccountId::from("BETFAIR-001");
7445        let bet_id = "late-fill-bet";
7446        let mut inner = OcmState::default();
7447        inner
7448            .register_submission(client_order_id, strategy_id)
7449            .unwrap();
7450        inner.mark_accepted(client_order_id);
7451        inner.bind_venue_order_id(&client_order_id, VenueOrderId::from(bet_id));
7452        let state = Arc::new(Mutex::new(inner));
7453        let (emitter, mut rx) = emitter_with_receiver(account_id);
7454        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
7455        let mut terminal = cancel_unmatched_order(
7456            bet_id,
7457            Some(make_customer_order_ref(client_order_id.as_str())),
7458        );
7459
7460        let first = BetfairExecutionClient::process_unmatched_order(
7461            &terminal,
7462            instrument_id,
7463            account_id,
7464            Currency::GBP(),
7465            &emitter,
7466            &state,
7467            UnixNanos::from(1),
7468            UnixNanos::from(1),
7469        );
7470        let canceled = match rx.try_recv().unwrap() {
7471            ExecutionEvent::Order(OrderEventAny::Canceled(canceled)) => canceled,
7472            other => panic!("expected initial cancel, was {other:?}"),
7473        };
7474
7475        terminal.rfo = None;
7476        terminal.sm = Some(Decimal::from(2));
7477        terminal.sc = Some(Decimal::from(18));
7478        terminal.avp = Some(Decimal::from(3));
7479        let late = BetfairExecutionClient::process_unmatched_order(
7480            &terminal,
7481            instrument_id,
7482            account_id,
7483            Currency::GBP(),
7484            &emitter,
7485            &state,
7486            UnixNanos::from(2),
7487            UnixNanos::from(2),
7488        );
7489        let filled = match rx.try_recv().unwrap() {
7490            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => filled,
7491            other => panic!("expected direct late fill, was {other:?}"),
7492        };
7493        let reclosed = match rx.try_recv().unwrap() {
7494            ExecutionEvent::Order(OrderEventAny::Canceled(canceled)) => canceled,
7495            other => panic!("expected re-close after late fill, was {other:?}"),
7496        };
7497        let duplicate = BetfairExecutionClient::process_unmatched_order(
7498            &terminal,
7499            instrument_id,
7500            account_id,
7501            Currency::GBP(),
7502            &emitter,
7503            &state,
7504            UnixNanos::from(3),
7505            UnixNanos::from(3),
7506        );
7507
7508        terminal.sv = Some(Decimal::from(2));
7509        terminal.sc = Some(Decimal::from(16));
7510        let corrected = BetfairExecutionClient::process_unmatched_order(
7511            &terminal,
7512            instrument_id,
7513            account_id,
7514            Currency::GBP(),
7515            &emitter,
7516            &state,
7517            UnixNanos::from(4),
7518            UnixNanos::from(4),
7519        );
7520        let correction_fill = match rx.try_recv().unwrap() {
7521            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => filled,
7522            other => panic!("expected fill preceding correction, was {other:?}"),
7523        };
7524        let correction_reclose = match rx.try_recv().unwrap() {
7525            ExecutionEvent::Order(OrderEventAny::Canceled(canceled)) => canceled,
7526            other => panic!("expected re-close preceding correction, was {other:?}"),
7527        };
7528        let fill_voided = match rx.try_recv().unwrap() {
7529            ExecutionEvent::Order(OrderEventAny::FillVoided(fill_voided)) => fill_voided,
7530            other => panic!("expected direct fill void correction, was {other:?}"),
7531        };
7532        let correction_duplicate = BetfairExecutionClient::process_unmatched_order(
7533            &terminal,
7534            instrument_id,
7535            account_id,
7536            Currency::GBP(),
7537            &emitter,
7538            &state,
7539            UnixNanos::from(5),
7540            UnixNanos::from(5),
7541        );
7542
7543        assert!(first);
7544        assert!(late);
7545        assert_eq!(canceled.client_order_id, client_order_id);
7546        assert_eq!(filled.client_order_id, client_order_id);
7547        assert_eq!(filled.strategy_id, strategy_id);
7548        assert_eq!(filled.venue_order_id, VenueOrderId::from(bet_id));
7549        assert_eq!(filled.last_qty, Quantity::from("2.00"));
7550        assert_eq!(reclosed.client_order_id, client_order_id);
7551        assert!(!duplicate);
7552        assert!(corrected);
7553        assert_eq!(correction_fill.client_order_id, client_order_id);
7554        assert_eq!(correction_fill.last_qty, Quantity::from("2.00"));
7555        assert_eq!(correction_reclose.client_order_id, client_order_id);
7556        assert_eq!(fill_voided.client_order_id, client_order_id);
7557        assert_eq!(fill_voided.strategy_id, strategy_id);
7558        assert_eq!(fill_voided.venue_order_id, VenueOrderId::from(bet_id));
7559        assert_eq!(fill_voided.voided_qty, Quantity::from("2.00"));
7560        assert_eq!(fill_voided.causation_id, Some(correction_fill.event_id));
7561        assert!(!correction_duplicate);
7562        assert!(rx.try_recv().is_err());
7563    }
7564
7565    #[rstest]
7566    fn test_retained_terminal_price_restatement_does_not_reclose() {
7567        let client_order_id = ClientOrderId::from("O-RULE4-LAPSE");
7568        let strategy_id = StrategyId::from("S-001");
7569        let account_id = AccountId::from("BETFAIR-001");
7570        let bet_id = "rule4-lapse-bet";
7571        let mut inner = OcmState::default();
7572        inner
7573            .register_submission(client_order_id, strategy_id)
7574            .unwrap();
7575        inner.mark_accepted(client_order_id);
7576        inner.bind_venue_order_id(&client_order_id, VenueOrderId::from(bet_id));
7577        let state = Arc::new(Mutex::new(inner));
7578        let (emitter, mut rx) = emitter_with_receiver(account_id);
7579        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
7580        let mut terminal = cancel_unmatched_order(
7581            bet_id,
7582            Some(make_customer_order_ref(client_order_id.as_str())),
7583        );
7584        terminal.sm = Some(Decimal::from(2));
7585        terminal.sc = Some(Decimal::from(18));
7586        terminal.avp = Some(Decimal::from(12));
7587
7588        assert!(BetfairExecutionClient::process_unmatched_order(
7589            &terminal,
7590            instrument_id,
7591            account_id,
7592            Currency::GBP(),
7593            &emitter,
7594            &state,
7595            UnixNanos::from(1),
7596            UnixNanos::from(1),
7597        ));
7598        let opened = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
7599        assert!(
7600            opened
7601                .iter()
7602                .any(|event| matches!(event, ExecutionEvent::Order(OrderEventAny::Filled(_))))
7603        );
7604        assert!(
7605            opened
7606                .iter()
7607                .any(|event| matches!(event, ExecutionEvent::Order(OrderEventAny::Canceled(_))))
7608        );
7609
7610        terminal.avp = Some(Decimal::new(947, 2));
7611        assert!(BetfairExecutionClient::process_unmatched_order(
7612            &terminal,
7613            instrument_id,
7614            account_id,
7615            Currency::GBP(),
7616            &emitter,
7617            &state,
7618            UnixNanos::from(2),
7619            UnixNanos::from(2),
7620        ));
7621
7622        let restated = match rx.try_recv().expect("price restatement") {
7623            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => filled,
7624            other => panic!("expected a price restatement, was {other:?}"),
7625        };
7626
7627        assert_eq!(restated.client_order_id, client_order_id);
7628        assert_eq!(restated.last_px, Price::from("9.47"));
7629        assert_eq!(restated.last_qty, Quantity::from("2.00"));
7630        assert!(rx.try_recv().is_err());
7631    }
7632
7633    #[rstest]
7634    fn test_retained_terminal_all_void_correction_does_not_reclose() {
7635        let client_order_id = ClientOrderId::from("O-ALL-VOID");
7636        let strategy_id = StrategyId::from("S-001");
7637        let account_id = AccountId::from("BETFAIR-001");
7638        let bet_id = "all-void-bet";
7639        let mut inner = OcmState::default();
7640        inner.restore_order(client_order_id, strategy_id, VenueOrderId::from(bet_id));
7641        inner.fill_tracker.advance_cumulative_fill(
7642            bet_id,
7643            Decimal::from(2),
7644            Some(Decimal::from(3)),
7645            Decimal::from(3),
7646        );
7647        inner.retain_terminal_order(client_order_id, bet_id);
7648        let state = Arc::new(Mutex::new(inner));
7649        let (emitter, mut rx) = emitter_with_receiver(account_id);
7650        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
7651        let mut correction = cancel_unmatched_order(bet_id, None);
7652        correction.sm = Some(Decimal::ZERO);
7653        correction.sc = None;
7654        correction.sv = Some(Decimal::from(4));
7655        correction.avp = Some(Decimal::from(3));
7656
7657        let processed = BetfairExecutionClient::process_unmatched_order(
7658            &correction,
7659            instrument_id,
7660            account_id,
7661            Currency::GBP(),
7662            &emitter,
7663            &state,
7664            UnixNanos::from(1),
7665            UnixNanos::from(1),
7666        );
7667        let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
7668        let duplicate = BetfairExecutionClient::process_unmatched_order(
7669            &correction,
7670            instrument_id,
7671            account_id,
7672            Currency::GBP(),
7673            &emitter,
7674            &state,
7675            UnixNanos::from(2),
7676            UnixNanos::from(2),
7677        );
7678
7679        assert!(processed);
7680        assert_eq!(events.len(), 3);
7681        assert!(matches!(
7682            &events[0],
7683            ExecutionEvent::Order(OrderEventAny::Filled(filled))
7684                if filled.client_order_id == client_order_id
7685                    && filled.last_qty == Quantity::from("2.00")
7686        ));
7687        assert!(matches!(
7688            &events[1],
7689            ExecutionEvent::Order(OrderEventAny::FillVoided(fill_voided))
7690                if fill_voided.client_order_id == client_order_id
7691                    && fill_voided.voided_qty == Quantity::from("2.00")
7692        ));
7693        assert!(matches!(
7694            &events[2],
7695            ExecutionEvent::Order(OrderEventAny::FillVoided(fill_voided))
7696                if fill_voided.client_order_id == client_order_id
7697                    && fill_voided.voided_qty == Quantity::from("2.00")
7698        ));
7699        assert!(!duplicate);
7700        assert!(rx.try_recv().is_err());
7701    }
7702
7703    fn fill_unmatched_order(
7704        bet_id: &str,
7705        rfo: Option<String>,
7706        size_matched: Decimal,
7707    ) -> crate::stream::messages::UnmatchedOrder {
7708        crate::stream::messages::UnmatchedOrder {
7709            id: bet_id.to_string(),
7710            p: Decimal::new(20, 1),
7711            s: Decimal::new(20, 0),
7712            side: crate::common::enums::StreamingSide::Back,
7713            status: crate::common::enums::StreamingOrderStatus::Executable,
7714            pt: Some(crate::common::enums::StreamingPersistenceType::Lapse),
7715            ot: crate::common::enums::StreamingOrderType::Limit,
7716            pd: 1617863365000,
7717            bsp: None,
7718            rfo,
7719            rfs: None,
7720            rc: None,
7721            rac: None,
7722            md: None,
7723            cd: None,
7724            ld: None,
7725            avp: Some(Decimal::new(20, 1)),
7726            sm: Some(size_matched),
7727            sr: None,
7728            sl: None,
7729            sc: None,
7730            sv: None,
7731            lsrc: None,
7732        }
7733    }
7734
7735    #[rstest]
7736    fn test_tracked_fill_synthesizes_accept_then_emits_filled() {
7737        // First OCM is a fill with no prior accept: synthesize OrderAccepted, then OrderFilled.
7738        let account_id = AccountId::from("BETFAIR-001");
7739        let client_order_id = ClientOrderId::from("O-FILL-100");
7740        let strategy_id = StrategyId::from("S-QUOTER");
7741
7742        let mut inner = OcmState::default();
7743        inner
7744            .register_submission(client_order_id, strategy_id)
7745            .unwrap();
7746        let ocm_state = Arc::new(Mutex::new(inner));
7747
7748        let (emitter, mut rx) = emitter_with_receiver(account_id);
7749        let rfo = make_customer_order_ref(client_order_id.as_str());
7750        let uo = fill_unmatched_order("bet_fill", Some(rfo), Decimal::new(10, 0));
7751
7752        let processed = BetfairExecutionClient::process_unmatched_order(
7753            &uo,
7754            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7755            account_id,
7756            Currency::from("GBP"),
7757            &emitter,
7758            &ocm_state,
7759            UnixNanos::default(),
7760            UnixNanos::default(),
7761        );
7762
7763        assert!(processed);
7764
7765        match rx.try_recv().expect("expected an accepted event") {
7766            ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) => {
7767                assert_eq!(accepted.client_order_id, client_order_id);
7768                assert_eq!(accepted.strategy_id, strategy_id);
7769            }
7770            other => panic!("expected a synthesized OrderAccepted, was {other:?}"),
7771        }
7772
7773        match rx.try_recv().expect("expected a filled event") {
7774            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
7775                assert_eq!(filled.client_order_id, client_order_id);
7776                assert_eq!(filled.strategy_id, strategy_id);
7777                assert_eq!(filled.venue_order_id, VenueOrderId::from("bet_fill"));
7778                assert_eq!(filled.last_qty.as_decimal(), Decimal::new(10, 0));
7779            }
7780            other => panic!("expected a direct OrderFilled event, was {other:?}"),
7781        }
7782
7783        assert!(
7784            rx.try_recv().is_err(),
7785            "tracked fill must not also emit a report",
7786        );
7787    }
7788
7789    #[rstest]
7790    fn test_tracked_fill_on_accepted_order_skips_synth_accept() {
7791        // Already accepted via HTTP ack: the tracked fill emits only OrderFilled,
7792        // no duplicate accept.
7793        let account_id = AccountId::from("BETFAIR-001");
7794        let client_order_id = ClientOrderId::from("O-FILL-200");
7795        let strategy_id = StrategyId::from("S-QUOTER");
7796
7797        let mut inner = OcmState::default();
7798        inner
7799            .register_submission(client_order_id, strategy_id)
7800            .unwrap();
7801        inner.mark_accepted(client_order_id); // already accepted via HTTP place ack
7802        let ocm_state = Arc::new(Mutex::new(inner));
7803
7804        let (emitter, mut rx) = emitter_with_receiver(account_id);
7805        let rfo = make_customer_order_ref(client_order_id.as_str());
7806        let uo = fill_unmatched_order("bet_fill2", Some(rfo), Decimal::new(10, 0));
7807
7808        let processed = BetfairExecutionClient::process_unmatched_order(
7809            &uo,
7810            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
7811            account_id,
7812            Currency::from("GBP"),
7813            &emitter,
7814            &ocm_state,
7815            UnixNanos::default(),
7816            UnixNanos::default(),
7817        );
7818
7819        assert!(processed);
7820
7821        match rx.try_recv().expect("expected a filled event") {
7822            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
7823                assert_eq!(filled.client_order_id, client_order_id);
7824                assert_eq!(filled.last_qty.as_decimal(), Decimal::new(10, 0));
7825            }
7826            other => panic!("expected OrderFilled with no preceding accept, was {other:?}"),
7827        }
7828
7829        assert!(
7830            rx.try_recv().is_err(),
7831            "already-accepted order must not re-synthesize OrderAccepted",
7832        );
7833    }
7834
7835    #[rstest]
7836    fn test_rule4_average_restatement_keeps_filled_quantity_and_original_void_price() {
7837        let account_id = AccountId::from("BETFAIR-001");
7838        let client_order_id = ClientOrderId::from("O-RULE4");
7839        let strategy_id = StrategyId::from("S-RULE4");
7840        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
7841        let mut inner = OcmState::default();
7842        inner
7843            .register_submission(client_order_id, strategy_id)
7844            .unwrap();
7845        let state = Arc::new(Mutex::new(inner));
7846        let (emitter, mut rx) = emitter_with_receiver(account_id);
7847        let rfo = make_customer_order_ref(client_order_id.as_str());
7848        let mut uo = fill_unmatched_order("bet-rule4", Some(rfo), Decimal::from(2));
7849        uo.s = Decimal::from(2);
7850        uo.status = crate::common::enums::StreamingOrderStatus::ExecutionComplete;
7851        uo.avp = Some(Decimal::from(12));
7852
7853        assert!(BetfairExecutionClient::process_unmatched_order(
7854            &uo,
7855            instrument_id,
7856            account_id,
7857            Currency::GBP(),
7858            &emitter,
7859            &state,
7860            UnixNanos::from(1),
7861            UnixNanos::from(1),
7862        ));
7863        let opened = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
7864
7865        let accepted = opened.iter().find_map(|event| match event {
7866            ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) => Some(*accepted),
7867            _ => None,
7868        });
7869
7870        let filled = opened.iter().find_map(|event| match event {
7871            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => Some(filled.clone()),
7872            _ => None,
7873        });
7874
7875        let accepted = accepted.expect("synthesized accept");
7876        let filled = filled.expect("initial fill");
7877
7878        uo.avp = Some(Decimal::new(947, 2));
7879        assert!(BetfairExecutionClient::process_unmatched_order(
7880            &uo,
7881            instrument_id,
7882            account_id,
7883            Currency::GBP(),
7884            &emitter,
7885            &state,
7886            UnixNanos::from(2),
7887            UnixNanos::from(2),
7888        ));
7889
7890        let restatement = match rx.try_recv().expect("price restatement") {
7891            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => filled,
7892            other => panic!("expected a price restatement, was {other:?}"),
7893        };
7894
7895        assert!(rx.try_recv().is_err());
7896
7897        let repeat = BetfairExecutionClient::process_unmatched_order(
7898            &uo,
7899            instrument_id,
7900            account_id,
7901            Currency::GBP(),
7902            &emitter,
7903            &state,
7904            UnixNanos::from(3),
7905            UnixNanos::from(3),
7906        );
7907        assert!(!repeat);
7908        assert!(rx.try_recv().is_err());
7909
7910        uo.sm = Some(Decimal::ZERO);
7911        uo.sv = Some(Decimal::from(2));
7912        assert!(BetfairExecutionClient::process_unmatched_order(
7913            &uo,
7914            instrument_id,
7915            account_id,
7916            Currency::GBP(),
7917            &emitter,
7918            &state,
7919            UnixNanos::from(4),
7920            UnixNanos::from(4),
7921        ));
7922
7923        let voided = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event {
7924            ExecutionEvent::Order(OrderEventAny::FillVoided(voided)) => Some(voided),
7925            _ => None,
7926        });
7927
7928        let voided = voided.expect("fill void");
7929
7930        let mut order = OrderTestBuilder::new(OrderType::Limit)
7931            .trader_id(TraderId::from("TESTER-001"))
7932            .strategy_id(strategy_id)
7933            .instrument_id(instrument_id)
7934            .client_order_id(client_order_id)
7935            .side(OrderSide::Sell)
7936            .quantity(Quantity::from("2.00"))
7937            .price(Price::from("12.00"))
7938            .build();
7939        order.apply(OrderEventAny::Accepted(accepted)).unwrap();
7940        order.apply(OrderEventAny::Filled(filled.clone())).unwrap();
7941        order
7942            .apply(OrderEventAny::Filled(restatement.clone()))
7943            .unwrap();
7944
7945        let audusd = audusd_sim();
7946        let position_id = PositionId::from("P-RULE4");
7947        let mut opening = filled.clone();
7948        opening.instrument_id = audusd.id();
7949        opening.position_id = Some(position_id);
7950        let mut restated_fill = restatement.clone();
7951        restated_fill.instrument_id = audusd.id();
7952        restated_fill.position_id = Some(position_id);
7953        let mut position = Position::new(&InstrumentAny::CurrencyPair(audusd), opening);
7954        position.apply(&restated_fill);
7955
7956        assert_eq!(restatement.trade_id, filled.trade_id);
7957        assert_eq!(restatement.last_qty.as_decimal(), dec!(2));
7958        assert_eq!(restatement.last_px.as_decimal(), dec!(9.47));
7959        assert_eq!(order.status(), OrderStatus::Filled);
7960        assert_eq!(order.filled_qty().as_decimal(), dec!(2));
7961        assert!(order.leaves_qty().is_zero());
7962        assert_eq!(order.avg_px(), Some(dec!(9.47)));
7963        assert_eq!(filled.last_px.as_decimal(), dec!(12));
7964        assert_eq!(position.quantity.as_decimal(), dec!(2));
7965        assert_eq!(position.avg_px_open, restatement.last_px.as_f64());
7966        assert_eq!(position.events[0].last_px, filled.last_px);
7967        assert_eq!(voided.trade_id, filled.trade_id);
7968        assert_eq!(voided.last_px, filled.last_px);
7969        order.apply(OrderEventAny::FillVoided(voided)).unwrap();
7970    }
7971
7972    #[rstest]
7973    fn test_rule4_restatement_prices_later_fill_from_corrected_average() {
7974        let account_id = AccountId::from("BETFAIR-001");
7975        let client_order_id = ClientOrderId::from("O-RULE4-PARTIAL");
7976        let strategy_id = StrategyId::from("S-RULE4");
7977        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
7978        let mut inner = OcmState::default();
7979        inner
7980            .register_submission(client_order_id, strategy_id)
7981            .unwrap();
7982        inner.mark_accepted(client_order_id);
7983        let state = Arc::new(Mutex::new(inner));
7984        let (emitter, mut rx) = emitter_with_receiver(account_id);
7985        let rfo = make_customer_order_ref(client_order_id.as_str());
7986        let mut uo = fill_unmatched_order("bet-rule4-partial", Some(rfo), Decimal::from(2));
7987        uo.s = Decimal::from(4);
7988        uo.avp = Some(Decimal::from(12));
7989
7990        assert!(BetfairExecutionClient::process_unmatched_order(
7991            &uo,
7992            instrument_id,
7993            account_id,
7994            Currency::GBP(),
7995            &emitter,
7996            &state,
7997            UnixNanos::from(1),
7998            UnixNanos::from(1),
7999        ));
8000        let _ = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
8001
8002        uo.avp = Some(Decimal::new(947, 2));
8003        assert!(BetfairExecutionClient::process_unmatched_order(
8004            &uo,
8005            instrument_id,
8006            account_id,
8007            Currency::GBP(),
8008            &emitter,
8009            &state,
8010            UnixNanos::from(2),
8011            UnixNanos::from(2),
8012        ));
8013        let _ = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
8014
8015        uo.sm = Some(Decimal::from(4));
8016        uo.avp = Some(Decimal::from(10));
8017        assert!(BetfairExecutionClient::process_unmatched_order(
8018            &uo,
8019            instrument_id,
8020            account_id,
8021            Currency::GBP(),
8022            &emitter,
8023            &state,
8024            UnixNanos::from(3),
8025            UnixNanos::from(3),
8026        ));
8027
8028        let later = match rx.try_recv().expect("later fill") {
8029            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => filled,
8030            other => panic!("expected the later fill, was {other:?}"),
8031        };
8032
8033        // (10 * 4 - 9.47 * 2) / 2 = 10.53
8034        assert_eq!(later.last_qty.as_decimal(), dec!(2));
8035        assert_eq!(later.last_px.as_decimal(), dec!(10.53));
8036    }
8037
8038    #[rstest]
8039    fn test_ocm_state_suppress_cancel_for_replaced() {
8040        let mut state = OcmState::default();
8041        let client_oid = ClientOrderId::from("O-001");
8042
8043        state.replaced_venue_order_ids.insert("old_bet".to_string());
8044        assert!(state.should_suppress_cancel(&client_oid, "old_bet"));
8045        assert!(!state.should_suppress_cancel(&client_oid, "new_bet"));
8046    }
8047
8048    #[rstest]
8049    fn test_ocm_state_suppress_cancel_for_pending_replace() {
8050        let mut state = OcmState::default();
8051        let client_oid = ClientOrderId::from("O-001");
8052
8053        state.register_pending_replace(client_oid, "old_bet".to_string(), None);
8054
8055        assert!(state.should_suppress_cancel(&client_oid, "old_bet"));
8056        assert!(!state.should_suppress_cancel(&client_oid, "other_bet"));
8057    }
8058
8059    #[rstest]
8060    fn test_ocm_state_retains_terminal_with_pending_replace() {
8061        let mut state = OcmState::default();
8062        let client_oid = ClientOrderId::from("O-001");
8063
8064        state.register_order_ref(client_oid).unwrap();
8065        state.register_pending_replace(client_oid, "old_bet".to_string(), None);
8066
8067        state.retain_terminal_order(client_oid, "old_bet");
8068        let rfo = make_customer_order_ref(client_oid.as_str());
8069        assert!(state.resolve_client_order_id(Some(&rfo)).is_some());
8070    }
8071
8072    #[rstest]
8073    fn test_ocm_state_retains_terminal_without_pending() {
8074        let mut state = OcmState::default();
8075        let client_oid = ClientOrderId::from("O-001");
8076
8077        state.register_order_ref(client_oid).unwrap();
8078
8079        state.retain_terminal_order(client_oid, "bet-1");
8080        let rfo = make_customer_order_ref(client_oid.as_str());
8081        assert_eq!(state.resolve_client_order_id(Some(&rfo)), Some(client_oid));
8082    }
8083
8084    #[rstest]
8085    fn test_ocm_state_sync_from_orders() {
8086        let mut state = OcmState::default();
8087
8088        let orders = vec![
8089            OrderSyncEntry {
8090                bet_id: "bet1".to_string(),
8091                venue_order_ids: vec!["bet1".to_string()],
8092                client_order_id: ClientOrderId::from("O-001"),
8093                strategy_id: StrategyId::from("S-001"),
8094                filled_qty: Decimal::new(10, 0),
8095                avg_px: Decimal::new(25, 1),
8096                is_closed: false,
8097                trade_ids: Vec::new(),
8098            },
8099            OrderSyncEntry {
8100                bet_id: "bet2".to_string(),
8101                venue_order_ids: vec!["bet2".to_string()],
8102                client_order_id: ClientOrderId::from("O-002"),
8103                strategy_id: StrategyId::from("S-001"),
8104                filled_qty: Decimal::new(5, 0),
8105                avg_px: Decimal::new(30, 1),
8106                is_closed: true,
8107                trade_ids: Vec::new(),
8108            },
8109        ];
8110
8111        state.sync_from_orders(&orders);
8112
8113        let open_client_order_id = ClientOrderId::from("O-001");
8114        let rfo1 = make_customer_order_ref("O-001");
8115        assert_eq!(
8116            state.resolve_client_order_id(Some(&rfo1)),
8117            Some(open_client_order_id),
8118        );
8119        assert_eq!(
8120            state.order_strategy_id(&open_client_order_id),
8121            Some(StrategyId::from("S-001")),
8122        );
8123        assert!(!state.mark_accepted(open_client_order_id));
8124
8125        assert!(state.terminal_orders.contains("bet2"));
8126        let rfo2 = make_customer_order_ref("O-002");
8127        assert_eq!(
8128            state.resolve_client_order_id(Some(&rfo2)),
8129            Some(ClientOrderId::from("O-002")),
8130        );
8131        assert_eq!(
8132            state.order_strategy_id(&ClientOrderId::from("O-002")),
8133            Some(StrategyId::from("S-001")),
8134        );
8135    }
8136
8137    #[rstest]
8138    fn test_sync_ocm_state_preserves_per_bet_fill_cursors_after_replace() {
8139        let trader_id = TraderId::from("TESTER-001");
8140        let strategy_id = StrategyId::from("S-001");
8141        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
8142        let client_order_id = ClientOrderId::from("O-SYNC-REPLACE");
8143        let account_id = AccountId::from("BETFAIR-001");
8144        let old_bet_id = VenueOrderId::from("old-bet");
8145        let new_bet_id = VenueOrderId::from("new-bet");
8146        let mut order = OrderTestBuilder::new(OrderType::Limit)
8147            .trader_id(trader_id)
8148            .strategy_id(strategy_id)
8149            .instrument_id(instrument_id)
8150            .client_order_id(client_order_id)
8151            .quantity(Quantity::from(10))
8152            .price(Price::from("3.0"))
8153            .build();
8154        order
8155            .apply(OrderEventAny::Submitted(OrderSubmitted::new(
8156                trader_id,
8157                strategy_id,
8158                instrument_id,
8159                client_order_id,
8160                account_id,
8161                UUID4::new(),
8162                UnixNanos::from(1),
8163                UnixNanos::from(1),
8164            )))
8165            .unwrap();
8166        order
8167            .apply(OrderEventAny::Filled(OrderFilled::new(
8168                trader_id,
8169                strategy_id,
8170                instrument_id,
8171                client_order_id,
8172                old_bet_id,
8173                account_id,
8174                TradeId::from("old-bet-2"),
8175                OrderSide::Buy,
8176                OrderType::Limit,
8177                Quantity::from(2),
8178                Price::from("3.0"),
8179                Currency::GBP(),
8180                LiquiditySide::Maker,
8181                UUID4::new(),
8182                UnixNanos::from(2),
8183                UnixNanos::from(2),
8184                false,
8185                None,
8186                None,
8187                None,
8188            )))
8189            .unwrap();
8190        order
8191            .apply(OrderEventAny::Updated(OrderUpdated::new(
8192                trader_id,
8193                strategy_id,
8194                instrument_id,
8195                client_order_id,
8196                Quantity::from(4),
8197                UUID4::new(),
8198                UnixNanos::from(3),
8199                UnixNanos::from(3),
8200                false,
8201                Some(new_bet_id),
8202                Some(account_id),
8203                Some(Price::from("4.0")),
8204                None,
8205                None,
8206                false,
8207            )))
8208            .unwrap();
8209        order
8210            .apply(OrderEventAny::Filled(OrderFilled::new(
8211                trader_id,
8212                strategy_id,
8213                instrument_id,
8214                client_order_id,
8215                new_bet_id,
8216                account_id,
8217                TradeId::from("new-bet-1"),
8218                OrderSide::Buy,
8219                OrderType::Limit,
8220                Quantity::from(1),
8221                Price::from("4.0"),
8222                Currency::GBP(),
8223                LiquiditySide::Taker,
8224                UUID4::new(),
8225                UnixNanos::from(4),
8226                UnixNanos::from(4),
8227                false,
8228                None,
8229                None,
8230                None,
8231            )))
8232            .unwrap();
8233
8234        let cache = Rc::new(RefCell::new(Cache::default()));
8235        cache
8236            .borrow_mut()
8237            .add_order(order, None, Some(ClientId::from("BETFAIR-SYNC")), false)
8238            .unwrap();
8239        let config = BetfairExecutionClientConfig::default();
8240        let core = ExecutionClientCore::new(
8241            trader_id,
8242            ClientId::from("BETFAIR-SYNC"),
8243            *BETFAIR_VENUE,
8244            OmsType::Netting,
8245            account_id,
8246            AccountType::Betting,
8247            None,
8248            cache,
8249        );
8250        let credential = BetfairCredential::new(
8251            "username".to_string(),
8252            "password".to_string(),
8253            "app-key".to_string(),
8254        );
8255        let http_client =
8256            BetfairHttpClient::new(credential.clone(), None, None, None, None, None, None).unwrap();
8257        let client = BetfairExecutionClient::new(
8258            core,
8259            http_client,
8260            credential,
8261            config.stream_config(),
8262            config,
8263            Currency::GBP(),
8264        );
8265
8266        client.sync_ocm_state_from_cache();
8267
8268        let mut state = client.ocm_state.lock();
8269        let old_increment = state.fill_tracker.advance_cumulative_fill(
8270            old_bet_id.as_str(),
8271            Decimal::from(3),
8272            Some(Decimal::from(3)),
8273            Decimal::from(3),
8274        );
8275        let current_replay = state.fill_tracker.advance_cumulative_fill(
8276            new_bet_id.as_str(),
8277            Decimal::ONE,
8278            Some(Decimal::from(4)),
8279            Decimal::from(4),
8280        );
8281
8282        assert_eq!(state.order_strategy_id(&client_order_id), Some(strategy_id),);
8283        assert!(!state.mark_accepted(client_order_id));
8284        assert_eq!(
8285            state.client_order_id_by_venue_order_id(old_bet_id.as_str()),
8286            Some(client_order_id),
8287        );
8288        assert_eq!(
8289            old_increment.map(|(_, quantity, price)| (quantity, price)),
8290            Some((Quantity::from(1), Price::from("3.0"))),
8291        );
8292        assert_eq!(current_replay, None);
8293    }
8294
8295    #[rstest]
8296    fn test_sync_ocm_state_closed_order_limit_counts_only_venue_identities() {
8297        let trader_id = TraderId::from("TESTER-001");
8298        let strategy_id = StrategyId::from("S-001");
8299        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
8300        let client_order_id = ClientOrderId::from("O-RETAINED-TERMINAL");
8301        let account_id = AccountId::from("BETFAIR-001");
8302        let venue_order_id = VenueOrderId::from("retained-terminal-bet");
8303        let client_id = ClientId::from("BETFAIR-SYNC");
8304        let mut retained = OrderTestBuilder::new(OrderType::Limit)
8305            .trader_id(trader_id)
8306            .strategy_id(strategy_id)
8307            .instrument_id(instrument_id)
8308            .client_order_id(client_order_id)
8309            .quantity(Quantity::from(10))
8310            .price(Price::from("3.0"))
8311            .build();
8312        retained
8313            .apply(OrderEventAny::Accepted(OrderAccepted::new(
8314                trader_id,
8315                strategy_id,
8316                instrument_id,
8317                client_order_id,
8318                venue_order_id,
8319                account_id,
8320                UUID4::new(),
8321                UnixNanos::from(1),
8322                UnixNanos::from(1),
8323                false,
8324            )))
8325            .unwrap();
8326        retained
8327            .apply(OrderEventAny::Canceled(OrderCanceled::new(
8328                trader_id,
8329                strategy_id,
8330                instrument_id,
8331                client_order_id,
8332                UUID4::new(),
8333                UnixNanos::from(2),
8334                UnixNanos::from(2),
8335                false,
8336                Some(venue_order_id),
8337                Some(account_id),
8338                None,
8339            )))
8340            .unwrap();
8341
8342        let mut cache = Cache::default();
8343        cache
8344            .add_order(retained, None, Some(client_id), false)
8345            .unwrap();
8346
8347        for index in 0..OcmState::DEDUP_RETENTION {
8348            let denied_client_order_id = ClientOrderId::from(format!("O-DENIED-{index}"));
8349            let mut denied = OrderTestBuilder::new(OrderType::Limit)
8350                .trader_id(trader_id)
8351                .strategy_id(strategy_id)
8352                .instrument_id(instrument_id)
8353                .client_order_id(denied_client_order_id)
8354                .quantity(Quantity::from(10))
8355                .price(Price::from("3.0"))
8356                .build();
8357            denied
8358                .apply(OrderEventAny::Denied(OrderDenied::new(
8359                    trader_id,
8360                    strategy_id,
8361                    instrument_id,
8362                    denied_client_order_id,
8363                    Ustr::from("not placed"),
8364                    UUID4::new(),
8365                    UnixNanos::from(index as u64 + 3),
8366                    UnixNanos::from(index as u64 + 3),
8367                )))
8368                .unwrap();
8369            cache
8370                .add_order(denied, None, Some(client_id), false)
8371                .unwrap();
8372        }
8373
8374        let cache = Rc::new(RefCell::new(cache));
8375        let config = BetfairExecutionClientConfig::default();
8376        let core = ExecutionClientCore::new(
8377            trader_id,
8378            client_id,
8379            *BETFAIR_VENUE,
8380            OmsType::Netting,
8381            account_id,
8382            AccountType::Betting,
8383            None,
8384            cache,
8385        );
8386        let credential = BetfairCredential::new(
8387            "username".to_string(),
8388            "password".to_string(),
8389            "app-key".to_string(),
8390        );
8391        let http_client =
8392            BetfairHttpClient::new(credential.clone(), None, None, None, None, None, None).unwrap();
8393        let client = BetfairExecutionClient::new(
8394            core,
8395            http_client,
8396            credential,
8397            config.stream_config(),
8398            config,
8399            Currency::GBP(),
8400        );
8401
8402        client.sync_ocm_state_from_cache();
8403
8404        let state = client.ocm_state.lock();
8405        assert_eq!(
8406            state.client_order_id_by_venue_order_id(venue_order_id.as_str()),
8407            Some(client_order_id),
8408        );
8409        assert_eq!(state.order_strategy_id(&client_order_id), Some(strategy_id));
8410        assert!(state.terminal_orders.contains(venue_order_id.as_str()));
8411        assert!(state.is_retained_terminal_order(&client_order_id));
8412    }
8413
8414    #[rstest]
8415    fn test_sync_cached_fills_restores_terminal_void_cursor() {
8416        let trader_id = TraderId::from("TESTER-001");
8417        let strategy_id = StrategyId::from("S-001");
8418        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
8419        let client_order_id = ClientOrderId::from("O-SYNC-VOID");
8420        let account_id = AccountId::from("BETFAIR-001");
8421        let venue_order_id = VenueOrderId::from("voided-bet");
8422        let mut order = OrderTestBuilder::new(OrderType::Limit)
8423            .trader_id(trader_id)
8424            .strategy_id(strategy_id)
8425            .instrument_id(instrument_id)
8426            .client_order_id(client_order_id)
8427            .quantity(Quantity::from(10))
8428            .price(Price::from("3.0"))
8429            .build();
8430        order
8431            .apply(OrderEventAny::Accepted(OrderAccepted::new(
8432                trader_id,
8433                strategy_id,
8434                instrument_id,
8435                client_order_id,
8436                venue_order_id,
8437                account_id,
8438                UUID4::new(),
8439                UnixNanos::from(1),
8440                UnixNanos::from(1),
8441                false,
8442            )))
8443            .unwrap();
8444        order
8445            .apply(OrderEventAny::FillVoided(OrderFillVoided::new(
8446                trader_id,
8447                strategy_id,
8448                instrument_id,
8449                client_order_id,
8450                venue_order_id,
8451                account_id,
8452                Ustr::from("voided-bet-sv"),
8453                TradeId::from("VOID-voided-bet"),
8454                Quantity::from(4),
8455                None,
8456                OrderSide::Buy,
8457                OrderType::Limit,
8458                Price::from("3.0"),
8459                Currency::GBP(),
8460                LiquiditySide::NoLiquiditySide,
8461                None,
8462                None,
8463                None,
8464                UUID4::new(),
8465                UnixNanos::from(2),
8466                UnixNanos::from(2),
8467                false,
8468                false,
8469            )))
8470            .unwrap();
8471
8472        let mut state = OcmState::default();
8473        state.sync_from_orders(&[BetfairExecutionClient::order_sync_entry(&order).unwrap()]);
8474        BetfairExecutionClient::sync_cached_fills(&mut state, [&order]);
8475        let mut replay = cancel_unmatched_order(venue_order_id.as_str(), None);
8476        replay.s = Decimal::from(10);
8477        replay.sc = None;
8478        replay.sm = Some(Decimal::ZERO);
8479        replay.sv = Some(Decimal::from(4));
8480
8481        assert_eq!(order.status(), OrderStatus::Voided);
8482        assert_eq!(order.voided_qty(), Quantity::from(4));
8483        assert!(!state.fill_tracker.has_unseen_fill_void(&replay));
8484        assert!(state.fill_tracker.maybe_fill_voids(&replay).is_empty());
8485
8486        replay.sv = Some(Decimal::from(6));
8487        let state = Arc::new(Mutex::new(state));
8488        let (emitter, mut rx) = emitter_with_receiver(account_id);
8489        let processed = BetfairExecutionClient::process_unmatched_order(
8490            &replay,
8491            instrument_id,
8492            account_id,
8493            Currency::GBP(),
8494            &emitter,
8495            &state,
8496            UnixNanos::from(3),
8497            UnixNanos::from(3),
8498        );
8499        let fill_voided = match rx.try_recv().unwrap() {
8500            ExecutionEvent::Order(OrderEventAny::FillVoided(fill_voided)) => fill_voided,
8501            other => panic!("expected cache-restored direct fill void, was {other:?}"),
8502        };
8503
8504        assert!(processed);
8505        assert_eq!(fill_voided.client_order_id, client_order_id);
8506        assert_eq!(fill_voided.strategy_id, strategy_id);
8507        assert_eq!(fill_voided.venue_order_id, venue_order_id);
8508        assert_eq!(fill_voided.voided_qty, Quantity::from(6));
8509        assert!(rx.try_recv().is_err());
8510    }
8511
8512    #[rstest]
8513    fn test_reconnect_signal_not_sent_on_initial_connection() {
8514        let has_initial_connection = Arc::new(AtomicBool::new(false));
8515        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
8516
8517        let has_initial = Arc::clone(&has_initial_connection);
8518        let handler = move |_data: &[u8]| {
8519            if has_initial.swap(true, Ordering::SeqCst) {
8520                let _ = reconnect_tx.send(());
8521            }
8522        };
8523
8524        // First connection message: no signal
8525        handler(br#"{"op":"connection","connectionId":"abc"}"#);
8526        assert!(reconnect_rx.try_recv().is_err());
8527        assert!(has_initial_connection.load(Ordering::SeqCst));
8528    }
8529
8530    #[rstest]
8531    fn test_reconnect_signal_sent_on_subsequent_connection() {
8532        let has_initial_connection = Arc::new(AtomicBool::new(false));
8533        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
8534
8535        let has_initial = Arc::clone(&has_initial_connection);
8536        let tx = reconnect_tx;
8537        let handler = move |_data: &[u8]| {
8538            if has_initial.swap(true, Ordering::SeqCst) {
8539                let _ = tx.send(());
8540            }
8541        };
8542
8543        // First connection: no signal
8544        handler(br#"{"op":"connection","connectionId":"abc"}"#);
8545        assert!(reconnect_rx.try_recv().is_err());
8546
8547        // Second connection: signal sent
8548        handler(br#"{"op":"connection","connectionId":"def"}"#);
8549        assert!(reconnect_rx.try_recv().is_ok());
8550
8551        // Third connection: signal sent again
8552        handler(br#"{"op":"connection","connectionId":"ghi"}"#);
8553        assert!(reconnect_rx.try_recv().is_ok());
8554    }
8555
8556    #[rstest]
8557    fn test_reconnect_sets_reconciliation_gate() {
8558        let pending_resync = Arc::new(AtomicBool::new(false));
8559        let reconciliation_gate = Arc::new(ReconciliationGate::default());
8560        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
8561        let handler = ocm_reconnect_handler(
8562            reconnect_tx,
8563            Arc::clone(&pending_resync),
8564            Arc::clone(&reconciliation_gate),
8565        );
8566
8567        handler(stream_decode(br#"{"op":"connection","connectionId":"first"}"#).unwrap());
8568        assert!(reconnect_rx.try_recv().is_err());
8569        assert!(!pending_resync.load(Ordering::Acquire));
8570        assert!(!reconciliation_gate.is_halted());
8571
8572        handler(stream_decode(br#"{"op":"connection","connectionId":"second"}"#).unwrap());
8573        assert!(reconnect_rx.try_recv().is_err());
8574        handler(
8575            stream_decode(br#"{"op":"ocm","id":2,"pt":1000,"ct":"RESUB_DELTA","oc":[]}"#).unwrap(),
8576        );
8577        assert_eq!(reconnect_rx.try_recv().unwrap(), 1);
8578        handler(
8579            stream_decode(br#"{"op":"ocm","id":2,"pt":1001,"ct":"RESUB_DELTA","oc":[]}"#).unwrap(),
8580        );
8581        assert!(reconnect_rx.try_recv().is_err());
8582        assert!(pending_resync.load(Ordering::Acquire));
8583        assert!(reconciliation_gate.is_halted());
8584    }
8585
8586    #[rstest]
8587    fn test_first_connection_after_transport_loss_schedules_reconciliation() {
8588        let pending_resync = Arc::new(AtomicBool::new(false));
8589        let reconciliation_gate = Arc::new(ReconciliationGate::default());
8590        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
8591        let handler = ocm_reconnect_handler(
8592            reconnect_tx,
8593            Arc::clone(&pending_resync),
8594            Arc::clone(&reconciliation_gate),
8595        );
8596
8597        assert_eq!(reconciliation_gate.halt(), 1);
8598        handler(stream_decode(br#"{"op":"connection","connectionId":"replacement"}"#).unwrap());
8599        assert!(reconnect_rx.try_recv().is_err());
8600        handler(
8601            stream_decode(br#"{"op":"ocm","id":2,"pt":1000,"ct":"SUB_IMAGE","oc":[]}"#).unwrap(),
8602        );
8603
8604        assert_eq!(reconnect_rx.try_recv().unwrap(), 1);
8605        assert!(pending_resync.load(Ordering::Acquire));
8606        assert!(reconciliation_gate.is_halted());
8607    }
8608
8609    #[rstest]
8610    fn test_new_503_epoch_supersedes_in_flight_recovery() {
8611        let pending_resync = Arc::new(AtomicBool::new(false));
8612        let reconciliation_gate = Arc::new(ReconciliationGate::default());
8613        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
8614        let handler = ocm_reconnect_handler(
8615            reconnect_tx,
8616            Arc::clone(&pending_resync),
8617            Arc::clone(&reconciliation_gate),
8618        );
8619
8620        handler(
8621            stream_decode(br#"{"op":"ocm","id":2,"pt":1000,"ct":"HEARTBEAT","status":503}"#)
8622                .unwrap(),
8623        );
8624        handler(stream_decode(br#"{"op":"ocm","id":2,"pt":1001,"ct":"HEARTBEAT"}"#).unwrap());
8625        let stale = reconnect_rx.try_recv().unwrap();
8626
8627        handler(
8628            stream_decode(br#"{"op":"ocm","id":2,"pt":1002,"ct":"HEARTBEAT","status":503}"#)
8629                .unwrap(),
8630        );
8631        let current = reconciliation_gate.current_generation();
8632        handler(
8633            stream_decode(br#"{"op":"ocm","id":2,"pt":1003,"ct":"HEARTBEAT","status":503}"#)
8634                .unwrap(),
8635        );
8636
8637        assert_ne!(current, stale);
8638        assert_eq!(reconciliation_gate.current_generation(), current);
8639        assert!(!reconciliation_gate.try_resume(stale));
8640
8641        handler(stream_decode(br#"{"op":"ocm","id":2,"pt":1004,"ct":"HEARTBEAT"}"#).unwrap());
8642        assert_eq!(reconnect_rx.try_recv().unwrap(), current);
8643        assert!(pending_resync.load(Ordering::Acquire));
8644        assert!(reconciliation_gate.is_halted());
8645    }
8646
8647    fn ocm_reconnect_handler(
8648        reconnect_tx: tokio::sync::mpsc::UnboundedSender<u64>,
8649        pending_resync: Arc<AtomicBool>,
8650        reconciliation_gate: Arc<ReconciliationGate>,
8651    ) -> StreamMessageHandler {
8652        let account_id = AccountId::from("BETFAIR-001");
8653        let (emitter, _execution_rx) = emitter_with_receiver(account_id);
8654        let (data_tx, _data_rx) = tokio::sync::mpsc::unbounded_channel();
8655        let (account_refresh_tx, _account_refresh_rx) = tokio::sync::mpsc::unbounded_channel();
8656
8657        BetfairExecutionClient::create_ocm_handler(
8658            emitter,
8659            account_id,
8660            Currency::GBP(),
8661            Arc::new(Mutex::new(OcmState::default())),
8662            data_tx.into(),
8663            None,
8664            false,
8665            reconnect_tx,
8666            Arc::new(AtomicU64::new(0)),
8667            pending_resync,
8668            reconciliation_gate,
8669            Arc::new(Mutex::new(Vec::new())),
8670            account_refresh_tx,
8671            get_atomic_clock_realtime(),
8672        )
8673    }
8674
8675    #[rstest]
8676    fn test_reconciliation_gate_rejects_stale_completion() {
8677        let gate = ReconciliationGate::default();
8678
8679        let stale = gate.halt();
8680        let current = gate.halt();
8681
8682        assert!(gate.is_halted());
8683        assert!(!gate.try_resume(stale));
8684        assert!(gate.is_halted());
8685        assert!(gate.try_resume(current));
8686        assert!(!gate.is_halted());
8687    }
8688
8689    #[rstest]
8690    fn test_reconciliation_gate_publish_failure_stays_halted() {
8691        let gate = ReconciliationGate::default();
8692        let generation = gate.halt();
8693
8694        let result = gate.commit(generation, || anyhow::bail!("receiver closed"));
8695
8696        assert!(result.is_err());
8697        assert!(gate.is_halted());
8698        assert_eq!(gate.current_generation(), generation);
8699    }
8700
8701    #[rstest]
8702    #[case::unbounded(None, None, vec![0, 1, 2, 3, 4])]
8703    #[case::lower_bound(Some(1), None, vec![1, 2, 3, 4])]
8704    #[case::bounded(Some(1), Some(3), vec![1, 2, 3])]
8705    fn test_snapshot_fill_lookback_preserves_order_coverage(
8706        #[case] start: Option<usize>,
8707        #[case] end: Option<usize>,
8708        #[case] expected: Vec<usize>,
8709    ) {
8710        let dates = [
8711            "2026-09-17T10:00:00.000Z",
8712            "2026-09-17T10:00:00.001Z",
8713            "2026-09-17T10:00:00.002Z",
8714            "2026-09-17T10:00:00.003Z",
8715            "2026-09-17T10:00:00.004Z",
8716        ];
8717        let data = load_test_json("rest/list_current_orders_execution_complete.json");
8718        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
8719        let account_id = AccountId::from("BETFAIR-001");
8720        let (emitter, _receiver) = emitter_with_receiver(account_id);
8721        let mut orders = Vec::new();
8722
8723        for (index, date) in dates.iter().enumerate() {
8724            let mut order = response.current_orders[1].clone();
8725            order.bet_id = format!("bet-{index}");
8726            order.matched_date = Some((*date).to_string());
8727            orders.push(order);
8728        }
8729
8730        let mut unmatched = response.current_orders[0].clone();
8731        unmatched.bet_id = "unmatched".to_string();
8732        orders.push(unmatched);
8733
8734        let reports = orders
8735            .iter()
8736            .map(|order| {
8737                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap()
8738            })
8739            .collect();
8740
8741        let mut eligible = orders.clone();
8742        retain_snapshot_fill_orders(
8743            &mut eligible,
8744            start.map(|index| parse_betfair_timestamp(dates[index]).unwrap()),
8745            end.map(|index| parse_betfair_timestamp(dates[index]).unwrap()),
8746        )
8747        .unwrap();
8748        let mut state = OcmState::default();
8749
8750        let (snapshot, updates) = MassStatusSnapshot {
8751            client_id: ClientId::from("BETFAIR"),
8752            account_id,
8753            currency: Currency::GBP(),
8754            ts_init: UnixNanos::default(),
8755            order_reports: reports,
8756            active_quantities: AHashMap::new(),
8757            voided_quantities: AHashMap::new(),
8758            fill_orders: eligible,
8759            account_state: None,
8760        }
8761        .build(&mut state, &emitter)
8762        .unwrap();
8763
8764        assert_eq!(snapshot.order_reports().len(), orders.len());
8765        assert_eq!(snapshot.fill_reports().len(), expected.len());
8766        assert!(updates.is_empty());
8767
8768        for (index, order) in orders.iter().enumerate() {
8769            assert_eq!(
8770                state.fill_tracker.has_fill_lots(&order.bet_id),
8771                expected.contains(&index)
8772            );
8773        }
8774
8775        // A later unbounded request must still deliver every excluded increment
8776        retain_snapshot_fill_orders(&mut orders, None, None).unwrap();
8777        let fills = build_incremental_fill_reports(
8778            &orders,
8779            &mut state.fill_tracker,
8780            &state.customer_order_refs,
8781            account_id,
8782            Currency::GBP(),
8783            UnixNanos::default(),
8784        )
8785        .unwrap();
8786        let actual = fills
8787            .iter()
8788            .map(|fill| fill.venue_order_id.to_string())
8789            .collect::<Vec<_>>();
8790        let remaining = (0..dates.len())
8791            .filter(|index| !expected.contains(index))
8792            .map(|index| format!("bet-{index}"))
8793            .collect::<Vec<_>>();
8794        assert_eq!(actual, remaining);
8795    }
8796
8797    #[rstest]
8798    fn test_snapshot_voided_only_order_does_not_create_fill() {
8799        let data = load_test_json("rest/list_current_orders_execution_complete.json");
8800        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
8801        let mut order = response.current_orders[1].clone();
8802        order.size_matched = Some(Decimal::ZERO);
8803        order.size_voided = Some(Decimal::from(10));
8804        let account_id = AccountId::from("BETFAIR-001");
8805        let (emitter, _receiver) = emitter_with_receiver(account_id);
8806        let report = parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
8807        let mut orders = vec![order.clone()];
8808        retain_snapshot_fill_orders(&mut orders, None, None).unwrap();
8809        let mut state = OcmState::default();
8810
8811        let (snapshot, updates) = MassStatusSnapshot {
8812            client_id: ClientId::from("BETFAIR"),
8813            account_id,
8814            currency: Currency::GBP(),
8815            ts_init: UnixNanos::default(),
8816            order_reports: vec![report],
8817            active_quantities: AHashMap::new(),
8818            voided_quantities: AHashMap::new(),
8819            fill_orders: orders,
8820            account_state: None,
8821        }
8822        .build(&mut state, &emitter)
8823        .unwrap();
8824
8825        let actual = &snapshot.order_reports()[&VenueOrderId::from(order.bet_id.as_str())];
8826        assert_eq!(actual.order_status, OrderStatus::Voided);
8827        assert_eq!(actual.filled_qty, Quantity::from(0));
8828        assert!(snapshot.fill_reports().is_empty());
8829        assert!(updates.is_empty());
8830        assert!(!state.fill_tracker.has_fill_lots(&order.bet_id));
8831        let mut replay = fill_unmatched_order(&order.bet_id, None, Decimal::ZERO);
8832        replay.sv = Some(Decimal::from(10));
8833        assert!(!state.fill_tracker.has_unseen_fill_void(&replay));
8834    }
8835
8836    #[rstest]
8837    fn test_unpublished_recovery_does_not_advance_fill_tracker() {
8838        let gate = ReconciliationGate::default();
8839        let generation = gate.halt();
8840        let account_id = AccountId::from("BETFAIR-001");
8841        let (emitter, receiver) = emitter_with_receiver(account_id);
8842        drop(receiver);
8843        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
8844        let data = load_test_json("rest/list_current_orders_execution_complete.json");
8845        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
8846        let mut fill_order = response.current_orders[1].clone();
8847        fill_order.bet_id = "bet-unpublished".to_string();
8848
8849        let recovery = || MassStatusSnapshot {
8850            client_id: ClientId::from("BETFAIR"),
8851            account_id,
8852            currency: Currency::GBP(),
8853            ts_init: UnixNanos::default(),
8854            order_reports: Vec::new(),
8855            active_quantities: AHashMap::new(),
8856            voided_quantities: AHashMap::new(),
8857            fill_orders: vec![fill_order.clone()],
8858            account_state: None,
8859        };
8860
8861        let result =
8862            commit_post_reconnect_mass_status(&gate, generation, &ocm_state, &emitter, recovery());
8863
8864        assert!(result.is_err());
8865        assert!(gate.is_halted());
8866        assert!(
8867            !ocm_state
8868                .lock()
8869                .fill_tracker
8870                .has_fill_lots("bet-unpublished")
8871        );
8872        let (emitter, mut receiver) = emitter_with_receiver(account_id);
8873        let committed =
8874            commit_post_reconnect_mass_status(&gate, generation, &ocm_state, &emitter, recovery())
8875                .unwrap();
8876
8877        let published = match receiver.try_recv().unwrap() {
8878            ExecutionEvent::Report(ExecutionReport::MassStatus(status)) => status,
8879            other => panic!("expected retried mass status, was {other:?}"),
8880        };
8881
8882        let fills = published.fill_reports();
8883        let fill = &fills[&VenueOrderId::from("bet-unpublished")][0];
8884        assert_eq!(
8885            committed.map(|(orders, fills, _)| (orders, fills)),
8886            Some((0, 1))
8887        );
8888        assert_eq!(fill.last_qty, Quantity::from(10));
8889        assert_eq!(fill.trade_id, TradeId::from("bet-unpublished-10.00"));
8890        assert!(!gate.is_halted());
8891
8892        let generation = gate.halt();
8893        let repeated =
8894            commit_post_reconnect_mass_status(&gate, generation, &ocm_state, &emitter, recovery())
8895                .unwrap();
8896        assert_eq!(
8897            repeated.map(|(orders, fills, _)| (orders, fills)),
8898            Some((0, 0))
8899        );
8900    }
8901
8902    #[rstest]
8903    fn test_invalid_recovery_does_not_publish_or_advance_fill_tracker() {
8904        let gate = ReconciliationGate::default();
8905        let generation = gate.halt();
8906        let account_id = AccountId::from("BETFAIR-001");
8907        let (emitter, mut receiver) = emitter_with_receiver(account_id);
8908        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
8909        let data = load_test_json("rest/list_current_orders_execution_complete.json");
8910        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
8911        let mut fill_order = response.current_orders[1].clone();
8912        fill_order.bet_id = "bet-invalid".to_string();
8913        fill_order.placed_date = "not-a-timestamp".to_string();
8914
8915        let recovery = MassStatusSnapshot {
8916            client_id: ClientId::from("BETFAIR"),
8917            account_id,
8918            currency: Currency::GBP(),
8919            ts_init: UnixNanos::default(),
8920            order_reports: Vec::new(),
8921            active_quantities: AHashMap::new(),
8922            voided_quantities: AHashMap::new(),
8923            fill_orders: vec![fill_order],
8924            account_state: None,
8925        };
8926
8927        let result =
8928            commit_post_reconnect_mass_status(&gate, generation, &ocm_state, &emitter, recovery);
8929
8930        assert!(result.is_err());
8931        assert!(gate.is_halted());
8932        assert!(receiver.try_recv().is_err());
8933        assert!(!ocm_state.lock().fill_tracker.has_fill_lots("bet-invalid"));
8934    }
8935
8936    #[rstest]
8937    fn test_recovery_commit_uses_current_fill_tracker() {
8938        let gate = ReconciliationGate::default();
8939        let generation = gate.halt();
8940        let account_id = AccountId::from("BETFAIR-001");
8941        let (emitter, mut receiver) = emitter_with_receiver(account_id);
8942        let data = load_test_json("rest/list_current_orders_execution_complete.json");
8943        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
8944        let fill_order = response.current_orders[1].clone();
8945        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
8946        assert!(
8947            ocm_state
8948                .lock()
8949                .fill_tracker
8950                .advance_cumulative_fill(
8951                    &fill_order.bet_id,
8952                    Decimal::from(5),
8953                    fill_order.average_price_matched,
8954                    fill_order.price_size.price,
8955                )
8956                .is_some()
8957        );
8958
8959        let recovery = MassStatusSnapshot {
8960            client_id: ClientId::from("BETFAIR"),
8961            account_id,
8962            currency: Currency::GBP(),
8963            ts_init: UnixNanos::default(),
8964            order_reports: Vec::new(),
8965            active_quantities: AHashMap::new(),
8966            voided_quantities: AHashMap::new(),
8967            fill_orders: vec![fill_order.clone()],
8968            account_state: None,
8969        };
8970
8971        let committed =
8972            commit_post_reconnect_mass_status(&gate, generation, &ocm_state, &emitter, recovery)
8973                .unwrap();
8974        let mass_status = match receiver.try_recv().unwrap() {
8975            ExecutionEvent::Report(ExecutionReport::MassStatus(status)) => status,
8976            other => panic!("expected mass status, was {other:?}"),
8977        };
8978        let fills = mass_status.fill_reports();
8979        let fill = &fills[&VenueOrderId::from(fill_order.bet_id.as_str())][0];
8980
8981        assert_eq!(
8982            committed.map(|(orders, fills, _)| (orders, fills)),
8983            Some((0, 1))
8984        );
8985        assert_eq!(fill.last_qty, Quantity::from("5.00"));
8986        assert!(!gate.is_halted());
8987        assert!(
8988            ocm_state
8989                .lock()
8990                .fill_tracker
8991                .advance_cumulative_fill(
8992                    &fill_order.bet_id,
8993                    fill_order.size_matched.unwrap(),
8994                    fill_order.average_price_matched,
8995                    fill_order.price_size.price,
8996                )
8997                .is_none()
8998        );
8999    }
9000
9001    #[rstest]
9002    fn test_recovery_commit_resolves_pending_reduction() {
9003        let gate = ReconciliationGate::default();
9004        let generation = gate.halt();
9005        let account_id = AccountId::from("BETFAIR-001");
9006        let client_order_id = ClientOrderId::from("O-RECOVERY-REDUCTION");
9007        let strategy_id = StrategyId::from("S-001");
9008        let data = load_test_json("rest/list_current_orders_executable.json");
9009        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
9010        let order = response.current_orders[0].clone();
9011        let bet_id = order.bet_id.clone();
9012        let mut report =
9013            parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
9014        report.client_order_id = Some(client_order_id);
9015        let mut state = OcmState::default();
9016        state
9017            .register_submission(client_order_id, strategy_id)
9018            .unwrap();
9019        state.register_pending_reduction(
9020            client_order_id,
9021            bet_id.clone(),
9022            Quantity::from(10),
9023            Quantity::from(4),
9024        );
9025        let ocm_state = Arc::new(Mutex::new(state));
9026        let (emitter, mut receiver) = emitter_with_receiver(account_id);
9027
9028        let recovery = MassStatusSnapshot {
9029            client_id: ClientId::from("BETFAIR"),
9030            account_id,
9031            currency: Currency::GBP(),
9032            ts_init: UnixNanos::default(),
9033            order_reports: vec![report],
9034            active_quantities: AHashMap::from([(bet_id.clone(), Quantity::from(4))]),
9035            voided_quantities: AHashMap::new(),
9036            fill_orders: Vec::new(),
9037            account_state: None,
9038        };
9039
9040        let committed =
9041            commit_post_reconnect_mass_status(&gate, generation, &ocm_state, &emitter, recovery)
9042                .unwrap();
9043        let updated = match receiver.try_recv().unwrap() {
9044            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => updated,
9045            other => panic!("expected reconciled order update, was {other:?}"),
9046        };
9047        let mass_status = match receiver.try_recv().unwrap() {
9048            ExecutionEvent::Report(ExecutionReport::MassStatus(status)) => status,
9049            other => panic!("expected mass status after order update, was {other:?}"),
9050        };
9051
9052        assert_eq!(
9053            committed.map(|(orders, fills, _)| (orders, fills)),
9054            Some((0, 0))
9055        );
9056        assert!(mass_status.order_reports().is_empty());
9057        assert_eq!(updated.client_order_id, client_order_id);
9058        assert_eq!(updated.quantity, Quantity::from(4));
9059        assert_eq!(updated.price, None);
9060        assert!(updated.reconciliation);
9061        assert_eq!(
9062            ocm_state.lock().reduced_quantity(&bet_id),
9063            Some(Quantity::from(4)),
9064        );
9065        assert!(!gate.is_halted());
9066    }
9067
9068    #[rstest]
9069    fn test_reconciliation_promotes_closed_replace_before_terminal_report() {
9070        let gate = ReconciliationGate::default();
9071        let account_id = AccountId::from("BETFAIR-001");
9072        let client_order_id = ClientOrderId::from("O-RECOVERY-REPLACE-CLOSED");
9073        let strategy_id = StrategyId::from("S-001");
9074        let old_bet_id = "old-bet";
9075        let new_bet_id = "new-bet";
9076        let mut old_order = make_summary(
9077            old_bet_id,
9078            "1.100",
9079            12345,
9080            Decimal::ZERO,
9081            BetfairOrderStatus::ExecutionComplete,
9082            "2026-08-25T00:00:00Z",
9083        );
9084        old_order.size_remaining = Some(Decimal::ZERO);
9085        old_order.size_cancelled = Some(Decimal::from(10));
9086        old_order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
9087        let mut old_report =
9088            parse_current_order_report(&old_order, account_id, UnixNanos::default()).unwrap();
9089        old_report.client_order_id = Some(client_order_id);
9090        let mut new_order = make_summary(
9091            new_bet_id,
9092            "1.100",
9093            12345,
9094            Decimal::ZERO,
9095            BetfairOrderStatus::ExecutionComplete,
9096            "2026-08-25T00:00:00Z",
9097        );
9098        new_order.size_remaining = Some(Decimal::ZERO);
9099        new_order.size_cancelled = Some(Decimal::from(10));
9100        new_order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
9101        let mut new_report =
9102            parse_current_order_report(&new_order, account_id, UnixNanos::default()).unwrap();
9103        new_report.client_order_id = Some(client_order_id);
9104        let mut state = OcmState::default();
9105        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(old_bet_id));
9106        state.register_pending_replace(
9107            client_order_id,
9108            old_bet_id.to_string(),
9109            Some(Quantity::from(10)),
9110        );
9111        state.mark_pending_replace_ambiguous(client_order_id, old_bet_id);
9112        let ocm_state = Arc::new(Mutex::new(state));
9113        let (emitter, mut receiver) = emitter_with_receiver(account_id);
9114
9115        let generation = gate.halt();
9116        let first = commit_post_reconnect_mass_status(
9117            &gate,
9118            generation,
9119            &ocm_state,
9120            &emitter,
9121            MassStatusSnapshot {
9122                client_id: ClientId::from("BETFAIR"),
9123                account_id,
9124                currency: Currency::GBP(),
9125                ts_init: UnixNanos::default(),
9126                order_reports: vec![old_report.clone(), new_report.clone()],
9127                active_quantities: AHashMap::new(),
9128                voided_quantities: AHashMap::new(),
9129                fill_orders: Vec::new(),
9130                account_state: None,
9131            },
9132        )
9133        .unwrap();
9134        let updated = match receiver.try_recv().unwrap() {
9135            ExecutionEvent::Order(OrderEventAny::Updated(updated)) => updated,
9136            other => panic!("expected replacement update before terminal report, was {other:?}"),
9137        };
9138        let first_mass_status = match receiver.try_recv().unwrap() {
9139            ExecutionEvent::Report(ExecutionReport::MassStatus(status)) => status,
9140            other => {
9141                panic!("expected terminal mass status after replacement update, was {other:?}")
9142            }
9143        };
9144
9145        let generation = gate.halt();
9146        let repeated = commit_post_reconnect_mass_status(
9147            &gate,
9148            generation,
9149            &ocm_state,
9150            &emitter,
9151            MassStatusSnapshot {
9152                client_id: ClientId::from("BETFAIR"),
9153                account_id,
9154                currency: Currency::GBP(),
9155                ts_init: UnixNanos::default(),
9156                order_reports: vec![old_report, new_report],
9157                active_quantities: AHashMap::new(),
9158                voided_quantities: AHashMap::new(),
9159                fill_orders: Vec::new(),
9160                account_state: None,
9161            },
9162        )
9163        .unwrap();
9164        let repeated_mass_status = match receiver.try_recv().unwrap() {
9165            ExecutionEvent::Report(ExecutionReport::MassStatus(status)) => status,
9166            other => panic!("closed replacement must not emit a second update, was {other:?}"),
9167        };
9168        let state = ocm_state.lock();
9169
9170        assert_eq!(
9171            first.map(|(orders, fills, _)| (orders, fills)),
9172            Some((1, 0))
9173        );
9174        assert_eq!(updated.client_order_id, client_order_id);
9175        assert_eq!(updated.strategy_id, strategy_id);
9176        assert_eq!(updated.venue_order_id, Some(VenueOrderId::from(new_bet_id)));
9177        assert_eq!(updated.quantity, Quantity::from(10));
9178        assert_eq!(updated.price, Some(Price::from("2.00")));
9179        assert!(updated.reconciliation);
9180        assert_eq!(first_mass_status.order_reports().len(), 1);
9181        let first_report = &first_mass_status.order_reports()[&VenueOrderId::from(new_bet_id)];
9182        assert_eq!(first_report.client_order_id, Some(client_order_id));
9183        assert_eq!(first_report.order_status, OrderStatus::Canceled);
9184        assert_eq!(first_report.quantity, Quantity::from(10));
9185        assert_eq!(
9186            repeated.map(|(orders, fills, _)| (orders, fills)),
9187            Some((2, 0))
9188        );
9189        assert_eq!(repeated_mass_status.order_reports().len(), 2);
9190        assert!(
9191            repeated_mass_status
9192                .order_reports()
9193                .contains_key(&VenueOrderId::from(old_bet_id))
9194        );
9195        assert!(
9196            repeated_mass_status
9197                .order_reports()
9198                .contains_key(&VenueOrderId::from(new_bet_id))
9199        );
9200        assert!(receiver.try_recv().is_err());
9201        assert_eq!(
9202            state.client_order_id_by_venue_order_id(old_bet_id),
9203            Some(client_order_id),
9204        );
9205        assert_eq!(
9206            state.client_order_id_by_venue_order_id(new_bet_id),
9207            Some(client_order_id),
9208        );
9209        assert!(state.is_retained_terminal_order(&client_order_id));
9210        assert!(state.replaced_venue_order_ids.contains(old_bet_id));
9211        assert!(!state.pending_replace_awaits_reconciliation(&client_order_id, old_bet_id));
9212    }
9213
9214    #[rstest]
9215    #[case::known(true, false, true)]
9216    #[case::new(false, false, false)]
9217    #[case::reregistered(true, true, false)]
9218    fn test_report_finalization_rejects_only_the_collected_replace(
9219        #[case] collected: bool,
9220        #[case] reregistered: bool,
9221        #[case] rejected: bool,
9222    ) {
9223        let account_id = AccountId::from("BETFAIR-001");
9224        let client_order_id = ClientOrderId::from("O-REPORT-REPLACE");
9225        let bet_id = "report-bet";
9226        let mut order = make_summary(
9227            bet_id,
9228            "1.100",
9229            12345,
9230            Decimal::ZERO,
9231            BetfairOrderStatus::Executable,
9232            "2026-08-25T00:00:00Z",
9233        );
9234        order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
9235        let report = parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
9236        let mut expected_report = report.clone();
9237        expected_report.client_order_id = Some(client_order_id);
9238        let state = Arc::new(Mutex::new(OcmState::default()));
9239        {
9240            let mut state = state.lock();
9241            state.restore_order(
9242                client_order_id,
9243                StrategyId::from("S-001"),
9244                VenueOrderId::from(bet_id),
9245            );
9246            state.register_pending_replace(
9247                client_order_id,
9248                bet_id.to_string(),
9249                Some(Quantity::from(10)),
9250            );
9251            state.mark_pending_replace_ambiguous(client_order_id, bet_id);
9252        }
9253
9254        let ambiguous_replaces = if collected {
9255            state.lock().pending_replace_reconciliation_ids()
9256        } else {
9257            AHashMap::new()
9258        };
9259
9260        if reregistered {
9261            let mut state = state.lock();
9262            state.take_pending_replace(client_order_id, bet_id);
9263            state.register_pending_replace(
9264                client_order_id,
9265                bet_id.to_string(),
9266                Some(Quantity::from(10)),
9267            );
9268            state.mark_pending_replace_ambiguous(client_order_id, bet_id);
9269        }
9270
9271        let fetched = FetchedOrderStatusReports {
9272            orders: vec![order],
9273            reports: vec![report],
9274            ambiguous_replaces,
9275            ..Default::default()
9276        };
9277
9278        let (emitter, mut receiver) = emitter_with_receiver(account_id);
9279
9280        let reports = finish_order_status_reports(fetched, None, &state, Some(&emitter), true);
9281        let events: Vec<_> = std::iter::from_fn(|| receiver.try_recv().ok()).collect();
9282
9283        assert_eq!(reports, vec![expected_report.clone()]);
9284        assert_eq!(
9285            state
9286                .lock()
9287                .pending_replace_awaits_reconciliation(&client_order_id, bet_id),
9288            !rejected
9289        );
9290        assert_eq!(events.len(), usize::from(rejected));
9291
9292        if rejected {
9293            let ExecutionEvent::Order(OrderEventAny::ModifyRejected(event)) = &events[0] else {
9294                panic!("expected modification rejection, was {:?}", events[0]);
9295            };
9296
9297            assert_eq!(
9298                *event,
9299                OrderModifyRejected::new(
9300                    emitter.trader_id(),
9301                    StrategyId::from("S-001"),
9302                    expected_report.instrument_id,
9303                    client_order_id,
9304                    Ustr::from("Original bet remained executable after ambiguous replace"),
9305                    event.event_id,
9306                    expected_report.ts_last,
9307                    expected_report.ts_init,
9308                    true,
9309                    Some(VenueOrderId::from(bet_id)),
9310                    Some(account_id),
9311                )
9312            );
9313        }
9314    }
9315
9316    #[rstest]
9317    fn test_report_owners_use_current_reference_resolution() {
9318        let account_id = AccountId::from("BETFAIR-001");
9319        let client_order_id = ClientOrderId::from("O-REPORT-OWNER");
9320        let mut order = make_summary(
9321            "owner-bet",
9322            "1.100",
9323            12345,
9324            Decimal::ZERO,
9325            BetfairOrderStatus::Executable,
9326            "2026-08-25T00:00:00Z",
9327        );
9328        let reference = make_customer_order_ref(client_order_id.as_str());
9329        order.customer_order_ref = Some(reference.clone());
9330        let report = parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
9331        let mut expected_report = report.clone();
9332        expected_report.client_order_id = None;
9333        let mut state = OcmState::default();
9334        state
9335            .customer_order_refs
9336            .insert(reference, CustomerOrderRefResolution::Ambiguous);
9337        let mut reports = vec![report];
9338
9339        assign_report_owners(&mut reports, &state);
9340
9341        assert_eq!(reports, vec![expected_report]);
9342    }
9343
9344    #[rstest]
9345    fn test_fill_finalization_uses_current_tracker_atomically() {
9346        let account_id = AccountId::from("BETFAIR-001");
9347        let currency = Currency::GBP();
9348        let mut order = make_summary(
9349            "fill-bet",
9350            "1.100",
9351            12345,
9352            Decimal::ZERO,
9353            BetfairOrderStatus::Executable,
9354            "2026-08-25T00:00:00Z",
9355        );
9356        order.size_matched = Some(Decimal::from(7));
9357        order.average_price_matched = Some(Decimal::from(3));
9358        let state = Arc::new(Mutex::new(OcmState::default()));
9359        state.lock().fill_tracker.advance_cumulative_fill(
9360            "fill-bet",
9361            Decimal::from(4),
9362            Some(Decimal::from(3)),
9363            Decimal::from(3),
9364        );
9365
9366        let mut invalid_order = order.clone();
9367        invalid_order.bet_id = "fill-invalid".to_string();
9368        invalid_order.placed_date = "invalid-date".to_string();
9369        let failed = finish_fill_reports(
9370            &[order.clone(), invalid_order],
9371            &state,
9372            account_id,
9373            currency,
9374            UnixNanos::default(),
9375        )
9376        .unwrap_err();
9377
9378        let reports = finish_fill_reports(
9379            &[order.clone()],
9380            &state,
9381            account_id,
9382            currency,
9383            UnixNanos::default(),
9384        )
9385        .unwrap();
9386        let repeated =
9387            finish_fill_reports(&[order], &state, account_id, currency, UnixNanos::default())
9388                .unwrap();
9389
9390        assert_eq!(
9391            failed.to_string(),
9392            format!(
9393                "Failed to parse fill report for fill-invalid: {}",
9394                parse_betfair_timestamp("invalid-date").unwrap_err(),
9395            )
9396        );
9397        assert_eq!(reports.len(), 1);
9398        assert_eq!(
9399            reports[0],
9400            FillReport::new(
9401                account_id,
9402                InstrumentId::from("1.100-12345.BETFAIR"),
9403                VenueOrderId::from("fill-bet"),
9404                TradeId::from("fill-bet-7.00"),
9405                OrderSide::Sell,
9406                Quantity::from("3.00"),
9407                Price::from("3.00"),
9408                Money::zero(currency),
9409                LiquiditySide::NoLiquiditySide,
9410                None,
9411                None,
9412                parse_betfair_timestamp("2026-08-25T00:00:00Z").unwrap(),
9413                UnixNanos::default(),
9414                Some(reports[0].report_id),
9415            )
9416        );
9417        assert!(repeated.is_empty());
9418    }
9419
9420    #[rstest]
9421    fn test_reconciliation_resolves_unique_closed_replace_without_new_bet() {
9422        let account_id = AccountId::from("BETFAIR-001");
9423        let client_order_id = ClientOrderId::from("O-RECOVERY-REPLACE-CANCELED");
9424        let strategy_id = StrategyId::from("S-001");
9425        let old_bet_id = "old-bet";
9426        let mut order = make_summary(
9427            old_bet_id,
9428            "1.100",
9429            12345,
9430            Decimal::ZERO,
9431            BetfairOrderStatus::ExecutionComplete,
9432            "2026-08-25T00:00:00Z",
9433        );
9434        order.size_remaining = Some(Decimal::ZERO);
9435        order.size_cancelled = Some(Decimal::from(10));
9436        let mut report =
9437            parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
9438        report.client_order_id = Some(client_order_id);
9439        let mut reports = vec![report];
9440        let mut state = OcmState::default();
9441        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(old_bet_id));
9442        state.register_pending_replace(
9443            client_order_id,
9444            old_bet_id.to_string(),
9445            Some(Quantity::from(10)),
9446        );
9447        state.mark_pending_replace_ambiguous(client_order_id, old_bet_id);
9448        let (emitter, _receiver) = emitter_with_receiver(account_id);
9449
9450        let updates = resolve_pending_modifies_in_state(
9451            &mut reports,
9452            &AHashMap::new(),
9453            &AHashMap::new(),
9454            &mut state,
9455            &emitter,
9456            None,
9457        );
9458
9459        assert!(updates.is_empty());
9460        assert_eq!(reports.len(), 1);
9461        assert_eq!(reports[0].client_order_id, Some(client_order_id));
9462        assert_eq!(reports[0].venue_order_id, VenueOrderId::from(old_bet_id));
9463        assert_eq!(reports[0].order_status, OrderStatus::Canceled);
9464        assert!(!state.pending_replace_awaits_reconciliation(&client_order_id, old_bet_id));
9465        assert!(state.is_retained_terminal_order(&client_order_id));
9466    }
9467
9468    #[rstest]
9469    fn test_reconciliation_folds_replaced_leg_fills_into_successor() {
9470        let account_id = AccountId::from("BETFAIR-001");
9471        let client_order_id = ClientOrderId::from("O-REPLACED-FILLS");
9472        let strategy_id = StrategyId::from("S-001");
9473        let old_bet_id = "old-bet";
9474        let new_bet_id = "new-bet";
9475        let mut old_order = make_summary(
9476            old_bet_id,
9477            "1.100",
9478            12345,
9479            Decimal::ZERO,
9480            BetfairOrderStatus::ExecutionComplete,
9481            "2026-08-25T00:00:00Z",
9482        );
9483        old_order.size_matched = Some(Decimal::from(4));
9484        old_order.size_remaining = Some(Decimal::ZERO);
9485        old_order.size_cancelled = Some(Decimal::from(6));
9486        old_order.average_price_matched = Some(Decimal::from(3));
9487        let mut old_report =
9488            parse_current_order_report(&old_order, account_id, UnixNanos::default()).unwrap();
9489        old_report.client_order_id = Some(client_order_id);
9490        let mut new_order = make_summary(
9491            new_bet_id,
9492            "1.100",
9493            12345,
9494            Decimal::ZERO,
9495            BetfairOrderStatus::Executable,
9496            "2026-08-25T00:01:00Z",
9497        );
9498        new_order.price_size.size = Decimal::from(6);
9499        new_order.size_matched = Some(Decimal::from(2));
9500        new_order.size_remaining = Some(Decimal::from(4));
9501        new_order.average_price_matched = Some(Decimal::from(5));
9502        let mut new_report =
9503            parse_current_order_report(&new_order, account_id, UnixNanos::default()).unwrap();
9504        new_report.client_order_id = Some(client_order_id);
9505        let mut reports = vec![old_report, new_report];
9506        let mut state = OcmState::default();
9507        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
9508        state
9509            .replaced_venue_order_ids
9510            .insert(old_bet_id.to_string());
9511        let (emitter, _receiver) = emitter_with_receiver(account_id);
9512
9513        let updates = resolve_pending_modifies_in_state(
9514            &mut reports,
9515            &AHashMap::new(),
9516            &AHashMap::new(),
9517            &mut state,
9518            &emitter,
9519            None,
9520        );
9521
9522        assert!(updates.is_empty());
9523        assert_eq!(reports.len(), 1);
9524        let successor = reports
9525            .iter()
9526            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
9527            .unwrap();
9528        assert_eq!(successor.order_status, OrderStatus::PartiallyFilled);
9529        assert_eq!(successor.quantity, Quantity::from("10"));
9530        assert_eq!(successor.filled_qty, Quantity::from("6"));
9531        assert_eq!(successor.avg_px, Some(Decimal::from(22) / Decimal::from(6)));
9532    }
9533
9534    #[rstest]
9535    fn test_reconciliation_fills_across_repeated_replacements() {
9536        let account_id = AccountId::from("BETFAIR-001");
9537        let client_order_id = ClientOrderId::from("O-REPEATED-REPLACEMENTS");
9538        let strategy_id = StrategyId::from("S-001");
9539        let first_bet_id = "first-bet";
9540        let second_bet_id = "second-bet";
9541        let third_bet_id = "third-bet";
9542        let mut first_order = make_summary(
9543            first_bet_id,
9544            "1.100",
9545            12345,
9546            Decimal::ZERO,
9547            BetfairOrderStatus::ExecutionComplete,
9548            "2026-08-25T00:00:00Z",
9549        );
9550        first_order.size_matched = Some(Decimal::from(1));
9551        first_order.size_remaining = Some(Decimal::ZERO);
9552        first_order.size_cancelled = Some(Decimal::from(9));
9553        first_order.average_price_matched = Some(Decimal::from(2));
9554        let mut second_order = make_summary(
9555            second_bet_id,
9556            "1.100",
9557            12345,
9558            Decimal::ZERO,
9559            BetfairOrderStatus::ExecutionComplete,
9560            "2026-08-25T00:01:00Z",
9561        );
9562        second_order.price_size.size = Decimal::from(9);
9563        second_order.size_matched = Some(Decimal::from(2));
9564        second_order.size_remaining = Some(Decimal::ZERO);
9565        second_order.size_cancelled = Some(Decimal::from(7));
9566        second_order.average_price_matched = Some(Decimal::from(3));
9567        let mut third_order = make_summary(
9568            third_bet_id,
9569            "1.100",
9570            12345,
9571            Decimal::ZERO,
9572            BetfairOrderStatus::Executable,
9573            "2026-08-25T00:02:00Z",
9574        );
9575        third_order.price_size.size = Decimal::from(7);
9576        third_order.size_matched = Some(Decimal::from(3));
9577        third_order.size_remaining = Some(Decimal::from(4));
9578        third_order.average_price_matched = Some(Decimal::from(4));
9579        let mut reports = Vec::new();
9580
9581        for order in [&first_order, &second_order, &third_order] {
9582            let mut report =
9583                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
9584            report.client_order_id = Some(client_order_id);
9585            reports.push(report);
9586        }
9587
9588        let mut state = OcmState::default();
9589        state.restore_order(
9590            client_order_id,
9591            strategy_id,
9592            VenueOrderId::from(third_bet_id),
9593        );
9594        state
9595            .replaced_venue_order_ids
9596            .insert(first_bet_id.to_string());
9597        state
9598            .replaced_venue_order_ids
9599            .insert(second_bet_id.to_string());
9600        let (emitter, _receiver) = emitter_with_receiver(account_id);
9601
9602        let updates = resolve_pending_modifies_in_state(
9603            &mut reports,
9604            &AHashMap::new(),
9605            &AHashMap::new(),
9606            &mut state,
9607            &emitter,
9608            None,
9609        );
9610
9611        assert!(updates.is_empty());
9612        let successor = reports
9613            .iter()
9614            .find(|report| report.venue_order_id == VenueOrderId::from(third_bet_id))
9615            .unwrap();
9616        assert_eq!(successor.quantity, Quantity::from("10"));
9617        assert_eq!(successor.filled_qty, Quantity::from("6"));
9618        assert_eq!(successor.avg_px, Some(Decimal::from(20) / Decimal::from(6)));
9619    }
9620
9621    #[rstest]
9622    fn test_reconciliation_folds_fills_into_terminal_successor() {
9623        let account_id = AccountId::from("BETFAIR-001");
9624        let client_order_id = ClientOrderId::from("O-TERMINAL-SUCCESSOR");
9625        let strategy_id = StrategyId::from("S-001");
9626        let old_bet_id = "old-bet";
9627        let new_bet_id = "new-bet";
9628        let mut old_order = make_summary(
9629            old_bet_id,
9630            "1.100",
9631            12345,
9632            Decimal::ZERO,
9633            BetfairOrderStatus::ExecutionComplete,
9634            "2026-08-25T00:00:00Z",
9635        );
9636        old_order.size_matched = Some(Decimal::from(4));
9637        old_order.size_remaining = Some(Decimal::ZERO);
9638        old_order.size_cancelled = Some(Decimal::from(6));
9639        old_order.average_price_matched = Some(Decimal::from(3));
9640        let mut new_order = make_summary(
9641            new_bet_id,
9642            "1.100",
9643            12345,
9644            Decimal::ZERO,
9645            BetfairOrderStatus::ExecutionComplete,
9646            "2026-08-25T00:01:00Z",
9647        );
9648        new_order.price_size.size = Decimal::from(6);
9649        new_order.size_matched = Some(Decimal::from(6));
9650        new_order.size_remaining = Some(Decimal::ZERO);
9651        new_order.average_price_matched = Some(Decimal::from(5));
9652        let mut reports = Vec::new();
9653
9654        for order in [&old_order, &new_order] {
9655            let mut report =
9656                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
9657            report.client_order_id = Some(client_order_id);
9658            reports.push(report);
9659        }
9660
9661        let mut state = OcmState::default();
9662        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
9663        state
9664            .replaced_venue_order_ids
9665            .insert(old_bet_id.to_string());
9666        let (emitter, _receiver) = emitter_with_receiver(account_id);
9667
9668        let updates = resolve_pending_modifies_in_state(
9669            &mut reports,
9670            &AHashMap::new(),
9671            &AHashMap::new(),
9672            &mut state,
9673            &emitter,
9674            None,
9675        );
9676
9677        assert!(updates.is_empty());
9678        // The retained terminal identity keeps the predecessor leg in the mass
9679        // status; the engine short-circuits it as a superseded cancel.
9680        assert_eq!(reports.len(), 2);
9681        let successor = reports
9682            .iter()
9683            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
9684            .unwrap();
9685        assert_eq!(successor.order_status, OrderStatus::Filled);
9686        assert_eq!(successor.quantity, Quantity::from("10"));
9687        assert_eq!(successor.filled_qty, Quantity::from("10"));
9688        assert_eq!(
9689            successor.avg_px,
9690            Some(Decimal::from(42) / Decimal::from(10))
9691        );
9692    }
9693
9694    #[rstest]
9695    fn test_replace_fold_includes_replaced_leg_voided_quantity() {
9696        let account_id = AccountId::from("BETFAIR-001");
9697        let client_order_id = ClientOrderId::from("O-REPLACE-VOIDED-LEG");
9698        let strategy_id = StrategyId::from("S-001");
9699        let old_bet_id = "old-bet";
9700        let new_bet_id = "new-bet";
9701        // Original size 10: 4 matched, 2 non-reopened voided, 4 still cancelled/unfilled
9702        let mut old_order = make_summary(
9703            old_bet_id,
9704            "1.100",
9705            12345,
9706            Decimal::ZERO,
9707            BetfairOrderStatus::ExecutionComplete,
9708            "2026-08-25T00:00:00Z",
9709        );
9710        old_order.size_matched = Some(Decimal::from(4));
9711        old_order.size_voided = Some(Decimal::from(2));
9712        old_order.size_remaining = Some(Decimal::ZERO);
9713        old_order.size_cancelled = Some(Decimal::from(4));
9714        // Betfair re-places the replacement bet for exactly the pre-replace remaining size
9715        // (10 - 4 matched - 2 voided = 4), not the original total.
9716        let mut new_order = make_summary(
9717            new_bet_id,
9718            "1.100",
9719            12345,
9720            Decimal::ZERO,
9721            BetfairOrderStatus::Executable,
9722            "2026-08-25T00:01:00Z",
9723        );
9724        new_order.price_size.size = Decimal::from(4);
9725        new_order.size_matched = Some(Decimal::ZERO);
9726        new_order.size_remaining = Some(Decimal::from(4));
9727        let mut reports = Vec::new();
9728
9729        for order in [&old_order, &new_order] {
9730            let mut report =
9731                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
9732            report.client_order_id = Some(client_order_id);
9733            reports.push(report);
9734        }
9735
9736        let mut state = OcmState::default();
9737        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
9738        state
9739            .replaced_venue_order_ids
9740            .insert(old_bet_id.to_string());
9741        let (emitter, _receiver) = emitter_with_receiver(account_id);
9742
9743        resolve_pending_modifies_in_state(
9744            &mut reports,
9745            &AHashMap::new(),
9746            &AHashMap::from([(old_bet_id.to_string(), Quantity::from(2))]),
9747            &mut state,
9748            &emitter,
9749            None,
9750        );
9751
9752        let successor = reports
9753            .iter()
9754            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
9755            .unwrap();
9756        // 4 (new leg's own placed size) + 4 (old leg matched) + 2 (old leg voided)
9757        assert_eq!(successor.quantity, Quantity::from("10"));
9758        assert_eq!(successor.filled_qty, Quantity::from("4"));
9759    }
9760
9761    #[rstest]
9762    fn test_replace_fold_of_voided_only_leg_keeps_successor_accepted() {
9763        let account_id = AccountId::from("BETFAIR-001");
9764        let client_order_id = ClientOrderId::from("O-REPLACE-VOIDED-ONLY-LEG");
9765        let strategy_id = StrategyId::from("S-001");
9766        let old_bet_id = "old-bet";
9767        let new_bet_id = "new-bet";
9768        let mut old_order = make_summary(
9769            old_bet_id,
9770            "1.100",
9771            12345,
9772            Decimal::ZERO,
9773            BetfairOrderStatus::ExecutionComplete,
9774            "2026-08-25T00:00:00Z",
9775        );
9776        old_order.size_matched = Some(Decimal::ZERO);
9777        old_order.size_voided = Some(Decimal::from(2));
9778        old_order.size_remaining = Some(Decimal::ZERO);
9779        old_order.size_cancelled = Some(Decimal::from(8));
9780        let mut new_order = make_summary(
9781            new_bet_id,
9782            "1.100",
9783            12345,
9784            Decimal::ZERO,
9785            BetfairOrderStatus::Executable,
9786            "2026-08-25T00:01:00Z",
9787        );
9788        new_order.price_size.size = Decimal::from(8);
9789        new_order.size_matched = Some(Decimal::ZERO);
9790        new_order.size_remaining = Some(Decimal::from(8));
9791        let mut reports = Vec::new();
9792
9793        for order in [&old_order, &new_order] {
9794            let mut report =
9795                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
9796            report.client_order_id = Some(client_order_id);
9797            reports.push(report);
9798        }
9799
9800        let mut state = OcmState::default();
9801        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
9802        state
9803            .replaced_venue_order_ids
9804            .insert(old_bet_id.to_string());
9805        let (emitter, _receiver) = emitter_with_receiver(account_id);
9806
9807        resolve_pending_modifies_in_state(
9808            &mut reports,
9809            &AHashMap::new(),
9810            &AHashMap::from([(old_bet_id.to_string(), Quantity::from(2))]),
9811            &mut state,
9812            &emitter,
9813            None,
9814        );
9815
9816        let successor = reports
9817            .iter()
9818            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
9819            .unwrap();
9820        assert_eq!(successor.order_status, OrderStatus::Accepted);
9821        assert_eq!(successor.quantity, Quantity::from("10"));
9822        assert_eq!(successor.filled_qty, Quantity::from("0"));
9823        assert_eq!(successor.avg_px, None);
9824    }
9825
9826    #[rstest]
9827    fn test_reconciliation_promotion_folds_replaced_leg_voided_quantity() {
9828        let account_id = AccountId::from("BETFAIR-001");
9829        let client_order_id = ClientOrderId::from("O-PROMOTED-VOIDED-LEG");
9830        let strategy_id = StrategyId::from("S-001");
9831        let old_bet_id = "old-bet";
9832        let new_bet_id = "new-bet";
9833        // Original size 10: the old bet matched 4 and had 2 voided before the replace
9834        let mut old_order = make_summary(
9835            old_bet_id,
9836            "1.100",
9837            12345,
9838            Decimal::ZERO,
9839            BetfairOrderStatus::ExecutionComplete,
9840            "2026-08-25T00:00:00Z",
9841        );
9842        old_order.size_matched = Some(Decimal::from(4));
9843        old_order.size_voided = Some(Decimal::from(2));
9844        old_order.size_remaining = Some(Decimal::ZERO);
9845        old_order.size_cancelled = Some(Decimal::from(4));
9846        let mut new_order = make_summary(
9847            new_bet_id,
9848            "1.100",
9849            12345,
9850            Decimal::ZERO,
9851            BetfairOrderStatus::ExecutionComplete,
9852            "2026-08-25T00:01:00Z",
9853        );
9854        new_order.price_size.size = Decimal::from(4);
9855        new_order.size_matched = Some(Decimal::ZERO);
9856        new_order.size_remaining = Some(Decimal::ZERO);
9857        new_order.size_cancelled = Some(Decimal::from(4));
9858        let mut reports = Vec::new();
9859
9860        for order in [&old_order, &new_order] {
9861            let mut report =
9862                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
9863            report.client_order_id = Some(client_order_id);
9864            reports.push(report);
9865        }
9866
9867        // The replace is still pending, so this pass is what marks the old bet replaced
9868        let mut state = OcmState::default();
9869        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(old_bet_id));
9870        state.register_pending_replace(
9871            client_order_id,
9872            old_bet_id.to_string(),
9873            Some(Quantity::from(10)),
9874        );
9875        let (emitter, _receiver) = emitter_with_receiver(account_id);
9876
9877        let updates = resolve_pending_modifies_in_state(
9878            &mut reports,
9879            &AHashMap::new(),
9880            &AHashMap::from([(old_bet_id.to_string(), Quantity::from(2))]),
9881            &mut state,
9882            &emitter,
9883            None,
9884        );
9885
9886        let [OrderEventAny::Updated(updated)] = updates.as_slice() else {
9887            panic!("expected one OrderUpdated, was {updates:?}");
9888        };
9889
9890        assert_eq!(updated.venue_order_id, Some(VenueOrderId::from(new_bet_id)));
9891        assert_eq!(updated.quantity, Quantity::from("10"));
9892        assert!(state.replaced_venue_order_ids.contains(old_bet_id));
9893        let [successor] = reports.as_slice() else {
9894            panic!("expected the successor's report only, was {reports:?}");
9895        };
9896
9897        // 4 (successor's own size) + 4 (old bet matched) + 2 (old bet voided)
9898        assert_eq!(successor.venue_order_id, VenueOrderId::from(new_bet_id));
9899        assert_eq!(successor.quantity, Quantity::from("10"));
9900        assert_eq!(successor.filled_qty, Quantity::from("4"));
9901        assert_eq!(successor.order_status, OrderStatus::Canceled);
9902    }
9903
9904    #[rstest]
9905    #[case::executable_successor(
9906        BetfairOrderStatus::Executable,
9907        1,
9908        1,
9909        OrderStatus::PartiallyFilled
9910    )]
9911    #[case::closed_successor(BetfairOrderStatus::ExecutionComplete, 0, 2, OrderStatus::Canceled)]
9912    fn test_ambiguous_replace_resolution_keeps_replaced_leg_voided_fold(
9913        #[case] successor_status: BetfairOrderStatus,
9914        #[case] expected_rejections: usize,
9915        #[case] expected_reports: usize,
9916        #[case] expected_status: OrderStatus,
9917    ) {
9918        let account_id = AccountId::from("BETFAIR-001");
9919        let client_order_id = ClientOrderId::from("O-AMBIGUOUS-AFTER-VOIDED-LEG");
9920        let strategy_id = StrategyId::from("S-001");
9921        let old_bet_id = "old-bet";
9922        let new_bet_id = "new-bet";
9923        // Original size 10: the replaced leg matched 4 and had 2 voided
9924        let mut old_order = make_summary(
9925            old_bet_id,
9926            "1.100",
9927            12345,
9928            Decimal::ZERO,
9929            BetfairOrderStatus::ExecutionComplete,
9930            "2026-08-25T00:00:00Z",
9931        );
9932        old_order.size_matched = Some(Decimal::from(4));
9933        old_order.size_voided = Some(Decimal::from(2));
9934        old_order.size_remaining = Some(Decimal::ZERO);
9935        old_order.size_cancelled = Some(Decimal::from(4));
9936        let mut new_order = make_summary(
9937            new_bet_id,
9938            "1.100",
9939            12345,
9940            Decimal::ZERO,
9941            successor_status,
9942            "2026-08-25T00:01:00Z",
9943        );
9944        new_order.price_size.size = Decimal::from(4);
9945        new_order.size_matched = Some(Decimal::ZERO);
9946
9947        if successor_status == BetfairOrderStatus::Executable {
9948            new_order.size_remaining = Some(Decimal::from(4));
9949        } else {
9950            new_order.size_remaining = Some(Decimal::ZERO);
9951            new_order.size_cancelled = Some(Decimal::from(4));
9952        }
9953
9954        let mut reports = Vec::new();
9955
9956        for order in [&old_order, &new_order] {
9957            let mut report =
9958                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
9959            report.client_order_id = Some(client_order_id);
9960            reports.push(report);
9961        }
9962
9963        // A second replace of the successor went ambiguous, and no further bet is listed
9964        let mut state = OcmState::default();
9965        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
9966        state
9967            .replaced_venue_order_ids
9968            .insert(old_bet_id.to_string());
9969        state.register_pending_replace(
9970            client_order_id,
9971            new_bet_id.to_string(),
9972            Some(Quantity::from(10)),
9973        );
9974        state.mark_pending_replace_ambiguous(client_order_id, new_bet_id);
9975        let (emitter, _receiver) = emitter_with_receiver(account_id);
9976
9977        let updates = resolve_pending_modifies_in_state(
9978            &mut reports,
9979            &AHashMap::new(),
9980            &AHashMap::from([(old_bet_id.to_string(), Quantity::from(2))]),
9981            &mut state,
9982            &emitter,
9983            None,
9984        );
9985
9986        let rejections = updates
9987            .iter()
9988            .filter(|update| matches!(update, OrderEventAny::ModifyRejected(_)))
9989            .count();
9990        assert_eq!(updates.len(), expected_rejections);
9991        assert_eq!(rejections, expected_rejections);
9992        assert!(!state.pending_replace_awaits_reconciliation(&client_order_id, new_bet_id));
9993        assert_eq!(reports.len(), expected_reports);
9994        let successor = reports
9995            .iter()
9996            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
9997            .unwrap();
9998        // 4 (successor's own size) + 4 (replaced leg matched) + 2 (replaced leg voided)
9999        assert_eq!(successor.quantity, Quantity::from("10"));
10000        assert_eq!(successor.filled_qty, Quantity::from("4"));
10001        assert_eq!(successor.order_status, expected_status);
10002    }
10003
10004    #[rstest]
10005    fn test_reconciliation_reduction_includes_replaced_leg_voided_quantity() {
10006        let account_id = AccountId::from("BETFAIR-001");
10007        let client_order_id = ClientOrderId::from("O-REDUCED-VOIDED-REPLACEMENT");
10008        let strategy_id = StrategyId::from("S-001");
10009        let old_bet_id = "old-bet";
10010        let new_bet_id = "new-bet";
10011        let mut old_order = make_summary(
10012            old_bet_id,
10013            "1.100",
10014            12345,
10015            Decimal::ZERO,
10016            BetfairOrderStatus::ExecutionComplete,
10017            "2026-08-25T00:00:00Z",
10018        );
10019        old_order.size_matched = Some(Decimal::from(4));
10020        old_order.size_remaining = Some(Decimal::ZERO);
10021        old_order.size_voided = Some(Decimal::from(2));
10022        old_order.size_cancelled = Some(Decimal::from(4));
10023        let mut new_order = make_summary(
10024            new_bet_id,
10025            "1.100",
10026            12345,
10027            Decimal::ZERO,
10028            BetfairOrderStatus::Executable,
10029            "2026-08-25T00:01:00Z",
10030        );
10031        new_order.price_size.size = Decimal::from(4);
10032        new_order.size_matched = Some(Decimal::ZERO);
10033        new_order.size_remaining = Some(Decimal::from(4));
10034        let mut reports = Vec::new();
10035
10036        for order in [&old_order, &new_order] {
10037            let mut report =
10038                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
10039            report.client_order_id = Some(client_order_id);
10040            reports.push(report);
10041        }
10042
10043        let mut state = OcmState::default();
10044        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
10045        state
10046            .replaced_venue_order_ids
10047            .insert(old_bet_id.to_string());
10048        // original(11) sits just above the computed active quantity (10 = 4 new-leg active + 4
10049        // old-leg matched + 2 old-leg voided), clearing the `< original` confirmation boundary
10050        // without the wide, order-data-disconnected margin a much larger original would give.
10051        state.register_pending_reduction(
10052            client_order_id,
10053            new_bet_id.to_string(),
10054            Quantity::from(11),
10055            Quantity::from(8),
10056        );
10057
10058        let (emitter, _receiver) = emitter_with_receiver(account_id);
10059
10060        let updates = resolve_pending_modifies_in_state(
10061            &mut reports,
10062            &AHashMap::from([(new_bet_id.to_string(), Quantity::from(4))]),
10063            &AHashMap::from([(old_bet_id.to_string(), Quantity::from(2))]),
10064            &mut state,
10065            &emitter,
10066            None,
10067        );
10068
10069        // 4 (new leg's own active) + 4 (old leg's matched) + 2 (old leg's voided) = 10, not the
10070        // 8 it would be without the replaced-leg voided quantity.
10071        assert_eq!(state.reduced_quantity(new_bet_id), Some(Quantity::from(10)));
10072        assert_eq!(updates.len(), 1);
10073
10074        let OrderEventAny::Updated(updated) = &updates[0] else {
10075            panic!("expected reduction update");
10076        };
10077        assert_eq!(updated.quantity, Quantity::from(10));
10078        assert_eq!(updated.client_order_id, client_order_id);
10079        assert_eq!(updated.venue_order_id, Some(VenueOrderId::from(new_bet_id)));
10080    }
10081
10082    #[rstest]
10083    #[case::confirmed(true)]
10084    #[case::pending(false)]
10085    fn test_reconciliation_preserves_confirmed_reduction_across_replacement(
10086        #[case] confirmed: bool,
10087    ) {
10088        let account_id = AccountId::from("BETFAIR-001");
10089        let client_order_id = ClientOrderId::from("O-REDUCED-REPLACEMENT");
10090        let strategy_id = StrategyId::from("S-001");
10091        let old_bet_id = "old-bet";
10092        let new_bet_id = "new-bet";
10093        let mut old_order = make_summary(
10094            old_bet_id,
10095            "1.100",
10096            12345,
10097            Decimal::ZERO,
10098            BetfairOrderStatus::ExecutionComplete,
10099            "2026-08-25T00:00:00Z",
10100        );
10101        old_order.size_matched = Some(Decimal::from(4));
10102        old_order.size_remaining = Some(Decimal::ZERO);
10103        old_order.size_cancelled = Some(Decimal::from(6));
10104        old_order.average_price_matched = Some(Decimal::from(3));
10105        let mut new_order = make_summary(
10106            new_bet_id,
10107            "1.100",
10108            12345,
10109            Decimal::ZERO,
10110            BetfairOrderStatus::Executable,
10111            "2026-08-25T00:01:00Z",
10112        );
10113        new_order.price_size.size = Decimal::from(4);
10114        new_order.size_matched = Some(Decimal::ZERO);
10115        new_order.size_remaining = Some(Decimal::from(4));
10116        let mut reports = Vec::new();
10117
10118        for order in [&old_order, &new_order] {
10119            let mut report =
10120                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
10121            report.client_order_id = Some(client_order_id);
10122            reports.push(report);
10123        }
10124
10125        let mut state = OcmState::default();
10126        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
10127        state
10128            .replaced_venue_order_ids
10129            .insert(old_bet_id.to_string());
10130        state.register_pending_reduction(
10131            client_order_id,
10132            new_bet_id.to_string(),
10133            Quantity::from(10),
10134            Quantity::from(8),
10135        );
10136
10137        if confirmed {
10138            assert!(state.complete_pending_reduction(
10139                &client_order_id,
10140                new_bet_id,
10141                Quantity::from(8)
10142            ));
10143        }
10144
10145        let (emitter, _receiver) = emitter_with_receiver(account_id);
10146
10147        let updates = resolve_pending_modifies_in_state(
10148            &mut reports,
10149            &AHashMap::from([(new_bet_id.to_string(), Quantity::from(4))]),
10150            &AHashMap::new(),
10151            &mut state,
10152            &emitter,
10153            None,
10154        );
10155
10156        assert_eq!(state.reduced_quantity(new_bet_id), Some(Quantity::from(8)));
10157
10158        if !confirmed {
10159            assert_eq!(updates.len(), 1);
10160
10161            let OrderEventAny::Updated(updated) = &updates[0] else {
10162                panic!("expected reduction update");
10163            };
10164
10165            assert_eq!(updated.quantity, Quantity::from(8));
10166            assert_eq!(updated.client_order_id, client_order_id);
10167            assert_eq!(updated.venue_order_id, Some(VenueOrderId::from(new_bet_id)));
10168            return;
10169        }
10170
10171        assert!(updates.is_empty());
10172        let successor = reports
10173            .iter()
10174            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
10175            .unwrap();
10176        assert_eq!(successor.quantity, Quantity::from("8"));
10177        assert_eq!(successor.filled_qty, Quantity::from("4"));
10178        assert_eq!(successor.avg_px, Some(Decimal::from(3)));
10179    }
10180
10181    #[rstest]
10182    #[case::confirmed(3, Some(8))]
10183    #[case::racing_fill(4, Some(9))]
10184    #[case::unchanged(5, None)]
10185    fn test_stream_reduction_after_replacement(#[case] active: i64, #[case] expected: Option<i64>) {
10186        let client_order_id = ClientOrderId::from("O-1");
10187        let strategy_id = StrategyId::from("S-001");
10188        let mut state = OcmState::default();
10189        state.restore_order(client_order_id, strategy_id, VenueOrderId::from("old-bet"));
10190        state.register_pending_replace(
10191            client_order_id,
10192            "old-bet".to_string(),
10193            Some(Quantity::from(10)),
10194        );
10195        state
10196            .complete_pending_replace(client_order_id, "old-bet", VenueOrderId::from("new-bet"))
10197            .unwrap();
10198        state
10199            .fill_tracker
10200            .sync_order("old-bet", Decimal::from(5), Decimal::from(3));
10201        state.fill_tracker.sync_voided_qty("old-bet", Decimal::ONE);
10202        state.register_pending_reduction(
10203            client_order_id,
10204            "new-bet".to_string(),
10205            Quantity::from(10),
10206            Quantity::from(8),
10207        );
10208        let fixture: serde_json::Value =
10209            serde_json::from_str(&load_test_json("stream/ocm_harness_partial_fill.json")).unwrap();
10210        let mut order: UnmatchedOrder =
10211            serde_json::from_value(fixture["oc"][0]["orc"][0]["uo"][0].clone()).unwrap();
10212        order.id = "new-bet".to_string();
10213        order.sm = Some(Decimal::ZERO);
10214        order.sr = Some(Decimal::from(active));
10215
10216        let result = BetfairExecutionClient::resolve_pending_reduction_from_stream(
10217            &mut state,
10218            Some((client_order_id, strategy_id)),
10219            &order,
10220        );
10221        let repeated = BetfairExecutionClient::resolve_pending_reduction_from_stream(
10222            &mut state,
10223            Some((client_order_id, strategy_id)),
10224            &order,
10225        );
10226
10227        let expected_quantity =
10228            expected.map(|quantity| parse_betfair_quantity(Decimal::from(quantity)).unwrap());
10229        assert_eq!(
10230            result,
10231            expected_quantity.map(|quantity| (client_order_id, strategy_id, quantity))
10232        );
10233        assert_eq!(state.reduced_quantity("new-bet"), expected_quantity);
10234        assert_eq!(repeated, None);
10235    }
10236
10237    #[rstest]
10238    fn test_query_replaced_leg_folds_skip_ambiguous_owner() {
10239        let suffix = "12345678901234567890123456789012";
10240        let first = ClientOrderId::from(format!("FIRST-{suffix}"));
10241        let second = ClientOrderId::from(format!("SECOND-{suffix}"));
10242        let mut state = OcmState::default();
10243        state.restore_order(
10244            first,
10245            StrategyId::from("S-FIRST"),
10246            VenueOrderId::from("old-bet"),
10247        );
10248        state.restore_order(
10249            second,
10250            StrategyId::from("S-SECOND"),
10251            VenueOrderId::from("new-bet"),
10252        );
10253        state.replaced_venue_order_ids.insert("old-bet".to_string());
10254        let mut old_order = make_summary(
10255            "old-bet",
10256            "1.100",
10257            12345,
10258            Decimal::ZERO,
10259            BetfairOrderStatus::ExecutionComplete,
10260            "2026-08-25T00:00:00Z",
10261        );
10262        old_order.size_matched = Some(Decimal::from(4));
10263        old_order.size_remaining = Some(Decimal::ZERO);
10264        old_order.size_cancelled = Some(Decimal::from(6));
10265        old_order.customer_order_ref = Some(suffix.to_string());
10266        let mut new_order = make_summary(
10267            "new-bet",
10268            "1.100",
10269            12345,
10270            Decimal::ZERO,
10271            BetfairOrderStatus::Executable,
10272            "2026-08-25T00:01:00Z",
10273        );
10274        new_order.size_remaining = Some(Decimal::from(4));
10275        new_order.customer_order_ref = Some(suffix.to_string());
10276        let candidates = vec![old_order, new_order];
10277
10278        let fills = replaced_leg_fills_from_candidates(&candidates, &candidates[1], &state);
10279
10280        assert!(fills.matched.is_zero());
10281    }
10282
10283    #[rstest]
10284    fn test_replaced_leg_fills_unknown_average_degrades_to_none() {
10285        let account_id = AccountId::from("BETFAIR-001");
10286        let mut successor_order = make_summary(
10287            "new-bet",
10288            "1.100",
10289            12345,
10290            Decimal::ZERO,
10291            BetfairOrderStatus::Executable,
10292            "2026-08-25T00:01:00Z",
10293        );
10294        successor_order.price_size.size = Decimal::from(2);
10295        successor_order.size_matched = Some(Decimal::from(2));
10296        successor_order.size_remaining = Some(Decimal::ZERO);
10297        successor_order.average_price_matched = Some(Decimal::from(5));
10298        let mut report =
10299            parse_current_order_report(&successor_order, account_id, UnixNanos::default()).unwrap();
10300
10301        let mut fills = ReplacedLegFills::default();
10302        fills.accumulate(Quantity::from("4"), None);
10303        fills.fold(&mut report, false);
10304
10305        assert_eq!(report.quantity, Quantity::from("6"));
10306        assert_eq!(report.filled_qty, Quantity::from("6"));
10307        assert_eq!(report.avg_px, None);
10308    }
10309
10310    #[rstest]
10311    fn test_query_replaced_leg_folds_keep_confirmed_reduction_quantity() {
10312        let account_id = AccountId::from("BETFAIR-001");
10313        let client_order_id = ClientOrderId::from("O-QUERY-REDUCED-REPLACEMENT");
10314        let strategy_id = StrategyId::from("S-001");
10315        let old_bet_id = "old-bet";
10316        let new_bet_id = "new-bet";
10317        let mut state = OcmState::default();
10318        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
10319        state
10320            .replaced_venue_order_ids
10321            .insert(old_bet_id.to_string());
10322        state.register_pending_reduction(
10323            client_order_id,
10324            new_bet_id.to_string(),
10325            Quantity::from(10),
10326            Quantity::from(8),
10327        );
10328        assert!(state.complete_pending_reduction(&client_order_id, new_bet_id, Quantity::from(8)));
10329
10330        let mut old_order = make_summary(
10331            old_bet_id,
10332            "1.100",
10333            12345,
10334            Decimal::ZERO,
10335            BetfairOrderStatus::ExecutionComplete,
10336            "2026-08-25T00:00:00Z",
10337        );
10338        old_order.size_matched = Some(Decimal::from(4));
10339        old_order.size_remaining = Some(Decimal::ZERO);
10340        old_order.size_cancelled = Some(Decimal::from(6));
10341        old_order.average_price_matched = Some(Decimal::from(3));
10342        old_order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
10343        // The venue can keep the successor's original placed size after a reduction.
10344        let mut new_order = make_summary(
10345            new_bet_id,
10346            "1.100",
10347            12345,
10348            Decimal::ZERO,
10349            BetfairOrderStatus::Executable,
10350            "2026-08-25T00:01:00Z",
10351        );
10352        new_order.price_size.size = Decimal::from(6);
10353        new_order.size_remaining = Some(Decimal::from(4));
10354        new_order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
10355        let candidates = vec![old_order, new_order.clone()];
10356        let mut report =
10357            parse_current_order_report(&new_order, account_id, UnixNanos::default()).unwrap();
10358
10359        let fills = replaced_leg_fills_from_candidates(&candidates, &candidates[1], &state);
10360        assert!(!fills.matched.is_zero());
10361        let reduced_quantity = state.reduced_quantity(&new_order.bet_id);
10362        if !fills.matched.is_zero()
10363            && let Some(quantity) = reduced_quantity
10364        {
10365            report.quantity = quantity;
10366        }
10367
10368        fills.fold(&mut report, reduced_quantity.is_some());
10369
10370        assert_eq!(report.quantity, Quantity::from("8"));
10371        assert_eq!(report.filled_qty, Quantity::from("4"));
10372        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
10373    }
10374
10375    #[rstest]
10376    fn test_query_replaced_leg_folds_include_voided_quantity() {
10377        let account_id = AccountId::from("BETFAIR-001");
10378        let client_order_id = ClientOrderId::from("O-QUERY-VOIDED-REPLACEMENT");
10379        let strategy_id = StrategyId::from("S-001");
10380        let old_bet_id = "old-bet";
10381        let new_bet_id = "new-bet";
10382        let mut state = OcmState::default();
10383        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
10384        state
10385            .replaced_venue_order_ids
10386            .insert(old_bet_id.to_string());
10387
10388        // Original size 10: the replaced leg matched 4 and had 2 voided
10389        let mut old_order = make_summary(
10390            old_bet_id,
10391            "1.100",
10392            12345,
10393            Decimal::ZERO,
10394            BetfairOrderStatus::ExecutionComplete,
10395            "2026-08-25T00:00:00Z",
10396        );
10397        old_order.size_matched = Some(Decimal::from(4));
10398        old_order.size_voided = Some(Decimal::from(2));
10399        old_order.size_remaining = Some(Decimal::ZERO);
10400        old_order.size_cancelled = Some(Decimal::from(4));
10401        old_order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
10402        let mut new_order = make_summary(
10403            new_bet_id,
10404            "1.100",
10405            12345,
10406            Decimal::ZERO,
10407            BetfairOrderStatus::Executable,
10408            "2026-08-25T00:01:00Z",
10409        );
10410        new_order.price_size.size = Decimal::from(4);
10411        new_order.size_remaining = Some(Decimal::from(4));
10412        new_order.customer_order_ref = Some(make_customer_order_ref(client_order_id.as_str()));
10413        let candidates = vec![old_order, new_order.clone()];
10414        let mut report =
10415            parse_current_order_report(&new_order, account_id, UnixNanos::default()).unwrap();
10416
10417        let fills = replaced_leg_fills_from_candidates(&candidates, &candidates[1], &state);
10418        assert_eq!(fills.matched, Quantity::from("4"));
10419        assert_eq!(fills.voided, Quantity::from("2"));
10420
10421        fills.fold(&mut report, false);
10422
10423        assert_eq!(report.quantity, Quantity::from("10"));
10424        assert_eq!(report.filled_qty, Quantity::from("4"));
10425        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
10426    }
10427
10428    #[rstest]
10429    fn test_replaced_leg_fills_promote_accepted_to_partially_filled() {
10430        let account_id = AccountId::from("BETFAIR-001");
10431        let client_order_id = ClientOrderId::from("O-RECOVERY-PRIOR-FILLS");
10432        let strategy_id = StrategyId::from("S-001");
10433        let old_bet_id = "old-bet";
10434        let new_bet_id = "new-bet";
10435        let mut old_order = make_summary(
10436            old_bet_id,
10437            "1.100",
10438            12345,
10439            Decimal::ZERO,
10440            BetfairOrderStatus::ExecutionComplete,
10441            "2026-08-25T00:00:00Z",
10442        );
10443        old_order.size_matched = Some(Decimal::from(4));
10444        old_order.size_remaining = Some(Decimal::ZERO);
10445        old_order.size_cancelled = Some(Decimal::from(6));
10446        old_order.average_price_matched = Some(Decimal::from(3));
10447        let mut new_order = make_summary(
10448            new_bet_id,
10449            "1.100",
10450            12345,
10451            Decimal::ZERO,
10452            BetfairOrderStatus::Executable,
10453            "2026-08-25T00:01:00Z",
10454        );
10455        new_order.price_size.size = Decimal::from(6);
10456        new_order.size_matched = Some(Decimal::ZERO);
10457        new_order.size_remaining = Some(Decimal::from(6));
10458        let mut reports = Vec::new();
10459
10460        for order in [&old_order, &new_order] {
10461            let mut report =
10462                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
10463            report.client_order_id = Some(client_order_id);
10464            reports.push(report);
10465        }
10466
10467        let mut state = OcmState::default();
10468        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(new_bet_id));
10469        state
10470            .replaced_venue_order_ids
10471            .insert(old_bet_id.to_string());
10472        let (emitter, _receiver) = emitter_with_receiver(account_id);
10473
10474        let updates = resolve_pending_modifies_in_state(
10475            &mut reports,
10476            &AHashMap::new(),
10477            &AHashMap::new(),
10478            &mut state,
10479            &emitter,
10480            None,
10481        );
10482
10483        assert!(updates.is_empty());
10484        let successor = reports
10485            .iter()
10486            .find(|report| report.venue_order_id == VenueOrderId::from(new_bet_id))
10487            .unwrap();
10488        assert_eq!(successor.order_status, OrderStatus::PartiallyFilled);
10489        assert_eq!(successor.quantity, Quantity::from("10"));
10490        assert_eq!(successor.filled_qty, Quantity::from("4"));
10491        assert_eq!(successor.avg_px, Some(Decimal::from(3)));
10492    }
10493
10494    #[rstest]
10495    fn test_reconciliation_promotes_replace_folding_prior_fills() {
10496        let account_id = AccountId::from("BETFAIR-001");
10497        let client_order_id = ClientOrderId::from("O-PROMOTED-REPLACE-FILLS");
10498        let strategy_id = StrategyId::from("S-001");
10499        let old_bet_id = "old-bet";
10500        let new_bet_id = "new-bet";
10501        let mut old_order = make_summary(
10502            old_bet_id,
10503            "1.100",
10504            12345,
10505            Decimal::ZERO,
10506            BetfairOrderStatus::ExecutionComplete,
10507            "2026-08-25T00:00:00Z",
10508        );
10509        old_order.size_matched = Some(Decimal::from(4));
10510        old_order.size_remaining = Some(Decimal::ZERO);
10511        old_order.size_cancelled = Some(Decimal::from(6));
10512        old_order.average_price_matched = Some(Decimal::from(3));
10513        let mut new_order = make_summary(
10514            new_bet_id,
10515            "1.100",
10516            12345,
10517            Decimal::ZERO,
10518            BetfairOrderStatus::ExecutionComplete,
10519            "2026-08-25T00:01:00Z",
10520        );
10521        new_order.price_size.size = Decimal::from(6);
10522        new_order.size_matched = Some(Decimal::from(6));
10523        new_order.size_remaining = Some(Decimal::ZERO);
10524        new_order.average_price_matched = Some(Decimal::from(5));
10525        let mut reports = Vec::new();
10526
10527        for order in [&old_order, &new_order] {
10528            let mut report =
10529                parse_current_order_report(order, account_id, UnixNanos::default()).unwrap();
10530            report.client_order_id = Some(client_order_id);
10531            reports.push(report);
10532        }
10533
10534        let mut state = OcmState::default();
10535        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(old_bet_id));
10536        state.register_pending_replace(
10537            client_order_id,
10538            old_bet_id.to_string(),
10539            Some(Quantity::from(10)),
10540        );
10541        let (emitter, _receiver) = emitter_with_receiver(account_id);
10542
10543        let updates = resolve_pending_modifies_in_state(
10544            &mut reports,
10545            &AHashMap::new(),
10546            &AHashMap::new(),
10547            &mut state,
10548            &emitter,
10549            None,
10550        );
10551
10552        assert_eq!(updates.len(), 1);
10553
10554        match updates.into_iter().next().unwrap() {
10555            OrderEventAny::Updated(updated) => {
10556                assert_eq!(updated.client_order_id, client_order_id);
10557                assert_eq!(updated.venue_order_id, Some(VenueOrderId::from(new_bet_id)));
10558                assert_eq!(updated.quantity, Quantity::from("10"));
10559                assert!(updated.reconciliation);
10560            }
10561            other => panic!("expected replacement update, was {other:?}"),
10562        }
10563
10564        assert_eq!(reports.len(), 1);
10565        let successor = &reports[0];
10566        assert_eq!(successor.venue_order_id, VenueOrderId::from(new_bet_id));
10567        assert_eq!(successor.order_status, OrderStatus::Filled);
10568        assert_eq!(successor.quantity, Quantity::from("10"));
10569        assert_eq!(successor.filled_qty, Quantity::from("10"));
10570        assert_eq!(
10571            successor.avg_px,
10572            Some(Decimal::from(42) / Decimal::from(10))
10573        );
10574    }
10575
10576    #[rstest]
10577    fn test_reconciliation_keeps_terminal_report_after_reduction_confirmation() {
10578        let account_id = AccountId::from("BETFAIR-001");
10579        let client_order_id = ClientOrderId::from("O-RECOVERY-REDUCTION-CLOSED");
10580        let strategy_id = StrategyId::from("S-001");
10581        let bet_id = "reduced-bet";
10582        let mut order = make_summary(
10583            bet_id,
10584            "1.100",
10585            12345,
10586            Decimal::ZERO,
10587            BetfairOrderStatus::ExecutionComplete,
10588            "2026-08-25T00:00:00Z",
10589        );
10590        order.size_matched = Some(Decimal::from(4));
10591        order.size_remaining = Some(Decimal::ZERO);
10592        order.size_cancelled = Some(Decimal::from(6));
10593        order.average_price_matched = Some(Decimal::from(2));
10594        let mut report =
10595            parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
10596        report.client_order_id = Some(client_order_id);
10597        let mut reports = vec![report];
10598        let mut state = OcmState::default();
10599        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(bet_id));
10600        state.register_pending_reduction(
10601            client_order_id,
10602            bet_id.to_string(),
10603            Quantity::from(10),
10604            Quantity::from(4),
10605        );
10606        let active_quantities = AHashMap::from([(bet_id.to_string(), Quantity::from(4))]);
10607        let (emitter, _receiver) = emitter_with_receiver(account_id);
10608
10609        let updates = resolve_pending_modifies_in_state(
10610            &mut reports,
10611            &active_quantities,
10612            &AHashMap::new(),
10613            &mut state,
10614            &emitter,
10615            None,
10616        );
10617
10618        assert!(updates.is_empty());
10619        assert_eq!(reports.len(), 1);
10620        assert_eq!(reports[0].client_order_id, Some(client_order_id));
10621        assert_eq!(reports[0].venue_order_id, VenueOrderId::from(bet_id));
10622        assert_eq!(reports[0].order_status, OrderStatus::Canceled);
10623        assert_eq!(reports[0].quantity, Quantity::from(4));
10624        assert_eq!(state.reduced_quantity(bet_id), Some(Quantity::from(4)));
10625        assert!(state.is_retained_terminal_order(&client_order_id));
10626    }
10627
10628    #[rstest]
10629    fn test_terminal_reconciliation_discards_non_actionable_reduction() {
10630        let account_id = AccountId::from("BETFAIR-001");
10631        let client_order_id = ClientOrderId::from("O-RECOVERY-REDUCTION-LAPSED");
10632        let strategy_id = StrategyId::from("S-001");
10633        let bet_id = "lapsed-bet";
10634        let mut order = make_summary(
10635            bet_id,
10636            "1.100",
10637            12345,
10638            Decimal::ZERO,
10639            BetfairOrderStatus::ExecutionComplete,
10640            "2026-08-25T00:00:00Z",
10641        );
10642        order.size_matched = Some(Decimal::from(2));
10643        order.size_remaining = Some(Decimal::ZERO);
10644        order.size_cancelled = Some(Decimal::from(8));
10645        order.average_price_matched = Some(Decimal::from(2));
10646        let mut report =
10647            parse_current_order_report(&order, account_id, UnixNanos::default()).unwrap();
10648        report.client_order_id = Some(client_order_id);
10649        let mut reports = vec![report];
10650        let mut state = OcmState::default();
10651        state.restore_order(client_order_id, strategy_id, VenueOrderId::from(bet_id));
10652        state.register_pending_reduction(
10653            client_order_id,
10654            bet_id.to_string(),
10655            Quantity::from(10),
10656            Quantity::from(4),
10657        );
10658        let active_quantities = AHashMap::from([(bet_id.to_string(), Quantity::from(2))]);
10659        let (emitter, _receiver) = emitter_with_receiver(account_id);
10660
10661        let updates = resolve_pending_modifies_in_state(
10662            &mut reports,
10663            &active_quantities,
10664            &AHashMap::new(),
10665            &mut state,
10666            &emitter,
10667            None,
10668        );
10669        let late_rest =
10670            state.complete_pending_reduction(&client_order_id, bet_id, Quantity::from(4));
10671
10672        assert!(updates.is_empty());
10673        assert_eq!(reports.len(), 1);
10674        assert_eq!(reports[0].client_order_id, Some(client_order_id));
10675        assert_eq!(reports[0].venue_order_id, VenueOrderId::from(bet_id));
10676        assert_eq!(reports[0].order_status, OrderStatus::Canceled);
10677        assert_eq!(reports[0].quantity, Quantity::from(10));
10678        assert_eq!(state.reduced_quantity(bet_id), None);
10679        assert!(state.is_retained_terminal_order(&client_order_id));
10680        assert!(!late_rest);
10681    }
10682
10683    #[rstest]
10684    fn test_reconciliation_gate_stale_commit_does_not_publish() {
10685        let gate = ReconciliationGate::default();
10686        let stale = gate.halt();
10687        let current = gate.halt();
10688        let published = AtomicBool::new(false);
10689
10690        let committed = gate
10691            .commit(stale, || {
10692                published.store(true, Ordering::Release);
10693                Ok(())
10694            })
10695            .unwrap();
10696
10697        assert!(!committed);
10698        assert!(!published.load(Ordering::Acquire));
10699        assert_eq!(gate.current_generation(), current);
10700    }
10701
10702    #[rstest]
10703    fn test_ocm_state_persists_across_reconnections() {
10704        let ocm_state = Arc::new(Mutex::new(OcmState::default()));
10705
10706        // Populate state before "reconnect"
10707        {
10708            let mut state = ocm_state.lock();
10709            let orders = vec![
10710                OrderSyncEntry {
10711                    bet_id: "bet1".to_string(),
10712                    venue_order_ids: vec!["bet1".to_string()],
10713                    client_order_id: ClientOrderId::from("O-001"),
10714                    strategy_id: StrategyId::from("S-001"),
10715                    filled_qty: Decimal::new(10, 0),
10716                    avg_px: Decimal::new(25, 1),
10717                    is_closed: false,
10718                    trade_ids: Vec::new(),
10719                },
10720                OrderSyncEntry {
10721                    bet_id: "bet2".to_string(),
10722                    venue_order_ids: vec!["bet2".to_string()],
10723                    client_order_id: ClientOrderId::from("O-002"),
10724                    strategy_id: StrategyId::from("S-001"),
10725                    filled_qty: Decimal::ZERO,
10726                    avg_px: Decimal::ZERO,
10727                    is_closed: true,
10728                    trade_ids: Vec::new(),
10729                },
10730            ];
10731            state.sync_from_orders(&orders);
10732        }
10733
10734        // Verify state survives (simulates reconnection where Arc<Mutex<OcmState>> persists)
10735        let state = ocm_state.lock();
10736        let rfo = make_customer_order_ref("O-001");
10737        assert_eq!(
10738            state.resolve_client_order_id(Some(&rfo)),
10739            Some(ClientOrderId::from("O-001")),
10740        );
10741        assert!(state.terminal_orders.contains("bet2"));
10742        assert!(!state.terminal_orders.contains("bet1"));
10743    }
10744
10745    #[rstest]
10746    fn test_ocm_state_sync_from_orders_populates_fill_tracker() {
10747        let mut state = OcmState::default();
10748
10749        let orders = vec![OrderSyncEntry {
10750            bet_id: "bet_fill".to_string(),
10751            venue_order_ids: vec!["bet_fill".to_string()],
10752            client_order_id: ClientOrderId::from("O-FILL-001"),
10753            strategy_id: StrategyId::from("S-001"),
10754            filled_qty: Decimal::new(15, 0),
10755            avg_px: Decimal::new(30, 1),
10756            is_closed: false,
10757            trade_ids: Vec::new(),
10758        }];
10759
10760        state.sync_from_orders(&orders);
10761
10762        // Fill tracker should be pre-populated so that a stream update with
10763        // sm=15 does NOT produce a duplicate fill
10764        let uo = crate::stream::messages::UnmatchedOrder {
10765            id: "bet_fill".to_string(),
10766            p: Decimal::new(30, 1),
10767            s: Decimal::new(20, 0),
10768            side: crate::common::enums::StreamingSide::Back,
10769            status: crate::common::enums::StreamingOrderStatus::Executable,
10770            pt: Some(crate::common::enums::StreamingPersistenceType::Lapse),
10771            ot: crate::common::enums::StreamingOrderType::Limit,
10772            pd: 1617863365000,
10773            bsp: None,
10774            rfo: Some("O-FILL-001".to_string()),
10775            rfs: None,
10776            rc: None,
10777            rac: None,
10778            md: None,
10779            cd: None,
10780            ld: None,
10781            avp: Some(Decimal::new(30, 1)),
10782            sm: Some(Decimal::new(15, 0)),
10783            sr: None,
10784            sl: None,
10785            sc: None,
10786            sv: None,
10787            lsrc: None,
10788        };
10789
10790        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
10791        let result = state.fill_tracker.maybe_fill_report(
10792            &uo,
10793            uo.s,
10794            instrument_id,
10795            AccountId::from("BETFAIR-001"),
10796            Currency::from("GBP"),
10797            UnixNanos::default(),
10798            UnixNanos::default(),
10799        );
10800
10801        assert!(
10802            result.is_none(),
10803            "synced fill should prevent duplicate fill report"
10804        );
10805    }
10806
10807    #[rstest]
10808    fn test_terminal_stream_state_prevents_rest_fill_replay() {
10809        let data = load_test_json("rest/list_current_orders_execution_complete.json");
10810        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
10811        let mut state = OcmState::default();
10812        let account_id = AccountId::from("BETFAIR-001");
10813        let currency = Currency::GBP();
10814
10815        let mut stream_fill_count = 0;
10816
10817        for order in &response.current_orders {
10818            let size_matched = order.size_matched.unwrap_or(Decimal::ZERO);
10819
10820            if state
10821                .fill_tracker
10822                .advance_cumulative_fill(
10823                    &order.bet_id,
10824                    size_matched,
10825                    order.average_price_matched,
10826                    order.price_size.price,
10827                )
10828                .is_some()
10829            {
10830                stream_fill_count += 1;
10831            }
10832            state.mark_terminal_order(order.bet_id.clone());
10833        }
10834
10835        let customer_order_refs = state.customer_order_refs.clone();
10836        let replay = build_incremental_fill_reports(
10837            &response.current_orders,
10838            &mut state.fill_tracker,
10839            &customer_order_refs,
10840            account_id,
10841            currency,
10842            UnixNanos::default(),
10843        )
10844        .unwrap();
10845
10846        assert_eq!(stream_fill_count, 2);
10847        assert!(replay.is_empty());
10848    }
10849
10850    #[rstest]
10851    fn test_terminal_marker_without_fill_state_allows_rest_recovery() {
10852        let data = load_test_json("rest/list_current_orders_execution_complete.json");
10853        let response: CurrentOrderSummaryReport = parse_jsonrpc(&data);
10854        let mut state = OcmState::default();
10855        for order in &response.current_orders {
10856            state.mark_terminal_order(order.bet_id.clone());
10857        }
10858
10859        let customer_order_refs = state.customer_order_refs.clone();
10860        let reports = build_incremental_fill_reports(
10861            &response.current_orders,
10862            &mut state.fill_tracker,
10863            &customer_order_refs,
10864            AccountId::from("BETFAIR-001"),
10865            Currency::GBP(),
10866            UnixNanos::default(),
10867        )
10868        .unwrap();
10869
10870        assert!(!reports.is_empty());
10871    }
10872
10873    #[rstest]
10874    fn test_match_time_recovery_keeps_fill_for_order_placed_before_lookback() {
10875        let mut order = make_summary(
10876            "bet_pre_lookback",
10877            "1.100",
10878            12345,
10879            Decimal::ZERO,
10880            BetfairOrderStatus::ExecutionComplete,
10881            "2020-01-01T00:00:00Z",
10882        );
10883        order.matched_date = Some("2026-08-24T00:00:00Z".to_string());
10884        order.size_matched = Some(Decimal::new(10, 0));
10885        order.size_remaining = Some(Decimal::ZERO);
10886        order.average_price_matched = Some(Decimal::new(25, 1));
10887        let mut fill_tracker = FillTracker::default();
10888
10889        let reports = build_incremental_fill_reports(
10890            &[order],
10891            &mut fill_tracker,
10892            &AHashMap::new(),
10893            AccountId::from("BETFAIR-001"),
10894            Currency::GBP(),
10895            UnixNanos::default(),
10896        )
10897        .unwrap();
10898
10899        assert_eq!(reports.len(), 1);
10900        assert_eq!(
10901            reports[0].venue_order_id,
10902            VenueOrderId::from("bet_pre_lookback")
10903        );
10904        assert_eq!(reports[0].last_qty, Quantity::from("10.00"));
10905    }
10906
10907    #[rstest]
10908    fn test_rest_fill_omits_ambiguous_customer_order_ref() {
10909        let reference = "12345678901234567890123456789012";
10910        let first = ClientOrderId::from(format!("FIRST-{reference}"));
10911        let second = ClientOrderId::from(format!("SECOND-{reference}"));
10912        let strategy_id = StrategyId::from("S-001");
10913        let mut state = OcmState::default();
10914        state.restore_order(first, strategy_id, VenueOrderId::from("bet-first"));
10915        state.restore_order(second, strategy_id, VenueOrderId::from("bet-second"));
10916
10917        let mut order = make_summary(
10918            "bet-ambiguous",
10919            "1.100",
10920            12345,
10921            Decimal::ZERO,
10922            BetfairOrderStatus::ExecutionComplete,
10923            "2026-08-25T00:00:00Z",
10924        );
10925        order.customer_order_ref = Some(reference.to_string());
10926        order.size_matched = Some(Decimal::new(10, 0));
10927        order.size_remaining = Some(Decimal::ZERO);
10928        order.average_price_matched = Some(Decimal::new(25, 1));
10929        let customer_order_refs = state.customer_order_refs.clone();
10930
10931        let reports = build_incremental_fill_reports(
10932            &[order],
10933            &mut state.fill_tracker,
10934            &customer_order_refs,
10935            AccountId::from("BETFAIR-001"),
10936            Currency::GBP(),
10937            UnixNanos::default(),
10938        )
10939        .unwrap();
10940
10941        assert_eq!(reports.len(), 1);
10942        assert_eq!(reports[0].client_order_id, None);
10943        assert_eq!(
10944            reports[0].venue_order_id,
10945            VenueOrderId::from("bet-ambiguous"),
10946        );
10947    }
10948
10949    #[rstest]
10950    fn test_first_seen_rest_void_does_not_suppress_later_matched_quantity() {
10951        let mut order = make_summary(
10952            "bet_void_then_fill",
10953            "1.100",
10954            12345,
10955            Decimal::ZERO,
10956            BetfairOrderStatus::ExecutionComplete,
10957            "2026-04-18T10:00:00Z",
10958        );
10959        order.price_size.size = Decimal::new(60, 0);
10960        order.size_matched = Some(Decimal::ZERO);
10961        order.size_remaining = Some(Decimal::ZERO);
10962        order.size_voided = Some(Decimal::new(50, 0));
10963        let mut state = OcmState::default();
10964        let account_id = AccountId::from("BETFAIR-001");
10965
10966        let customer_order_refs = state.customer_order_refs.clone();
10967        let first = build_incremental_fill_reports(
10968            &[order.clone()],
10969            &mut state.fill_tracker,
10970            &customer_order_refs,
10971            account_id,
10972            Currency::GBP(),
10973            UnixNanos::default(),
10974        )
10975        .unwrap();
10976        order.size_matched = Some(Decimal::new(10, 0));
10977        order.average_price_matched = Some(Decimal::new(25, 1));
10978        let later = build_incremental_fill_reports(
10979            &[order],
10980            &mut state.fill_tracker,
10981            &customer_order_refs,
10982            account_id,
10983            Currency::GBP(),
10984            UnixNanos::default(),
10985        )
10986        .unwrap();
10987
10988        assert!(first.is_empty());
10989        assert_eq!(later.len(), 1);
10990        assert_eq!(later[0].last_qty, Quantity::from("10.00"));
10991        assert_eq!(later[0].last_px.as_decimal(), Decimal::new(25, 1));
10992    }
10993
10994    #[rstest]
10995    fn test_fill_report_does_not_commit_price_restatement() {
10996        let mut order = make_summary(
10997            "bet-report-restate",
10998            "1.100",
10999            12345,
11000            Decimal::ZERO,
11001            BetfairOrderStatus::ExecutionComplete,
11002            "2026-04-18T10:00:00Z",
11003        );
11004        order.price_size.size = Decimal::from(2);
11005        order.size_matched = Some(Decimal::from(2));
11006        order.size_remaining = Some(Decimal::ZERO);
11007        order.average_price_matched = Some(Decimal::from(12));
11008        let mut state = OcmState::default();
11009        let customer_order_refs = state.customer_order_refs.clone();
11010        let account_id = AccountId::from("BETFAIR-001");
11011        let first = build_incremental_fill_reports(
11012            &[order.clone()],
11013            &mut state.fill_tracker,
11014            &customer_order_refs,
11015            account_id,
11016            Currency::GBP(),
11017            UnixNanos::default(),
11018        )
11019        .unwrap();
11020        order.average_price_matched = Some(Decimal::new(947, 2));
11021        let restated = build_incremental_fill_reports(
11022            &[order],
11023            &mut state.fill_tracker,
11024            &customer_order_refs,
11025            account_id,
11026            Currency::GBP(),
11027            UnixNanos::default(),
11028        )
11029        .unwrap();
11030        let stream = state.fill_tracker.advance_cumulative_fill(
11031            "bet-report-restate",
11032            Decimal::from(2),
11033            Some(Decimal::new(947, 2)),
11034            Decimal::from(12),
11035        );
11036
11037        assert_eq!(first.len(), 1);
11038        assert!(restated.is_empty());
11039        assert_eq!(stream.unwrap().2.as_decimal(), Decimal::new(947, 2));
11040    }
11041
11042    #[rstest]
11043    fn test_rest_void_with_applied_fill_lots_remains_unseen_until_applied() {
11044        let bet_id = "bet_rest_void_pending";
11045        let mut state = OcmState::default();
11046        let initial = state.fill_tracker.advance_cumulative_fill(
11047            bet_id,
11048            Decimal::new(50, 0),
11049            Some(Decimal::new(20, 1)),
11050            Decimal::new(20, 1),
11051        );
11052        let mut order = make_summary(
11053            bet_id,
11054            "1.100",
11055            12345,
11056            Decimal::ZERO,
11057            BetfairOrderStatus::ExecutionComplete,
11058            "2026-04-18T10:00:00Z",
11059        );
11060        order.price_size.size = Decimal::new(70, 0);
11061        order.size_matched = Some(Decimal::new(50, 0));
11062        order.size_remaining = Some(Decimal::ZERO);
11063        order.size_voided = Some(Decimal::new(20, 0));
11064        order.average_price_matched = Some(Decimal::new(20, 1));
11065
11066        let customer_order_refs = state.customer_order_refs.clone();
11067        let reports = build_incremental_fill_reports(
11068            &[order],
11069            &mut state.fill_tracker,
11070            &customer_order_refs,
11071            AccountId::from("BETFAIR-001"),
11072            Currency::GBP(),
11073            UnixNanos::default(),
11074        )
11075        .unwrap();
11076        let update = crate::stream::messages::UnmatchedOrder {
11077            id: bet_id.to_string(),
11078            p: Decimal::new(20, 1),
11079            s: Decimal::new(70, 0),
11080            side: crate::common::enums::StreamingSide::Back,
11081            status: crate::common::enums::StreamingOrderStatus::ExecutionComplete,
11082            pt: Some(crate::common::enums::StreamingPersistenceType::Lapse),
11083            ot: crate::common::enums::StreamingOrderType::Limit,
11084            pd: 1617863365000,
11085            bsp: None,
11086            rfo: None,
11087            rfs: None,
11088            rc: None,
11089            rac: None,
11090            md: None,
11091            cd: None,
11092            ld: None,
11093            avp: Some(Decimal::new(20, 1)),
11094            sm: Some(Decimal::new(50, 0)),
11095            sr: Some(Decimal::ZERO),
11096            sl: None,
11097            sc: None,
11098            sv: Some(Decimal::new(20, 0)),
11099            lsrc: None,
11100        };
11101
11102        assert!(initial.is_some());
11103        assert_eq!(reports.len(), 1);
11104        assert_eq!(reports[0].last_qty, Quantity::from("20.00"));
11105        assert!(state.fill_tracker.has_unseen_fill_void(&update));
11106    }
11107
11108    #[rstest]
11109    fn test_ocm_state_sync_seeds_published_trade_ids_from_cache() {
11110        use crate::stream::parse::make_trade_id;
11111
11112        let mut state = OcmState::default();
11113
11114        // Seeded filled_qty < sm: cumulative-size gate would pass, so only
11115        // the trade-ids seeded by sync_from_orders can block the fill
11116        let uo_for_id = crate::stream::messages::UnmatchedOrder {
11117            id: "bet_seed".to_string(),
11118            p: Decimal::new(25, 1),
11119            s: Decimal::new(20, 0),
11120            side: crate::common::enums::StreamingSide::Back,
11121            status: crate::common::enums::StreamingOrderStatus::Executable,
11122            pt: Some(crate::common::enums::StreamingPersistenceType::Lapse),
11123            ot: crate::common::enums::StreamingOrderType::Limit,
11124            pd: 1617863365000,
11125            bsp: None,
11126            rfo: Some("O-SEED-001".to_string()),
11127            rfs: None,
11128            rc: None,
11129            rac: None,
11130            md: None,
11131            cd: None,
11132            ld: None,
11133            avp: Some(Decimal::new(25, 1)),
11134            sm: Some(Decimal::new(8, 0)),
11135            sr: None,
11136            sl: None,
11137            sc: None,
11138            sv: None,
11139            lsrc: None,
11140        };
11141        let trade_id = make_trade_id(&uo_for_id).to_string();
11142
11143        let orders = vec![OrderSyncEntry {
11144            bet_id: "bet_seed".to_string(),
11145            venue_order_ids: vec!["bet_seed".to_string()],
11146            client_order_id: ClientOrderId::from("O-SEED-001"),
11147            strategy_id: StrategyId::from("S-001"),
11148            filled_qty: Decimal::new(4, 0),
11149            avg_px: Decimal::new(25, 1),
11150            is_closed: false,
11151            trade_ids: vec![trade_id],
11152        }];
11153
11154        state.sync_from_orders(&orders);
11155
11156        let result = state.fill_tracker.maybe_fill_report(
11157            &uo_for_id,
11158            uo_for_id.s,
11159            InstrumentId::from("1.234567-12345-0.0.BETFAIR"),
11160            AccountId::from("BETFAIR-001"),
11161            Currency::from("GBP"),
11162            UnixNanos::default(),
11163            UnixNanos::default(),
11164        );
11165
11166        assert!(
11167            result.is_none(),
11168            "sync_from_orders must seed trade-ids so a known fill is not re-emitted \
11169             even when the cumulative-size gate would pass",
11170        );
11171    }
11172
11173    #[rstest]
11174    fn test_ocm_state_sync_from_orders_incremental_fill_after_sync() {
11175        let mut state = OcmState::default();
11176
11177        let orders = vec![OrderSyncEntry {
11178            bet_id: "bet_inc".to_string(),
11179            venue_order_ids: vec!["bet_inc".to_string()],
11180            client_order_id: ClientOrderId::from("O-INC-001"),
11181            strategy_id: StrategyId::from("S-001"),
11182            filled_qty: Decimal::new(10, 0),
11183            avg_px: Decimal::new(25, 1),
11184            is_closed: false,
11185            trade_ids: Vec::new(),
11186        }];
11187
11188        state.sync_from_orders(&orders);
11189
11190        // Stream update with sm=18 (8 more than synced 10)
11191        let uo = crate::stream::messages::UnmatchedOrder {
11192            id: "bet_inc".to_string(),
11193            p: Decimal::new(25, 1),
11194            s: Decimal::new(20, 0),
11195            side: crate::common::enums::StreamingSide::Lay,
11196            status: crate::common::enums::StreamingOrderStatus::Executable,
11197            pt: Some(crate::common::enums::StreamingPersistenceType::Persist),
11198            ot: crate::common::enums::StreamingOrderType::Limit,
11199            pd: 1617863365000,
11200            bsp: None,
11201            rfo: Some("O-INC-001".to_string()),
11202            rfs: None,
11203            rc: None,
11204            rac: None,
11205            md: None,
11206            cd: None,
11207            ld: None,
11208            avp: Some(Decimal::new(26, 1)),
11209            sm: Some(Decimal::new(18, 0)),
11210            sr: None,
11211            sl: None,
11212            sc: None,
11213            sv: None,
11214            lsrc: None,
11215        };
11216
11217        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
11218        let result = state.fill_tracker.maybe_fill_report(
11219            &uo,
11220            uo.s,
11221            instrument_id,
11222            AccountId::from("BETFAIR-001"),
11223            Currency::from("GBP"),
11224            UnixNanos::default(),
11225            UnixNanos::default(),
11226        );
11227
11228        let fill = result.expect("should produce incremental fill of 8");
11229        assert_eq!(fill.last_qty, Quantity::from("8.00"));
11230    }
11231
11232    #[rstest]
11233    fn test_ocm_state_sync_from_orders_zero_filled_not_synced() {
11234        let mut state = OcmState::default();
11235
11236        let orders = vec![OrderSyncEntry {
11237            bet_id: "bet_zero".to_string(),
11238            venue_order_ids: vec!["bet_zero".to_string()],
11239            client_order_id: ClientOrderId::from("O-ZERO-001"),
11240            strategy_id: StrategyId::from("S-001"),
11241            filled_qty: Decimal::ZERO,
11242            avg_px: Decimal::ZERO,
11243            is_closed: false,
11244            trade_ids: Vec::new(),
11245        }];
11246
11247        state.sync_from_orders(&orders);
11248
11249        // RFO should still be registered even if no fills
11250        let rfo = make_customer_order_ref("O-ZERO-001");
11251        assert!(state.resolve_client_order_id(Some(&rfo)).is_some());
11252
11253        // A stream update with sm=5 should produce a fill (not blocked by sync)
11254        let uo = crate::stream::messages::UnmatchedOrder {
11255            id: "bet_zero".to_string(),
11256            p: Decimal::new(30, 1),
11257            s: Decimal::new(10, 0),
11258            side: crate::common::enums::StreamingSide::Back,
11259            status: crate::common::enums::StreamingOrderStatus::Executable,
11260            pt: Some(crate::common::enums::StreamingPersistenceType::Lapse),
11261            ot: crate::common::enums::StreamingOrderType::Limit,
11262            pd: 1617863365000,
11263            bsp: None,
11264            rfo: None,
11265            rfs: None,
11266            rc: None,
11267            rac: None,
11268            md: None,
11269            cd: None,
11270            ld: None,
11271            avp: Some(Decimal::new(30, 1)),
11272            sm: Some(Decimal::new(5, 0)),
11273            sr: None,
11274            sl: None,
11275            sc: None,
11276            sv: None,
11277            lsrc: None,
11278        };
11279        let instrument_id = InstrumentId::from("1.234567-12345-0.0.BETFAIR");
11280        let result = state.fill_tracker.maybe_fill_report(
11281            &uo,
11282            uo.s,
11283            instrument_id,
11284            AccountId::from("BETFAIR-001"),
11285            Currency::from("GBP"),
11286            UnixNanos::default(),
11287            UnixNanos::default(),
11288        );
11289        assert!(
11290            result.is_some(),
11291            "zero-filled order should not block new fills"
11292        );
11293    }
11294
11295    #[rstest]
11296    fn test_ocm_state_sync_multiple_open_and_closed() {
11297        let mut state = OcmState::default();
11298
11299        let orders = vec![
11300            OrderSyncEntry {
11301                bet_id: "bet_a".to_string(),
11302                venue_order_ids: vec!["bet_a".to_string()],
11303                client_order_id: ClientOrderId::from("O-A"),
11304                strategy_id: StrategyId::from("S-001"),
11305                filled_qty: Decimal::new(5, 0),
11306                avg_px: Decimal::new(20, 1),
11307                is_closed: false,
11308                trade_ids: Vec::new(),
11309            },
11310            OrderSyncEntry {
11311                bet_id: "bet_b".to_string(),
11312                venue_order_ids: vec!["bet_b".to_string()],
11313                client_order_id: ClientOrderId::from("O-B"),
11314                strategy_id: StrategyId::from("S-001"),
11315                filled_qty: Decimal::ZERO,
11316                avg_px: Decimal::ZERO,
11317                is_closed: true,
11318                trade_ids: Vec::new(),
11319            },
11320            OrderSyncEntry {
11321                bet_id: "bet_c".to_string(),
11322                venue_order_ids: vec!["bet_c".to_string()],
11323                client_order_id: ClientOrderId::from("O-C"),
11324                strategy_id: StrategyId::from("S-001"),
11325                filled_qty: Decimal::new(100, 0),
11326                avg_px: Decimal::new(15, 1),
11327                is_closed: true,
11328                trade_ids: Vec::new(),
11329            },
11330            OrderSyncEntry {
11331                bet_id: "bet_d".to_string(),
11332                venue_order_ids: vec!["bet_d".to_string()],
11333                client_order_id: ClientOrderId::from("O-D"),
11334                strategy_id: StrategyId::from("S-001"),
11335                filled_qty: Decimal::ZERO,
11336                avg_px: Decimal::ZERO,
11337                is_closed: false,
11338                trade_ids: Vec::new(),
11339            },
11340        ];
11341
11342        state.sync_from_orders(&orders);
11343
11344        // Open orders have RFO registered
11345        assert!(
11346            state
11347                .resolve_client_order_id(Some(&make_customer_order_ref("O-A")))
11348                .is_some()
11349        );
11350        assert!(
11351            state
11352                .resolve_client_order_id(Some(&make_customer_order_ref("O-D")))
11353                .is_some()
11354        );
11355
11356        // Closed orders are terminal
11357        assert!(state.terminal_orders.contains("bet_b"));
11358        assert!(state.terminal_orders.contains("bet_c"));
11359        assert!(!state.terminal_orders.contains("bet_a"));
11360        assert!(!state.terminal_orders.contains("bet_d"));
11361
11362        assert_eq!(
11363            state.resolve_client_order_id(Some(&make_customer_order_ref("O-B"))),
11364            Some(ClientOrderId::from("O-B")),
11365        );
11366    }
11367
11368    fn make_summary(
11369        bet_id: &str,
11370        market_id: &str,
11371        selection_id: u64,
11372        handicap: Decimal,
11373        status: BetfairOrderStatus,
11374        placed_date: &str,
11375    ) -> CurrentOrderSummary {
11376        CurrentOrderSummary {
11377            bet_id: bet_id.to_string(),
11378            market_id: market_id.to_string(),
11379            selection_id,
11380            handicap,
11381            price_size: crate::http::models::PriceSize {
11382                price: Decimal::new(20, 1),
11383                size: Decimal::new(10, 0),
11384            },
11385            bsp_liability: Decimal::ZERO,
11386            side: BetfairSide::Back,
11387            status,
11388            persistence_type: PersistenceType::Lapse,
11389            order_type: BetfairOrderType::Limit,
11390            placed_date: placed_date.to_string(),
11391            matched_date: None,
11392            average_price_matched: None,
11393            size_matched: None,
11394            size_remaining: Some(Decimal::new(10, 0)),
11395            size_lapsed: None,
11396            size_cancelled: None,
11397            size_voided: None,
11398            regulator_auth_code: None,
11399            regulator_code: None,
11400            customer_order_ref: None,
11401            customer_strategy_ref: None,
11402        }
11403    }
11404
11405    #[rstest]
11406    fn test_select_order_for_query_single_executable() {
11407        let cid = ClientOrderId::from("O-001");
11408        let orders = vec![make_summary(
11409            "bet_1",
11410            "1.100",
11411            12345,
11412            Decimal::ZERO,
11413            BetfairOrderStatus::Executable,
11414            "2026-04-18T10:00:00Z",
11415        )];
11416        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11417
11418        let selected = select_order_for_query(&orders, expected, cid, None);
11419        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_1"));
11420    }
11421
11422    #[rstest]
11423    fn test_select_order_for_query_single_terminal() {
11424        let cid = ClientOrderId::from("O-001");
11425        let orders = vec![make_summary(
11426            "bet_1",
11427            "1.100",
11428            12345,
11429            Decimal::ZERO,
11430            BetfairOrderStatus::ExecutionComplete,
11431            "2026-04-18T10:00:00Z",
11432        )];
11433        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11434
11435        let selected = select_order_for_query(&orders, expected, cid, None);
11436        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_1"));
11437    }
11438
11439    #[rstest]
11440    fn test_select_order_for_query_replace_prefers_executable() {
11441        let cid = ClientOrderId::from("O-001");
11442        let orders = vec![
11443            make_summary(
11444                "bet_old",
11445                "1.100",
11446                12345,
11447                Decimal::ZERO,
11448                BetfairOrderStatus::ExecutionComplete,
11449                "2026-04-18T10:00:00Z",
11450            ),
11451            make_summary(
11452                "bet_new",
11453                "1.100",
11454                12345,
11455                Decimal::ZERO,
11456                BetfairOrderStatus::Executable,
11457                "2026-04-18T10:05:00Z",
11458            ),
11459        ];
11460        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11461
11462        let selected = select_order_for_query(&orders, expected, cid, None);
11463        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_new"));
11464    }
11465
11466    #[rstest]
11467    fn test_select_order_for_query_multiple_executable_prefers_most_recent() {
11468        let cid = ClientOrderId::from("O-001");
11469        let orders = vec![
11470            make_summary(
11471                "bet_old",
11472                "1.100",
11473                12345,
11474                Decimal::ZERO,
11475                BetfairOrderStatus::Executable,
11476                "2026-04-18T10:00:00Z",
11477            ),
11478            make_summary(
11479                "bet_new",
11480                "1.100",
11481                12345,
11482                Decimal::ZERO,
11483                BetfairOrderStatus::Executable,
11484                "2026-04-18T10:05:00Z",
11485            ),
11486        ];
11487        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11488
11489        let selected = select_order_for_query(&orders, expected, cid, None);
11490        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_new"));
11491    }
11492
11493    #[rstest]
11494    fn test_select_order_for_query_multiple_terminal_prefers_most_recent() {
11495        let cid = ClientOrderId::from("O-001");
11496        let orders = vec![
11497            make_summary(
11498                "bet_old",
11499                "1.100",
11500                12345,
11501                Decimal::ZERO,
11502                BetfairOrderStatus::ExecutionComplete,
11503                "2026-04-18T10:00:00Z",
11504            ),
11505            make_summary(
11506                "bet_new",
11507                "1.100",
11508                12345,
11509                Decimal::ZERO,
11510                BetfairOrderStatus::ExecutionComplete,
11511                "2026-04-18T10:05:00Z",
11512            ),
11513        ];
11514        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11515
11516        let selected = select_order_for_query(&orders, expected, cid, None);
11517        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_new"));
11518    }
11519
11520    #[rstest]
11521    fn test_select_order_for_query_foreign_only_without_vid_returns_none() {
11522        let cid = ClientOrderId::from("O-001");
11523        let orders = vec![make_summary(
11524            "bet_foreign",
11525            "1.999",
11526            99999,
11527            Decimal::ZERO,
11528            BetfairOrderStatus::Executable,
11529            "2026-04-18T10:00:00Z",
11530        )];
11531        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11532
11533        let selected = select_order_for_query(&orders, expected, cid, None);
11534        assert!(selected.is_none());
11535    }
11536
11537    #[rstest]
11538    fn test_select_order_for_query_foreign_only_with_vid_match_returns_match() {
11539        let cid = ClientOrderId::from("O-001");
11540        let orders = vec![make_summary(
11541            "bet_foreign",
11542            "1.999",
11543            99999,
11544            Decimal::ZERO,
11545            BetfairOrderStatus::Executable,
11546            "2026-04-18T10:00:00Z",
11547        )];
11548        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11549        let vid = VenueOrderId::from("bet_foreign");
11550
11551        let selected = select_order_for_query(&orders, expected, cid, Some(vid));
11552        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_foreign"));
11553    }
11554
11555    #[rstest]
11556    fn test_select_order_for_query_foreign_only_vid_mismatch_returns_none() {
11557        let cid = ClientOrderId::from("O-001");
11558        let orders = vec![
11559            make_summary(
11560                "bet_foreign_1",
11561                "1.999",
11562                99999,
11563                Decimal::ZERO,
11564                BetfairOrderStatus::Executable,
11565                "2026-04-18T10:00:00Z",
11566            ),
11567            make_summary(
11568                "bet_foreign_2",
11569                "1.888",
11570                88888,
11571                Decimal::ZERO,
11572                BetfairOrderStatus::Executable,
11573                "2026-04-18T10:05:00Z",
11574            ),
11575        ];
11576        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11577        let vid = VenueOrderId::from("bet_unknown");
11578
11579        let selected = select_order_for_query(&orders, expected, cid, Some(vid));
11580        assert!(selected.is_none());
11581    }
11582
11583    #[rstest]
11584    fn test_select_order_for_query_mixed_returns_matching_instrument() {
11585        let cid = ClientOrderId::from("O-001");
11586        let orders = vec![
11587            make_summary(
11588                "bet_foreign",
11589                "1.999",
11590                99999,
11591                Decimal::ZERO,
11592                BetfairOrderStatus::Executable,
11593                "2026-04-18T10:05:00Z",
11594            ),
11595            make_summary(
11596                "bet_match",
11597                "1.100",
11598                12345,
11599                Decimal::ZERO,
11600                BetfairOrderStatus::ExecutionComplete,
11601                "2026-04-18T10:00:00Z",
11602            ),
11603        ];
11604        let expected = make_instrument_id("1.100", 12345, Decimal::ZERO);
11605
11606        let selected = select_order_for_query(&orders, expected, cid, None);
11607        assert_eq!(selected.map(|o| o.bet_id.as_str()), Some("bet_match"));
11608    }
11609
11610    #[rstest]
11611    fn test_extend_unique_filters_duplicates() {
11612        let mut candidates: Vec<CurrentOrderSummary> = Vec::new();
11613        let mut seen: AHashSet<String> = AHashSet::new();
11614
11615        let orders = vec![
11616            make_summary(
11617                "bet_1",
11618                "1.100",
11619                12345,
11620                Decimal::ZERO,
11621                BetfairOrderStatus::Executable,
11622                "2026-04-18T10:00:00Z",
11623            ),
11624            make_summary(
11625                "bet_1",
11626                "1.100",
11627                12345,
11628                Decimal::ZERO,
11629                BetfairOrderStatus::Executable,
11630                "2026-04-18T10:01:00Z",
11631            ),
11632            make_summary(
11633                "bet_2",
11634                "1.100",
11635                12345,
11636                Decimal::ZERO,
11637                BetfairOrderStatus::Executable,
11638                "2026-04-18T10:02:00Z",
11639            ),
11640        ];
11641
11642        extend_unique(&mut candidates, &mut seen, orders);
11643
11644        assert_eq!(candidates.len(), 2);
11645        assert_eq!(candidates[0].bet_id, "bet_1");
11646        assert_eq!(candidates[0].placed_date, "2026-04-18T10:00:00Z");
11647        assert_eq!(candidates[1].bet_id, "bet_2");
11648        assert!(seen.contains("bet_1"));
11649        assert!(seen.contains("bet_2"));
11650    }
11651
11652    #[rstest]
11653    fn test_extend_unique_skips_already_seen() {
11654        let mut candidates: Vec<CurrentOrderSummary> = vec![make_summary(
11655            "bet_1",
11656            "1.100",
11657            12345,
11658            Decimal::ZERO,
11659            BetfairOrderStatus::Executable,
11660            "2026-04-18T10:00:00Z",
11661        )];
11662        let mut seen: AHashSet<String> = AHashSet::new();
11663        seen.insert("bet_1".to_string());
11664
11665        let orders = vec![make_summary(
11666            "bet_1",
11667            "1.100",
11668            12345,
11669            Decimal::ZERO,
11670            BetfairOrderStatus::Executable,
11671            "2026-04-18T10:05:00Z",
11672        )];
11673
11674        extend_unique(&mut candidates, &mut seen, orders);
11675
11676        assert_eq!(candidates.len(), 1);
11677        assert_eq!(candidates[0].placed_date, "2026-04-18T10:00:00Z");
11678    }
11679
11680    #[rstest]
11681    fn test_list_current_orders_filter_bet_id_sets_only_bet_ids() {
11682        let params = list_current_orders_filter_bet_id("bet_abc".to_string());
11683
11684        assert_eq!(
11685            params.bet_ids.as_deref(),
11686            Some(&["bet_abc".to_string()][..])
11687        );
11688        assert!(params.customer_order_refs.is_none());
11689        assert!(params.market_ids.is_none());
11690        assert!(params.order_projection.is_none());
11691        assert!(params.customer_strategy_refs.is_none());
11692        assert!(params.date_range.is_none());
11693        assert!(params.order_by.is_none());
11694        assert!(params.sort_dir.is_none());
11695        assert!(params.from_record.is_none());
11696        assert!(params.record_count.is_none());
11697    }
11698
11699    #[rstest]
11700    fn test_list_current_orders_filter_ref_sets_only_customer_order_refs() {
11701        let params = list_current_orders_filter_ref("rfo_abc".to_string());
11702
11703        assert_eq!(
11704            params.customer_order_refs.as_deref(),
11705            Some(&["rfo_abc".to_string()][..])
11706        );
11707        assert!(params.bet_ids.is_none());
11708        assert!(params.market_ids.is_none());
11709        assert!(params.order_projection.is_none());
11710        assert!(params.customer_strategy_refs.is_none());
11711        assert!(params.date_range.is_none());
11712        assert!(params.order_by.is_none());
11713        assert!(params.sort_dir.is_none());
11714        assert!(params.from_record.is_none());
11715        assert!(params.record_count.is_none());
11716    }
11717
11718    #[rstest]
11719    fn test_list_current_orders_supplemental_queries_batch_and_deduplicate_ids() {
11720        let bet_ids: Vec<_> = (0..251).map(|index| format!("bet-{index:03}")).collect();
11721        let queries = list_current_orders_supplemental_queries(
11722            bet_ids.iter().chain(&bet_ids).cloned().collect(),
11723            ["reference-b", "reference-a", "reference-a"]
11724                .into_iter()
11725                .map(String::from)
11726                .collect(),
11727        );
11728        let actual: Vec<_> = queries
11729            .into_iter()
11730            .map(|query| serde_json::to_value(query).unwrap())
11731            .collect();
11732        assert_eq!(
11733            actual,
11734            vec![
11735                serde_json::json!({"betIds": bet_ids[..250], "orderProjection": "ALL"}),
11736                serde_json::json!({"betIds": bet_ids[250..], "orderProjection": "ALL"}),
11737                serde_json::json!({"customerOrderRefs": ["reference-a"], "orderProjection": "ALL"}),
11738                serde_json::json!({"customerOrderRefs": ["reference-b"], "orderProjection": "ALL"}),
11739            ]
11740        );
11741        assert!(
11742            list_current_orders_supplemental_queries(AHashSet::new(), AHashSet::new()).is_empty()
11743        );
11744    }
11745
11746    #[rstest]
11747    fn test_order_status_supplemental_ids_use_known_bet_when_reference_is_ambiguous() {
11748        let first = ClientOrderId::from("12345678901234567890123456789012-first");
11749        let second = ClientOrderId::from("12345678901234567890123456789012-second");
11750        let mut state = OcmState::default();
11751        state.restore_order(
11752            first,
11753            StrategyId::from("S-001"),
11754            VenueOrderId::from("first-old"),
11755        );
11756        state.register_pending_replace(first, "first-old".to_string(), Some(Quantity::from(10)));
11757        state
11758            .complete_pending_replace(first, "first-old", VenueOrderId::from("first-new"))
11759            .unwrap();
11760        state.restore_order(
11761            second,
11762            StrategyId::from("S-002"),
11763            VenueOrderId::from("second-new"),
11764        );
11765        let mut order = make_summary(
11766            "first-new",
11767            "1.100",
11768            12345,
11769            Decimal::ZERO,
11770            BetfairOrderStatus::Executable,
11771            "2026-10-01T00:00:00Z",
11772        );
11773        order.customer_order_ref = Some(make_customer_order_ref_legacy(first.as_str()));
11774        assert_eq!(
11775            state.customer_order_ref_resolution(order.customer_order_ref.as_ref().unwrap()),
11776            Some(CustomerOrderRefResolution::Ambiguous)
11777        );
11778        let state = Arc::new(Mutex::new(state));
11779        assert_eq!(
11780            order_status_supplemental_ids(&[order], &state.lock()),
11781            (
11782                AHashSet::from_iter(["first-old".to_string(), "first-new".to_string(),]),
11783                AHashMap::new()
11784            )
11785        );
11786    }
11787
11788    #[rstest]
11789    fn test_list_current_orders_market_id_batches_respect_betfair_limit() {
11790        let market_ids = (0..=MAX_LIST_CURRENT_ORDERS_MARKET_IDS)
11791            .map(|index| format!("1.{index}"))
11792            .collect::<Vec<_>>();
11793
11794        let batches = list_current_orders_market_id_batches(Some(market_ids.clone()));
11795
11796        assert_eq!(
11797            batches,
11798            vec![
11799                Some(market_ids[..MAX_LIST_CURRENT_ORDERS_MARKET_IDS].to_vec()),
11800                Some(market_ids[MAX_LIST_CURRENT_ORDERS_MARKET_IDS..].to_vec()),
11801            ],
11802        );
11803    }
11804}