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