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