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