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