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