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