Skip to main content

nautilus_betfair/
execution.rs

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