1use std::{
19 future::Future,
20 sync::Arc,
21 time::{Duration, Instant},
22};
23
24use ahash::{AHashMap, AHashSet};
25use anyhow::Context;
26use async_trait::async_trait;
27use dashmap::DashMap;
28use futures_util::{StreamExt, pin_mut};
29use nautilus_common::{
30 clients::{ExecutionClient, ExecutionReportTask},
31 enums::LogLevel,
32 live::{runner::get_exec_event_sender, sender::EventSender},
33 logging::map_log_level_to_filter,
34 messages::{
35 ExecutionEvent,
36 execution::{
37 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
38 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
39 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
40 },
41 },
42 msgbus::{self, TypedHandler},
43};
44use nautilus_core::{
45 AtomicMap, DurationNanos, Params, UUID4, UnixNanos,
46 string::secret::SecretString,
47 time::{AtomicTime, get_atomic_clock_realtime},
48};
49use nautilus_live::{
50 ExecutionClientCore, ExecutionEventEmitter, SocketControl,
51 execution::{failure::CommandFailure, reports::retain_order_status_reports},
52 task::{TaskGroup, TaskGroupGuard},
53};
54use nautilus_model::{
55 accounts::AccountAny,
56 enums::{
57 AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, PositionSide,
58 TimeInForce,
59 },
60 events::{
61 OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDeniedReason, OrderEventAny,
62 OrderExpired, OrderFilled, OrderInitialized, OrderRejected, OrderUpdated,
63 },
64 fees::MakerTakerFeeRates,
65 identifiers::{
66 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, Venue, VenueOrderId,
67 },
68 instruments::{Instrument, InstrumentAny},
69 orders::{Order, OrderAny},
70 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
71 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
72};
73use rust_decimal::Decimal;
74use ustr::Ustr;
75
76use crate::{
77 common::{
78 auth::run_auth_token_refresh,
79 consts::{
80 AX_ACCOUNT_REGISTRATION_TIMEOUT_SECS, AX_AUTH_TOKEN_TTL_SECS, AX_FILL_ORDER_PREFIX,
81 AX_FILLS_MAX_LOOKBACK_DAYS, AX_POST_ONLY_REJECT, AX_VENUE,
82 },
83 credential::Credential,
84 enums::{AxCancelRejectionReason, AxOrderSide, AxOrderStatus, AxTimeInForce},
85 parse::{
86 ax_timestamp_stn_to_unix_nanos, cid_to_client_order_id, client_order_id_to_cid,
87 quantity_to_contracts,
88 },
89 },
90 config::AxExecutionClientConfig,
91 http::{
92 client::AxHttpClient,
93 error::AxHttpError,
94 models::{AxOrderRejectReason, PreviewAggressiveLimitOrderRequest, ReplaceOrderRequest},
95 parse::update_order_report_timestamps,
96 },
97 websocket::{
98 AxOrdersWsMessage, AxWsOrderEvent,
99 messages::{AxWsOrder, AxWsTradeExecution, OrderMetadata},
100 orders::{
101 AxOrdersWebSocketClient, AxOrdersWsClientError, OrdersCaches,
102 client::reserve_cid_mapping,
103 },
104 },
105};
106
107#[derive(Debug)]
109pub struct AxExecutionClient {
110 core: ExecutionClientCore,
111 clock: &'static AtomicTime,
112 config: AxExecutionClientConfig,
113 emitter: ExecutionEventEmitter,
114 http_client: AxHttpClient,
115 ws_orders: AxOrdersWebSocketClient,
116 order_event_handler: Option<TypedHandler<OrderEventAny>>,
117 session_tasks: TaskGroup,
118 pending_tasks: TaskGroup,
119 shutdown_errors: Vec<String>,
120}
121
122impl AxExecutionClient {
123 pub fn new(core: ExecutionClientCore, config: AxExecutionClientConfig) -> anyhow::Result<Self> {
129 let http_client = AxHttpClient::with_credentials(
130 config
131 .api_key
132 .clone()
133 .map(|value| value.into_inner())
134 .unwrap_or_default(),
135 config
136 .api_secret
137 .clone()
138 .map(|value| value.into_inner())
139 .unwrap_or_default(),
140 Some(config.http_base_url()),
141 Some(config.orders_base_url()),
142 config.http_timeout_secs,
143 config.max_retries,
144 config.retry_delay_initial_ms,
145 config.retry_delay_max_ms,
146 config
147 .proxy_url
148 .as_ref()
149 .map(|url| url.expose_secret().to_owned()),
150 )?;
151
152 let clock = get_atomic_clock_realtime();
153 let trader_id = core.trader_id;
154 let account_id = core.account_id;
155 let emitter =
156 ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
157 let mut ws_url = config.ws_private_url();
158 if config.cancel_on_disconnect {
159 let separator = if ws_url.contains('?') { "&" } else { "?" };
160 ws_url.push_str(&format!("{separator}cancel_on_disconnect=true"));
161 }
162 let ws_orders = AxOrdersWebSocketClient::new(
163 ws_url,
164 account_id,
165 trader_id,
166 config.heartbeat_interval_secs,
167 config.transport_backend,
168 config
169 .proxy_url
170 .as_ref()
171 .map(|url| url.expose_secret().to_owned()),
172 )
173 .with_socket_control(SocketControl::new(
174 core.client_id,
175 Some(*AX_VENUE),
176 "architect-ax-user-streams",
177 ));
178
179 let session_tasks = TaskGroup::new();
180 let pending_tasks = TaskGroup::new();
181
182 Ok(Self {
183 core,
184 clock,
185 config,
186 emitter,
187 http_client,
188 ws_orders,
189 order_event_handler: None,
190 session_tasks,
191 pending_tasks,
192 shutdown_errors: Vec::new(),
193 })
194 }
195
196 pub(crate) fn set_event_sender(&mut self, sender: EventSender<ExecutionEvent>) {
197 self.emitter.set_sender(sender);
198 }
199
200 fn ensure_order_event_subscription(&mut self) {
201 if self.order_event_handler.is_some() {
202 return;
203 }
204
205 let caches = self.ws_orders.caches().clone();
206 let account_id = self.core.account_id;
207
208 let handler = TypedHandler::from(move |event: &OrderEventAny| {
209 let OrderEventAny::Accepted(accepted) = event else {
210 return;
211 };
212
213 if accepted.account_id != account_id {
214 return;
215 }
216
217 let Some(mut metadata) = caches.orders_metadata.get_mut(&accepted.client_order_id)
218 else {
219 return;
220 };
221
222 if metadata.trader_id != accepted.trader_id
223 || metadata.strategy_id != accepted.strategy_id
224 || metadata.instrument_id != accepted.instrument_id
225 {
226 return;
227 }
228
229 metadata.is_accepted = true;
230 if metadata.venue_order_id.is_none() {
231 metadata.venue_order_id = Some(accepted.venue_order_id);
232 drop(metadata);
233 caches
234 .venue_to_client_id
235 .insert(accepted.venue_order_id, accepted.client_order_id);
236 }
237 });
238
239 msgbus::subscribe_order_events("events.order.*".into(), handler.clone(), Some(10));
240 self.order_event_handler = Some(handler);
241 }
242
243 fn clear_order_event_subscription(&mut self) {
244 if let Some(handler) = self.order_event_handler.take() {
245 msgbus::unsubscribe_order_events("events.order.*".into(), &handler);
246 }
247 }
248
249 async fn authenticate(&self, credential: &Credential) -> anyhow::Result<SecretString> {
250 self.http_client
251 .authenticate(
252 credential.api_key(),
253 credential.api_secret(),
254 AX_AUTH_TOKEN_TTL_SECS,
255 )
256 .await
257 .map_err(|e| anyhow::anyhow!("Authentication failed: {e}"))
258 }
259
260 fn update_account_state(&self) {
261 let http_client = self.http_client.clone();
262 let account_id = self.core.account_id;
263 let emitter = self.emitter.clone();
264 let clock = self.clock;
265
266 self.spawn_task("query_account", async move {
267 let account_state = http_client
268 .request_account_state(account_id)
269 .await
270 .context("failed to request AX account state")?;
271 let ts_event = clock.get_time_ns();
272 emitter.emit_account_state(
273 account_state.balances.clone(),
274 account_state.margins.clone(),
275 account_state.is_reported,
276 ts_event,
277 account_state.info,
278 );
279 Ok(())
280 });
281 }
282
283 fn submit_order_internal(&self, cmd: &SubmitOrder) -> anyhow::Result<()> {
284 let (
285 order_for_task,
286 client_order_id,
287 instrument_id,
288 order_side,
289 order_type,
290 quantity,
291 time_in_force,
292 is_post_only,
293 limit_price,
294 ) = {
295 let cache = self.core.cache();
296 let order = cache.try_order(&cmd.client_order_id)?;
297 (
298 order.clone(),
299 order.client_order_id(),
300 order.instrument_id(),
301 order.order_side(),
302 order.order_type(),
303 order.quantity(),
304 order.time_in_force(),
305 order.is_post_only(),
306 order.price(),
307 )
308 };
309
310 let order_init = cmd.order_init.clone();
311 let ws_orders = self.ws_orders.clone();
312 let emitter = self.emitter.clone();
313 let clock = self.clock;
314
315 let http_client = self.http_client.clone();
316
317 self.spawn_task("submit_order", async move {
318 let (price, submit_time_in_force, submit_post_only) = if order_type
321 == OrderType::Market
322 {
323 let preview_result: anyhow::Result<Price> = async {
324 let symbol = instrument_id.symbol.inner();
325 let ax_side = AxOrderSide::from(order_side);
326 let qty_contracts = quantity_to_contracts(quantity)?;
327
328 let instrument = http_client.get_instrument(&symbol).ok_or_else(|| {
329 anyhow::anyhow!("Instrument {instrument_id} not found in cache")
330 })?;
331
332 let request =
333 PreviewAggressiveLimitOrderRequest::new(symbol, qty_contracts, ax_side);
334 let response = http_client
335 .inner
336 .preview_aggressive_limit_order(&request)
337 .await
338 .map_err(|e| {
339 anyhow::anyhow!("Failed to preview aggressive limit order: {e}")
340 })?;
341
342 if response.remaining_quantity > 0 {
343 log::warn!(
344 "Market order book depth insufficient: \
345 filled_qty={} remaining_qty={} for {instrument_id}",
346 response.filled_quantity,
347 response.remaining_quantity,
348 );
349 }
350
351 let limit_price_decimal = response.limit_price.ok_or_else(|| {
352 anyhow::anyhow!(
353 "No liquidity available for market order on {instrument_id}"
354 )
355 })?;
356
357 let price =
358 Price::from_decimal_dp(limit_price_decimal, instrument.price_precision())
359 .with_context(|| {
360 format!(
361 "Failed to convert AX take-through price {limit_price_decimal} for {instrument_id}"
362 )
363 })?;
364 log::debug!("Market order take-through price: {price} for {instrument_id}",);
365 Ok(price)
366 }
367 .await;
368
369 let price = match preview_result {
370 Ok(price) => price,
371 Err(e) => {
372 let reason = e.to_string();
373 log::warn!(
374 "AX market order preview failed for {client_order_id}: {reason}"
375 );
376 emitter.emit_order_rejected(
377 &order_for_task,
378 &reason,
379 clock.get_time_ns(),
380 false,
381 );
382 return Ok(());
383 }
384 };
385
386 (price, TimeInForce::Ioc, false)
387 } else {
388 (
389 limit_price.context("AX limit order is missing a price")?,
390 time_in_force,
391 is_post_only,
392 )
393 };
394
395 let result = ws_orders
396 .submit_order(
397 &order_init,
398 quantity,
399 submit_time_in_force,
400 price,
401 submit_post_only,
402 )
403 .await;
404
405 if let Err(e) = result {
406 match classify_ax_ws_failure(&e) {
407 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
410 log::warn!("AX submit failed for {client_order_id}: {reason}");
411 emitter.emit_order_rejected(
412 &order_for_task,
413 &reason,
414 clock.get_time_ns(),
415 false,
416 );
417 }
418 CommandFailure::Ambiguous(reason) => {
419 log::warn!(
420 "Ambiguous AX submit failure for {client_order_id}, awaiting reconciliation: {reason}"
421 );
422 }
423 }
424 }
425
426 Ok(())
427 });
428
429 Ok(())
430 }
431
432 fn cancel_order_internal(&self, cmd: &CancelOrder) {
433 let ws_orders = self.ws_orders.clone();
434 let client_order_id = cmd.client_order_id;
435 let venue_order_id = cmd.venue_order_id;
436
437 self.spawn_task("cancel_order", async move {
440 if let Err(e) = ws_orders.cancel_order(client_order_id, venue_order_id).await {
441 match classify_ax_ws_failure(&e) {
442 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
443 log::warn!("Cancel command failed for {client_order_id}: {reason}");
444 }
445 CommandFailure::Ambiguous(reason) => {
446 log::warn!(
447 "Ambiguous AX cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
448 );
449 }
450 }
451 }
452
453 Ok(())
454 });
455 }
456
457 fn spawn_task<F>(&self, description: &'static str, fut: F)
458 where
459 F: Future<Output = anyhow::Result<()>> + Send + 'static,
460 {
461 let future = async move {
462 if let Err(e) = fut.await {
463 log::warn!("{description} failed: {e}");
464 }
465 };
466
467 if let Err(e) = self.pending_tasks.spawn(future) {
468 log::warn!("Skipping AX {description} after shutdown began: {e}");
469 }
470 }
471
472 fn abort_pending_tasks(&self) {
473 self.pending_tasks.begin_shutdown();
474 }
475
476 fn abort_session_tasks(&self) {
477 self.session_tasks.begin_shutdown();
478 self.ws_orders.begin_shutdown();
479 }
480
481 async fn await_pending_tasks(&self) -> anyhow::Result<()> {
482 self.pending_tasks.begin_shutdown();
483 self.pending_tasks
484 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
485 .await
486 .map_err(|e| anyhow::anyhow!("Failed to terminate AX execution tasks: {e}"))?;
487 Ok(())
488 }
489
490 async fn await_session_tasks(&self) -> anyhow::Result<()> {
491 self.session_tasks.begin_shutdown();
492 self.session_tasks
493 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
494 .await
495 .map_err(|e| anyhow::anyhow!("Failed to terminate AX execution session tasks: {e}"))?;
496 Ok(())
497 }
498
499 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
500 self.clear_order_event_subscription();
501 self.abort_session_tasks();
502 self.abort_pending_tasks();
503 self.http_client.cancel_all_requests();
504
505 if let Err(e) = self.ws_orders.close().await {
506 self.shutdown_errors
507 .push(format!("AX orders WebSocket shutdown failed: {e}"));
508 }
509
510 let (session_result, pending_result) =
511 tokio::join!(self.await_session_tasks(), self.await_pending_tasks());
512 cleanup_deferred_order_tracking(self.ws_orders.caches());
513 self.core.set_disconnected();
514
515 if let Err(e) = session_result {
516 self.shutdown_errors.push(e.to_string());
517 }
518
519 if let Err(e) = pending_result {
520 self.shutdown_errors.push(e.to_string());
521 }
522
523 if !self.shutdown_errors.is_empty() {
524 anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
525 }
526 Ok(())
527 }
528
529 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
531 let account_id = self.core.account_id;
532
533 if self.core.cache().account(&account_id).is_some() {
534 log::info!("Account {account_id} registered");
535 return Ok(());
536 }
537
538 let start = Instant::now();
539 let timeout = Duration::from_secs_f64(timeout_secs);
540 let interval = Duration::from_millis(10);
541
542 loop {
543 tokio::time::sleep(interval).await;
544
545 if self.core.cache().account(&account_id).is_some() {
546 log::info!("Account {account_id} registered");
547 return Ok(());
548 }
549
550 if start.elapsed() >= timeout {
551 anyhow::bail!(
552 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
553 );
554 }
555 }
556 }
557}
558
559#[async_trait(?Send)]
560impl ExecutionClient for AxExecutionClient {
561 fn is_connected(&self) -> bool {
562 self.core.is_connected()
563 }
564
565 fn client_id(&self) -> ClientId {
566 self.core.client_id
567 }
568
569 fn account_id(&self) -> AccountId {
570 self.core.account_id
571 }
572
573 fn venue(&self) -> Venue {
574 *AX_VENUE
575 }
576
577 fn oms_type(&self) -> OmsType {
578 self.core.oms_type
579 }
580
581 fn get_account(&self) -> Option<AccountAny> {
582 self.core.cache().account_owned(&self.core.account_id)
583 }
584
585 async fn connect(&mut self) -> anyhow::Result<()> {
586 if self.core.is_connected() && self.pending_tasks.is_open() && self.session_tasks.is_open()
587 {
588 return Ok(());
589 }
590
591 if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
592 self.teardown_partial_connect().await?;
593 self.pending_tasks.start_generation().map_err(|e| {
594 anyhow::anyhow!("Failed to start AX execution task generation: {e}")
595 })?;
596 self.session_tasks.start_generation().map_err(|e| {
597 anyhow::anyhow!("Failed to start AX execution session generation: {e}")
598 })?;
599 }
600 let http_client = self.http_client.clone();
601 let ws_orders = self.ws_orders.clone();
602 let setup_guard =
603 TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
604 http_client.cancel_all_requests();
605 ws_orders.begin_shutdown();
606 });
607
608 self.http_client.reset_cancellation_token();
610
611 let credential = Credential::resolve(
612 self.config.api_key.clone().map(|value| value.into_inner()),
613 self.config
614 .api_secret
615 .clone()
616 .map(|value| value.into_inner()),
617 )
618 .context("API credentials not configured")?;
619 let token = self.authenticate(&credential).await?;
620
621 self.http_client
622 .request_account_fees()
623 .await
624 .context("failed to resolve AX account fee rates")?;
625
626 if !self.core.instruments_initialized() {
627 let instruments = self
628 .http_client
629 .request_instruments()
630 .await
631 .context("failed to request AX instruments")?;
632
633 if instruments.is_empty() {
634 log::warn!("No instruments returned from AX");
635 } else {
636 log::debug!("Loaded {} instruments", instruments.len());
637 self.http_client.cache_instruments(&instruments);
638 self.ws_orders.cache_instruments(&instruments);
639 }
640 self.core.set_instruments_initialized();
641 }
642
643 let orders = {
644 let cache = self.core.cache();
645 let mut orders = cache.orders_refs(Some(&AX_VENUE), None, None, None, None);
646 orders.retain(|order| {
647 (order.is_open() || order.is_inflight())
648 && order.trader_id() == self.core.trader_id
649 && order
650 .account_id()
651 .is_none_or(|account_id| account_id == self.core.account_id)
652 && cache
653 .client_id(&order.client_order_id())
654 .is_none_or(|client_id| *client_id == self.core.client_id)
655 });
656
657 orders
658 .into_iter()
659 .map(|order| order.cloned())
660 .collect::<Vec<_>>()
661 };
662
663 for order in orders {
664 let symbol = order.instrument_id().symbol.inner();
665 let needs_instrument = self.http_client.get_instrument(&symbol).is_none()
666 && self
667 .core
668 .cache()
669 .instrument(&order.instrument_id())
670 .is_none();
671
672 if needs_instrument {
673 let instrument = self
674 .http_client
675 .request_instrument(symbol)
676 .await
677 .with_context(|| format!("failed to restore AX instrument {symbol}"))?;
678 self.http_client.cache_instrument(instrument);
679 }
680
681 self.restore_order_tracking(&order, None)?;
682 }
683
684 self.ensure_order_event_subscription();
685 self.ws_orders.connect(token.expose_secret()).await?;
686 log::debug!("Connected to orders WebSocket");
687
688 let stream = self.ws_orders.stream();
689 let emitter = self.emitter.clone();
690 let caches = self.ws_orders.caches().clone();
691 let account_id = self.core.account_id;
692 let instruments_cache = self.ws_orders.instruments_cache();
693 let account_fee_rates = self.http_client.account_fee_rates.clone();
694 let clock = self.clock;
695
696 if let Err(e) = self.session_tasks.spawn(async move {
697 pin_mut!(stream);
698 while let Some(message) = stream.next().await {
699 dispatch_ws_message(
700 message,
701 &emitter,
702 &caches,
703 account_id,
704 &instruments_cache,
705 &account_fee_rates,
706 clock,
707 );
708 }
709 }) {
710 if let Err(teardown_error) = self.teardown_partial_connect().await {
711 return Err(anyhow::Error::new(e).context(format!(
712 "AX execution startup teardown failed: {teardown_error}"
713 )));
714 }
715 return Err(e.into());
716 }
717
718 let session_result = async {
719 let account_state = self
720 .http_client
721 .request_account_state(self.core.account_id)
722 .await
723 .context("failed to request AX account state")?;
724
725 if !account_state.balances.is_empty() {
726 log::debug!(
727 "Received account state with {} balance(s)",
728 account_state.balances.len()
729 );
730 }
731 self.emitter.send_account_state(account_state);
732
733 self.await_account_registered(AX_ACCOUNT_REGISTRATION_TIMEOUT_SECS)
734 .await?;
735
736 let ws_orders = self.ws_orders.clone();
737 self.session_tasks.spawn(run_auth_token_refresh(
738 self.http_client.clone(),
739 credential,
740 move |token| ws_orders.update_auth_token(token.expose_secret()),
741 ))?;
742 Ok::<(), anyhow::Error>(())
743 }
744 .await;
745
746 if let Err(e) = session_result {
747 if let Err(teardown_error) = self.teardown_partial_connect().await {
748 return Err(e.context(format!(
749 "AX execution startup teardown failed: {teardown_error}"
750 )));
751 }
752 return Err(e);
753 }
754
755 self.core.set_connected();
756 setup_guard.disarm();
757 log::info!("Connected: client_id={}", self.core.client_id);
758 Ok(())
759 }
760
761 async fn disconnect(&mut self) -> anyhow::Result<()> {
762 self.clear_order_event_subscription();
763 self.abort_session_tasks();
764 self.abort_pending_tasks();
765 self.http_client.cancel_all_requests();
766
767 let ws_result = self.ws_orders.close().await;
768 let (session_result, pending_result) =
769 tokio::join!(self.await_session_tasks(), self.await_pending_tasks());
770
771 cleanup_deferred_order_tracking(self.ws_orders.caches());
772 self.core.set_disconnected();
773 ws_result?;
774 session_result?;
775 pending_result?;
776 log::info!("Disconnected: client_id={}", self.core.client_id);
777 Ok(())
778 }
779
780 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
781 self.update_account_state();
782 Ok(())
783 }
784
785 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
786 let http_client = self.http_client.clone();
787 let account_id = self.core.account_id;
788 let client_order_id = cmd.client_order_id;
789 let venue_order_id = self
790 .ws_orders
791 .orders_metadata()
792 .get(&client_order_id)
793 .and_then(|metadata| metadata.venue_order_id)
794 .or(cmd.venue_order_id);
795 let instrument_id = cmd.instrument_id;
796 let emitter = self.emitter.clone();
797 let caches = self.ws_orders.caches().clone();
798
799 let (order_side, order_type, time_in_force) = {
801 let cache = self.core.cache();
802 match cache.order(&client_order_id) {
803 Some(order) => (
804 Some(order.order_side()),
805 order.order_type(),
806 order.time_in_force(),
807 ),
808 None => (None, OrderType::Limit, TimeInForce::Gtc),
809 }
810 };
811
812 self.spawn_task("query_order", async move {
813 match http_client
814 .request_order_status(
815 account_id,
816 instrument_id,
817 Some(client_order_id),
818 venue_order_id,
819 order_side,
820 order_type,
821 time_in_force,
822 )
823 .await
824 {
825 Ok(report) => {
826 if let Some(report) =
827 resolve_order_status_report(&http_client, &caches, client_order_id, report)
828 .await?
829 {
830 cleanup_resolved_order_status_report(&report, &caches);
831 emitter.send_order_status_report(report);
832 }
833 }
834 Err(e) => log::warn!("AX query order failed: {e}"),
835 }
836 Ok(())
837 });
838
839 Ok(())
840 }
841
842 fn generate_account_state(
843 &self,
844 balances: Vec<AccountBalance>,
845 margins: Vec<MarginBalance>,
846 reported: bool,
847 ts_event: UnixNanos,
848 info: Option<Params>,
849 ) -> anyhow::Result<()> {
850 self.emitter
851 .emit_account_state(balances, margins, reported, ts_event, info);
852 Ok(())
853 }
854
855 fn start(&mut self) -> anyhow::Result<()> {
856 self.ensure_order_event_subscription();
857
858 if self.core.is_started() {
859 return Ok(());
860 }
861
862 self.emitter.set_sender(get_exec_event_sender());
863 self.core.set_started();
864 log::info!(
865 "Started: client_id={}, account_id={}, environment={}",
866 self.core.client_id,
867 self.core.account_id,
868 self.config.environment,
869 );
870 Ok(())
871 }
872
873 fn stop(&mut self) -> anyhow::Result<()> {
874 self.clear_order_event_subscription();
875
876 if self.core.is_stopped() {
877 return Ok(());
878 }
879
880 self.core.set_stopped();
881 self.core.set_disconnected();
882
883 self.abort_session_tasks();
884 self.abort_pending_tasks();
885 cleanup_deferred_order_tracking(self.ws_orders.caches());
886 log::info!("Stopped: client_id={}", self.core.client_id);
887 Ok(())
888 }
889
890 fn reset(&mut self) -> anyhow::Result<()> {
891 self.clear_order_event_subscription();
892 self.abort_session_tasks();
893 self.abort_pending_tasks();
894 cleanup_deferred_order_tracking(self.ws_orders.caches());
895 self.core.set_disconnected();
896 Ok(())
897 }
898
899 fn dispose(&mut self) -> anyhow::Result<()> {
900 self.clear_order_event_subscription();
901 self.abort_session_tasks();
902 self.abort_pending_tasks();
903 cleanup_deferred_order_tracking(self.ws_orders.caches());
904 self.core.set_disconnected();
905 Ok(())
906 }
907
908 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
909 {
910 let cache = self.core.cache();
911 let order = cache.try_order(&cmd.client_order_id)?;
912
913 if order.is_closed() {
914 log::warn!("Cannot submit closed order {}", order.client_order_id());
915 return Ok(());
916 }
917
918 if let Err(e) = validate_order_for_ax_submit(&order)
919 .and_then(|()| validate_order_init_instructions(&cmd.order_init))
920 {
921 self.emitter.emit_order_denied(&order, &e.to_string());
922 return Ok(());
923 }
924
925 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
926 self.emitter.emit_order_submitted(&order);
927 }
928
929 self.submit_order_internal(&cmd)
930 }
931
932 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
933 for (client_order_id, order_init) in cmd
934 .order_list
935 .client_order_ids
936 .iter()
937 .zip(cmd.order_inits.iter())
938 {
939 let submit_cmd = SubmitOrder::new(
940 cmd.trader_id,
941 cmd.client_id,
942 cmd.strategy_id,
943 cmd.instrument_id,
944 *client_order_id,
945 order_init.clone(),
946 cmd.exec_algorithm_id,
947 cmd.position_id,
948 cmd.params.clone(),
949 UUID4::new(),
950 cmd.ts_init,
951 cmd.correlation_id,
952 );
953 self.submit_order(submit_cmd)?;
954 }
955 Ok(())
956 }
957
958 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
959 if cmd.trigger_price.is_some() {
960 emit_ax_modify_rejected(
961 &self.emitter,
962 self.clock,
963 cmd.strategy_id,
964 cmd.instrument_id,
965 cmd.client_order_id,
966 cmd.venue_order_id,
967 "AX does not support venue-native trigger prices",
968 );
969 return Ok(());
970 }
971
972 let tracked_venue_order_id = self
973 .ws_orders
974 .orders_metadata()
975 .get(&cmd.client_order_id)
976 .and_then(|metadata| metadata.venue_order_id);
977
978 let venue_order_id = match tracked_venue_order_id.or(cmd.venue_order_id) {
979 Some(voi) => voi,
980 None => {
981 emit_ax_modify_rejected(
982 &self.emitter,
983 self.clock,
984 cmd.strategy_id,
985 cmd.instrument_id,
986 cmd.client_order_id,
987 None,
988 "missing venue_order_id",
989 );
990 return Ok(());
991 }
992 };
993
994 let quantity = match cmd.quantity {
995 Some(quantity) => match quantity_to_contracts(quantity) {
996 Ok(contracts) => Some(contracts),
997 Err(e) => {
998 emit_ax_modify_rejected(
999 &self.emitter,
1000 self.clock,
1001 cmd.strategy_id,
1002 cmd.instrument_id,
1003 cmd.client_order_id,
1004 Some(venue_order_id),
1005 &e.to_string(),
1006 );
1007 return Ok(());
1008 }
1009 },
1010 None => None,
1011 };
1012
1013 if !self.core.is_connected() {
1014 emit_ax_modify_rejected(
1015 &self.emitter,
1016 self.clock,
1017 cmd.strategy_id,
1018 cmd.instrument_id,
1019 cmd.client_order_id,
1020 Some(venue_order_id),
1021 "AX execution client is not connected",
1022 );
1023 return Ok(());
1024 }
1025
1026 let http_client = self.http_client.clone();
1027 let emitter = self.emitter.clone();
1028 let clock = self.clock;
1029 let strategy_id = cmd.strategy_id;
1030 let instrument_id = cmd.instrument_id;
1031 let caches = self.ws_orders.caches().clone();
1032 let client_order_id = cmd.client_order_id;
1033 let price = cmd.price;
1034
1035 caches
1036 .pending_modifies
1037 .entry(venue_order_id)
1038 .and_modify(|(_, count, _)| *count += 1)
1039 .or_insert((client_order_id, 1, false));
1040
1041 self.spawn_task("modify_order", async move {
1042 let mut request = ReplaceOrderRequest::new(venue_order_id.as_str());
1043
1044 if let Some(price) = price {
1045 request = request.with_price(price.as_decimal());
1046 }
1047
1048 if let Some(contracts) = quantity {
1049 request = request.with_quantity(contracts);
1050 }
1051
1052 match http_client.inner.replace_order(&request).await {
1053 Ok(resp) => {
1054 let new_venue_order_id = match VenueOrderId::new_checked(&resp.oid) {
1055 Ok(venue_order_id) => venue_order_id,
1056 Err(e) => {
1057 log::warn!(
1058 "AX replace returned invalid venue order ID for {client_order_id}, awaiting reconciliation: {e}"
1059 );
1060 return Ok(());
1061 }
1062 };
1063
1064 record_replacement_proposal(&caches, client_order_id, venue_order_id, new_venue_order_id);
1065 log::debug!(
1066 "AX replace received: old={} proposed={new_venue_order_id}",
1067 request.oid,
1068 );
1069 }
1070 Err(e) => match classify_ax_http_failure(&e) {
1073 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
1074 complete_pending_modify(&caches, venue_order_id);
1075 emit_ax_modify_rejected(
1076 &emitter,
1077 clock,
1078 strategy_id,
1079 instrument_id,
1080 client_order_id,
1081 Some(venue_order_id),
1082 &reason,
1083 );
1084 }
1085 CommandFailure::Ambiguous(reason) => {
1086 log::warn!(
1087 "Ambiguous AX modify failure for {client_order_id}, awaiting reconciliation: {reason}"
1088 );
1089 }
1090 },
1091 }
1092
1093 Ok(())
1094 });
1095
1096 Ok(())
1097 }
1098
1099 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1100 self.cancel_order_internal(&cmd);
1101 Ok(())
1102 }
1103
1104 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
1105 if cmd.order_side.is_some() {
1106 let cancels: Vec<CancelOrder> = {
1109 let cache = self.core.cache();
1110 cache
1111 .orders_open(None, Some(&cmd.instrument_id), None, None, cmd.order_side)
1112 .iter()
1113 .map(|order| CancelOrder {
1114 trader_id: order.trader_id(),
1115 client_id: cmd.client_id,
1116 strategy_id: order.strategy_id(),
1117 instrument_id: order.instrument_id(),
1118 client_order_id: order.client_order_id(),
1119 venue_order_id: order.venue_order_id(),
1120 command_id: cmd.command_id,
1121 ts_init: cmd.ts_init,
1122 params: cmd.params.clone(),
1123 correlation_id: cmd.correlation_id,
1124 causation_id: cmd.causation_id,
1125 })
1126 .collect()
1127 };
1128
1129 if cancels.is_empty() {
1130 log::debug!("No open orders to cancel for {}", cmd.instrument_id);
1131 return Ok(());
1132 }
1133
1134 return self.batch_cancel_orders(BatchCancelOrders {
1135 trader_id: cmd.trader_id,
1136 client_id: cmd.client_id,
1137 strategy_id: cmd.strategy_id,
1138 instrument_id: cmd.instrument_id,
1139 cancels,
1140 command_id: cmd.command_id,
1141 ts_init: cmd.ts_init,
1142 params: cmd.params,
1143 correlation_id: cmd.correlation_id,
1144 causation_id: cmd.causation_id,
1145 });
1146 }
1147
1148 let http_client = self.http_client.clone();
1149 let emitter = self.emitter.clone();
1150 let clock = self.clock;
1151 let instrument_id = cmd.instrument_id;
1152 let account_id = self.core.account_id;
1153 let trader_id = self.core.trader_id;
1154
1155 let open_orders: Vec<(ClientOrderId, Option<VenueOrderId>, StrategyId)> = {
1157 let cache = self.core.cache();
1158 cache
1159 .orders_open(None, Some(&instrument_id), None, None, None)
1160 .iter()
1161 .map(|o| (o.client_order_id(), o.venue_order_id(), o.strategy_id()))
1162 .collect()
1163 };
1164
1165 let caches = self.ws_orders.caches().clone();
1166
1167 self.spawn_task("cancel_all_orders", async move {
1168 match http_client.cancel_all_orders(instrument_id).await {
1169 Ok(()) => {
1170 log::debug!("Canceled all orders for {instrument_id}");
1171
1172 let ts_event = clock.get_time_ns();
1176
1177 for (client_order_id, venue_order_id, strategy_id) in &open_orders {
1178 let event = OrderCanceled::new(
1179 trader_id,
1180 *strategy_id,
1181 instrument_id,
1182 *client_order_id,
1183 UUID4::new(),
1184 ts_event,
1185 clock.get_time_ns(),
1186 false,
1187 *venue_order_id,
1188 Some(account_id),
1189 None,
1190 );
1191 emitter.send_order_event(OrderEventAny::Canceled(event));
1192
1193 cleanup_terminal_order_tracking_ids(
1194 &caches,
1195 None,
1196 None,
1197 Some(*client_order_id),
1198 );
1199 }
1200 }
1201 Err(e) => match classify_ax_http_failure(&e) {
1204 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
1205 log::warn!("Cancel-all for {instrument_id} failed: {reason}");
1206 }
1207 CommandFailure::Ambiguous(reason) => {
1208 log::warn!(
1209 "Ambiguous AX cancel-all failure for {instrument_id}, awaiting reconciliation: {reason}"
1210 );
1211 }
1212 },
1213 }
1214 Ok(())
1215 });
1216
1217 Ok(())
1218 }
1219
1220 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1221 for cancel in &cmd.cancels {
1222 self.cancel_order_internal(cancel);
1223 }
1224 Ok(())
1225 }
1226
1227 fn generate_order_status_report_task(
1228 &self,
1229 cmd: &GenerateOrderStatusReport,
1230 ) -> Option<ExecutionReportTask<Option<OrderStatusReport>>> {
1231 let client = self.report_client();
1232 let command = cmd.clone();
1233 let caches = self.ws_orders.caches().clone();
1234 Some(ExecutionReportTask::new(
1235 async move { client.generate_order_status_report(&command).await },
1236 move |report| {
1237 if let Some(report) = &report {
1238 cleanup_closed_order_status_report(report, &caches);
1239 }
1240
1241 Ok(report)
1242 },
1243 ))
1244 }
1245
1246 fn generate_order_status_reports_task(
1247 &self,
1248 cmd: &GenerateOrderStatusReports,
1249 ) -> Option<ExecutionReportTask<Vec<OrderStatusReport>>> {
1250 let client = self.report_client();
1251 let command = cmd.clone();
1252 let caches = self.ws_orders.caches().clone();
1253 Some(ExecutionReportTask::new(
1254 async move { client.generate_order_status_reports(&command).await },
1255 move |reports| {
1256 for report in &reports {
1257 cleanup_closed_order_status_report(report, &caches);
1258 }
1259
1260 Ok(reports)
1261 },
1262 ))
1263 }
1264
1265 fn generate_fill_reports_task(
1266 &self,
1267 cmd: &GenerateFillReports,
1268 ) -> Option<ExecutionReportTask<Vec<FillReport>>> {
1269 let client = self.report_client();
1270 let command = cmd.clone();
1271 Some(ExecutionReportTask::new(
1272 async move { client.generate_fill_reports(command).await },
1273 Ok,
1274 ))
1275 }
1276
1277 fn generate_position_status_reports_task(
1278 &self,
1279 cmd: &GeneratePositionStatusReports,
1280 ) -> Option<ExecutionReportTask<Vec<PositionStatusReport>>> {
1281 let client = self.report_client();
1282 let command = cmd.clone();
1283 Some(ExecutionReportTask::new(
1284 async move { client.generate_position_status_reports(&command).await },
1285 Ok,
1286 ))
1287 }
1288
1289 async fn generate_order_status_report(
1290 &self,
1291 cmd: &GenerateOrderStatusReport,
1292 ) -> anyhow::Result<Option<OrderStatusReport>> {
1293 let report = self
1294 .report_client()
1295 .generate_order_status_report(cmd)
1296 .await?;
1297
1298 if let Some(report) = &report {
1299 cleanup_closed_order_status_report(report, self.ws_orders.caches());
1300 }
1301
1302 Ok(report)
1303 }
1304
1305 async fn generate_order_status_reports(
1306 &self,
1307 cmd: &GenerateOrderStatusReports,
1308 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1309 let reports = self
1310 .report_client()
1311 .generate_order_status_reports(cmd)
1312 .await?;
1313
1314 for report in &reports {
1315 cleanup_closed_order_status_report(report, self.ws_orders.caches());
1316 }
1317
1318 Ok(reports)
1319 }
1320
1321 async fn generate_fill_reports(
1322 &self,
1323 cmd: GenerateFillReports,
1324 ) -> anyhow::Result<Vec<FillReport>> {
1325 self.report_client().generate_fill_reports(cmd).await
1326 }
1327
1328 async fn generate_position_status_reports(
1329 &self,
1330 cmd: &GeneratePositionStatusReports,
1331 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1332 self.report_client()
1333 .generate_position_status_reports(cmd)
1334 .await
1335 }
1336
1337 async fn generate_mass_status(
1338 &self,
1339 lookback_mins: Option<u64>,
1340 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1341 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
1342
1343 let ts_now = self.clock.get_time_ns();
1344
1345 let positions_cached = self
1346 .core
1347 .cache()
1348 .positions_open(
1349 Some(&AX_VENUE),
1350 None,
1351 None,
1352 Some(&self.core.account_id),
1353 None,
1354 )
1355 .into_iter()
1356 .map(|position| (position.instrument_id, position.quantity.precision))
1357 .collect();
1358
1359 let start = lookback_mins
1360 .map(DurationNanos::try_from_mins)
1361 .transpose()?
1362 .map(|lookback| ts_now.saturating_sub(lookback));
1363
1364 let fills_span = DurationNanos::try_from_days(u64::try_from(AX_FILLS_MAX_LOOKBACK_DAYS)?)?;
1366 let fills_floor = ts_now.saturating_sub(fills_span);
1367
1368 let fills_latest_start = ts_now.saturating_sub(DurationNanos::new(1));
1370 let declared_start = Some(
1371 start
1372 .unwrap_or(fills_floor)
1373 .max(fills_floor)
1374 .min(fills_latest_start),
1375 );
1376
1377 let order_cmd = GenerateOrderStatusReports::new(
1378 UUID4::new(),
1379 ts_now,
1380 false, None, start,
1383 None, None, None, );
1387
1388 let mut fill_cmd = GenerateFillReports::new(
1389 UUID4::new(),
1390 ts_now,
1391 None, None, declared_start,
1394 Some(ts_now), None, None, );
1398
1399 fill_cmd.log_receipt_level = LogLevel::Off;
1400
1401 let mut position_cmd = GeneratePositionStatusReports::new(
1402 UUID4::new(),
1403 ts_now,
1404 None, start,
1406 None, None, None, );
1410
1411 position_cmd.log_receipt_level = LogLevel::Off;
1412
1413 let (mut order_reports, fill_reports, mut position_reports) = tokio::try_join!(
1414 async {
1415 let cid_resolver = |cid| {
1416 self.ws_orders
1417 .caches()
1418 .cid_to_client_order_id
1419 .get(&cid)
1420 .map(|v| *v)
1421 };
1422 let mut reports = self
1423 .http_client
1424 .request_order_history(self.core.account_id, start, None, Some(&cid_resolver))
1425 .await?;
1426 let active = self
1427 .http_client
1428 .request_order_status_reports(self.core.account_id, Some(&cid_resolver))
1429 .await?;
1430 reports = merge_active_order_reports(reports, active);
1431 retain_order_status_reports(&mut reports, &order_cmd);
1432
1433 for report in &reports {
1434 cleanup_closed_order_status_report(report, self.ws_orders.caches());
1435 }
1436
1437 Ok::<_, anyhow::Error>(reports)
1438 },
1439 self.generate_fill_reports(fill_cmd),
1440 self.generate_position_status_reports(&position_cmd),
1441 )?;
1442
1443 self.add_flat_position_reports(positions_cached, &mut position_reports, ts_now);
1444
1445 log::info!("Received {} OrderStatusReports", order_reports.len());
1446 log::info!("Received {} FillReports", fill_reports.len());
1447 log::info!("Received {} PositionReports", position_reports.len());
1448
1449 let mut mass_status = ExecutionMassStatus::new(
1450 self.core.client_id,
1451 self.core.account_id,
1452 *AX_VENUE,
1453 ts_now,
1454 None,
1455 );
1456
1457 mass_status.set_report_window(declared_start, true);
1459
1460 update_order_report_timestamps(&mut order_reports, &fill_reports);
1461 add_special_fill_order_reports(&mut order_reports, &fill_reports);
1462
1463 mass_status.add_order_reports(order_reports);
1464 mass_status.add_fill_reports(fill_reports);
1465 mass_status.add_position_reports(position_reports);
1466
1467 Ok(Some(mass_status))
1468 }
1469
1470 fn register_external_order(
1471 &self,
1472 client_order_id: ClientOrderId,
1473 venue_order_id: VenueOrderId,
1474 instrument_id: InstrumentId,
1475 strategy_id: StrategyId,
1476 _ts_init: UnixNanos,
1477 ) {
1478 let order = self.core.cache().order_owned(&client_order_id);
1479 if let Some(order) = order {
1480 if let Err(e) = self.restore_order_tracking(&order, Some(venue_order_id)) {
1481 log::warn!("Cannot restore AX order {client_order_id}: {e}");
1482 }
1483
1484 return;
1485 }
1486
1487 self.ws_orders.register_external_order(
1488 client_order_id,
1489 venue_order_id,
1490 instrument_id,
1491 strategy_id,
1492 );
1493 }
1494}
1495
1496impl AxExecutionClient {
1497 fn restore_order_tracking(
1498 &self,
1499 order: &OrderAny,
1500 venue_order_id: Option<VenueOrderId>,
1501 ) -> anyhow::Result<()> {
1502 let client_order_id = order.client_order_id();
1503 let caches = self.ws_orders.caches();
1504
1505 let instrument = self
1506 .http_client
1507 .get_instrument(&order.instrument_id().symbol.inner())
1508 .or_else(|| {
1509 self.core
1510 .cache()
1511 .instrument(&order.instrument_id())
1512 .cloned()
1513 })
1514 .context("cannot restore AX order without its instrument")?;
1515
1516 self.http_client.cache_instrument(instrument.clone());
1517 self.ws_orders.cache_instrument(instrument.clone());
1518 let cid = client_order_id_to_cid(&client_order_id);
1519 let cid_owner = caches.cid_to_client_order_id.get(&cid).map(|entry| *entry);
1520 if cid_owner != Some(client_order_id) {
1521 reserve_cid_mapping(caches, cid, client_order_id)?;
1522 }
1523
1524 let metadata = OrderMetadata {
1525 trader_id: order.trader_id(),
1526 strategy_id: order.strategy_id(),
1527 instrument_id: order.instrument_id(),
1528 client_order_id,
1529 order_init: order
1530 .events()
1531 .into_iter()
1532 .rev()
1533 .find_map(|event| match event {
1534 OrderEventAny::Initialized(init) => Some(init.clone()),
1535 _ => None,
1536 }),
1537 is_accepted: order.status() != OrderStatus::Submitted,
1538 venue_order_id: order.venue_order_id().or(venue_order_id),
1539 ts_init: order.init_event().ts_init,
1540 size_precision: instrument.size_precision(),
1541 price_precision: instrument.price_precision(),
1542 quote_currency: instrument.quote_currency(),
1543 };
1544
1545 let venue_order_id = caches
1546 .orders_metadata
1547 .entry(client_order_id)
1548 .or_insert(metadata)
1549 .venue_order_id;
1550
1551 if let Some(venue_order_id) = venue_order_id {
1552 caches
1553 .venue_to_client_id
1554 .insert(venue_order_id, client_order_id);
1555 }
1556
1557 Ok(())
1558 }
1559
1560 fn report_client(&self) -> AxReportClient {
1561 AxReportClient {
1562 http_client: self.http_client.clone(),
1563 account_id: self.core.account_id,
1564 cid_to_client_order_id: self.ws_orders.caches().cid_to_client_order_id.clone(),
1565 }
1566 }
1567
1568 fn add_flat_position_reports(
1569 &self,
1570 mut positions_expected: AHashMap<InstrumentId, u8>,
1571 position_reports: &mut Vec<PositionStatusReport>,
1572 ts_init: UnixNanos,
1573 ) {
1574 for report in position_reports.iter() {
1575 positions_expected.remove(&report.instrument_id);
1576 }
1577
1578 let mut positions_expected: Vec<_> = positions_expected.into_iter().collect();
1579 positions_expected.sort_unstable_by_key(|(instrument_id, _)| *instrument_id);
1580
1581 for (instrument_id, precision) in positions_expected {
1583 position_reports.push(PositionStatusReport::new(
1584 self.core.account_id,
1585 instrument_id,
1586 PositionSide::Flat,
1587 Quantity::zero(precision),
1588 ts_init,
1589 ts_init,
1590 None,
1591 None,
1592 None,
1593 ));
1594 }
1595 }
1596}
1597
1598struct AxReportClient {
1599 http_client: AxHttpClient,
1600 account_id: AccountId,
1601 cid_to_client_order_id: Arc<DashMap<u64, ClientOrderId>>,
1602}
1603
1604impl AxReportClient {
1605 async fn generate_order_status_report(
1606 &self,
1607 cmd: &GenerateOrderStatusReport,
1608 ) -> anyhow::Result<Option<OrderStatusReport>> {
1609 let cid_map = self.cid_to_client_order_id.clone();
1610 let cid_resolver = move |cid: u64| cid_map.get(&cid).map(|v| *v);
1611
1612 let Some(report) = self
1613 .http_client
1614 .request_order_report(
1615 self.account_id,
1616 cmd.client_order_id,
1617 cmd.venue_order_id,
1618 Some(cid_resolver),
1619 )
1620 .await?
1621 else {
1622 return Ok(None);
1623 };
1624
1625 anyhow::ensure!(
1626 cmd.instrument_id
1627 .is_none_or(|id| report.instrument_id == id),
1628 "AX order report instrument does not match requested instrument"
1629 );
1630 anyhow::ensure!(
1631 cmd.venue_order_id
1632 .is_none_or(|id| report.venue_order_id == id),
1633 "AX order report venue identity does not match requested order"
1634 );
1635 Ok(Some(report))
1636 }
1637
1638 async fn generate_order_status_reports(
1639 &self,
1640 cmd: &GenerateOrderStatusReports,
1641 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1642 let cid_map = self.cid_to_client_order_id.clone();
1643 let cid_resolver = move |cid: u64| cid_map.get(&cid).map(|v| *v);
1644
1645 let mut reports = if cmd.open_only {
1646 self.http_client
1647 .request_order_status_reports(self.account_id, Some(&cid_resolver))
1648 .await?
1649 } else {
1650 let reports = self
1651 .http_client
1652 .request_historical_order_status_reports(
1653 self.account_id,
1654 cmd.start,
1655 cmd.end,
1656 Some(&cid_resolver),
1657 )
1658 .await?;
1659 let active = self
1660 .http_client
1661 .request_order_status_reports(self.account_id, Some(&cid_resolver))
1662 .await?;
1663 merge_active_order_reports(reports, active)
1664 };
1665
1666 if let Some(instrument_id) = cmd.instrument_id {
1667 reports.retain(|report| report.instrument_id == instrument_id);
1668 }
1669
1670 retain_order_status_reports(&mut reports, cmd);
1671 log_report_receipt(reports.len(), "OrderStatusReport", cmd.log_receipt_level);
1672
1673 Ok(reports)
1674 }
1675
1676 async fn generate_fill_reports(
1677 &self,
1678 cmd: GenerateFillReports,
1679 ) -> anyhow::Result<Vec<FillReport>> {
1680 let log_level = cmd.log_receipt_level;
1681 let mut reports = self
1682 .http_client
1683 .request_fill_reports(self.account_id, cmd.start, cmd.end)
1684 .await?;
1685
1686 if let Some(instrument_id) = cmd.instrument_id {
1687 reports.retain(|report| report.instrument_id == instrument_id);
1688 }
1689
1690 if let Some(venue_order_id) = cmd.venue_order_id {
1691 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
1692 }
1693
1694 log_report_receipt(reports.len(), "FillReport", log_level);
1695 Ok(reports)
1696 }
1697
1698 async fn generate_position_status_reports(
1699 &self,
1700 cmd: &GeneratePositionStatusReports,
1701 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1702 let mut reports = self
1703 .http_client
1704 .request_position_reports(self.account_id)
1705 .await?;
1706
1707 if let Some(instrument_id) = cmd.instrument_id {
1708 reports.retain(|report| report.instrument_id == instrument_id);
1709 }
1710
1711 log_report_receipt(reports.len(), "PositionStatusReport", cmd.log_receipt_level);
1712 Ok(reports)
1713 }
1714}
1715
1716fn merge_active_order_reports(
1717 mut reports: Vec<OrderStatusReport>,
1718 active: Vec<OrderStatusReport>,
1719) -> Vec<OrderStatusReport> {
1720 let active_ids: AHashSet<_> = active.iter().map(|report| report.venue_order_id).collect();
1721 reports.retain(|report| !active_ids.contains(&report.venue_order_id));
1722 reports.extend(active);
1723 reports
1724}
1725
1726fn log_report_receipt(count: usize, report_type: &str, level: LogLevel) {
1727 if let Some(level) = map_log_level_to_filter(level).to_level() {
1728 log::log!(level, "Received {count} {report_type} reports");
1729 }
1730}
1731
1732fn add_special_fill_order_reports(
1733 order_reports: &mut Vec<OrderStatusReport>,
1734 fill_reports: &[FillReport],
1735) {
1736 for fill in fill_reports {
1737 if fill
1738 .venue_order_id
1739 .as_str()
1740 .strip_prefix(AX_FILL_ORDER_PREFIX)
1741 != Some(fill.trade_id.as_str())
1742 {
1743 continue;
1744 }
1745
1746 let report = OrderStatusReport::new(
1747 fill.account_id,
1748 fill.instrument_id,
1749 fill.client_order_id,
1750 fill.venue_order_id,
1751 Some(fill.order_side),
1752 OrderType::Market,
1753 TimeInForce::Gtc,
1754 OrderStatus::Filled,
1755 fill.last_qty,
1756 fill.last_qty,
1757 fill.ts_event,
1758 fill.ts_event,
1759 fill.ts_init,
1760 None,
1761 )
1762 .with_avg_px(fill.last_px.as_decimal());
1763 order_reports.push(report);
1764 }
1765}
1766
1767fn emit_ax_modify_rejected(
1768 emitter: &ExecutionEventEmitter,
1769 clock: &'static AtomicTime,
1770 strategy_id: StrategyId,
1771 instrument_id: InstrumentId,
1772 client_order_id: ClientOrderId,
1773 venue_order_id: Option<VenueOrderId>,
1774 reason: &str,
1775) {
1776 log::warn!("AX modify rejected for {client_order_id}: {reason}");
1777 emitter.emit_order_modify_rejected_event(
1778 strategy_id,
1779 instrument_id,
1780 client_order_id,
1781 venue_order_id,
1782 reason,
1783 clock.get_time_ns(),
1784 );
1785}
1786
1787fn dispatch_ws_message(
1789 message: AxOrdersWsMessage,
1790 emitter: &ExecutionEventEmitter,
1791 caches: &OrdersCaches,
1792 account_id: AccountId,
1793 instruments: &AtomicMap<Ustr, InstrumentAny>,
1794 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
1795 clock: &'static AtomicTime,
1796) {
1797 match message {
1798 AxOrdersWsMessage::Event(event) => {
1799 dispatch_order_event(
1800 *event,
1801 emitter,
1802 caches,
1803 account_id,
1804 instruments,
1805 account_fee_rates,
1806 clock,
1807 );
1808 }
1809 AxOrdersWsMessage::PlaceOrderResponse(resp) => {
1810 log::debug!(
1811 "Place order response: rid={} oid={}",
1812 resp.rid,
1813 resp.res.oid
1814 );
1815 }
1816 AxOrdersWsMessage::CancelOrderResponse(resp) => {
1817 log::debug!(
1818 "Cancel order response: rid={} accepted={}",
1819 resp.rid,
1820 resp.res.cxl_rx
1821 );
1822 }
1823 AxOrdersWsMessage::OpenOrdersResponse(resp) => {
1824 log::debug!("Open orders response: {} orders", resp.res.orders.len());
1825 }
1826 AxOrdersWsMessage::Error(err) => {
1827 log::debug!("WebSocket error: {}", err.message);
1828 }
1829 AxOrdersWsMessage::Reconnected => {
1830 log::info!("WebSocket reconnected");
1831 }
1832 AxOrdersWsMessage::Authenticated => {
1833 log::debug!("WebSocket authenticated");
1834 }
1835 }
1836}
1837
1838fn dispatch_order_event(
1839 event: AxWsOrderEvent,
1840 emitter: &ExecutionEventEmitter,
1841 caches: &OrdersCaches,
1842 account_id: AccountId,
1843 instruments: &AtomicMap<Ustr, InstrumentAny>,
1844 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
1845 clock: &'static AtomicTime,
1846) {
1847 let (order, replacement_id) = match &event {
1848 AxWsOrderEvent::Acknowledged(msg) => (Some(&msg.o), None),
1849 AxWsOrderEvent::PartiallyFilled(msg) => (Some(&msg.o), None),
1850 AxWsOrderEvent::Filled(msg) => (Some(&msg.o), None),
1851 AxWsOrderEvent::Canceled(msg) => (Some(&msg.o), None),
1852 AxWsOrderEvent::Rejected(msg) => (Some(&msg.o), None),
1853 AxWsOrderEvent::Expired(msg) => (Some(&msg.o), None),
1854 AxWsOrderEvent::Replaced(msg) => {
1855 (Some(msg.ro.as_ref()), replacement_venue_order_id(msg).ok())
1856 }
1857 AxWsOrderEvent::DoneForDay(msg) => (Some(&msg.o), None),
1858 AxWsOrderEvent::Heartbeat | AxWsOrderEvent::CancelRejected(_) => (None, None),
1859 };
1860
1861 let allow_previous = matches!(
1862 &event,
1863 AxWsOrderEvent::PartiallyFilled(_) | AxWsOrderEvent::Filled(_)
1864 );
1865
1866 if let Some(order) = order
1867 && !is_valid_order_update(order, caches, replacement_id, allow_previous)
1868 {
1869 log::warn!(
1870 "Deferring stale or malformed AX order update, order_id={}",
1871 order.oid
1872 );
1873 return;
1874 }
1875
1876 match event {
1877 AxWsOrderEvent::Heartbeat => {}
1878 AxWsOrderEvent::Acknowledged(msg) => {
1879 if let Some(event) =
1880 create_order_accepted(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1881 {
1882 emitter.send_order_event(OrderEventAny::Accepted(event));
1883 } else if let Some(report) = create_order_status_report(
1884 &msg.o,
1885 OrderStatus::Accepted,
1886 msg.ts,
1887 msg.tn,
1888 caches,
1889 account_id,
1890 instruments,
1891 clock,
1892 ) {
1893 emitter.send_order_status_report(report);
1894 }
1895 }
1896 AxWsOrderEvent::PartiallyFilled(msg) => {
1897 dispatch_fill_event(
1898 &msg.o,
1899 &msg.xs,
1900 msg.ts,
1901 msg.tn,
1902 emitter,
1903 caches,
1904 account_id,
1905 instruments,
1906 account_fee_rates,
1907 false,
1908 clock,
1909 );
1910 }
1911 AxWsOrderEvent::Filled(msg) => {
1912 dispatch_fill_event(
1913 &msg.o,
1914 &msg.xs,
1915 msg.ts,
1916 msg.tn,
1917 emitter,
1918 caches,
1919 account_id,
1920 instruments,
1921 account_fee_rates,
1922 true,
1923 clock,
1924 );
1925 }
1926 AxWsOrderEvent::Canceled(msg) => {
1927 if let Some(event) =
1928 create_order_canceled(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1929 {
1930 emitter.send_order_event(OrderEventAny::Canceled(event));
1931 } else if let Some(report) = create_order_status_report(
1932 &msg.o,
1933 OrderStatus::Canceled,
1934 msg.ts,
1935 msg.tn,
1936 caches,
1937 account_id,
1938 instruments,
1939 clock,
1940 ) {
1941 emitter.send_order_status_report(report);
1942 } else {
1943 return;
1944 }
1945 cleanup_terminal_order_tracking(&msg.o, caches);
1946 }
1947 AxWsOrderEvent::Rejected(msg) => {
1948 let reason = AxOrderRejectReason::reason_str(msg.r, msg.txt.as_deref())
1949 .unwrap_or_else(|| "UNKNOWN".to_string());
1950
1951 if let Some(event) =
1952 create_order_rejected(&msg.o, &reason, msg.ts, msg.tn, caches, account_id, clock)
1953 {
1954 emitter.send_order_event(OrderEventAny::Rejected(event));
1955 } else {
1956 return;
1957 }
1958 cleanup_terminal_order_tracking(&msg.o, caches);
1959 }
1960 AxWsOrderEvent::Expired(msg) => {
1961 let as_canceled = matches!(msg.o.tif, AxTimeInForce::Ioc | AxTimeInForce::Fok);
1963 let event = if as_canceled {
1964 create_order_canceled(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1965 .map(OrderEventAny::Canceled)
1966 } else {
1967 create_order_expired(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1968 .map(OrderEventAny::Expired)
1969 };
1970
1971 if let Some(event) = event {
1972 emitter.send_order_event(event);
1973 } else if let Some(report) = create_order_status_report(
1974 &msg.o,
1975 if as_canceled {
1976 OrderStatus::Canceled
1977 } else {
1978 OrderStatus::Expired
1979 },
1980 msg.ts,
1981 msg.tn,
1982 caches,
1983 account_id,
1984 instruments,
1985 clock,
1986 ) {
1987 emitter.send_order_status_report(report);
1988 } else {
1989 return;
1990 }
1991 cleanup_terminal_order_tracking(&msg.o, caches);
1992 }
1993 AxWsOrderEvent::Replaced(msg) => {
1994 let replacement_venue_order_id = match replacement_venue_order_id(&msg) {
1995 Ok(venue_order_id) => venue_order_id,
1996 Err(e) => {
1997 log::warn!("Invalid AX replace event, awaiting reconciliation: {e}");
1998 return;
1999 }
2000 };
2001
2002 let order = msg.no.as_deref().unwrap_or(&msg.ro);
2003
2004 if let Some(event) = create_order_updated(
2005 order,
2006 &msg.ro,
2007 replacement_venue_order_id,
2008 (msg.ts, msg.tn),
2009 caches,
2010 account_id,
2011 clock,
2012 ) {
2013 emitter.send_order_event(OrderEventAny::Updated(event));
2014 } else if lookup_order_metadata(&msg.ro, caches).is_none()
2015 && let Some(report) = create_order_status_report(
2016 order,
2017 OrderStatus::Accepted,
2018 msg.ts,
2019 msg.tn,
2020 caches,
2021 account_id,
2022 instruments,
2023 clock,
2024 )
2025 {
2026 emitter.send_order_status_report(report);
2027 }
2028 }
2029 AxWsOrderEvent::DoneForDay(msg) => {
2030 if let Some(event) =
2031 create_order_expired(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
2032 {
2033 emitter.send_order_event(OrderEventAny::Expired(event));
2034 } else if let Some(report) = create_order_status_report(
2035 &msg.o,
2036 OrderStatus::Expired,
2037 msg.ts,
2038 msg.tn,
2039 caches,
2040 account_id,
2041 instruments,
2042 clock,
2043 ) {
2044 emitter.send_order_status_report(report);
2045 } else {
2046 return;
2047 }
2048 cleanup_terminal_order_tracking(&msg.o, caches);
2049 }
2050 AxWsOrderEvent::CancelRejected(msg) => {
2051 let Ok(venue_order_id) = VenueOrderId::new_checked(&msg.oid) else {
2052 log::warn!("Invalid AX cancel rejection order ID");
2053 return;
2054 };
2055
2056 let reason = match msg.r {
2057 AxCancelRejectionReason::Unknown => msg
2058 .txt
2059 .as_deref()
2060 .filter(|text| !text.trim().is_empty())
2061 .unwrap_or(msg.r.as_ref()),
2062 _ => msg.r.as_ref(),
2063 };
2064
2065 let modify_rejected = reason == AX_POST_ONLY_REJECT;
2066
2067 let client_order_id = caches
2068 .venue_to_client_id
2069 .get(&venue_order_id)
2070 .map(|entry| *entry)
2071 .or_else(|| {
2072 modify_rejected
2073 .then(|| {
2074 caches
2075 .pending_modifies
2076 .get(&venue_order_id)
2077 .map(|entry| entry.0)
2078 })
2079 .flatten()
2080 });
2081
2082 if let Some(client_order_id) = client_order_id
2083 && let Some(metadata) = caches.orders_metadata.get(&client_order_id)
2084 && (modify_rejected
2085 || metadata
2086 .venue_order_id
2087 .is_none_or(|current| current == venue_order_id))
2088 {
2089 let strategy_id = metadata.strategy_id;
2090 let instrument_id = metadata.instrument_id;
2091 if modify_rejected {
2092 let active_venue_order_id = metadata.venue_order_id.unwrap_or(venue_order_id);
2093 drop(metadata);
2094 complete_pending_modify(caches, venue_order_id);
2095 emit_ax_modify_rejected(
2096 emitter,
2097 clock,
2098 strategy_id,
2099 instrument_id,
2100 client_order_id,
2101 Some(active_venue_order_id),
2102 reason,
2103 );
2104
2105 if let Some(order) = &msg.o
2106 && matches!(
2107 order.o,
2108 AxOrderStatus::Filled
2109 | AxOrderStatus::Canceled
2110 | AxOrderStatus::Expired
2111 )
2112 {
2113 cleanup_terminal_order_tracking(order, caches);
2114 }
2115 } else {
2116 let event = OrderCancelRejected::new(
2117 metadata.trader_id,
2118 strategy_id,
2119 instrument_id,
2120 client_order_id,
2121 Ustr::from(reason),
2122 UUID4::new(),
2123 clock.get_time_ns(),
2124 metadata.ts_init,
2125 false,
2126 Some(venue_order_id),
2127 Some(account_id),
2128 );
2129 emitter.send_order_event(OrderEventAny::CancelRejected(event));
2130 }
2131 } else {
2132 log::debug!(
2133 "Could not find metadata for cancel rejected order {}",
2134 msg.oid
2135 );
2136 }
2137 }
2138 }
2139}
2140
2141#[expect(clippy::too_many_arguments)]
2142fn dispatch_fill_event(
2143 order: &AxWsOrder,
2144 execution: &AxWsTradeExecution,
2145 ts: i64,
2146 tn: i64,
2147 emitter: &ExecutionEventEmitter,
2148 caches: &OrdersCaches,
2149 account_id: AccountId,
2150 instruments: &AtomicMap<Ustr, InstrumentAny>,
2151 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
2152 complete: bool,
2153 clock: &'static AtomicTime,
2154) {
2155 if order.o == AxOrderStatus::Unknown || order.tif == AxTimeInForce::Unknown {
2156 log::warn!(
2157 "Unknown AX order classification in fill, order_id={}, instrument={}, trade_id={}, state={}, time_in_force={}",
2158 order.oid,
2159 order.s,
2160 execution.tid,
2161 order.o,
2162 order.tif,
2163 );
2164 }
2165
2166 if execution.s != order.s {
2167 log::warn!("Deferring inconsistent AX fill, trade_id={}", execution.tid);
2168 return;
2169 }
2170
2171 let commission =
2172 match calculate_fill_commission(order, execution, instruments, account_fee_rates) {
2173 Ok(commission) => commission,
2174 Err(e) => {
2175 log::warn!(
2176 "Cannot calculate AX commission for trade_id={}: {e}",
2177 execution.tid
2178 );
2179 return;
2180 }
2181 };
2182
2183 if let Some(event) = create_order_filled(
2184 order, execution, ts, tn, caches, account_id, commission, clock,
2185 ) {
2186 let current = caches
2187 .orders_metadata
2188 .get(&event.client_order_id)
2189 .and_then(|metadata| metadata.venue_order_id);
2190
2191 if let Some(parent) = current
2192 && parent != event.venue_order_id
2193 && caches.pending_modifies.contains_key(&parent)
2194 && !caches.pending_modifies.contains_key(&event.venue_order_id)
2195 {
2196 record_replacement_venue_id(
2197 caches,
2198 event.client_order_id,
2199 parent,
2200 event.venue_order_id,
2201 false,
2202 );
2203 }
2204
2205 let needs_acceptance =
2206 lookup_order_metadata(order, caches).is_some_and(|metadata| !metadata.is_accepted);
2207
2208 if needs_acceptance
2209 && let Some(accepted) = create_order_accepted(order, ts, tn, caches, account_id, clock)
2210 {
2211 emitter.send_order_event(OrderEventAny::Accepted(accepted));
2212 }
2213
2214 emitter.send_order_event(OrderEventAny::Filled(event));
2215 } else if let Some(report) = create_fill_report(
2216 order,
2217 execution,
2218 ts,
2219 tn,
2220 caches,
2221 account_id,
2222 instruments,
2223 commission,
2224 clock,
2225 ) {
2226 emitter.send_fill_report(report);
2227 } else {
2228 return;
2229 }
2230
2231 if complete {
2232 cleanup_terminal_order_tracking(order, caches);
2233 }
2234}
2235
2236fn calculate_fill_commission(
2237 order: &AxWsOrder,
2238 execution: &AxWsTradeExecution,
2239 instruments: &AtomicMap<Ustr, InstrumentAny>,
2240 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
2241) -> anyhow::Result<Money> {
2242 let instruments = instruments.load();
2243 let instrument = instruments
2244 .get(&order.s)
2245 .context("AX fill instrument is not cached")?;
2246 let rates = account_fee_rates.load();
2247 let rates = rates
2248 .get(&execution.aid.or(order.aid))
2249 .context("AX fill account fee rates are not cached")?;
2250
2251 let liquidity_side = if execution.agg {
2252 LiquiditySide::Taker
2253 } else {
2254 LiquiditySide::Maker
2255 };
2256
2257 let rate = rates.rate_for(liquidity_side)?;
2258
2259 let commission = Decimal::from(execution.q)
2261 .checked_mul(execution.p.abs())
2262 .and_then(|notional| notional.checked_mul(instrument.multiplier().as_decimal()))
2263 .and_then(|notional| notional.checked_mul(rate))
2264 .context("AX fill commission calculation overflow")?;
2265
2266 Money::from_decimal(commission, Currency::USD())
2267 .context("failed to convert AX fill commission to Money")
2268}
2269
2270pub(crate) fn lookup_order_metadata<'a>(
2271 order: &AxWsOrder,
2272 caches: &'a OrdersCaches,
2273) -> Option<dashmap::mapref::one::Ref<'a, ClientOrderId, OrderMetadata>> {
2274 let client_order_id = VenueOrderId::new_checked(&order.oid)
2275 .ok()
2276 .and_then(|venue_order_id| {
2277 caches
2278 .venue_to_client_id
2279 .get(&venue_order_id)
2280 .map(|entry| *entry)
2281 });
2282
2283 if let Some(client_order_id) = client_order_id
2284 && let Some(metadata) = caches.orders_metadata.get(&client_order_id)
2285 {
2286 return Some(metadata);
2287 }
2288
2289 if let Some(cid) = order.cid
2290 && let Some(client_order_id) = caches.cid_to_client_order_id.get(&cid).map(|entry| *entry)
2291 && let Some(metadata) = caches.orders_metadata.get(&client_order_id)
2292 {
2293 return Some(metadata);
2294 }
2295
2296 None
2297}
2298
2299fn is_valid_order_update(
2300 order: &AxWsOrder,
2301 caches: &OrdersCaches,
2302 replacement_id: Option<VenueOrderId>,
2303 allow_previous: bool,
2304) -> bool {
2305 let Ok(venue_order_id) = VenueOrderId::new_checked(&order.oid) else {
2306 return false;
2307 };
2308
2309 let Some(metadata) = lookup_order_metadata(order, caches) else {
2310 return true;
2311 };
2312
2313 if metadata.instrument_id.symbol.inner() != order.s {
2314 return false;
2315 }
2316
2317 if let Some(order_init) = &metadata.order_init
2318 && order_init.order_side != OrderSide::from(order.d)
2319 {
2320 return false;
2321 }
2322
2323 let is_current = metadata
2324 .venue_order_id
2325 .is_none_or(|current| current == venue_order_id || replacement_id == Some(current));
2326 let client_order_id = metadata.client_order_id;
2327 drop(metadata);
2328
2329 is_current
2330 || (allow_previous
2331 && caches
2332 .venue_to_client_id
2333 .get(&venue_order_id)
2334 .is_some_and(|mapped| *mapped == client_order_id))
2335}
2336
2337pub(crate) fn replacement_venue_order_id(
2338 message: &crate::websocket::messages::AxWsOrderReplaced,
2339) -> anyhow::Result<VenueOrderId> {
2340 match (&message.noid, &message.no) {
2341 (Some(noid), Some(order)) if noid == &order.oid => {
2342 VenueOrderId::new_checked(noid).map_err(anyhow::Error::from)
2343 }
2344 (None, None) => VenueOrderId::new_checked(&message.ro.oid).map_err(anyhow::Error::from),
2345 _ => anyhow::bail!("Inconsistent AX replacement order identity"),
2346 }
2347}
2348
2349pub(crate) fn create_order_accepted(
2350 order: &AxWsOrder,
2351 event_ts: i64,
2352 event_tn: i64,
2353 caches: &OrdersCaches,
2354 account_id: AccountId,
2355 clock: &'static AtomicTime,
2356) -> Option<OrderAccepted> {
2357 if !is_valid_order_update(order, caches, None, false) {
2358 return None;
2359 }
2360
2361 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2362 .map_err(|e| log::error!("{e}"))
2363 .ok()?;
2364 let venue_order_id = VenueOrderId::new_checked(&order.oid).ok()?;
2365 let metadata = lookup_order_metadata(order, caches)?;
2366
2367 if metadata.is_accepted {
2368 return None;
2369 }
2370
2371 let client_order_id = metadata.client_order_id;
2372 let trader_id = metadata.trader_id;
2373 let strategy_id = metadata.strategy_id;
2374 let instrument_id = metadata.instrument_id;
2375 drop(metadata);
2376
2377 let mut entry = caches.orders_metadata.get_mut(&client_order_id)?;
2378 if entry.is_accepted
2379 || entry
2380 .venue_order_id
2381 .is_some_and(|current| current != venue_order_id)
2382 {
2383 return None;
2384 }
2385
2386 entry.venue_order_id = Some(venue_order_id);
2387 entry.is_accepted = true;
2388 drop(entry);
2389 caches
2390 .venue_to_client_id
2391 .insert(venue_order_id, client_order_id);
2392
2393 Some(OrderAccepted::new(
2394 trader_id,
2395 strategy_id,
2396 instrument_id,
2397 client_order_id,
2398 venue_order_id,
2399 account_id,
2400 UUID4::new(),
2401 ts_event,
2402 clock.get_time_ns(),
2403 false,
2404 ))
2405}
2406
2407pub(crate) fn create_order_updated(
2408 order: &AxWsOrder,
2409 replaced_order: &AxWsOrder,
2410 replacement_venue_order_id: VenueOrderId,
2411 event_timestamp: (i64, i64),
2412 caches: &OrdersCaches,
2413 account_id: AccountId,
2414 clock: &'static AtomicTime,
2415) -> Option<OrderUpdated> {
2416 let metadata = lookup_order_metadata(order, caches)
2417 .or_else(|| lookup_order_metadata(replaced_order, caches))?;
2418
2419 if metadata.instrument_id.symbol.inner() != order.s
2420 || metadata
2421 .order_init
2422 .as_ref()
2423 .is_some_and(|order_init| order_init.order_side != OrderSide::from(order.d))
2424 {
2425 log::warn!(
2426 "Deferring inconsistent AX replacement, order_id={}",
2427 order.oid
2428 );
2429 return None;
2430 }
2431
2432 let client_order_id = metadata.client_order_id;
2433 let trader_id = metadata.trader_id;
2434 let strategy_id = metadata.strategy_id;
2435 let instrument_id = metadata.instrument_id;
2436 let price_precision = metadata.price_precision;
2437 let size_precision = metadata.size_precision;
2438 drop(metadata);
2439
2440 let ts_event = ax_timestamp_stn_to_unix_nanos(event_timestamp.0, event_timestamp.1)
2441 .map_err(|e| log::error!("{e}"))
2442 .ok()?;
2443
2444 let quantity = Quantity::from_decimal_dp(Decimal::from(order.q), size_precision).ok()?;
2445 let price = Some(Price::from_decimal_dp(order.p, price_precision).ok()?);
2446 let previous_venue_order_id = VenueOrderId::new_checked(&replaced_order.oid).ok()?;
2447
2448 if !record_replacement_venue_id(
2449 caches,
2450 client_order_id,
2451 previous_venue_order_id,
2452 replacement_venue_order_id,
2453 true,
2454 ) {
2455 return None;
2456 }
2457
2458 complete_pending_modify(caches, previous_venue_order_id);
2459
2460 Some(OrderUpdated::new(
2461 trader_id,
2462 strategy_id,
2463 instrument_id,
2464 client_order_id,
2465 quantity,
2466 UUID4::new(),
2467 ts_event,
2468 clock.get_time_ns(),
2469 false,
2470 Some(replacement_venue_order_id),
2471 Some(account_id),
2472 price,
2473 None, None, false,
2476 ))
2477}
2478
2479#[expect(clippy::too_many_arguments)]
2480pub(crate) fn create_order_filled(
2481 order: &AxWsOrder,
2482 execution: &AxWsTradeExecution,
2483 event_ts: i64,
2484 event_tn: i64,
2485 caches: &OrdersCaches,
2486 account_id: AccountId,
2487 commission: Money,
2488 clock: &'static AtomicTime,
2489) -> Option<OrderFilled> {
2490 let venue_order_id = VenueOrderId::new_checked(&order.oid).ok()?;
2491 let metadata = lookup_order_metadata(order, caches)?;
2492
2493 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2494 .map_err(|e| log::error!("{e}"))
2495 .ok()?;
2496
2497 let last_qty =
2498 Quantity::from_decimal_dp(Decimal::from(execution.q), metadata.size_precision).ok()?;
2499 let last_px = Price::from_decimal_dp(execution.p, metadata.price_precision).ok()?;
2500
2501 let order_side = metadata.order_init.as_ref().map_or_else(
2502 || OrderSide::from(order.d),
2503 |order_init| order_init.order_side,
2504 );
2505 let order_type = metadata
2506 .order_init
2507 .as_ref()
2508 .map_or(OrderType::Limit, |order_init| order_init.order_type);
2509 let trade_id = TradeId::new_checked(&execution.tid).ok()?;
2510
2511 let liquidity_side = if execution.agg {
2512 LiquiditySide::Taker
2513 } else {
2514 LiquiditySide::Maker
2515 };
2516
2517 Some(OrderFilled::new(
2518 metadata.trader_id,
2519 metadata.strategy_id,
2520 metadata.instrument_id,
2521 metadata.client_order_id,
2522 venue_order_id,
2523 account_id,
2524 trade_id,
2525 order_side,
2526 order_type,
2527 last_qty,
2528 last_px,
2529 metadata.quote_currency,
2530 liquidity_side,
2531 UUID4::new(),
2532 ts_event,
2533 clock.get_time_ns(),
2534 false,
2535 None,
2536 Some(commission),
2537 None,
2538 ))
2539}
2540
2541pub(crate) fn create_order_canceled(
2542 order: &AxWsOrder,
2543 event_ts: i64,
2544 event_tn: i64,
2545 caches: &OrdersCaches,
2546 account_id: AccountId,
2547 clock: &'static AtomicTime,
2548) -> Option<OrderCanceled> {
2549 let venue_order_id = VenueOrderId::new_checked(&order.oid).ok()?;
2550 let metadata = lookup_order_metadata(order, caches)?;
2551
2552 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2553 .map_err(|e| log::error!("{e}"))
2554 .ok()?;
2555
2556 Some(OrderCanceled::new(
2557 metadata.trader_id,
2558 metadata.strategy_id,
2559 metadata.instrument_id,
2560 metadata.client_order_id,
2561 UUID4::new(),
2562 ts_event,
2563 clock.get_time_ns(),
2564 false,
2565 Some(venue_order_id),
2566 Some(account_id),
2567 None,
2568 ))
2569}
2570
2571pub(crate) fn create_order_expired(
2572 order: &AxWsOrder,
2573 event_ts: i64,
2574 event_tn: i64,
2575 caches: &OrdersCaches,
2576 account_id: AccountId,
2577 clock: &'static AtomicTime,
2578) -> Option<OrderExpired> {
2579 let venue_order_id = VenueOrderId::new_checked(&order.oid).ok()?;
2580 let metadata = lookup_order_metadata(order, caches)?;
2581
2582 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2583 .map_err(|e| log::error!("{e}"))
2584 .ok()?;
2585
2586 Some(OrderExpired::new(
2587 metadata.trader_id,
2588 metadata.strategy_id,
2589 metadata.instrument_id,
2590 metadata.client_order_id,
2591 UUID4::new(),
2592 ts_event,
2593 clock.get_time_ns(),
2594 false,
2595 Some(venue_order_id),
2596 Some(account_id),
2597 ))
2598}
2599
2600pub(crate) fn create_order_rejected(
2601 order: &AxWsOrder,
2602 reason: &str,
2603 event_ts: i64,
2604 event_tn: i64,
2605 caches: &OrdersCaches,
2606 account_id: AccountId,
2607 clock: &'static AtomicTime,
2608) -> Option<OrderRejected> {
2609 let metadata = lookup_order_metadata(order, caches)?;
2610
2611 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2612 .map_err(|e| log::error!("{e}"))
2613 .ok()?;
2614 let due_post_only = reason.contains(AX_POST_ONLY_REJECT);
2615
2616 Some(OrderRejected::new(
2617 metadata.trader_id,
2618 metadata.strategy_id,
2619 metadata.instrument_id,
2620 metadata.client_order_id,
2621 account_id,
2622 Ustr::from(reason),
2623 UUID4::new(),
2624 ts_event,
2625 clock.get_time_ns(),
2626 false,
2627 due_post_only,
2628 ))
2629}
2630
2631fn cleanup_closed_order_status_report(report: &OrderStatusReport, caches: &OrdersCaches) {
2632 if caches.pending_modifies.contains_key(&report.venue_order_id) {
2633 return;
2634 }
2635
2636 cleanup_resolved_order_status_report(report, caches);
2637}
2638
2639fn cleanup_resolved_order_status_report(report: &OrderStatusReport, caches: &OrdersCaches) {
2640 if !report.order_status.is_closed() {
2641 return;
2642 }
2643
2644 let client_order_id = report.client_order_id.or_else(|| {
2645 caches
2646 .venue_to_client_id
2647 .get(&report.venue_order_id)
2648 .map(|entry| *entry)
2649 });
2650
2651 if let Some(client_order_id) = client_order_id
2652 && let Some(metadata) = caches.orders_metadata.get(&client_order_id)
2653 && metadata
2654 .venue_order_id
2655 .is_some_and(|current| current != report.venue_order_id)
2656 {
2657 return;
2658 }
2659
2660 cleanup_terminal_order_tracking_ids(
2661 caches,
2662 Some(&report.venue_order_id),
2663 report.client_order_id.as_ref().map(client_order_id_to_cid),
2664 report.client_order_id,
2665 );
2666}
2667
2668pub(crate) fn cleanup_terminal_order_tracking(order: &AxWsOrder, caches: &OrdersCaches) {
2669 if !is_valid_order_update(order, caches, None, false) {
2670 return;
2671 }
2672
2673 let Ok(venue_order_id) = VenueOrderId::new_checked(&order.oid) else {
2674 return;
2675 };
2676
2677 cleanup_terminal_order_tracking_ids(caches, Some(&venue_order_id), order.cid, None);
2678}
2679
2680fn cleanup_terminal_order_tracking_ids(
2681 caches: &OrdersCaches,
2682 venue_order_id: Option<&VenueOrderId>,
2683 cid: Option<u64>,
2684 known_client_order_id: Option<ClientOrderId>,
2685) {
2686 if let Some(id) = venue_order_id
2688 && let Some(mut pending) = caches.pending_modifies.get_mut(id)
2689 {
2690 pending.2 = true;
2691 return;
2692 }
2693
2694 let client_order_id = venue_order_id
2695 .and_then(|venue_order_id| {
2696 caches
2697 .venue_to_client_id
2698 .remove(venue_order_id)
2699 .map(|(_, v)| v)
2700 })
2701 .or_else(|| cid.and_then(|cid| caches.cid_to_client_order_id.remove(&cid).map(|(_, v)| v)))
2702 .or(known_client_order_id);
2703
2704 if let Some(client_order_id) = client_order_id {
2705 caches.orders_metadata.remove(&client_order_id);
2706 caches
2707 .pending_modifies
2708 .retain(|_, (owner, _, _)| *owner != client_order_id);
2709 caches
2710 .venue_to_client_id
2711 .retain(|_, mapped_client_order_id| *mapped_client_order_id != client_order_id);
2712 caches
2713 .cid_to_client_order_id
2714 .retain(|_, mapped_client_order_id| *mapped_client_order_id != client_order_id);
2715 }
2716
2717 if let Some(cid) = cid {
2718 caches.cid_to_client_order_id.remove(&cid);
2719 }
2720}
2721
2722fn cleanup_deferred_order_tracking(caches: &OrdersCaches) {
2723 let terminal: Vec<_> = caches
2724 .pending_modifies
2725 .iter()
2726 .filter_map(|entry| entry.2.then_some((*entry.key(), entry.0)))
2727 .collect();
2728
2729 for (venue_order_id, client_order_id) in terminal {
2730 cleanup_deferred_order_tracking_ids(caches, venue_order_id, client_order_id);
2731 }
2732}
2733
2734fn complete_pending_modify(caches: &OrdersCaches, venue_order_id: VenueOrderId) {
2735 let completed = caches
2736 .pending_modifies
2737 .remove_if_mut(&venue_order_id, |_, (_, count, _)| {
2738 *count -= 1;
2739 *count == 0
2740 });
2741
2742 if let Some((_, (client_order_id, _, terminal))) = completed {
2743 if !caches
2744 .pending_modifies
2745 .iter()
2746 .any(|entry| entry.0 == client_order_id)
2747 {
2748 let current = caches
2749 .orders_metadata
2750 .get(&client_order_id)
2751 .and_then(|metadata| metadata.venue_order_id);
2752 caches
2753 .venue_to_client_id
2754 .retain(|venue_id, owner| *owner != client_order_id || Some(*venue_id) == current);
2755 }
2756
2757 if terminal {
2758 cleanup_deferred_order_tracking_ids(caches, venue_order_id, client_order_id);
2759 }
2760 }
2761}
2762
2763fn cleanup_deferred_order_tracking_ids(
2764 caches: &OrdersCaches,
2765 venue_order_id: VenueOrderId,
2766 client_order_id: ClientOrderId,
2767) {
2768 let is_current = caches
2769 .orders_metadata
2770 .get(&client_order_id)
2771 .is_some_and(|metadata| metadata.venue_order_id == Some(venue_order_id));
2772 if is_current {
2773 caches.pending_modifies.remove(&venue_order_id);
2774 cleanup_terminal_order_tracking_ids(
2775 caches,
2776 Some(&venue_order_id),
2777 None,
2778 Some(client_order_id),
2779 );
2780 }
2781}
2782
2783async fn resolve_order_status_report(
2784 http_client: &AxHttpClient,
2785 caches: &OrdersCaches,
2786 client_order_id: ClientOrderId,
2787 report: OrderStatusReport,
2788) -> anyhow::Result<Option<OrderStatusReport>> {
2789 let parent = report.venue_order_id;
2790 let stale = caches
2791 .orders_metadata
2792 .get(&client_order_id)
2793 .and_then(|metadata| metadata.venue_order_id)
2794 .is_some_and(|current| current != parent);
2795
2796 if stale {
2797 return Ok(None);
2798 }
2799
2800 if !report.order_status.is_closed() {
2801 return Ok(Some(report));
2802 }
2803
2804 let candidates: Vec<_> = caches
2805 .venue_to_client_id
2806 .iter()
2807 .filter_map(|entry| {
2808 (*entry.value() == client_order_id && *entry.key() != parent).then_some(*entry.key())
2809 })
2810 .collect();
2811
2812 if candidates.is_empty() && !caches.pending_modifies.contains_key(&parent) {
2813 return Ok(Some(report));
2814 }
2815
2816 let status = http_client
2817 .inner
2818 .get_order_status_by_id(parent.as_str())
2819 .await?;
2820
2821 if status.status.state != AxOrderStatus::Replaced {
2822 return Ok(Some(report));
2823 }
2824
2825 for candidate in candidates {
2826 let Some(candidate_report) = http_client
2827 .request_order_report(
2828 report.account_id,
2829 Some(client_order_id),
2830 Some(candidate),
2831 None::<fn(u64) -> Option<ClientOrderId>>,
2832 )
2833 .await?
2834 else {
2835 continue;
2836 };
2837
2838 anyhow::ensure!(
2839 candidate_report.venue_order_id == candidate
2840 && candidate_report.instrument_id == report.instrument_id,
2841 "AX replacement report identity does not match the tracked order"
2842 );
2843
2844 if candidate_report.order_status == OrderStatus::Rejected {
2845 continue;
2846 }
2847
2848 if candidate_report.order_status == OrderStatus::Canceled {
2849 let status = http_client
2850 .inner
2851 .get_order_status_by_id(candidate.as_str())
2852 .await?;
2853
2854 if status.status.state == AxOrderStatus::Replaced {
2855 continue;
2856 }
2857 }
2858
2859 if record_replacement_venue_id(caches, client_order_id, parent, candidate, false) {
2860 return Ok(Some(candidate_report));
2861 }
2862
2863 return Ok(None);
2864 }
2865
2866 log::warn!(
2867 "AX replaced parent {parent} has no confirmed working replacement, awaiting reconciliation"
2868 );
2869 Ok(None)
2870}
2871
2872fn record_replacement_proposal(
2873 caches: &OrdersCaches,
2874 client_order_id: ClientOrderId,
2875 parent: VenueOrderId,
2876 proposed: VenueOrderId,
2877) {
2878 let Some(pending) = caches.pending_modifies.get(&parent) else {
2879 return;
2880 };
2881
2882 let current = caches
2883 .orders_metadata
2884 .get(&client_order_id)
2885 .and_then(|metadata| metadata.venue_order_id);
2886
2887 if pending.0 == client_order_id && current == Some(parent) {
2888 caches.venue_to_client_id.insert(proposed, client_order_id);
2889 }
2890}
2891
2892fn record_replacement_venue_id(
2893 caches: &OrdersCaches,
2894 client_order_id: ClientOrderId,
2895 previous_venue_order_id: VenueOrderId,
2896 venue_order_id: VenueOrderId,
2897 remove_previous_venue_ids: bool,
2898) -> bool {
2899 let Some(mut entry) = caches.orders_metadata.get_mut(&client_order_id) else {
2900 return false;
2901 };
2902
2903 if entry
2904 .venue_order_id
2905 .is_some_and(|current| current != previous_venue_order_id && current != venue_order_id)
2906 {
2907 log::warn!("Deferring stale AX replacement for {client_order_id}");
2908 return false;
2909 }
2910
2911 entry.venue_order_id = Some(venue_order_id);
2912 drop(entry);
2913 caches
2914 .venue_to_client_id
2915 .insert(venue_order_id, client_order_id);
2916
2917 if remove_previous_venue_ids {
2918 caches
2919 .venue_to_client_id
2920 .retain(|mapped_venue_order_id, mapped_client_order_id| {
2921 *mapped_client_order_id != client_order_id
2922 || *mapped_venue_order_id == venue_order_id
2923 });
2924 }
2925
2926 true
2927}
2928
2929#[expect(clippy::too_many_arguments)]
2930fn create_order_status_report(
2931 order: &AxWsOrder,
2932 order_status: OrderStatus,
2933 event_ts: i64,
2934 event_tn: i64,
2935 caches: &OrdersCaches,
2936 account_id: AccountId,
2937 instruments: &AtomicMap<Ustr, InstrumentAny>,
2938 clock: &'static AtomicTime,
2939) -> Option<OrderStatusReport> {
2940 if lookup_order_metadata(order, caches).is_some() {
2941 return None;
2942 }
2943
2944 let instruments_snap = instruments.load();
2945 let instrument = instruments_snap.get(&order.s)?;
2946 let venue_order_id = VenueOrderId::new_checked(&order.oid).ok()?;
2947 let instrument_id = instrument.id();
2948 let order_side = OrderSide::from(order.d);
2949
2950 let time_in_force = TimeInForce::try_from(order.tif)
2951 .map_err(|e| {
2952 log::warn!(
2953 "Cannot map AX order time in force, order_id={}, instrument={}, error={e}",
2954 order.oid,
2955 order.s
2956 );
2957 })
2958 .ok()?;
2959
2960 let quantity =
2961 Quantity::from_decimal_dp(Decimal::from(order.q), instrument.size_precision()).ok()?;
2962 let filled_qty =
2963 Quantity::from_decimal_dp(Decimal::from(order.xq), instrument.size_precision()).ok()?;
2964
2965 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2966 .map_err(|e| log::error!("{e}"))
2967 .ok()?;
2968 let ts_init = clock.get_time_ns();
2969
2970 let client_order_id = order.cid.map(|cid| {
2971 caches
2972 .cid_to_client_order_id
2973 .get(&cid)
2974 .map_or_else(|| cid_to_client_order_id(cid), |v| *v)
2975 });
2976
2977 let mut report = OrderStatusReport::new(
2978 account_id,
2979 instrument_id,
2980 client_order_id,
2981 venue_order_id,
2982 order_side.into(),
2983 OrderType::Limit,
2984 time_in_force,
2985 order_status,
2986 quantity,
2987 filled_qty,
2988 ts_event,
2989 ts_event,
2990 ts_init,
2991 Some(UUID4::new()),
2992 );
2993
2994 if let Ok(price) = Price::from_decimal_dp(order.p, instrument.price_precision()) {
2995 report = report.with_price(price);
2996 }
2997
2998 report = report.with_post_only(order.po);
2999
3000 Some(report)
3001}
3002
3003#[expect(clippy::too_many_arguments)]
3004fn create_fill_report(
3005 order: &AxWsOrder,
3006 execution: &AxWsTradeExecution,
3007 event_ts: i64,
3008 event_tn: i64,
3009 caches: &OrdersCaches,
3010 account_id: AccountId,
3011 instruments: &AtomicMap<Ustr, InstrumentAny>,
3012 commission: Money,
3013 clock: &'static AtomicTime,
3014) -> Option<FillReport> {
3015 if lookup_order_metadata(order, caches).is_some() {
3016 return None;
3017 }
3018
3019 let instruments_snap = instruments.load();
3020 let instrument = instruments_snap.get(&order.s)?;
3021 let venue_order_id = VenueOrderId::new_checked(&order.oid).ok()?;
3022 let instrument_id = instrument.id();
3023 let order_side = order.d.into();
3024
3025 let last_qty =
3026 Quantity::from_decimal_dp(Decimal::from(execution.q), instrument.size_precision()).ok()?;
3027 let last_px = Price::from_decimal_dp(execution.p, instrument.price_precision()).ok()?;
3028
3029 let liquidity_side = if execution.agg {
3030 LiquiditySide::Taker
3031 } else {
3032 LiquiditySide::Maker
3033 };
3034
3035 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
3036 .map_err(|e| log::error!("{e}"))
3037 .ok()?;
3038 let ts_init = clock.get_time_ns();
3039
3040 let client_order_id = order.cid.map(|cid| {
3041 caches
3042 .cid_to_client_order_id
3043 .get(&cid)
3044 .map_or_else(|| cid_to_client_order_id(cid), |v| *v)
3045 });
3046
3047 Some(FillReport::new(
3048 account_id,
3049 instrument_id,
3050 venue_order_id,
3051 TradeId::new(&execution.tid),
3052 order_side,
3053 last_qty,
3054 last_px,
3055 commission,
3056 liquidity_side,
3057 client_order_id,
3058 None,
3059 ts_event,
3060 ts_init,
3061 Some(UUID4::new()),
3062 ))
3063}
3064
3065fn validate_order_for_ax_submit(order: &OrderAny) -> anyhow::Result<()> {
3066 if !matches!(order.order_type(), OrderType::Market | OrderType::Limit) {
3067 anyhow::bail!(
3068 "Unsupported order type: {:?}, the Architect AX adapter accepts Nautilus MARKET and LIMIT orders",
3069 order.order_type(),
3070 );
3071 }
3072
3073 if !matches!(
3075 order.time_in_force(),
3076 TimeInForce::Gtc | TimeInForce::Ioc | TimeInForce::Day
3077 ) {
3078 anyhow::bail!(
3079 "Unsupported time in force: {:?}, AX supports GTC, IOC, and DAY",
3080 order.time_in_force(),
3081 );
3082 }
3083
3084 validate_order_instructions(
3085 order.is_reduce_only(),
3086 order.is_quote_quantity(),
3087 order.display_qty().is_some(),
3088 )?;
3089
3090 quantity_to_contracts(order.quantity())?;
3091
3092 Ok(())
3093}
3094
3095fn validate_order_init_instructions(order_init: &OrderInitialized) -> anyhow::Result<()> {
3096 validate_order_instructions(
3097 order_init.reduce_only,
3098 order_init.quote_quantity,
3099 order_init.display_qty.is_some(),
3100 )
3101}
3102
3103fn validate_order_instructions(
3104 reduce_only: bool,
3105 quote_quantity: bool,
3106 has_display_qty: bool,
3107) -> anyhow::Result<()> {
3108 if reduce_only {
3109 anyhow::bail!("AX does not support reduce-only orders");
3110 }
3111
3112 if quote_quantity {
3113 anyhow::bail!(OrderDeniedReason::unsupported_quote_quantity());
3114 }
3115
3116 if has_display_qty {
3117 anyhow::bail!("Architect AX adapter cannot encode display_qty iceberg instructions");
3118 }
3119
3120 Ok(())
3121}
3122
3123fn classify_ax_http_failure(error: &AxHttpError) -> CommandFailure {
3128 let message = error.to_string();
3129 match error {
3130 AxHttpError::MissingCredentials
3131 | AxHttpError::MissingSessionToken
3132 | AxHttpError::ValidationError(_)
3133 | AxHttpError::BuildError(_) => CommandFailure::NotSent(message),
3134 AxHttpError::ApiError { .. }
3135 | AxHttpError::JsonError(_)
3136 | AxHttpError::Canceled(_)
3137 | AxHttpError::NetworkError(_)
3138 | AxHttpError::UnexpectedStatus { .. } => CommandFailure::Ambiguous(message),
3139 }
3140}
3141
3142fn classify_ax_ws_failure(error: &AxOrdersWsClientError) -> CommandFailure {
3143 match error {
3144 AxOrdersWsClientError::ClientError(message) => CommandFailure::NotSent(message.clone()),
3145 AxOrdersWsClientError::Transport(_)
3146 | AxOrdersWsClientError::ChannelError(_)
3147 | AxOrdersWsClientError::AuthenticationError(_) => {
3148 CommandFailure::Ambiguous(error.to_string())
3149 }
3150 }
3151}
3152
3153#[cfg(test)]
3154mod tests {
3155 use std::{cell::RefCell, net::SocketAddr, rc::Rc, sync::Arc, time::Duration};
3156
3157 use dashmap::DashMap;
3158 use nautilus_common::{
3159 cache::Cache,
3160 live::runner::replace_exec_event_sender,
3161 messages::{ExecutionEvent, ExecutionReport},
3162 };
3163 use nautilus_core::time::get_atomic_clock_realtime;
3164 use nautilus_live::{ExecutionClientCore, testing::ExecutionHarness};
3165 use nautilus_model::{
3166 enums::AssetClass,
3167 events::order::spec::OrderSubmittedSpec,
3168 identifiers::{
3169 AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
3170 },
3171 instruments::{InstrumentAny, PerpetualContract},
3172 orders::{builder::OrderTestBuilder, stubs::TestOrderEventStubs},
3173 types::{Currency, Price, Quantity},
3174 };
3175 use rstest::rstest;
3176 use rust_decimal::Decimal;
3177 use rust_decimal_macros::dec;
3178 use ustr::Ustr;
3179
3180 use super::*;
3181 use crate::{
3182 common::{
3183 consts::{AX_CLIENT_ID, AX_VENUE},
3184 enums::{AxEnvironment, AxOrderSide, AxOrderStatus, AxTimeInForce},
3185 },
3186 config::AxExecutionClientConfig,
3187 http::{error::AxBuildError, models::AxFillsResponse},
3188 websocket::{
3189 messages::{AxWsOrderExpired, AxWsOrderFilled, AxWsTradeExecution, OrderMetadata},
3190 orders::OrdersCaches,
3191 },
3192 };
3193
3194 #[rstest]
3195 fn test_start_replaces_sender_for_emitter_clone() {
3196 std::thread::spawn(|| {
3197 let core = ExecutionClientCore::new(
3198 TraderId::from("TESTER-002"),
3199 *AX_CLIENT_ID,
3200 *AX_VENUE,
3201 OmsType::Netting,
3202 AccountId::from("AX-002"),
3203 AccountType::Margin,
3204 None,
3205 Rc::new(RefCell::new(Cache::default())),
3206 );
3207 let mut client =
3208 AxExecutionClient::new(core, AxExecutionClientConfig::default()).unwrap();
3209 let emitter = client.emitter.clone();
3210 let (sender_initial, mut receiver_initial) = tokio::sync::mpsc::unbounded_channel();
3211 let (sender_current, mut receiver_current) = tokio::sync::mpsc::unbounded_channel();
3212 let event = OrderEventAny::Submitted(
3213 OrderSubmittedSpec::builder()
3214 .trader_id(TraderId::from("TESTER-002"))
3215 .account_id(AccountId::from("AX-002"))
3216 .client_order_id(ClientOrderId::from("O-SENDER-002"))
3217 .build(),
3218 );
3219
3220 client.set_event_sender(sender_initial.into());
3221 emitter.send_order_event(event.clone());
3222
3223 let ExecutionEvent::Order(received) = receiver_initial.try_recv().unwrap() else {
3224 panic!("Expected order event");
3225 };
3226 assert_eq!(received, event);
3227
3228 replace_exec_event_sender(sender_current);
3229 client.start().unwrap();
3230 emitter.send_order_event(event.clone());
3231
3232 let ExecutionEvent::Order(received) = receiver_current.try_recv().unwrap() else {
3233 panic!("Expected order event");
3234 };
3235 assert_eq!(received, event);
3236 assert_eq!(
3237 receiver_initial.try_recv().unwrap_err(),
3238 tokio::sync::mpsc::error::TryRecvError::Disconnected
3239 );
3240 assert_eq!(
3241 receiver_current.try_recv().unwrap_err(),
3242 tokio::sync::mpsc::error::TryRecvError::Empty
3243 );
3244 })
3245 .join()
3246 .unwrap();
3247 }
3248
3249 fn test_caches() -> OrdersCaches {
3250 OrdersCaches {
3251 orders_metadata: Arc::new(DashMap::new()),
3252 venue_to_client_id: Arc::new(DashMap::new()),
3253 cid_to_client_order_id: Arc::new(DashMap::new()),
3254 pending_modifies: Arc::new(DashMap::new()),
3255 }
3256 }
3257
3258 fn test_ws_order(oid: &str, price: Decimal, qty: u64) -> AxWsOrder {
3259 AxWsOrder {
3260 aid: None,
3261 po: false,
3262 rb: None,
3263 r: None,
3264 oid: oid.to_string(),
3265 u: Ustr::from("user"),
3266 s: Ustr::from("BTC-PERP"),
3267 p: price,
3268 q: qty,
3269 xq: 0,
3270 rq: qty,
3271 o: AxOrderStatus::Accepted,
3272 d: AxOrderSide::Buy,
3273 tif: AxTimeInForce::Gtc,
3274 ts: 1609459200,
3275 tn: 0,
3276 cid: None,
3277 tag: None,
3278 txt: None,
3279 }
3280 }
3281
3282 #[rstest]
3283 fn test_create_order_updated_uses_ws_replacement_id_before_http_response() {
3284 let caches = test_caches();
3285 let clock = get_atomic_clock_realtime();
3286 let account_id = AccountId::from("AX-001");
3287 let client_order_id = ClientOrderId::from("O-WS-FIRST");
3288 let old_venue_order_id = VenueOrderId::new("OLD-OID");
3289 let new_venue_order_id = VenueOrderId::new("NEW-OID");
3290
3291 let mut metadata = test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX"));
3292 metadata.venue_order_id = Some(old_venue_order_id);
3293 caches.orders_metadata.insert(client_order_id, metadata);
3294 caches
3295 .venue_to_client_id
3296 .insert(old_venue_order_id, client_order_id);
3297
3298 let old_order = test_ws_order(old_venue_order_id.as_str(), dec!(50000.00), 100);
3299 let new_order = test_ws_order(new_venue_order_id.as_str(), dec!(50001.00), 100);
3300 let event = create_order_updated(
3301 &new_order,
3302 &old_order,
3303 new_venue_order_id,
3304 (1609459200, 0),
3305 &caches,
3306 account_id,
3307 clock,
3308 )
3309 .expect("should produce OrderUpdated");
3310
3311 assert_eq!(event.venue_order_id, Some(new_venue_order_id));
3312 assert_eq!(
3313 caches
3314 .orders_metadata
3315 .get(&client_order_id)
3316 .unwrap()
3317 .venue_order_id,
3318 Some(new_venue_order_id),
3319 );
3320 assert!(!caches.venue_to_client_id.contains_key(&old_venue_order_id));
3321 assert_eq!(
3322 *caches.venue_to_client_id.get(&new_venue_order_id).unwrap(),
3323 client_order_id,
3324 );
3325 }
3326
3327 #[rstest]
3328 fn test_pending_replacement_retains_parent_until_ws_event() {
3329 let caches = test_caches();
3330 let client_order_id = ClientOrderId::from("O-HTTP-FIRST");
3331 let old_venue_order_id = VenueOrderId::new("OLD-OID");
3332 let new_venue_order_id = VenueOrderId::new("NEW-OID");
3333 let mut metadata = test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX"));
3334 metadata.venue_order_id = Some(old_venue_order_id);
3335 caches.orders_metadata.insert(client_order_id, metadata);
3336 caches
3337 .venue_to_client_id
3338 .insert(old_venue_order_id, client_order_id);
3339
3340 caches
3341 .pending_modifies
3342 .insert(old_venue_order_id, (client_order_id, 1, false));
3343 let mut parent = test_ws_order(old_venue_order_id.as_str(), dec!(50000), 100);
3344 parent.o = AxOrderStatus::Filled;
3345 parent.xq = 100;
3346 parent.rq = 0;
3347 cleanup_terminal_order_tracking(&parent, &caches);
3348
3349 assert!(caches.venue_to_client_id.contains_key(&old_venue_order_id));
3350 assert!(!caches.venue_to_client_id.contains_key(&new_venue_order_id));
3351 assert_eq!(
3352 caches
3353 .orders_metadata
3354 .get(&client_order_id)
3355 .unwrap()
3356 .venue_order_id,
3357 Some(old_venue_order_id),
3358 );
3359
3360 record_replacement_venue_id(
3361 &caches,
3362 client_order_id,
3363 old_venue_order_id,
3364 new_venue_order_id,
3365 true,
3366 );
3367
3368 assert!(!caches.venue_to_client_id.contains_key(&old_venue_order_id));
3369 assert!(caches.venue_to_client_id.contains_key(&new_venue_order_id));
3370 }
3371
3372 #[rstest]
3373 fn test_late_replacement_cannot_regress_active_leg() {
3374 let caches = test_caches();
3375 let client_order_id = ClientOrderId::from("O-CHAIN");
3376 let parent = VenueOrderId::from("OID-A");
3377 let child = VenueOrderId::from("OID-B");
3378 let current = VenueOrderId::from("OID-C");
3379 let mut metadata = test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX"));
3380 metadata.venue_order_id = Some(parent);
3381 caches.orders_metadata.insert(client_order_id, metadata);
3382 caches.venue_to_client_id.insert(parent, client_order_id);
3383 assert!(record_replacement_venue_id(
3384 &caches,
3385 client_order_id,
3386 parent,
3387 child,
3388 true
3389 ));
3390 assert!(record_replacement_venue_id(
3391 &caches,
3392 client_order_id,
3393 child,
3394 current,
3395 true
3396 ));
3397 let late_replacement =
3398 record_replacement_venue_id(&caches, client_order_id, parent, child, true);
3399 let old_order = test_ws_order(parent.as_str(), dec!(50000), 100);
3400 let new_order = test_ws_order(child.as_str(), dec!(50001), 100);
3401 caches.cid_to_client_order_id.insert(127, client_order_id);
3402 let mut old_order = old_order;
3403 old_order.cid = Some(127);
3404 let late_event = create_order_updated(
3405 &new_order,
3406 &old_order,
3407 child,
3408 (1609459200, 0),
3409 &caches,
3410 AccountId::from("AX-001"),
3411 get_atomic_clock_realtime(),
3412 );
3413
3414 assert!(!late_replacement);
3415 assert!(late_event.is_none());
3416 assert_eq!(
3417 caches
3418 .orders_metadata
3419 .get(&client_order_id)
3420 .unwrap()
3421 .venue_order_id,
3422 Some(current)
3423 );
3424 assert_eq!(
3425 *caches.venue_to_client_id.get(¤t).unwrap(),
3426 client_order_id
3427 );
3428 assert_eq!(caches.venue_to_client_id.len(), 1);
3429 }
3430
3431 #[rstest]
3432 #[case::timestamp(0)]
3433 #[case::price(1)]
3434 #[case::instrument(2)]
3435 #[case::side(3)]
3436 fn test_malformed_replacement_does_not_promote(#[case] invalid: u8) {
3437 let caches = test_caches();
3438 let client_order_id = ClientOrderId::from("O-BAD-REPLACE");
3439 let parent = VenueOrderId::from("OID-PARENT");
3440 let child = VenueOrderId::from("OID-CHILD");
3441 let mut metadata = test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX"));
3442 metadata.venue_order_id = Some(parent);
3443 let submitted = OrderTestBuilder::new(OrderType::Limit)
3444 .instrument_id(InstrumentId::from("BTC-PERP.AX"))
3445 .client_order_id(client_order_id)
3446 .side(OrderSide::Buy)
3447 .quantity(Quantity::from("100"))
3448 .price(Price::from("50000.00"))
3449 .build();
3450 metadata.order_init = Some(submitted.init_event().clone());
3451 caches.orders_metadata.insert(client_order_id, metadata);
3452 caches.venue_to_client_id.insert(parent, client_order_id);
3453 let old_order = test_ws_order(parent.as_str(), dec!(50000), 100);
3454
3455 let mut new_order = test_ws_order(
3456 child.as_str(),
3457 if invalid == 1 {
3458 Decimal::MAX
3459 } else {
3460 dec!(50001)
3461 },
3462 101,
3463 );
3464
3465 if invalid == 2 {
3466 new_order.s = Ustr::from("WRONG-PERP");
3467 }
3468
3469 if invalid == 3 {
3470 new_order.d = AxOrderSide::Sell;
3471 }
3472
3473 let event = create_order_updated(
3474 &new_order,
3475 &old_order,
3476 child,
3477 (if invalid == 0 { -1 } else { 1609459200 }, 0),
3478 &caches,
3479 AccountId::from("AX-001"),
3480 get_atomic_clock_realtime(),
3481 );
3482
3483 let mut message: crate::websocket::messages::AxWsOrderReplaced =
3484 serde_json::from_str(include_str!("../test_data/ws_order_replaced_live.json")).unwrap();
3485 message.ro = Box::new(old_order);
3486 message.no = Some(Box::new(new_order));
3487 message.noid = Some(child.to_string());
3488 if invalid == 0 {
3489 message.ts = -1;
3490 }
3491
3492 let instruments = AtomicMap::new();
3493 instruments.insert(Ustr::from("BTC-PERP"), test_perp_instrument("BTC-PERP"));
3494 let clock = get_atomic_clock_realtime();
3495 let account_id = AccountId::from("AX-001");
3496 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3497
3498 let mut emitter = ExecutionEventEmitter::new(
3499 clock,
3500 TraderId::from("TRADER-001"),
3501 account_id,
3502 AccountType::Margin,
3503 None,
3504 );
3505 emitter.set_sender(tx);
3506 dispatch_order_event(
3507 AxWsOrderEvent::Replaced(message),
3508 &emitter,
3509 &caches,
3510 account_id,
3511 &instruments,
3512 &AtomicMap::new(),
3513 clock,
3514 );
3515
3516 assert!(rx.try_recv().is_err());
3517 assert!(event.is_none());
3518 assert_eq!(
3519 caches
3520 .orders_metadata
3521 .get(&client_order_id)
3522 .unwrap()
3523 .venue_order_id,
3524 Some(parent)
3525 );
3526 assert_eq!(
3527 *caches.venue_to_client_id.get(&parent).unwrap(),
3528 client_order_id
3529 );
3530 assert_eq!(caches.venue_to_client_id.len(), 1);
3531 }
3532
3533 fn test_metadata(client_order_id: ClientOrderId, instrument_id: InstrumentId) -> OrderMetadata {
3534 OrderMetadata {
3535 trader_id: TraderId::from("TRADER-001"),
3536 strategy_id: StrategyId::from("S-001"),
3537 instrument_id,
3538 client_order_id,
3539 order_init: None,
3540 is_accepted: true,
3541 venue_order_id: None,
3542 ts_init: 0.into(),
3543 size_precision: 0,
3544 price_precision: 2,
3545 quote_currency: Currency::USD(),
3546 }
3547 }
3548
3549 fn test_execution(tid: &str, price: Decimal, qty: u64, agg: bool) -> AxWsTradeExecution {
3550 AxWsTradeExecution {
3551 aid: None,
3552 tid: tid.to_string(),
3553 s: Ustr::from("BTC-PERP"),
3554 q: qty,
3555 p: price,
3556 d: AxOrderSide::Buy,
3557 agg,
3558 }
3559 }
3560
3561 fn seed_terminal_tracking(
3562 caches: &OrdersCaches,
3563 client_order_id: ClientOrderId,
3564 venue_order_id: VenueOrderId,
3565 stale_venue_order_id: VenueOrderId,
3566 cid_value: u64,
3567 ) {
3568 caches.orders_metadata.insert(
3569 client_order_id,
3570 test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX")),
3571 );
3572 caches
3573 .venue_to_client_id
3574 .insert(venue_order_id, client_order_id);
3575 caches
3576 .venue_to_client_id
3577 .insert(stale_venue_order_id, client_order_id);
3578 caches
3579 .cid_to_client_order_id
3580 .insert(cid_value, client_order_id);
3581 }
3582
3583 fn test_perp_instrument(symbol: &str) -> InstrumentAny {
3584 let symbol = Symbol::new(symbol);
3585 let instrument = PerpetualContract::builder()
3586 .instrument_id(InstrumentId::new(symbol, *AX_VENUE))
3587 .raw_symbol(symbol)
3588 .underlying(Ustr::from("BTC"))
3589 .asset_class(AssetClass::Cryptocurrency)
3590 .quote_currency(Currency::USD())
3591 .settlement_currency(Currency::USD())
3592 .is_inverse(false)
3593 .price_precision(2)
3594 .size_precision(0)
3595 .price_increment(Price::from("0.01"))
3596 .size_increment(Quantity::from("1"))
3597 .margin_init(Decimal::new(1, 2))
3598 .margin_maint(Decimal::new(5, 3))
3599 .ts_event(0.into())
3600 .ts_init(0.into())
3601 .build()
3602 .unwrap();
3603 InstrumentAny::PerpetualContract(instrument)
3604 }
3605
3606 async fn start_orders_report_server(body: serde_json::Value) -> SocketAddr {
3607 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3608 .await
3609 .expect("bind report server");
3610 let addr = listener.local_addr().expect("report server addr");
3611
3612 tokio::spawn(async move {
3613 let mut open_body = body.clone();
3614 open_body["total_count"] = serde_json::json!(body["orders"].as_array().unwrap().len());
3615 open_body["limit"] = serde_json::json!(100);
3616 open_body["offset"] = serde_json::json!(0);
3617 let order = &body["orders"][0];
3618 let status_body = serde_json::json!({"status": {
3619 "order_id": order["oid"], "state": order["o"], "symbol": order["s"],
3620 "clord_id": order["cid"], "filled_quantity": order["xq"], "remaining_quantity": order["rq"]
3621 }});
3622
3623 let app = axum::Router::new()
3624 .route(
3625 "/order-status",
3626 axum::routing::get(move || async { axum::Json(status_body) }),
3627 )
3628 .route(
3629 "/orders",
3630 axum::routing::get(move || async { axum::Json(body) }),
3631 )
3632 .route(
3633 "/open-orders",
3634 axum::routing::get(move || async { axum::Json(open_body) }),
3635 )
3636 .route(
3637 "/fills",
3638 axum::routing::get(|| async {
3639 axum::Json(AxFillsResponse {
3640 fills: Vec::new(),
3641 total_count: Some(0),
3642 limit: Some(100),
3643 next_cursor: None,
3644 })
3645 }),
3646 );
3647 axum::serve(listener, app).await.unwrap();
3648 });
3649
3650 for _ in 0..50 {
3651 if tokio::net::TcpStream::connect(addr).await.is_ok() {
3652 break;
3653 }
3654
3655 tokio::time::sleep(Duration::from_millis(10)).await;
3656 }
3657
3658 addr
3659 }
3660
3661 fn test_status_report(
3662 client_order_id: Option<ClientOrderId>,
3663 venue_order_id: VenueOrderId,
3664 order_status: OrderStatus,
3665 ) -> OrderStatusReport {
3666 OrderStatusReport::new(
3667 AccountId::from("AX-001"),
3668 InstrumentId::from("BTC-PERP.AX"),
3669 client_order_id,
3670 venue_order_id,
3671 Some(OrderSide::Buy),
3672 OrderType::Limit,
3673 TimeInForce::Gtc,
3674 order_status,
3675 Quantity::from("1"),
3676 Quantity::from("1"),
3677 0.into(),
3678 0.into(),
3679 0.into(),
3680 None,
3681 )
3682 }
3683
3684 #[rstest]
3685 fn test_create_order_accepted_populates_cache_and_event() {
3686 let caches = test_caches();
3687 let clock = get_atomic_clock_realtime();
3688 let account_id = AccountId::from("AX-001");
3689 let client_order_id = ClientOrderId::from("O-ACK");
3690 let instrument_id = InstrumentId::from("BTC-PERP.AX");
3691 let venue_order_id = VenueOrderId::new("OID-ACK");
3692
3693 let mut metadata = test_metadata(client_order_id, instrument_id);
3694 metadata.is_accepted = false;
3695 caches.orders_metadata.insert(client_order_id, metadata);
3696 let cid_value = 7u64;
3697 caches
3698 .cid_to_client_order_id
3699 .insert(cid_value, client_order_id);
3700
3701 let mut ws_order = test_ws_order(venue_order_id.as_str(), dec!(50500.00), 100);
3702 ws_order.cid = Some(cid_value);
3703
3704 let event = create_order_accepted(&ws_order, 1609459200, 500, &caches, account_id, clock)
3705 .expect("should produce OrderAccepted");
3706
3707 assert_eq!(event.venue_order_id, venue_order_id);
3708 assert_eq!(event.client_order_id, client_order_id);
3709 assert_eq!(event.account_id, account_id);
3710 assert_eq!(event.instrument_id, instrument_id);
3711 assert_eq!(event.trader_id, TraderId::from("TRADER-001"));
3712 assert_eq!(event.strategy_id, StrategyId::from("S-001"));
3713 assert_eq!(
3714 event.ts_event,
3715 UnixNanos::from(1_609_459_200_000_000_500u64)
3716 );
3717
3718 assert_eq!(
3720 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
3721 client_order_id,
3722 );
3723 let meta = caches.orders_metadata.get(&client_order_id).unwrap();
3724 assert_eq!(meta.venue_order_id, Some(venue_order_id));
3725 }
3726
3727 #[rstest]
3728 #[case::unbound_partial(false, false)]
3729 #[case::response_before_partial(true, false)]
3730 #[case::unbound_complete(false, true)]
3731 #[case::response_before_complete(true, true)]
3732 fn test_market_fill_before_acknowledgement(#[case] bound: bool, #[case] complete: bool) {
3733 let raw = if complete {
3734 include_str!("../test_data/ws_order_filled.json")
3735 } else {
3736 include_str!("../test_data/ws_order_partially_filled.json")
3737 };
3738
3739 let mut event: AxWsOrderEvent = serde_json::from_str(raw).unwrap();
3740
3741 let order = match &mut event {
3742 AxWsOrderEvent::Filled(msg) => &mut msg.o,
3743 AxWsOrderEvent::PartiallyFilled(msg) => &mut msg.o,
3744 _ => unreachable!(),
3745 };
3746
3747 order.cid = Some(117);
3748 let venue_order_id = VenueOrderId::from(order.oid.as_str());
3749 let client_order_id = ClientOrderId::from("O-ORIGINAL-MARKET");
3750 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
3751 let local_order = OrderTestBuilder::new(OrderType::Market)
3752 .trader_id(TraderId::from("TRADER-001"))
3753 .strategy_id(StrategyId::from("S-001"))
3754 .instrument_id(instrument_id)
3755 .client_order_id(client_order_id)
3756 .side(OrderSide::Buy)
3757 .quantity(Quantity::from("100"))
3758 .time_in_force(TimeInForce::Ioc)
3759 .build();
3760 let mut metadata = test_metadata(client_order_id, instrument_id);
3761 metadata.is_accepted = false;
3762 metadata.order_init = Some(local_order.init_event().clone());
3763 metadata.venue_order_id = bound.then_some(venue_order_id);
3764 let caches = test_caches();
3765 caches.orders_metadata.insert(client_order_id, metadata);
3766 caches.cid_to_client_order_id.insert(117, client_order_id);
3767 if bound {
3768 caches
3769 .venue_to_client_id
3770 .insert(venue_order_id, client_order_id);
3771 }
3772
3773 let instruments = AtomicMap::new();
3774 instruments.insert(
3775 Ustr::from("EURUSD-PERP"),
3776 test_perp_instrument("EURUSD-PERP"),
3777 );
3778 let rates = AtomicMap::new();
3779 rates.insert(
3780 Some(Ustr::from("fixture-account")),
3781 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
3782 );
3783 let clock = get_atomic_clock_realtime();
3784 let account_id = AccountId::from("AX-001");
3785 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3786
3787 let mut emitter = ExecutionEventEmitter::new(
3788 clock,
3789 TraderId::from("TRADER-001"),
3790 account_id,
3791 AccountType::Margin,
3792 None,
3793 );
3794 emitter.set_sender(tx);
3795 dispatch_order_event(
3796 event,
3797 &emitter,
3798 &caches,
3799 account_id,
3800 &instruments,
3801 &rates,
3802 clock,
3803 );
3804
3805 let ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) = rx.try_recv().unwrap()
3806 else {
3807 panic!("expected acceptance before fill");
3808 };
3809
3810 let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = rx.try_recv().unwrap() else {
3811 panic!("expected tracked fill");
3812 };
3813
3814 assert_eq!(accepted.client_order_id, client_order_id);
3815 assert_eq!(accepted.venue_order_id, venue_order_id);
3816 assert_eq!(accepted.instrument_id, instrument_id);
3817 assert_eq!(fill.client_order_id, client_order_id);
3818 assert_eq!(fill.trader_id, local_order.trader_id());
3819 assert_eq!(fill.strategy_id, local_order.strategy_id());
3820 assert_eq!(fill.instrument_id, instrument_id);
3821 assert_eq!(fill.account_id, account_id);
3822 assert_eq!(fill.venue_order_id, venue_order_id);
3823 assert_eq!(fill.order_type, OrderType::Market);
3824 assert_eq!(fill.order_side, OrderSide::Buy);
3825 assert_eq!(
3826 fill.last_qty.as_decimal(),
3827 if complete { dec!(100) } else { dec!(50) }
3828 );
3829 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
3830 assert_eq!(
3831 fill.commission,
3832 Some(Money::from(if complete {
3833 "1000.00 USD"
3834 } else {
3835 "500.00 USD"
3836 }))
3837 );
3838 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
3839 assert!(rx.try_recv().is_err());
3840
3841 if complete {
3842 assert!(caches.orders_metadata.is_empty());
3843 assert!(caches.cid_to_client_order_id.is_empty());
3844 } else {
3845 let metadata = caches.orders_metadata.get(&client_order_id).unwrap();
3846 assert_eq!(metadata.venue_order_id, Some(venue_order_id));
3847 assert!(metadata.is_accepted);
3848 assert_eq!(metadata.order_init.as_ref(), Some(local_order.init_event()));
3849 }
3850 }
3851
3852 #[rstest]
3853 #[case::partial_first(false, false)]
3854 #[case::complete_first(true, false)]
3855 #[case::partial_second(false, true)]
3856 #[case::complete_second(true, true)]
3857 fn test_parent_fill_before_stream_replacement(#[case] complete: bool, #[case] second: bool) {
3858 let mut event: AxWsOrderEvent = serde_json::from_str(if complete {
3859 include_str!("../test_data/ws_order_filled.json")
3860 } else {
3861 include_str!("../test_data/ws_order_partially_filled.json")
3862 })
3863 .unwrap();
3864
3865 let order = match &mut event {
3866 AxWsOrderEvent::Filled(msg) => &mut msg.o,
3867 AxWsOrderEvent::PartiallyFilled(msg) => &mut msg.o,
3868 _ => unreachable!(),
3869 };
3870
3871 order.cid = Some(131);
3872 let first_id = VenueOrderId::from("OID-FIRST");
3873 let parent_id = VenueOrderId::from(order.oid.as_str());
3874 let child_id = VenueOrderId::from("OID-REPLACEMENT");
3875 let client_order_id = ClientOrderId::from("O-PARENT-FILL");
3876 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
3877 let caches = test_caches();
3878 let mut metadata = test_metadata(client_order_id, instrument_id);
3879 metadata.venue_order_id = Some(if second { first_id } else { parent_id });
3880 caches.orders_metadata.insert(client_order_id, metadata);
3881 caches.cid_to_client_order_id.insert(131, client_order_id);
3882 if second {
3883 caches.venue_to_client_id.insert(first_id, client_order_id);
3884 assert!(record_replacement_venue_id(
3885 &caches,
3886 client_order_id,
3887 first_id,
3888 parent_id,
3889 true
3890 ));
3891 }
3892
3893 caches.venue_to_client_id.insert(parent_id, client_order_id);
3894 caches
3895 .pending_modifies
3896 .insert(parent_id, (client_order_id, 1, false));
3897 record_replacement_proposal(&caches, client_order_id, parent_id, child_id);
3898 let parent_order = order.clone();
3899 let mut child_order = parent_order.clone();
3900 child_order.oid = child_id.to_string();
3901 child_order.p = dec!(50001);
3902 let instruments = AtomicMap::new();
3903 instruments.insert(
3904 Ustr::from("EURUSD-PERP"),
3905 test_perp_instrument("EURUSD-PERP"),
3906 );
3907 let rates = AtomicMap::new();
3908 rates.insert(
3909 Some(Ustr::from("fixture-account")),
3910 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
3911 );
3912 let clock = get_atomic_clock_realtime();
3913 let account_id = AccountId::from("AX-001");
3914 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3915
3916 let mut emitter = ExecutionEventEmitter::new(
3917 clock,
3918 TraderId::from("TRADER-001"),
3919 account_id,
3920 AccountType::Margin,
3921 None,
3922 );
3923 emitter.set_sender(tx);
3924 dispatch_order_event(
3925 event.clone(),
3926 &emitter,
3927 &caches,
3928 account_id,
3929 &instruments,
3930 &rates,
3931 clock,
3932 );
3933
3934 let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = rx.try_recv().unwrap() else {
3935 panic!("expected tracked parent fill");
3936 };
3937
3938 assert_eq!(fill.client_order_id, client_order_id);
3939 assert_eq!(fill.trader_id, TraderId::from("TRADER-001"));
3940 assert_eq!(fill.strategy_id, StrategyId::from("S-001"));
3941 assert_eq!(fill.instrument_id, instrument_id);
3942 assert_eq!(fill.account_id, account_id);
3943 assert_eq!(fill.venue_order_id, parent_id);
3944 assert_eq!(fill.order_type, OrderType::Limit);
3945 assert_eq!(fill.order_side, OrderSide::Buy);
3946 assert_eq!(
3947 fill.last_qty.as_decimal(),
3948 if complete { dec!(100) } else { dec!(50) }
3949 );
3950 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
3951 assert_eq!(
3952 fill.commission,
3953 Some(Money::from(if complete {
3954 "1000.00 USD"
3955 } else {
3956 "500.00 USD"
3957 }))
3958 );
3959 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
3960 assert_eq!(fill.ts_event, UnixNanos::from(1_609_459_200_123_456_789u64));
3961 assert_eq!(
3962 rx.try_recv().unwrap_err(),
3963 tokio::sync::mpsc::error::TryRecvError::Empty
3964 );
3965 assert_eq!(
3966 caches
3967 .orders_metadata
3968 .get(&client_order_id)
3969 .unwrap()
3970 .venue_order_id,
3971 Some(parent_id)
3972 );
3973 assert_eq!(
3974 *caches.venue_to_client_id.get(&parent_id).unwrap(),
3975 client_order_id
3976 );
3977 assert_eq!(
3978 *caches.venue_to_client_id.get(&child_id).unwrap(),
3979 client_order_id
3980 );
3981 assert_eq!(
3982 *caches.cid_to_client_order_id.get(&131).unwrap(),
3983 client_order_id
3984 );
3985
3986 let updated = create_order_updated(
3987 &child_order,
3988 &parent_order,
3989 child_id,
3990 (1609459201, 0),
3991 &caches,
3992 account_id,
3993 clock,
3994 )
3995 .unwrap();
3996 dispatch_order_event(
3997 event,
3998 &emitter,
3999 &caches,
4000 account_id,
4001 &instruments,
4002 &rates,
4003 clock,
4004 );
4005 assert_eq!(updated.venue_order_id, Some(child_id));
4006 assert_eq!(
4007 rx.try_recv().unwrap_err(),
4008 tokio::sync::mpsc::error::TryRecvError::Empty
4009 );
4010 assert!(!caches.venue_to_client_id.contains_key(&parent_id));
4011 assert_eq!(caches.venue_to_client_id.len(), 1);
4012 assert_eq!(
4013 caches
4014 .orders_metadata
4015 .get(&client_order_id)
4016 .unwrap()
4017 .venue_order_id,
4018 Some(child_id)
4019 );
4020 }
4021
4022 #[rstest]
4023 fn test_reconciled_acceptance_suppresses_duplicate_stream_acceptance() {
4024 let mut event: AxWsOrderEvent =
4025 serde_json::from_str(include_str!("../test_data/ws_order_partially_filled.json"))
4026 .unwrap();
4027
4028 let AxWsOrderEvent::PartiallyFilled(msg) = &mut event else {
4029 unreachable!()
4030 };
4031
4032 msg.o.cid = Some(133);
4033 let venue_order_id = VenueOrderId::from(msg.o.oid.as_str());
4034 let client_order_id = ClientOrderId::from("O-RECONCILED-ACCEPTANCE");
4035 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
4036
4037 let core = ExecutionClientCore::new(
4038 TraderId::from("TRADER-001"),
4039 *AX_CLIENT_ID,
4040 *AX_VENUE,
4041 OmsType::Netting,
4042 AccountId::from("AX-001"),
4043 AccountType::Margin,
4044 None,
4045 Rc::new(RefCell::new(Cache::default())),
4046 );
4047 let mut client = AxExecutionClient::new(core, AxExecutionClientConfig::default()).unwrap();
4048 client.ensure_order_event_subscription();
4049 client.ensure_order_event_subscription();
4050 let caches = client.ws_orders.caches().clone();
4051 let mut metadata = test_metadata(client_order_id, instrument_id);
4052 metadata.is_accepted = false;
4053 caches.orders_metadata.insert(client_order_id, metadata);
4054 caches.cid_to_client_order_id.insert(133, client_order_id);
4055 let accepted = OrderEventAny::Accepted(OrderAccepted::new(
4056 TraderId::from("TRADER-001"),
4057 StrategyId::from("S-001"),
4058 instrument_id,
4059 client_order_id,
4060 venue_order_id,
4061 AccountId::from("AX-001"),
4062 UUID4::new(),
4063 1.into(),
4064 2.into(),
4065 true,
4066 ));
4067 msgbus::publish_order_event("events.order.S-001".into(), &accepted);
4068 let instruments = AtomicMap::new();
4069 instruments.insert(
4070 Ustr::from("EURUSD-PERP"),
4071 test_perp_instrument("EURUSD-PERP"),
4072 );
4073 let rates = AtomicMap::new();
4074 rates.insert(
4075 Some(Ustr::from("fixture-account")),
4076 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
4077 );
4078 let clock = get_atomic_clock_realtime();
4079 let account_id = AccountId::from("AX-001");
4080 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4081
4082 let mut emitter = ExecutionEventEmitter::new(
4083 clock,
4084 TraderId::from("TRADER-001"),
4085 account_id,
4086 AccountType::Margin,
4087 None,
4088 );
4089 emitter.set_sender(tx);
4090 dispatch_order_event(
4091 event,
4092 &emitter,
4093 &caches,
4094 account_id,
4095 &instruments,
4096 &rates,
4097 clock,
4098 );
4099
4100 let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = rx.try_recv().unwrap() else {
4101 panic!("expected fill without duplicate acceptance");
4102 };
4103
4104 assert_eq!(fill.client_order_id, client_order_id);
4105 assert_eq!(fill.venue_order_id, venue_order_id);
4106 assert_eq!(fill.last_qty, Quantity::from("50"));
4107 assert_eq!(
4108 rx.try_recv().unwrap_err(),
4109 tokio::sync::mpsc::error::TryRecvError::Empty
4110 );
4111 let metadata = caches.orders_metadata.get(&client_order_id).unwrap();
4112 assert!(metadata.is_accepted);
4113 assert_eq!(metadata.venue_order_id, Some(venue_order_id));
4114 drop(metadata);
4115 client.stop().unwrap();
4116 caches
4117 .orders_metadata
4118 .get_mut(&client_order_id)
4119 .unwrap()
4120 .is_accepted = false;
4121 msgbus::publish_order_event("events.order.S-001".into(), &accepted);
4122 assert!(
4123 !caches
4124 .orders_metadata
4125 .get(&client_order_id)
4126 .unwrap()
4127 .is_accepted
4128 );
4129 assert!(client.order_event_handler.is_none());
4130 }
4131
4132 #[rstest]
4133 #[case::acknowledgement(false, false)]
4134 #[case::terminal_parent(true, false)]
4135 #[case::pending_acknowledgement(false, true)]
4136 #[case::pending_terminal_parent(true, true)]
4137 fn test_stale_parent_preserves_replacement(#[case] terminal: bool, #[case] mapped: bool) {
4138 let mut event: AxWsOrderEvent = serde_json::from_str(if terminal {
4139 include_str!("../test_data/ws_order_canceled.json")
4140 } else {
4141 include_str!("../test_data/ws_order_acknowledged.json")
4142 })
4143 .unwrap();
4144
4145 let order = match &mut event {
4146 AxWsOrderEvent::Acknowledged(msg) => &mut msg.o,
4147 AxWsOrderEvent::Canceled(msg) => &mut msg.o,
4148 _ => unreachable!(),
4149 };
4150
4151 order.cid = Some(119);
4152 let parent_id = VenueOrderId::from(order.oid.as_str());
4153 let child_id = VenueOrderId::from("OID-ACTIVE-CHILD");
4154 let client_order_id = ClientOrderId::from("O-STALE-PARENT");
4155 let caches = test_caches();
4156 let mut metadata = test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX"));
4157 metadata.venue_order_id = Some(child_id);
4158 metadata.is_accepted = true;
4159 caches.orders_metadata.insert(client_order_id, metadata);
4160 caches.cid_to_client_order_id.insert(119, client_order_id);
4161 caches.venue_to_client_id.insert(child_id, client_order_id);
4162 if mapped {
4163 caches.venue_to_client_id.insert(parent_id, client_order_id);
4164 }
4165
4166 let clock = get_atomic_clock_realtime();
4167 let account_id = AccountId::from("AX-001");
4168 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4169
4170 let mut emitter = ExecutionEventEmitter::new(
4171 clock,
4172 TraderId::from("TRADER-001"),
4173 account_id,
4174 AccountType::Margin,
4175 None,
4176 );
4177 emitter.set_sender(tx);
4178 let instruments = AtomicMap::new();
4179 instruments.insert(
4180 Ustr::from("EURUSD-PERP"),
4181 test_perp_instrument("EURUSD-PERP"),
4182 );
4183 dispatch_order_event(
4184 event,
4185 &emitter,
4186 &caches,
4187 account_id,
4188 &instruments,
4189 &AtomicMap::new(),
4190 clock,
4191 );
4192 let report = test_status_report(Some(client_order_id), parent_id, OrderStatus::Canceled);
4193 cleanup_closed_order_status_report(&report, &caches);
4194
4195 assert!(rx.try_recv().is_err());
4196 assert_eq!(
4197 caches
4198 .orders_metadata
4199 .get(&client_order_id)
4200 .unwrap()
4201 .venue_order_id,
4202 Some(child_id)
4203 );
4204 assert_eq!(
4205 *caches.venue_to_client_id.get(&child_id).unwrap(),
4206 client_order_id
4207 );
4208 assert_eq!(
4209 *caches.cid_to_client_order_id.get(&119).unwrap(),
4210 client_order_id
4211 );
4212 assert_eq!(caches.venue_to_client_id.len(), if mapped { 2 } else { 1 });
4213 }
4214
4215 #[rstest]
4216 #[case::cancel(false)]
4217 #[case::modify(true)]
4218 fn test_stale_parent_rejection_preserves_active_leg(#[case] modify: bool) {
4219 let mut event: AxWsOrderEvent =
4220 serde_json::from_str(include_str!("../test_data/ws_cancel_rejected.json")).unwrap();
4221
4222 if modify {
4223 let AxWsOrderEvent::CancelRejected(msg) = &mut event else {
4224 unreachable!()
4225 };
4226
4227 msg.r = AxCancelRejectionReason::Unknown;
4228 msg.txt = Some("post-only order would cross the book".to_string());
4229 }
4230
4231 let parent = VenueOrderId::from("O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
4232 let child = VenueOrderId::from("OID-CHILD");
4233 let client_order_id = ClientOrderId::from("O-STALE-CANCEL");
4234 let caches = test_caches();
4235 let mut metadata = test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX"));
4236 metadata.venue_order_id = Some(child);
4237 caches.orders_metadata.insert(client_order_id, metadata);
4238 if !modify {
4239 caches.venue_to_client_id.insert(parent, client_order_id);
4240 }
4241
4242 caches
4243 .pending_modifies
4244 .insert(parent, (client_order_id, 1, false));
4245 caches.venue_to_client_id.insert(child, client_order_id);
4246 let clock = get_atomic_clock_realtime();
4247 let account_id = AccountId::from("AX-001");
4248 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4249
4250 let mut emitter = ExecutionEventEmitter::new(
4251 clock,
4252 TraderId::from("TRADER-001"),
4253 account_id,
4254 AccountType::Margin,
4255 None,
4256 );
4257 emitter.set_sender(tx);
4258 dispatch_order_event(
4259 event,
4260 &emitter,
4261 &caches,
4262 account_id,
4263 &AtomicMap::new(),
4264 &AtomicMap::new(),
4265 clock,
4266 );
4267
4268 if modify {
4269 let ExecutionEvent::Order(OrderEventAny::ModifyRejected(rejected)) =
4270 rx.try_recv().unwrap()
4271 else {
4272 panic!("expected stale parent modify rejection");
4273 };
4274
4275 assert_eq!(rejected.client_order_id, client_order_id);
4276 assert_eq!(rejected.venue_order_id, Some(child));
4277 assert_eq!(rejected.reason, "post-only order would cross the book");
4278 }
4279
4280 assert!(rx.try_recv().is_err());
4281 assert_eq!(caches.pending_modifies.contains_key(&parent), !modify);
4282 assert_eq!(
4283 caches
4284 .orders_metadata
4285 .get(&client_order_id)
4286 .unwrap()
4287 .venue_order_id,
4288 Some(child)
4289 );
4290 assert_eq!(
4291 *caches.venue_to_client_id.get(&child).unwrap(),
4292 client_order_id
4293 );
4294 }
4295
4296 #[rstest]
4297 #[case::trade_id(0)]
4298 #[case::timestamp(1)]
4299 #[case::venue_id(2)]
4300 #[case::instrument(3)]
4301 fn test_malformed_tracked_fill_preserves_context(#[case] invalid: u8) {
4302 let mut fill: AxWsOrderFilled =
4303 serde_json::from_str(include_str!("../test_data/ws_order_filled.json")).unwrap();
4304 fill.o.cid = Some(121);
4305 let venue_order_id = VenueOrderId::from(fill.o.oid.as_str());
4306 let client_order_id = ClientOrderId::from("O-MALFORMED");
4307 let caches = test_caches();
4308 let mut metadata = test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX"));
4309 metadata.venue_order_id = Some(venue_order_id);
4310 caches.orders_metadata.insert(client_order_id, metadata);
4311 caches.cid_to_client_order_id.insert(121, client_order_id);
4312 caches
4313 .venue_to_client_id
4314 .insert(venue_order_id, client_order_id);
4315
4316 match invalid {
4317 0 => fill.xs.tid.clear(),
4318 1 => fill.ts = -1,
4319 2 => fill.o.oid.clear(),
4320 3 => fill.o.s = Ustr::from("WRONG-PERP"),
4321 _ => unreachable!(),
4322 }
4323
4324 let instruments = AtomicMap::new();
4325 instruments.insert(
4326 Ustr::from("EURUSD-PERP"),
4327 test_perp_instrument("EURUSD-PERP"),
4328 );
4329 let rates = AtomicMap::new();
4330 rates.insert(
4331 Some(Ustr::from("fixture-account")),
4332 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
4333 );
4334 let clock = get_atomic_clock_realtime();
4335 let account_id = AccountId::from("AX-001");
4336 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4337
4338 let mut emitter = ExecutionEventEmitter::new(
4339 clock,
4340 TraderId::from("TRADER-001"),
4341 account_id,
4342 AccountType::Margin,
4343 None,
4344 );
4345 emitter.set_sender(tx);
4346 dispatch_order_event(
4347 AxWsOrderEvent::Filled(fill),
4348 &emitter,
4349 &caches,
4350 account_id,
4351 &instruments,
4352 &rates,
4353 clock,
4354 );
4355
4356 assert!(rx.try_recv().is_err());
4357 assert_eq!(
4358 caches
4359 .orders_metadata
4360 .get(&client_order_id)
4361 .unwrap()
4362 .venue_order_id,
4363 Some(venue_order_id)
4364 );
4365 assert_eq!(
4366 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
4367 client_order_id
4368 );
4369 assert_eq!(
4370 *caches.cid_to_client_order_id.get(&121).unwrap(),
4371 client_order_id
4372 );
4373 }
4374
4375 #[rstest]
4376 fn test_create_order_accepted_returns_none_without_metadata() {
4377 let caches = test_caches();
4378 let clock = get_atomic_clock_realtime();
4379 let account_id = AccountId::from("AX-001");
4380 let ws_order = test_ws_order("OID-UNKNOWN", dec!(100.00), 10);
4381
4382 let result = create_order_accepted(&ws_order, 1609459200, 0, &caches, account_id, clock);
4383 assert!(result.is_none());
4384 assert!(caches.venue_to_client_id.is_empty());
4385 }
4386
4387 #[rstest]
4388 fn test_lookup_order_metadata_cid_fallback() {
4389 let caches = test_caches();
4390 let client_order_id = ClientOrderId::from("O-CID");
4391 let instrument_id = InstrumentId::from("BTC-PERP.AX");
4392 caches.orders_metadata.insert(
4393 client_order_id,
4394 test_metadata(client_order_id, instrument_id),
4395 );
4396 caches.cid_to_client_order_id.insert(99, client_order_id);
4397
4398 let mut ws_order = test_ws_order("UNKNOWN-OID", dec!(0), 0);
4399 ws_order.cid = Some(99);
4400
4401 let found = lookup_order_metadata(&ws_order, &caches).expect("cid fallback should find");
4402 assert_eq!(found.client_order_id, client_order_id);
4403 }
4404
4405 #[rstest]
4406 fn test_lookup_order_metadata_returns_none_when_unknown() {
4407 let caches = test_caches();
4408 let ws_order = test_ws_order("UNKNOWN-OID", dec!(0), 0);
4409 assert!(lookup_order_metadata(&ws_order, &caches).is_none());
4410 }
4411
4412 #[rstest]
4413 #[case(true, LiquiditySide::Taker)]
4414 #[case(false, LiquiditySide::Maker)]
4415 fn test_create_order_filled_maps_liquidity_side(
4416 #[case] agg: bool,
4417 #[case] expected: LiquiditySide,
4418 ) {
4419 let caches = test_caches();
4420 let clock = get_atomic_clock_realtime();
4421 let account_id = AccountId::from("AX-001");
4422 let client_order_id = ClientOrderId::from("O-FILL");
4423 let instrument_id = InstrumentId::from("BTC-PERP.AX");
4424 let venue_order_id = VenueOrderId::new("OID-FILL");
4425
4426 caches.orders_metadata.insert(
4427 client_order_id,
4428 test_metadata(client_order_id, instrument_id),
4429 );
4430 caches
4431 .venue_to_client_id
4432 .insert(venue_order_id, client_order_id);
4433
4434 let order = test_ws_order(venue_order_id.as_str(), dec!(50500.00), 100);
4435 let execution = test_execution("TID-1", dec!(50500.00), 25, agg);
4436
4437 let event = create_order_filled(
4438 &order,
4439 &execution,
4440 1609459200,
4441 0,
4442 &caches,
4443 account_id,
4444 Money::from("12.34 USD"),
4445 clock,
4446 )
4447 .expect("should produce OrderFilled");
4448
4449 assert_eq!(event.venue_order_id, venue_order_id);
4450 assert_eq!(event.client_order_id, client_order_id);
4451 assert_eq!(event.trade_id, TradeId::new("TID-1"));
4452 assert_eq!(event.last_qty, Quantity::new(25.0, 0));
4453 assert_eq!(event.last_px, Price::from("50500.00"));
4454 assert_eq!(event.liquidity_side, expected);
4455 assert_eq!(event.commission, Some(Money::from("12.34 USD")));
4456 }
4457
4458 #[rstest]
4459 #[case::maker(false, dec!(0.0002), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "1.00 USD")]
4460 #[case::taker(true, dec!(0.0002), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "12.53 USD")]
4461 #[case::zero(false, dec!(0), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "0.00 USD")]
4462 #[case::rebate(false, dec!(-0.0002), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "-1.00 USD")]
4463 #[case::negative_price(false, dec!(0.0002), dec!(0.0025), dec!(-100.25), 20, dec!(2.5), "1.00 USD")]
4464 #[case::sub_cent_notional(false, dec!(0.0002), dec!(0.0025), dec!(0.007499), 10_001, dec!(1), "0.01 USD")]
4465 fn test_calculate_fill_commission(
4466 #[case] agg: bool,
4467 #[case] maker: Decimal,
4468 #[case] taker: Decimal,
4469 #[case] price: Decimal,
4470 #[case] quantity: u64,
4471 #[case] multiplier: Decimal,
4472 #[case] expected: &str,
4473 ) {
4474 let mut instrument = test_perp_instrument("BTC-PERP");
4475 if let InstrumentAny::PerpetualContract(ref mut instrument) = instrument {
4476 instrument.multiplier = Quantity::from_decimal(multiplier).unwrap();
4477 }
4478
4479 let instruments = AtomicMap::new();
4480 instruments.insert(Ustr::from("BTC-PERP"), instrument);
4481 let rates = AtomicMap::new();
4482 rates.insert(None, MakerTakerFeeRates::new(maker, taker));
4483 let order = test_ws_order("OID-FILL", price, quantity);
4484 let execution = test_execution("TID-FILL", price, quantity, agg);
4485
4486 let commission =
4487 calculate_fill_commission(&order, &execution, &instruments, &rates).unwrap();
4488
4489 assert_eq!(commission, Money::from(expected));
4490 }
4491
4492 #[rstest]
4493 #[case::default(None, None, "0.05 USD")]
4494 #[case::order_account(Some("order-account"), None, "0.10 USD")]
4495 #[case::execution_account(None, Some("execution-account"), "0.15 USD")]
4496 #[case::execution_account_precedence(
4497 Some("order-account"),
4498 Some("execution-account"),
4499 "0.15 USD"
4500 )]
4501 fn test_calculate_fill_commission_resolves_account(
4502 #[case] order_account: Option<&str>,
4503 #[case] execution_account: Option<&str>,
4504 #[case] expected: &str,
4505 ) {
4506 let instruments = AtomicMap::new();
4507 instruments.insert(Ustr::from("BTC-PERP"), test_perp_instrument("BTC-PERP"));
4508 let rates = AtomicMap::new();
4509 rates.insert(None, MakerTakerFeeRates::new(dec!(0.0001), dec!(0.0002)));
4510 rates.insert(
4511 Some(Ustr::from("order-account")),
4512 MakerTakerFeeRates::new(dec!(0.0003), dec!(0.0004)),
4513 );
4514 rates.insert(
4515 Some(Ustr::from("execution-account")),
4516 MakerTakerFeeRates::new(dec!(0.0005), dec!(0.0006)),
4517 );
4518 let mut order = test_ws_order("OID-FILL", dec!(10), 25);
4519 order.aid = order_account.map(Ustr::from);
4520 let mut execution = test_execution("TID-FILL", dec!(10), 25, true);
4521 execution.aid = execution_account.map(Ustr::from);
4522
4523 let commission =
4524 calculate_fill_commission(&order, &execution, &instruments, &rates).unwrap();
4525
4526 assert_eq!(commission, Money::from(expected));
4527 }
4528
4529 #[rstest]
4530 #[case::tracked_partial(true, false, "500.00 USD")]
4531 #[case::tracked_full(true, true, "1000.00 USD")]
4532 #[case::untracked_partial(false, false, "500.00 USD")]
4533 #[case::untracked_full(false, true, "1000.00 USD")]
4534 fn test_dispatch_stream_fill_carries_commission(
4535 #[case] tracked: bool,
4536 #[case] complete: bool,
4537 #[case] expected: &str,
4538 ) {
4539 let raw = if complete {
4540 include_str!("../test_data/ws_order_filled.json")
4541 } else {
4542 include_str!("../test_data/ws_order_partially_filled.json")
4543 };
4544
4545 let event: AxWsOrderEvent = serde_json::from_str(raw).unwrap();
4546 let clock = get_atomic_clock_realtime();
4547 let account_id = AccountId::from("AX-001");
4548 let client_order_id = ClientOrderId::from("O-FILL");
4549 let venue_order_id = VenueOrderId::new("O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
4550 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
4551 let caches = test_caches();
4552 if tracked {
4553 caches.orders_metadata.insert(
4554 client_order_id,
4555 test_metadata(client_order_id, instrument_id),
4556 );
4557 caches
4558 .venue_to_client_id
4559 .insert(venue_order_id, client_order_id);
4560 }
4561
4562 let instruments = AtomicMap::new();
4563 instruments.insert(
4564 Ustr::from("EURUSD-PERP"),
4565 test_perp_instrument("EURUSD-PERP"),
4566 );
4567 let rates = AtomicMap::new();
4568 rates.insert(
4569 Some(Ustr::from("fixture-account")),
4570 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
4571 );
4572 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4573
4574 let mut emitter = ExecutionEventEmitter::new(
4575 clock,
4576 TraderId::from("TESTER-001"),
4577 account_id,
4578 AccountType::Margin,
4579 None,
4580 );
4581 emitter.set_sender(tx);
4582
4583 dispatch_order_event(
4584 event,
4585 &emitter,
4586 &caches,
4587 account_id,
4588 &instruments,
4589 &rates,
4590 clock,
4591 );
4592
4593 let expected_qty = if complete { dec!(100) } else { dec!(50) };
4594
4595 match rx.try_recv().unwrap() {
4596 ExecutionEvent::Order(OrderEventAny::Filled(fill)) => {
4597 assert!(tracked);
4598 assert_eq!(fill.commission, Some(Money::from(expected)));
4599 assert_eq!(fill.client_order_id, client_order_id);
4600 assert_eq!(fill.venue_order_id, venue_order_id);
4601 assert_eq!(fill.trade_id.as_str(), "T-01ARZ3NDEKTSV4RRFFQ69G5FAV");
4602 assert_eq!(fill.last_qty.as_decimal(), expected_qty);
4603 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
4604 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
4605 }
4606 ExecutionEvent::Report(report) => {
4607 let ExecutionReport::Fill(fill) = report else {
4608 panic!("expected fill report");
4609 };
4610
4611 assert!(!tracked);
4612 assert_eq!(fill.commission, Money::from(expected));
4613 assert_eq!(fill.instrument_id, instrument_id);
4614 assert_eq!(fill.venue_order_id, venue_order_id);
4615 assert_eq!(fill.last_qty.as_decimal(), expected_qty);
4616 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
4617 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
4618 }
4619 other => panic!("unexpected fill event: {other:?}"),
4620 }
4621
4622 assert!(rx.try_recv().is_err());
4623 assert_eq!(
4624 caches.orders_metadata.len(),
4625 usize::from(tracked && !complete)
4626 );
4627 assert_eq!(
4628 caches.venue_to_client_id.len(),
4629 usize::from(tracked && !complete)
4630 );
4631 }
4632
4633 #[rstest]
4634 #[case::missing_account(Some("unknown"), dec!(100), "AX fill account fee rates are not cached")]
4635 #[case::overflow(None, Decimal::MAX, "AX fill commission calculation overflow")]
4636 fn test_dispatch_fill_fee_error_retains_tracking(
4637 #[case] account: Option<&str>,
4638 #[case] price: Decimal,
4639 #[case] expected: &str,
4640 ) {
4641 let clock = get_atomic_clock_realtime();
4642 let account_id = AccountId::from("AX-001");
4643 let client_order_id = ClientOrderId::from("O-FILL");
4644 let venue_order_id = VenueOrderId::new("OID-FILL");
4645 let caches = test_caches();
4646 caches.orders_metadata.insert(
4647 client_order_id,
4648 test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX")),
4649 );
4650 caches
4651 .venue_to_client_id
4652 .insert(venue_order_id, client_order_id);
4653 let instruments = AtomicMap::new();
4654 instruments.insert(Ustr::from("BTC-PERP"), test_perp_instrument("BTC-PERP"));
4655 let rates = AtomicMap::new();
4656 rates.insert(None, MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)));
4657 let mut order = test_ws_order("OID-FILL", dec!(100), 25);
4658 order.aid = account.map(Ustr::from);
4659 let execution = test_execution("TID-FILL", price, 25, true);
4660 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4661
4662 let mut emitter = ExecutionEventEmitter::new(
4663 clock,
4664 TraderId::from("TESTER-001"),
4665 account_id,
4666 AccountType::Margin,
4667 None,
4668 );
4669 emitter.set_sender(tx);
4670
4671 let event = AxWsOrderEvent::Filled(AxWsOrderFilled {
4672 ts: 1609459200,
4673 tn: 0,
4674 eid: "E-FILL".to_owned(),
4675 o: order.clone(),
4676 xs: execution.clone(),
4677 });
4678
4679 let error =
4680 calculate_fill_commission(&order, &execution, &instruments, &rates).unwrap_err();
4681 dispatch_order_event(
4682 event,
4683 &emitter,
4684 &caches,
4685 account_id,
4686 &instruments,
4687 &rates,
4688 clock,
4689 );
4690
4691 assert_eq!(error.to_string(), expected);
4692 assert!(rx.try_recv().is_err());
4693 assert_eq!(
4694 caches
4695 .orders_metadata
4696 .get(&client_order_id)
4697 .unwrap()
4698 .client_order_id,
4699 client_order_id
4700 );
4701 assert_eq!(
4702 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
4703 client_order_id
4704 );
4705 }
4706
4707 #[rstest]
4708 #[case::unaccepted_partial(true, false, false, false)]
4709 #[case::unaccepted_terminal(true, false, true, false)]
4710 #[case::partial_order(true, true, false, false)]
4711 #[case::partial_order_terminal(true, true, true, false)]
4712 #[case::untracked_partial(false, true, false, false)]
4713 #[case::untracked_terminal(false, true, true, false)]
4714 #[case::rest_partial(true, true, false, true)]
4715 #[case::rest_terminal(true, true, true, true)]
4716 fn test_commission_failure_corrected_replay_applies_once(
4717 #[case] tracked: bool,
4718 #[case] prior_fill: bool,
4719 #[case] complete: bool,
4720 #[case] rest_recovery: bool,
4721 ) {
4722 let clock = get_atomic_clock_realtime();
4723 let trader_id = TraderId::from("TRADER-001");
4724 let account_id = AccountId::from("AX-001");
4725 let client_order_id = ClientOrderId::from("O-FEE-REPLAY");
4726 let venue_order_id = VenueOrderId::from("OID-FEE-REPLAY");
4727 let instrument = test_perp_instrument("BTC-PERP");
4728 let instrument_id = instrument.id();
4729 let harness =
4730 ExecutionHarness::new(trader_id, *AX_CLIENT_ID, account_id, instrument.clone());
4731
4732 let prior_qty = if prior_fill { 10 } else { 0 };
4733 let total_qty = prior_qty + if complete { 25 } else { 50 };
4734 let order = OrderTestBuilder::new(OrderType::Limit)
4735 .trader_id(trader_id)
4736 .strategy_id(StrategyId::from("S-001"))
4737 .instrument_id(instrument_id)
4738 .client_order_id(client_order_id)
4739 .side(OrderSide::Buy)
4740 .quantity(Quantity::from(total_qty))
4741 .price(Price::from("100.00"))
4742 .build();
4743 initialize_commission_replay_order(
4744 &harness,
4745 &order,
4746 &instrument,
4747 venue_order_id,
4748 prior_fill,
4749 );
4750
4751 let before = harness
4752 .cache()
4753 .borrow()
4754 .order(&client_order_id)
4755 .unwrap()
4756 .clone();
4757 assert_eq!(
4758 before.venue_order_id(),
4759 prior_fill.then_some(venue_order_id)
4760 );
4761 let caches = test_caches();
4762
4763 if tracked {
4764 let mut metadata = test_metadata(client_order_id, instrument_id);
4765 metadata.order_init = Some(order.init_event().clone());
4766 metadata.is_accepted = prior_fill;
4767 metadata.venue_order_id = prior_fill.then_some(venue_order_id);
4768 caches.orders_metadata.insert(client_order_id, metadata);
4769 if prior_fill {
4770 caches
4771 .venue_to_client_id
4772 .insert(venue_order_id, client_order_id);
4773 }
4774
4775 caches.cid_to_client_order_id.insert(42, client_order_id);
4776 }
4777
4778 let instruments = AtomicMap::new();
4779 instruments.insert(Ustr::from("BTC-PERP"), instrument.clone());
4780 let rates = AtomicMap::new();
4781 rates.insert(
4782 None,
4783 MakerTakerFeeRates::new(dec!(1000000000000000000000), dec!(0.0025)),
4784 );
4785 let mut wire_order = test_ws_order(venue_order_id.as_str(), dec!(100), total_qty);
4786 wire_order.cid = tracked.then_some(42);
4787 wire_order.xq = prior_qty + 25;
4788 wire_order.rq = total_qty - wire_order.xq;
4789
4790 let execution = test_execution("T-FEE-REPLAY", dec!(80), 25, false);
4791 let event = commission_replay_event(wire_order.clone(), execution.clone(), complete);
4792
4793 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4794 let mut emitter =
4795 ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
4796 emitter.set_sender(tx);
4797
4798 let error =
4799 calculate_fill_commission(&wire_order, &execution, &instruments, &rates).unwrap_err();
4800 dispatch_order_event(
4801 event.clone(),
4802 &emitter,
4803 &caches,
4804 account_id,
4805 &instruments,
4806 &rates,
4807 clock,
4808 );
4809
4810 assert_eq!(
4811 error.to_string(),
4812 "failed to convert AX fill commission to Money"
4813 );
4814 assert!(rx.try_recv().is_err());
4815 assert_eq!(caches.orders_metadata.len(), usize::from(tracked));
4816 assert_eq!(
4817 caches.venue_to_client_id.len(),
4818 usize::from(tracked && prior_fill)
4819 );
4820 assert_eq!(caches.cid_to_client_order_id.len(), usize::from(tracked));
4821 assert!(caches.pending_modifies.is_empty());
4822
4823 if tracked {
4824 let metadata = caches.orders_metadata.get(&client_order_id).unwrap();
4825 assert_eq!(metadata.is_accepted, prior_fill);
4826 assert_eq!(
4827 metadata.venue_order_id,
4828 prior_fill.then_some(venue_order_id)
4829 );
4830 assert_eq!(
4831 caches
4832 .venue_to_client_id
4833 .get(&venue_order_id)
4834 .map(|owner| *owner),
4835 prior_fill.then_some(client_order_id)
4836 );
4837 assert_eq!(
4838 *caches.cid_to_client_order_id.get(&42).unwrap(),
4839 client_order_id
4840 );
4841 }
4842
4843 assert_eq!(
4844 harness
4845 .cache()
4846 .borrow()
4847 .order(&client_order_id)
4848 .unwrap()
4849 .events(),
4850 before.events()
4851 );
4852 rates.insert(None, MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)));
4853
4854 let retained = usize::from(tracked && !complete);
4855
4856 for replay in 0..2 {
4857 if rest_recovery {
4858 let mut response: AxFillsResponse =
4859 serde_json::from_str(include_str!("../test_data/http_get_fills.json")).unwrap();
4860 let fill = &mut response.fills[0];
4861 fill.trade_id = execution.tid.clone();
4862 fill.order_id = Some(wire_order.oid.clone());
4863 fill.symbol = wire_order.s;
4864 fill.quantity = execution.q;
4865 fill.price = execution.p;
4866 fill.fee = dec!(0.40);
4867 fill.is_taker = false;
4868 let report = crate::http::parse::parse_fill_report(
4869 fill,
4870 account_id,
4871 &instrument,
4872 clock.get_time_ns(),
4873 )
4874 .unwrap();
4875 emitter.send_fill_report(report);
4876 } else {
4877 dispatch_order_event(
4878 event.clone(),
4879 &emitter,
4880 &caches,
4881 account_id,
4882 &instruments,
4883 &rates,
4884 clock,
4885 );
4886 assert_eq!(caches.orders_metadata.len(), retained);
4887 assert_eq!(caches.venue_to_client_id.len(), retained);
4888 assert_eq!(caches.cid_to_client_order_id.len(), retained);
4889 }
4890
4891 apply_commission_replay(
4892 &harness,
4893 &mut rx,
4894 client_order_id,
4895 venue_order_id,
4896 usize::from(!rest_recovery && tracked && !prior_fill && replay == 0),
4897 );
4898
4899 assert_commission_replay_state(
4900 &harness,
4901 client_order_id,
4902 venue_order_id,
4903 prior_fill,
4904 complete,
4905 );
4906 }
4907 }
4908
4909 fn initialize_commission_replay_order(
4910 harness: &ExecutionHarness,
4911 order: &OrderAny,
4912 instrument: &InstrumentAny,
4913 venue_order_id: VenueOrderId,
4914 prior_fill: bool,
4915 ) {
4916 let account_id = harness.account_id();
4917 let client_order_id = order.client_order_id();
4918 harness
4919 .cache()
4920 .borrow_mut()
4921 .add_order(order.clone(), None, Some(*AX_CLIENT_ID), false)
4922 .unwrap();
4923 let submitted = TestOrderEventStubs::submitted(order, account_id);
4924 harness.exec_engine().borrow_mut().process(&submitted);
4925
4926 if prior_fill {
4927 let accepted = TestOrderEventStubs::accepted(order, account_id, venue_order_id);
4928 harness.exec_engine().borrow_mut().process(&accepted);
4929 let accepted_order = harness
4930 .cache()
4931 .borrow()
4932 .order_owned(&client_order_id)
4933 .unwrap();
4934 let prior = TestOrderEventStubs::filled(
4935 &accepted_order,
4936 instrument,
4937 Some(TradeId::from("T-PRIOR")),
4938 None,
4939 Some(Price::from("90.00")),
4940 Some(Quantity::from(10)),
4941 Some(LiquiditySide::Taker),
4942 Some(Money::from("0.09 USD")),
4943 Some(UnixNanos::from(1_609_459_199_000_000_000)),
4944 Some(account_id),
4945 );
4946 harness.exec_engine().borrow_mut().process(&prior);
4947 }
4948 }
4949
4950 fn commission_replay_event(
4951 mut order: AxWsOrder,
4952 execution: AxWsTradeExecution,
4953 complete: bool,
4954 ) -> AxWsOrderEvent {
4955 order.o = if complete {
4956 AxOrderStatus::Filled
4957 } else {
4958 AxOrderStatus::PartiallyFilled
4959 };
4960
4961 let mut event: AxWsOrderEvent = serde_json::from_str(if complete {
4962 include_str!("../test_data/ws_order_filled.json")
4963 } else {
4964 include_str!("../test_data/ws_order_partially_filled.json")
4965 })
4966 .unwrap();
4967
4968 match &mut event {
4969 AxWsOrderEvent::Filled(message) => {
4970 message.o = order;
4971 message.xs = execution;
4972 }
4973 AxWsOrderEvent::PartiallyFilled(message) => {
4974 message.o = order;
4975 message.xs = execution;
4976 }
4977 _ => unreachable!(),
4978 }
4979
4980 event
4981 }
4982
4983 fn apply_commission_replay(
4984 harness: &ExecutionHarness,
4985 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
4986 client_order_id: ClientOrderId,
4987 venue_order_id: VenueOrderId,
4988 expected_accepted: usize,
4989 ) {
4990 let mut accepted = 0;
4991 let mut filled = 0;
4992
4993 while let Ok(message) = rx.try_recv() {
4994 match message {
4995 ExecutionEvent::Order(OrderEventAny::Accepted(event)) => {
4996 accepted += 1;
4997 harness
4998 .exec_engine()
4999 .borrow_mut()
5000 .process(&OrderEventAny::Accepted(event));
5001 }
5002 ExecutionEvent::Order(OrderEventAny::Filled(event)) => {
5003 assert_eq!(event.client_order_id, client_order_id);
5004 assert_eq!(event.venue_order_id, venue_order_id);
5005 assert_eq!(event.account_id, harness.account_id());
5006 assert_eq!(event.instrument_id, harness.instrument_id());
5007 assert_eq!(event.trade_id, TradeId::from("T-FEE-REPLAY"));
5008 assert_eq!(event.order_side, OrderSide::Buy);
5009 assert_eq!(event.last_qty.as_decimal(), dec!(25));
5010 assert_eq!(event.last_px.as_decimal(), dec!(80));
5011 assert_eq!(event.currency, Currency::USD());
5012 assert_eq!(event.commission, Some(Money::from("0.40 USD")));
5013 assert_eq!(event.liquidity_side, LiquiditySide::Maker);
5014 filled += 1;
5015 harness
5016 .exec_engine()
5017 .borrow_mut()
5018 .process(&OrderEventAny::Filled(event));
5019 }
5020 ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
5021 assert_eq!(report.trade_id, TradeId::from("T-FEE-REPLAY"));
5022 assert_eq!(report.venue_order_id, venue_order_id);
5023 assert_eq!(report.account_id, harness.account_id());
5024 assert_eq!(report.instrument_id, harness.instrument_id());
5025 assert_eq!(report.order_side, OrderSide::Buy);
5026 assert_eq!(report.last_qty.as_decimal(), dec!(25));
5027 assert_eq!(report.last_px.as_decimal(), dec!(80));
5028 assert_eq!(report.commission, Money::from("0.40 USD"));
5029 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
5030 filled += 1;
5031 harness
5032 .exec_engine()
5033 .borrow_mut()
5034 .reconcile_fill_report(&report);
5035 }
5036 other => panic!("unexpected commission replay event: {other:?}"),
5037 }
5038 }
5039
5040 assert_eq!(accepted, expected_accepted);
5041 assert_eq!(filled, 1);
5042 }
5043
5044 fn assert_commission_replay_state(
5045 harness: &ExecutionHarness,
5046 client_order_id: ClientOrderId,
5047 venue_order_id: VenueOrderId,
5048 prior_fill: bool,
5049 complete: bool,
5050 ) {
5051 let cache = harness.cache().borrow();
5052 let cached = cache.order(&client_order_id).unwrap();
5053 assert_eq!(cached.venue_order_id(), Some(venue_order_id));
5054 assert_eq!(
5055 cached.status(),
5056 if complete {
5057 OrderStatus::Filled
5058 } else {
5059 OrderStatus::PartiallyFilled
5060 }
5061 );
5062 assert_eq!(
5063 cached.filled_qty(),
5064 Quantity::from(if prior_fill { 35 } else { 25 })
5065 );
5066 assert_eq!(
5067 cached.leaves_qty(),
5068 Quantity::from(if complete { 0 } else { 25 })
5069 );
5070 assert_eq!(
5071 cached.commissions().values().copied().collect::<Vec<_>>(),
5072 vec![Money::from(if prior_fill {
5073 "0.49 USD"
5074 } else {
5075 "0.40 USD"
5076 })]
5077 );
5078
5079 let expected_trades = if prior_fill {
5080 vec![TradeId::from("T-PRIOR"), TradeId::from("T-FEE-REPLAY")]
5081 } else {
5082 vec![TradeId::from("T-FEE-REPLAY")]
5083 };
5084
5085 assert_eq!(
5086 cached.trade_ids().into_iter().copied().collect::<Vec<_>>(),
5087 expected_trades
5088 );
5089 assert_eq!(
5090 cached
5091 .events()
5092 .iter()
5093 .filter(|event| matches!(event, OrderEventAny::Filled(_)))
5094 .count(),
5095 usize::from(prior_fill) + 1
5096 );
5097 }
5098
5099 #[rstest]
5100 fn test_create_order_canceled_populates_identifiers() {
5101 let caches = test_caches();
5102 let clock = get_atomic_clock_realtime();
5103 let account_id = AccountId::from("AX-001");
5104 let client_order_id = ClientOrderId::from("O-CXL");
5105 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5106 let venue_order_id = VenueOrderId::new("OID-CXL");
5107
5108 caches.orders_metadata.insert(
5109 client_order_id,
5110 test_metadata(client_order_id, instrument_id),
5111 );
5112 caches
5113 .venue_to_client_id
5114 .insert(venue_order_id, client_order_id);
5115
5116 let order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
5117 let event = create_order_canceled(&order, 1609459200, 0, &caches, account_id, clock)
5118 .expect("should produce OrderCanceled");
5119
5120 assert_eq!(event.venue_order_id, Some(venue_order_id));
5121 assert_eq!(event.client_order_id, client_order_id);
5122 assert_eq!(event.account_id, Some(account_id));
5123 assert_eq!(event.instrument_id, instrument_id);
5124 }
5125
5126 #[rstest]
5127 fn test_create_order_expired_populates_identifiers() {
5128 let caches = test_caches();
5129 let clock = get_atomic_clock_realtime();
5130 let account_id = AccountId::from("AX-001");
5131 let client_order_id = ClientOrderId::from("O-EXP");
5132 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5133 let venue_order_id = VenueOrderId::new("OID-EXP");
5134
5135 caches.orders_metadata.insert(
5136 client_order_id,
5137 test_metadata(client_order_id, instrument_id),
5138 );
5139 caches
5140 .venue_to_client_id
5141 .insert(venue_order_id, client_order_id);
5142
5143 let order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
5144 let event = create_order_expired(&order, 1609459200, 0, &caches, account_id, clock)
5145 .expect("should produce OrderExpired");
5146
5147 assert_eq!(event.venue_order_id, Some(venue_order_id));
5148 assert_eq!(event.client_order_id, client_order_id);
5149 }
5150
5151 #[rstest]
5152 #[case(AxTimeInForce::Ioc, true)]
5153 #[case(AxTimeInForce::Fok, true)]
5154 #[case(AxTimeInForce::Day, false)]
5155 #[case(AxTimeInForce::Gtc, false)]
5156 fn test_dispatch_expired_maps_ioc_fok_to_canceled(
5157 #[case] tif: AxTimeInForce,
5158 #[case] expect_canceled: bool,
5159 ) {
5160 let clock = get_atomic_clock_realtime();
5161 let account_id = AccountId::from("AX-001");
5162 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5163 let mut emitter = ExecutionEventEmitter::new(
5164 clock,
5165 TraderId::from("TESTER-001"),
5166 account_id,
5167 AccountType::Margin,
5168 None,
5169 );
5170 emitter.set_sender(tx);
5171
5172 let caches = test_caches();
5173 let client_order_id = ClientOrderId::from("O-EXP");
5174 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5175 caches.orders_metadata.insert(
5176 client_order_id,
5177 test_metadata(client_order_id, instrument_id),
5178 );
5179 caches
5180 .venue_to_client_id
5181 .insert(VenueOrderId::new("OID-EXP"), client_order_id);
5182
5183 let mut order = test_ws_order("OID-EXP", dec!(100.00), 10);
5184 order.tif = tif;
5185 let event = AxWsOrderEvent::Expired(AxWsOrderExpired {
5186 ts: 1609459200,
5187 tn: 0,
5188 eid: "E-EXP".to_string(),
5189 o: order,
5190 });
5191
5192 let instruments: AtomicMap<Ustr, InstrumentAny> = AtomicMap::new();
5193 dispatch_order_event(
5194 event,
5195 &emitter,
5196 &caches,
5197 account_id,
5198 &instruments,
5199 &AtomicMap::new(),
5200 clock,
5201 );
5202
5203 match rx.try_recv().expect("an order event should be emitted") {
5204 ExecutionEvent::Order(OrderEventAny::Canceled(_)) => assert!(expect_canceled),
5205 ExecutionEvent::Order(OrderEventAny::Expired(_)) => assert!(!expect_canceled),
5206 other => panic!("unexpected event: {other:?}"),
5207 }
5208 }
5209
5210 #[rstest]
5211 fn test_create_order_rejected_sets_due_post_only_when_reason_matches() {
5212 let caches = test_caches();
5213 let clock = get_atomic_clock_realtime();
5214 let account_id = AccountId::from("AX-001");
5215 let client_order_id = ClientOrderId::from("O-REJ");
5216 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5217
5218 caches.orders_metadata.insert(
5219 client_order_id,
5220 test_metadata(client_order_id, instrument_id),
5221 );
5222 caches
5223 .venue_to_client_id
5224 .insert(VenueOrderId::new("OID-REJ"), client_order_id);
5225
5226 let order = test_ws_order("OID-REJ", dec!(100.00), 10);
5227 let reason = "post-only order would cross the book";
5229 let event =
5230 create_order_rejected(&order, reason, 1609459200, 0, &caches, account_id, clock)
5231 .expect("should produce OrderRejected");
5232
5233 assert!(event.due_post_only, "post-only reason should set flag");
5234 assert_eq!(event.reason, Ustr::from(reason));
5235 }
5236
5237 #[rstest]
5238 fn test_create_order_rejected_clears_due_post_only_for_other_reasons() {
5239 let caches = test_caches();
5240 let clock = get_atomic_clock_realtime();
5241 let account_id = AccountId::from("AX-001");
5242 let client_order_id = ClientOrderId::from("O-REJ-2");
5243 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5244
5245 caches.orders_metadata.insert(
5246 client_order_id,
5247 test_metadata(client_order_id, instrument_id),
5248 );
5249 caches
5250 .venue_to_client_id
5251 .insert(VenueOrderId::new("OID-REJ-2"), client_order_id);
5252
5253 let order = test_ws_order("OID-REJ-2", dec!(100.00), 10);
5254 let event = create_order_rejected(
5255 &order,
5256 "INSUFFICIENT_MARGIN",
5257 1609459200,
5258 0,
5259 &caches,
5260 account_id,
5261 clock,
5262 )
5263 .expect("should produce OrderRejected");
5264
5265 assert!(!event.due_post_only);
5266 assert_eq!(event.reason, Ustr::from("INSUFFICIENT_MARGIN"));
5267 }
5268
5269 #[rstest]
5270 fn test_cleanup_terminal_order_tracking_removes_all_caches() {
5271 let caches = test_caches();
5272 let client_order_id = ClientOrderId::from("O-CLEAN");
5273 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5274 let venue_order_id = VenueOrderId::new("OID-CLEAN");
5275 let stale_venue_order_id = VenueOrderId::new("OID-CLEAN-OLD");
5276 let cid_value = 123u64;
5277
5278 caches.orders_metadata.insert(
5279 client_order_id,
5280 test_metadata(client_order_id, instrument_id),
5281 );
5282 caches
5283 .venue_to_client_id
5284 .insert(venue_order_id, client_order_id);
5285 caches
5286 .venue_to_client_id
5287 .insert(stale_venue_order_id, client_order_id);
5288 caches
5289 .cid_to_client_order_id
5290 .insert(cid_value, client_order_id);
5291
5292 let mut order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
5293 order.cid = Some(cid_value);
5294
5295 cleanup_terminal_order_tracking(&order, &caches);
5296
5297 assert!(caches.orders_metadata.is_empty());
5298 assert!(caches.venue_to_client_id.is_empty());
5299 assert!(caches.cid_to_client_order_id.is_empty());
5300 }
5301
5302 #[rstest]
5303 fn test_cleanup_terminal_order_tracking_via_cid_when_venue_missing() {
5304 let caches = test_caches();
5305 let client_order_id = ClientOrderId::from("O-CLEAN-CID");
5306 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5307 let cid_value = 321u64;
5308
5309 caches.orders_metadata.insert(
5310 client_order_id,
5311 test_metadata(client_order_id, instrument_id),
5312 );
5313 caches
5314 .cid_to_client_order_id
5315 .insert(cid_value, client_order_id);
5316
5317 let mut order = test_ws_order("OID-UNKNOWN", dec!(100.00), 10);
5319 order.cid = Some(cid_value);
5320
5321 cleanup_terminal_order_tracking(&order, &caches);
5322
5323 assert!(caches.orders_metadata.is_empty());
5324 assert!(caches.cid_to_client_order_id.is_empty());
5325 }
5326
5327 #[rstest]
5328 fn test_cleanup_terminal_order_tracking_noop_when_unknown() {
5329 let caches = test_caches();
5330 let other = ClientOrderId::from("OTHER");
5331 let instrument_id = InstrumentId::from("BTC-PERP.AX");
5332 caches
5333 .orders_metadata
5334 .insert(other, test_metadata(other, instrument_id));
5335
5336 let order = test_ws_order("OID-NOT-TRACKED", dec!(100.00), 10);
5337 cleanup_terminal_order_tracking(&order, &caches);
5338
5339 assert_eq!(caches.orders_metadata.len(), 1);
5341 }
5342
5343 #[rstest]
5344 fn test_cleanup_terminal_order_tracking_by_client_clears_pending_replacements() {
5345 let caches = test_caches();
5346 let client_order_id = ClientOrderId::from("O-CANCEL-ALL");
5347 let parent = VenueOrderId::from("OID-CANCEL-ALL-PARENT");
5348 let child = VenueOrderId::from("OID-CANCEL-ALL-CHILD");
5349 seed_terminal_tracking(&caches, client_order_id, parent, child, 891);
5350 caches
5351 .pending_modifies
5352 .insert(parent, (client_order_id, 2, false));
5353 caches
5354 .pending_modifies
5355 .insert(child, (client_order_id, 1, true));
5356
5357 cleanup_terminal_order_tracking_ids(&caches, None, None, Some(client_order_id));
5358
5359 assert!(caches.pending_modifies.is_empty());
5360 assert!(caches.orders_metadata.is_empty());
5361 assert!(caches.venue_to_client_id.is_empty());
5362 assert!(caches.cid_to_client_order_id.is_empty());
5363 }
5364
5365 #[rstest]
5366 fn test_cleanup_closed_order_status_report_removes_all_caches() {
5367 let caches = test_caches();
5368 let client_order_id = ClientOrderId::from("O-RECON-FILL");
5369 let venue_order_id = VenueOrderId::new("OID-RECON-FILL");
5370 let stale_venue_order_id = VenueOrderId::new("OID-RECON-FILL-OLD");
5371 let cid_value = client_order_id_to_cid(&client_order_id);
5372
5373 seed_terminal_tracking(
5374 &caches,
5375 client_order_id,
5376 venue_order_id,
5377 stale_venue_order_id,
5378 cid_value,
5379 );
5380
5381 let report = test_status_report(Some(client_order_id), venue_order_id, OrderStatus::Filled);
5382 cleanup_closed_order_status_report(&report, &caches);
5383
5384 assert!(caches.orders_metadata.is_empty());
5385 assert!(caches.venue_to_client_id.is_empty());
5386 assert!(caches.cid_to_client_order_id.is_empty());
5387 }
5388
5389 #[rstest]
5390 fn test_cleanup_closed_order_status_report_via_venue_when_client_id_absent() {
5391 let caches = test_caches();
5392 let client_order_id = ClientOrderId::from("O-RECON-CANCEL");
5393 let venue_order_id = VenueOrderId::new("OID-RECON-CANCEL");
5394 let stale_venue_order_id = VenueOrderId::new("OID-RECON-CANCEL-OLD");
5395 let cid_value = 654u64;
5396
5397 seed_terminal_tracking(
5398 &caches,
5399 client_order_id,
5400 venue_order_id,
5401 stale_venue_order_id,
5402 cid_value,
5403 );
5404
5405 let report = test_status_report(None, venue_order_id, OrderStatus::Canceled);
5406 cleanup_closed_order_status_report(&report, &caches);
5407
5408 assert!(caches.orders_metadata.is_empty());
5409 assert!(caches.venue_to_client_id.is_empty());
5410 assert!(caches.cid_to_client_order_id.is_empty());
5411 }
5412
5413 #[rstest]
5414 fn test_cleanup_closed_order_status_report_prefers_venue_identity() {
5415 let caches = test_caches();
5416 let client_order_id = ClientOrderId::from("O-EXT-RECON");
5417 let venue_order_id = VenueOrderId::new("OID-EXT-RECON");
5418 let stale_venue_order_id = VenueOrderId::new("OID-EXT-RECON-OLD");
5419
5420 caches.orders_metadata.insert(
5421 client_order_id,
5422 test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX")),
5423 );
5424 caches
5425 .venue_to_client_id
5426 .insert(venue_order_id, client_order_id);
5427 caches
5428 .venue_to_client_id
5429 .insert(stale_venue_order_id, client_order_id);
5430
5431 let report = test_status_report(
5432 Some(cid_to_client_order_id(99)),
5433 venue_order_id,
5434 OrderStatus::Filled,
5435 );
5436 cleanup_closed_order_status_report(&report, &caches);
5437
5438 assert!(caches.orders_metadata.is_empty());
5439 assert!(caches.venue_to_client_id.is_empty());
5440 assert!(caches.cid_to_client_order_id.is_empty());
5441 }
5442
5443 #[rstest]
5444 fn test_cleanup_open_order_status_report_retains_caches() {
5445 let caches = test_caches();
5446 let client_order_id = ClientOrderId::from("O-RECON-OPEN");
5447 let venue_order_id = VenueOrderId::new("OID-RECON-OPEN");
5448 let stale_venue_order_id = VenueOrderId::new("OID-RECON-OPEN-OLD");
5449 let cid_value = client_order_id_to_cid(&client_order_id);
5450
5451 seed_terminal_tracking(
5452 &caches,
5453 client_order_id,
5454 venue_order_id,
5455 stale_venue_order_id,
5456 cid_value,
5457 );
5458
5459 let report =
5460 test_status_report(Some(client_order_id), venue_order_id, OrderStatus::Accepted);
5461 cleanup_closed_order_status_report(&report, &caches);
5462
5463 assert!(caches.orders_metadata.contains_key(&client_order_id));
5464 assert_eq!(
5465 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
5466 client_order_id
5467 );
5468 assert_eq!(
5469 *caches.cid_to_client_order_id.get(&cid_value).unwrap(),
5470 client_order_id
5471 );
5472 }
5473
5474 #[rstest]
5475 fn test_cleanup_closed_order_status_reports_only_cleans_terminal() {
5476 let caches = test_caches();
5477 let filled_client_order_id = ClientOrderId::from("O-RECON-BATCH-FILL");
5478 let filled_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-FILL");
5479 let filled_stale_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-FILL-OLD");
5480 let filled_cid = client_order_id_to_cid(&filled_client_order_id);
5481 let open_client_order_id = ClientOrderId::from("O-RECON-BATCH-OPEN");
5482 let open_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-OPEN");
5483 let open_stale_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-OPEN-OLD");
5484 let open_cid = client_order_id_to_cid(&open_client_order_id);
5485
5486 seed_terminal_tracking(
5487 &caches,
5488 filled_client_order_id,
5489 filled_venue_order_id,
5490 filled_stale_venue_order_id,
5491 filled_cid,
5492 );
5493 seed_terminal_tracking(
5494 &caches,
5495 open_client_order_id,
5496 open_venue_order_id,
5497 open_stale_venue_order_id,
5498 open_cid,
5499 );
5500
5501 let reports = vec![
5502 test_status_report(
5503 Some(filled_client_order_id),
5504 filled_venue_order_id,
5505 OrderStatus::Filled,
5506 ),
5507 test_status_report(
5508 Some(open_client_order_id),
5509 open_venue_order_id,
5510 OrderStatus::Accepted,
5511 ),
5512 ];
5513
5514 for report in &reports {
5515 cleanup_closed_order_status_report(report, &caches);
5516 }
5517
5518 assert!(!caches.orders_metadata.contains_key(&filled_client_order_id));
5519 assert!(
5520 !caches
5521 .venue_to_client_id
5522 .contains_key(&filled_venue_order_id)
5523 );
5524 assert!(!caches.cid_to_client_order_id.contains_key(&filled_cid));
5525 assert!(caches.orders_metadata.contains_key(&open_client_order_id));
5526 assert_eq!(
5527 *caches.venue_to_client_id.get(&open_venue_order_id).unwrap(),
5528 open_client_order_id
5529 );
5530 assert_eq!(
5531 *caches.cid_to_client_order_id.get(&open_cid).unwrap(),
5532 open_client_order_id
5533 );
5534 }
5535
5536 #[rstest]
5537 #[case::inline_bulk(false, false, false)]
5538 #[case::owned_bulk(true, false, false)]
5539 #[case::inline_single(false, true, false)]
5540 #[case::owned_single(true, true, false)]
5541 #[case::canceled_bulk(true, false, true)]
5542 #[case::canceled_single(true, true, true)]
5543 #[tokio::test]
5544 async fn test_generate_order_status_reports_cleans_terminal_tracking(
5545 #[case] owned: bool,
5546 #[case] single: bool,
5547 #[case] canceled: bool,
5548 ) {
5549 let filled_client_order_id = ClientOrderId::from("O-RECON-HTTP-FILL");
5550 let filled_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-FILL");
5551 let filled_stale_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-FILL-OLD");
5552 let filled_cid = client_order_id_to_cid(&filled_client_order_id);
5553 let open_client_order_id = ClientOrderId::from("O-RECON-HTTP-OPEN");
5554 let open_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-OPEN");
5555 let open_stale_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-OPEN-OLD");
5556 let open_cid = client_order_id_to_cid(&open_client_order_id);
5557
5558 let addr = start_orders_report_server(serde_json::json!({
5559 "orders": [
5560 {
5561 "ts": 1_704_067_200,
5562 "tn": 500_000_000,
5563 "oid": filled_venue_order_id.as_str(),
5564 "u": "u",
5565 "s": "BTC-PERP",
5566 "p": "100.00",
5567 "q": 10,
5568 "xq": 10,
5569 "rq": 0,
5570 "o": "FILLED",
5571 "d": "B",
5572 "tif": "GTC",
5573 "cid": filled_cid,
5574 "po": false
5575 },
5576 {
5577 "ts": 1_704_067_201,
5578 "tn": 500_000_000,
5579 "oid": open_venue_order_id.as_str(),
5580 "u": "u",
5581 "s": "BTC-PERP",
5582 "p": "100.00",
5583 "q": 10,
5584 "xq": 0,
5585 "rq": 10,
5586 "o": "ACCEPTED",
5587 "d": "B",
5588 "tif": "GTC",
5589 "cid": open_cid,
5590 "po": false
5591 }
5592 ]
5593 }))
5594 .await;
5595
5596 let cache = Rc::new(RefCell::new(Cache::default()));
5597
5598 let core = ExecutionClientCore::new(
5599 TraderId::from("TESTER-001"),
5600 *AX_CLIENT_ID,
5601 *AX_VENUE,
5602 OmsType::Netting,
5603 AccountId::from("AX-001"),
5604 AccountType::Margin,
5605 None,
5606 cache,
5607 );
5608
5609 let config = AxExecutionClientConfig {
5610 api_key: Some("test_api_key".into()),
5611 api_secret: Some("test_api_secret".into()),
5612 environment: AxEnvironment::Sandbox,
5613 base_url_http: Some(format!("http://{addr}")),
5614 base_url_orders: Some(format!("http://{addr}")),
5615 base_url_ws_private: Some(format!("ws://{addr}/orders/ws")),
5616 http_timeout_secs: 5,
5617 max_retries: 1,
5618 retry_delay_initial_ms: 10,
5619 retry_delay_max_ms: 10,
5620 ..Default::default()
5621 };
5622
5623 let client = AxExecutionClient::new(core, config).expect("create exec client");
5624 client.http_client.set_session_token("test-token".into());
5625 client
5626 .http_client
5627 .cache_instrument(test_perp_instrument("BTC-PERP"));
5628
5629 seed_terminal_tracking(
5630 client.ws_orders.caches(),
5631 filled_client_order_id,
5632 filled_venue_order_id,
5633 filled_stale_venue_order_id,
5634 filled_cid,
5635 );
5636 seed_terminal_tracking(
5637 client.ws_orders.caches(),
5638 open_client_order_id,
5639 open_venue_order_id,
5640 open_stale_venue_order_id,
5641 open_cid,
5642 );
5643
5644 let cmd = GenerateOrderStatusReports::new(
5645 UUID4::new(),
5646 UnixNanos::default(),
5647 false,
5648 None,
5649 None,
5650 None,
5651 None,
5652 None,
5653 );
5654
5655 let single_cmd = GenerateOrderStatusReport::new(
5656 UUID4::new(),
5657 UnixNanos::from(123),
5658 Some(InstrumentId::from("BTC-PERP.AX")),
5659 Some(filled_client_order_id),
5660 Some(filled_venue_order_id),
5661 None,
5662 None,
5663 );
5664
5665 let reports = if owned {
5666 client
5667 .ws_orders
5668 .caches()
5669 .cid_to_client_order_id
5670 .remove(&filled_cid);
5671
5672 let task = if single {
5673 let task = client
5674 .generate_order_status_report_task(&single_cmd)
5675 .unwrap();
5676
5677 ExecutionReportTask {
5678 collection: task.collection,
5679 finish: Box::new(move || Ok((task.finish)()?.into_iter().collect::<Vec<_>>())),
5680 }
5681 } else {
5682 client.generate_order_status_reports_task(&cmd).unwrap()
5683 };
5684
5685 client
5686 .ws_orders
5687 .caches()
5688 .cid_to_client_order_id
5689 .insert(filled_cid, filled_client_order_id);
5690
5691 let worker = tokio::spawn(task.collection);
5692 if canceled {
5693 worker.abort();
5694 assert!(worker.await.unwrap_err().is_cancelled());
5695 } else {
5696 worker.await.unwrap();
5697 }
5698
5699 let caches = client.ws_orders.caches();
5700 assert!(caches.orders_metadata.contains_key(&filled_client_order_id));
5701 assert!(
5702 caches
5703 .venue_to_client_id
5704 .contains_key(&filled_venue_order_id)
5705 );
5706 assert!(caches.cid_to_client_order_id.contains_key(&filled_cid));
5707
5708 if canceled {
5709 let error = (task.finish)().unwrap_err();
5710 assert_eq!(
5711 error.to_string(),
5712 "report collection stopped without a result"
5713 );
5714 assert_eq!(caches.orders_metadata.len(), 2);
5715 assert_eq!(caches.venue_to_client_id.len(), 4);
5716 assert_eq!(caches.cid_to_client_order_id.len(), 2);
5717 return;
5718 }
5719
5720 (task.finish)().unwrap()
5721 } else if single {
5722 client
5723 .generate_order_status_report(&single_cmd)
5724 .await
5725 .unwrap()
5726 .into_iter()
5727 .collect()
5728 } else {
5729 client.generate_order_status_reports(&cmd).await.unwrap()
5730 };
5731
5732 assert_eq!(reports.len(), if single { 1 } else { 2 });
5733 let filled = reports
5734 .iter()
5735 .find(|report| report.venue_order_id == filled_venue_order_id)
5736 .expect("filled report");
5737 assert_eq!(filled.order_status, OrderStatus::Filled);
5738 assert_eq!(filled.client_order_id, Some(filled_client_order_id));
5739
5740 if !single {
5741 let open = reports
5742 .iter()
5743 .find(|report| report.venue_order_id == open_venue_order_id)
5744 .unwrap();
5745 assert_eq!(open.order_status, OrderStatus::Accepted);
5746 assert_eq!(open.client_order_id, Some(open_client_order_id));
5747 }
5748
5749 let caches = client.ws_orders.caches();
5750 assert!(!caches.orders_metadata.contains_key(&filled_client_order_id));
5751 assert!(
5752 !caches
5753 .venue_to_client_id
5754 .contains_key(&filled_venue_order_id)
5755 );
5756 assert!(
5757 !caches
5758 .venue_to_client_id
5759 .contains_key(&filled_stale_venue_order_id)
5760 );
5761 assert!(!caches.cid_to_client_order_id.contains_key(&filled_cid));
5762 assert!(caches.orders_metadata.contains_key(&open_client_order_id));
5763 assert_eq!(
5764 *caches.venue_to_client_id.get(&open_venue_order_id).unwrap(),
5765 open_client_order_id
5766 );
5767 assert_eq!(
5768 *caches.cid_to_client_order_id.get(&open_cid).unwrap(),
5769 open_client_order_id
5770 );
5771 }
5772
5773 #[tokio::test]
5774 #[ignore = "requires AX sandbox credentials and network access"]
5775 async fn test_live_report_tasks_sandbox() {
5776 let credential = Credential::resolve(None, None).expect("AX sandbox credentials");
5777
5778 let core = ExecutionClientCore::new(
5779 TraderId::from("TESTER-001"),
5780 *AX_CLIENT_ID,
5781 *AX_VENUE,
5782 OmsType::Netting,
5783 AccountId::from("AX-001"),
5784 AccountType::Margin,
5785 None,
5786 Rc::new(RefCell::new(Cache::default())),
5787 );
5788
5789 let config = AxExecutionClientConfig {
5790 api_key: Some(credential.api_key().to_owned().into()),
5791 api_secret: Some(credential.api_secret().to_owned().into()),
5792 environment: AxEnvironment::Sandbox,
5793 ..Default::default()
5794 };
5795
5796 let client = AxExecutionClient::new(core, config).unwrap();
5797 client
5798 .authenticate(&credential)
5799 .await
5800 .expect("sandbox authentication");
5801 let now = client.clock.get_time_ns();
5802
5803 let single = GenerateOrderStatusReport::new(
5804 UUID4::new(),
5805 now,
5806 None,
5807 None,
5808 Some(VenueOrderId::from("O-01ARZ3NDEKTSV4RRFFQ69G5FAV")),
5809 None,
5810 None,
5811 );
5812 let orders =
5813 GenerateOrderStatusReports::new(UUID4::new(), now, false, None, None, None, None, None);
5814 let fills = GenerateFillReports::new(UUID4::new(), now, None, None, None, None, None, None);
5815 let positions =
5816 GeneratePositionStatusReports::new(UUID4::new(), now, None, None, None, None, None);
5817 let task = client.generate_order_status_report_task(&single).unwrap();
5818 tokio::spawn(task.collection).await.unwrap();
5819 let single = (task.finish)().expect("sandbox single order reports");
5820 let task = client.generate_order_status_reports_task(&orders).unwrap();
5821 tokio::spawn(task.collection).await.unwrap();
5822 let orders = (task.finish)().expect("sandbox bulk order reports");
5823 let task = client.generate_fill_reports_task(&fills).unwrap();
5824 tokio::spawn(task.collection).await.unwrap();
5825 let fills = (task.finish)().expect("sandbox fill reports");
5826 let task = client
5827 .generate_position_status_reports_task(&positions)
5828 .unwrap();
5829
5830 tokio::spawn(task.collection).await.unwrap();
5831 let positions = (task.finish)().expect("sandbox position reports");
5832
5833 for account_id in single
5834 .iter()
5835 .map(|r| r.account_id)
5836 .chain(orders.iter().map(|r| r.account_id))
5837 .chain(fills.iter().map(|r| r.account_id))
5838 .chain(positions.iter().map(|r| r.account_id))
5839 {
5840 assert_eq!(account_id, client.account_id());
5841 }
5842
5843 println!(
5844 "AX sandbox reports passed: single={}, orders={}, fills={}, positions={}",
5845 usize::from(single.is_some()),
5846 orders.len(),
5847 fills.len(),
5848 positions.len()
5849 );
5850 }
5851
5852 #[rstest]
5853 fn test_cancel_on_disconnect_url_no_existing_query() {
5854 let mut url = "wss://example.com/orders/ws".to_string();
5855 let separator = if url.contains('?') { "&" } else { "?" };
5856 url.push_str(&format!("{separator}cancel_on_disconnect=true"));
5857 assert_eq!(url, "wss://example.com/orders/ws?cancel_on_disconnect=true");
5858 }
5859
5860 #[rstest]
5861 fn test_cancel_on_disconnect_url_with_existing_query() {
5862 let mut url = "wss://example.com/orders/ws?token=abc".to_string();
5863 let separator = if url.contains('?') { "&" } else { "?" };
5864 url.push_str(&format!("{separator}cancel_on_disconnect=true"));
5865 assert_eq!(
5866 url,
5867 "wss://example.com/orders/ws?token=abc&cancel_on_disconnect=true"
5868 );
5869 }
5870
5871 fn limit_order_for_validation(quantity: Quantity) -> OrderAny {
5872 OrderTestBuilder::new(OrderType::Limit)
5873 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5874 .side(OrderSide::Buy)
5875 .quantity(quantity)
5876 .price(Price::from("1.10"))
5877 .build()
5878 }
5879
5880 #[rstest]
5881 fn test_validate_order_for_ax_submit_accepts_supported_limit_order() {
5882 let order = limit_order_for_validation(Quantity::from("10"));
5883
5884 assert!(validate_order_for_ax_submit(&order).is_ok());
5885 }
5886
5887 #[rstest]
5888 fn test_validate_order_for_ax_submit_denies_unsupported_order_type() {
5889 let order = OrderTestBuilder::new(OrderType::StopMarket)
5890 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5891 .side(OrderSide::Buy)
5892 .quantity(Quantity::from("10"))
5893 .trigger_price(Price::from("1.10"))
5894 .build();
5895
5896 let err = validate_order_for_ax_submit(&order).unwrap_err();
5897
5898 assert!(err.to_string().contains("Unsupported order type"));
5899 }
5900
5901 #[rstest]
5902 fn test_validate_order_for_ax_submit_denies_gtd_time_in_force() {
5903 let order = OrderTestBuilder::new(OrderType::Limit)
5904 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5905 .side(OrderSide::Buy)
5906 .quantity(Quantity::from("10"))
5907 .price(Price::from("1.10"))
5908 .time_in_force(TimeInForce::Gtd)
5909 .expire_time(UnixNanos::from(2_000_000_000_000_000_000u64))
5910 .build();
5911
5912 let err = validate_order_for_ax_submit(&order).unwrap_err();
5913
5914 assert!(err.to_string().contains("Unsupported time in force"));
5915 }
5916
5917 #[rstest]
5918 fn test_validate_order_for_ax_submit_denies_stop_limit_order() {
5919 let order = OrderTestBuilder::new(OrderType::StopLimit)
5920 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5921 .side(OrderSide::Buy)
5922 .quantity(Quantity::from("10"))
5923 .price(Price::from("1.11"))
5924 .trigger_price(Price::from("1.10"))
5925 .build();
5926
5927 let err = validate_order_for_ax_submit(&order).unwrap_err();
5928
5929 assert!(err.to_string().contains("Unsupported order type"));
5930 }
5931
5932 #[rstest]
5933 fn test_validate_order_for_ax_submit_denies_fok_time_in_force() {
5934 let order = OrderTestBuilder::new(OrderType::Limit)
5935 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5936 .side(OrderSide::Buy)
5937 .quantity(Quantity::from("10"))
5938 .price(Price::from("1.10"))
5939 .time_in_force(TimeInForce::Fok)
5940 .build();
5941
5942 let err = validate_order_for_ax_submit(&order).unwrap_err();
5943
5944 assert!(err.to_string().contains("Unsupported time in force"));
5945 }
5946
5947 #[rstest]
5948 fn test_validate_order_for_ax_submit_denies_fractional_quantity() {
5949 let order = limit_order_for_validation(Quantity::from("10.5"));
5950
5951 let err = validate_order_for_ax_submit(&order).unwrap_err();
5952
5953 assert!(err.to_string().contains("whole contract"));
5954 }
5955
5956 #[rstest]
5957 fn test_validate_order_for_ax_submit_denies_quote_quantity() {
5958 let order = OrderTestBuilder::new(OrderType::Market)
5959 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5960 .side(OrderSide::Buy)
5961 .quantity(Quantity::from("10"))
5962 .time_in_force(TimeInForce::Ioc)
5963 .quote_quantity(true)
5964 .build();
5965
5966 let err = validate_order_for_ax_submit(&order).unwrap_err();
5967
5968 assert_eq!(err.to_string(), "UNSUPPORTED_QUOTE_QUANTITY");
5969 }
5970
5971 #[rstest]
5972 fn test_validate_order_for_ax_submit_denies_display_quantity() {
5973 let order = OrderTestBuilder::new(OrderType::Limit)
5974 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
5975 .side(OrderSide::Buy)
5976 .quantity(Quantity::from("10"))
5977 .price(Price::from("1.10"))
5978 .display_qty(Quantity::from("5"))
5979 .build();
5980
5981 let err = validate_order_for_ax_submit(&order).unwrap_err();
5982
5983 assert!(err.to_string().contains("display_qty"));
5984 }
5985
5986 #[rstest]
5987 #[case(AxHttpError::MissingCredentials, true)]
5988 #[case(AxHttpError::MissingSessionToken, true)]
5989 #[case(AxHttpError::ValidationError("bad param".to_string()), true)]
5990 #[case(AxHttpError::BuildError(AxBuildError::MissingOrderId), true)]
5991 #[case(AxHttpError::ApiError { message: "invalid modification".to_string() }, false)]
5992 #[case(AxHttpError::JsonError("parse failure".to_string()), false)]
5993 #[case(AxHttpError::Canceled("shutdown".to_string()), false)]
5994 #[case(AxHttpError::NetworkError("timeout".to_string()), false)]
5995 #[case(AxHttpError::UnexpectedStatus { status: 400, body: "invalid".to_string() }, false)]
5996 #[case(AxHttpError::UnexpectedStatus { status: 503, body: String::new() }, false)]
5997 fn test_classify_ax_http_failure(#[case] error: AxHttpError, #[case] expect_not_sent: bool) {
5998 let expected = if expect_not_sent {
6000 CommandFailure::NotSent(error.to_string())
6001 } else {
6002 CommandFailure::Ambiguous(error.to_string())
6003 };
6004
6005 assert_eq!(classify_ax_http_failure(&error), expected);
6006 }
6007
6008 #[rstest]
6009 #[case(
6010 AxOrdersWsClientError::ClientError("missing venue_order_id".to_string()),
6011 Some("missing venue_order_id")
6012 )]
6013 #[case(AxOrdersWsClientError::ChannelError("handler closed".to_string()), None)]
6014 #[case(AxOrdersWsClientError::Transport("connection reset".to_string()), None)]
6015 #[case(AxOrdersWsClientError::AuthenticationError("token expired".to_string()), None)]
6016 fn test_classify_ax_ws_failure(
6017 #[case] error: AxOrdersWsClientError,
6018 #[case] not_sent_reason: Option<&str>,
6019 ) {
6020 let expected = match not_sent_reason {
6022 Some(reason) => CommandFailure::NotSent(reason.to_string()),
6023 None => CommandFailure::Ambiguous(error.to_string()),
6024 };
6025
6026 assert_eq!(classify_ax_ws_failure(&error), expected);
6027 }
6028
6029 #[rstest]
6030 #[case(include_str!("../test_data/ws_order_filled_unknown_state.json"))]
6031 #[case(include_str!("../test_data/ws_order_filled_unknown_tif.json"))]
6032 fn test_dispatch_fill_with_unknown_sibling_classification(#[case] raw: &str) {
6033 let event: AxWsOrderEvent = serde_json::from_str(raw).unwrap();
6034 let clock = get_atomic_clock_realtime();
6035 let account_id = AccountId::from("AX-001");
6036 let client_order_id = ClientOrderId::from("O-UNKNOWN-STATE");
6037 let venue_order_id = VenueOrderId::new("O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
6038 let caches = test_caches();
6039 caches.orders_metadata.insert(
6040 client_order_id,
6041 test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX")),
6042 );
6043 caches
6044 .venue_to_client_id
6045 .insert(venue_order_id, client_order_id);
6046 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6047
6048 let mut emitter = ExecutionEventEmitter::new(
6049 clock,
6050 TraderId::from("TESTER-001"),
6051 account_id,
6052 AccountType::Margin,
6053 None,
6054 );
6055 emitter.set_sender(tx);
6056 let instruments = AtomicMap::new();
6057 instruments.insert(
6058 Ustr::from("EURUSD-PERP"),
6059 test_perp_instrument("EURUSD-PERP"),
6060 );
6061 let account_fee_rates = AtomicMap::new();
6062 account_fee_rates.insert(
6063 Some(Ustr::from("fixture-account")),
6064 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
6065 );
6066
6067 dispatch_order_event(
6068 event,
6069 &emitter,
6070 &caches,
6071 account_id,
6072 &instruments,
6073 &account_fee_rates,
6074 clock,
6075 );
6076
6077 let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = rx.try_recv().unwrap() else {
6078 panic!("expected fill")
6079 };
6080
6081 assert_eq!(fill.venue_order_id, venue_order_id);
6082 assert_eq!(fill.client_order_id, client_order_id);
6083 assert_eq!(fill.trade_id.as_str(), "T-01ARZ3NDEKTSV4RRFFQ69G5FAV");
6084 assert_eq!(fill.last_qty.as_decimal(), dec!(100));
6085 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
6086 assert_eq!(fill.ts_event, UnixNanos::from(1609459200123456789));
6087 assert!(rx.try_recv().is_err());
6088 assert!(caches.orders_metadata.is_empty());
6089 assert!(caches.venue_to_client_id.is_empty());
6090 }
6091
6092 #[rstest]
6093 fn test_amendment_keeps_venue_identity_and_updates_quantity() {
6094 let message: crate::websocket::messages::AxWsOrderReplaced =
6095 serde_json::from_str(include_str!("../test_data/ws_order_amended.json")).unwrap();
6096 let venue_order_id = VenueOrderId::new(&message.ro.oid);
6097 let client_order_id = ClientOrderId::from("O-AMEND");
6098 let caches = test_caches();
6099 caches.orders_metadata.insert(
6100 client_order_id,
6101 test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX")),
6102 );
6103 caches
6104 .venue_to_client_id
6105 .insert(venue_order_id, client_order_id);
6106 let clock = get_atomic_clock_realtime();
6107 let account_id = AccountId::from("AX-001");
6108 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6109
6110 let mut emitter = ExecutionEventEmitter::new(
6111 clock,
6112 TraderId::from("TESTER-001"),
6113 account_id,
6114 AccountType::Margin,
6115 None,
6116 );
6117 emitter.set_sender(tx);
6118 let instruments = AtomicMap::new();
6119
6120 dispatch_order_event(
6121 AxWsOrderEvent::Replaced(message),
6122 &emitter,
6123 &caches,
6124 account_id,
6125 &instruments,
6126 &AtomicMap::new(),
6127 clock,
6128 );
6129
6130 let ExecutionEvent::Order(OrderEventAny::Updated(update)) = rx.try_recv().unwrap() else {
6131 panic!("expected update")
6132 };
6133
6134 assert_eq!(update.venue_order_id, Some(venue_order_id));
6135 assert_eq!(update.client_order_id, client_order_id);
6136 assert_eq!(update.quantity.as_decimal(), dec!(150));
6137 assert_eq!(
6138 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
6139 client_order_id
6140 );
6141 assert_eq!(caches.venue_to_client_id.len(), 1);
6142 assert!(rx.try_recv().is_err());
6143 }
6144
6145 #[rstest]
6146 #[case(true, false)]
6147 #[case(false, true)]
6148 #[case(true, true)]
6149 fn test_replacement_rejects_inconsistent_identity(
6150 #[case] has_id: bool,
6151 #[case] has_order: bool,
6152 ) {
6153 let mut message: crate::websocket::messages::AxWsOrderReplaced =
6154 serde_json::from_str(include_str!("../test_data/ws_order_replaced_live.json")).unwrap();
6155 message.noid = has_id.then(|| "MISMATCHED".to_owned());
6156
6157 if !has_order {
6158 message.no = None;
6159 }
6160
6161 assert_eq!(
6162 replacement_venue_order_id(&message)
6163 .unwrap_err()
6164 .to_string(),
6165 "Inconsistent AX replacement order identity"
6166 );
6167 }
6168
6169 #[rstest]
6170 #[case::known(
6171 AxCancelRejectionReason::OrderNotFound,
6172 Some("ignored"),
6173 "ORDER_NOT_FOUND",
6174 false
6175 )]
6176 #[case::unknown(
6177 AxCancelRejectionReason::Unknown,
6178 Some("cancel denied by venue"),
6179 "cancel denied by venue",
6180 false
6181 )]
6182 #[case::empty(AxCancelRejectionReason::Unknown, Some(" "), "UNKNOWN", false)]
6183 #[case::post_only(
6184 AxCancelRejectionReason::Unknown,
6185 Some("post-only order would cross the book"),
6186 "post-only order would cross the book",
6187 true
6188 )]
6189 fn test_pending_modify_does_not_misclassify_cancel_rejection(
6190 #[case] reason: AxCancelRejectionReason,
6191 #[case] text: Option<&str>,
6192 #[case] expected_reason: &str,
6193 #[case] modify: bool,
6194 ) {
6195 let mut event: AxWsOrderEvent =
6196 serde_json::from_str(include_str!("../test_data/ws_cancel_rejected.json")).unwrap();
6197
6198 let AxWsOrderEvent::CancelRejected(msg) = &mut event else {
6199 unreachable!()
6200 };
6201
6202 msg.r = reason;
6203 msg.txt = text.map(str::to_owned);
6204 let venue_order_id = VenueOrderId::from(msg.oid.as_str());
6205 let client_order_id = ClientOrderId::from("O-PENDING-MODIFY");
6206 let caches = test_caches();
6207 let mut metadata = test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX"));
6208 metadata.venue_order_id = Some(venue_order_id);
6209 caches.orders_metadata.insert(client_order_id, metadata);
6210 caches
6211 .venue_to_client_id
6212 .insert(venue_order_id, client_order_id);
6213 caches
6214 .pending_modifies
6215 .insert(venue_order_id, (client_order_id, 1, false));
6216 let clock = get_atomic_clock_realtime();
6217 let account_id = AccountId::from("AX-001");
6218 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6219
6220 let mut emitter = ExecutionEventEmitter::new(
6221 clock,
6222 TraderId::from("TRADER-001"),
6223 account_id,
6224 AccountType::Margin,
6225 None,
6226 );
6227 emitter.set_sender(tx);
6228 dispatch_order_event(
6229 event,
6230 &emitter,
6231 &caches,
6232 account_id,
6233 &AtomicMap::new(),
6234 &AtomicMap::new(),
6235 clock,
6236 );
6237
6238 let ExecutionEvent::Order(event) = rx.try_recv().unwrap() else {
6239 panic!("expected order event")
6240 };
6241
6242 let actual_reason = match &event {
6243 OrderEventAny::ModifyRejected(rejected) if modify => rejected.reason,
6244 OrderEventAny::CancelRejected(rejected) if !modify => rejected.reason,
6245 _ => panic!("unexpected event {event:?}"),
6246 };
6247
6248 assert_eq!(actual_reason.as_str(), expected_reason);
6249 assert_eq!(event.client_order_id(), client_order_id);
6250 assert_eq!(event.venue_order_id(), Some(venue_order_id));
6251 assert_eq!(
6252 caches.pending_modifies.contains_key(&venue_order_id),
6253 !modify
6254 );
6255 assert!(rx.try_recv().is_err());
6256 }
6257
6258 #[rstest]
6259 fn test_pending_modify_completes_deferred_terminal_cleanup() {
6260 let caches = test_caches();
6261 let venue_order_id = VenueOrderId::from("OID-DEFERRED");
6262 let client_order_id = ClientOrderId::from("O-DEFERRED");
6263 let mut metadata = test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX"));
6264 metadata.venue_order_id = Some(venue_order_id);
6265 caches.orders_metadata.insert(client_order_id, metadata);
6266 caches
6267 .venue_to_client_id
6268 .insert(venue_order_id, client_order_id);
6269 caches.cid_to_client_order_id.insert(765, client_order_id);
6270 caches
6271 .pending_modifies
6272 .insert(venue_order_id, (client_order_id, 2, false));
6273 cleanup_terminal_order_tracking_ids(&caches, Some(&venue_order_id), Some(765), None);
6274 complete_pending_modify(&caches, venue_order_id);
6275 assert_eq!(
6276 *caches.pending_modifies.get(&venue_order_id).unwrap(),
6277 (client_order_id, 1, true)
6278 );
6279 assert_eq!(caches.orders_metadata.len(), 1);
6280 complete_pending_modify(&caches, venue_order_id);
6281 assert!(caches.pending_modifies.is_empty());
6282 assert!(caches.orders_metadata.is_empty());
6283 assert!(caches.venue_to_client_id.is_empty());
6284 assert!(caches.cid_to_client_order_id.is_empty());
6285 }
6286
6287 #[rstest]
6288 #[case::without_stream_terminal(false, false)]
6289 #[case::after_stream_terminal(true, false)]
6290 #[case::resolved_terminal(false, true)]
6291 #[case::resolved_after_stream_terminal(true, true)]
6292 fn test_closed_parent_report_retains_pending_replacement(
6293 #[case] terminal: bool,
6294 #[case] resolved: bool,
6295 ) {
6296 let caches = test_caches();
6297 let venue_order_id = VenueOrderId::from("OID-AMBIGUOUS");
6298 let client_order_id = ClientOrderId::from("O-AMBIGUOUS");
6299 let old_venue_order_id = VenueOrderId::from("OID-AMBIGUOUS-OLD");
6300 seed_terminal_tracking(
6301 &caches,
6302 client_order_id,
6303 venue_order_id,
6304 old_venue_order_id,
6305 987,
6306 );
6307 caches
6308 .orders_metadata
6309 .get_mut(&client_order_id)
6310 .unwrap()
6311 .venue_order_id = Some(venue_order_id);
6312 caches
6313 .pending_modifies
6314 .insert(venue_order_id, (client_order_id, 1, terminal));
6315 let report =
6316 test_status_report(Some(client_order_id), venue_order_id, OrderStatus::Canceled);
6317
6318 if resolved {
6319 cleanup_resolved_order_status_report(&report, &caches);
6320 } else {
6321 cleanup_closed_order_status_report(&report, &caches);
6322 }
6323
6324 let terminal = terminal || resolved;
6325 assert_eq!(
6326 *caches.pending_modifies.get(&venue_order_id).unwrap(),
6327 (client_order_id, 1, terminal)
6328 );
6329 assert_eq!(
6330 caches
6331 .orders_metadata
6332 .get(&client_order_id)
6333 .unwrap()
6334 .venue_order_id,
6335 Some(venue_order_id)
6336 );
6337 assert_eq!(
6338 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
6339 client_order_id
6340 );
6341 assert_eq!(
6342 *caches.cid_to_client_order_id.get(&987).unwrap(),
6343 client_order_id
6344 );
6345 cleanup_deferred_order_tracking(&caches);
6346 assert_eq!(
6347 caches.orders_metadata.contains_key(&client_order_id),
6348 !terminal
6349 );
6350 assert_eq!(
6351 caches.pending_modifies.contains_key(&venue_order_id),
6352 !terminal
6353 );
6354 assert_eq!(
6355 caches.venue_to_client_id.contains_key(&venue_order_id),
6356 !terminal
6357 );
6358 assert_eq!(caches.cid_to_client_order_id.contains_key(&987), !terminal);
6359 }
6360
6361 #[rstest]
6362 #[case::terminal(true, false)]
6363 #[case::working(false, false)]
6364 #[case::replaced(true, true)]
6365 fn test_cleanup_deferred_order_tracking_preserves_working_legs(
6366 #[case] terminal: bool,
6367 #[case] replaced: bool,
6368 ) {
6369 let caches = test_caches();
6370 let parent = VenueOrderId::from("OID-TEARDOWN-PARENT");
6371 let child = VenueOrderId::from("OID-TEARDOWN-CHILD");
6372 let client_order_id = ClientOrderId::from("O-TEARDOWN");
6373
6374 let active = if replaced { child } else { parent };
6375 let mut metadata = test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX"));
6376 metadata.venue_order_id = Some(active);
6377 caches.orders_metadata.insert(client_order_id, metadata);
6378 caches.venue_to_client_id.insert(active, client_order_id);
6379 caches.cid_to_client_order_id.insert(678, client_order_id);
6380 caches
6381 .pending_modifies
6382 .insert(parent, (client_order_id, 1, terminal));
6383 if !terminal {
6384 record_replacement_proposal(&caches, client_order_id, parent, child);
6385 }
6386
6387 cleanup_deferred_order_tracking(&caches);
6388 let cleared = terminal && !replaced;
6389 assert_eq!(caches.pending_modifies.contains_key(&parent), !cleared);
6390
6391 if !cleared {
6392 assert_eq!(
6393 *caches.pending_modifies.get(&parent).unwrap(),
6394 (client_order_id, 1, terminal)
6395 );
6396 }
6397
6398 if cleared {
6399 assert!(caches.orders_metadata.is_empty());
6400 assert!(caches.venue_to_client_id.is_empty());
6401 assert!(caches.cid_to_client_order_id.is_empty());
6402 } else {
6403 assert_eq!(
6404 caches
6405 .orders_metadata
6406 .get(&client_order_id)
6407 .unwrap()
6408 .venue_order_id,
6409 Some(active)
6410 );
6411 assert_eq!(
6412 *caches.venue_to_client_id.get(&active).unwrap(),
6413 client_order_id
6414 );
6415 assert_eq!(
6416 *caches.cid_to_client_order_id.get(&678).unwrap(),
6417 client_order_id
6418 );
6419 assert_eq!(caches.orders_metadata.len(), 1);
6420 assert_eq!(
6421 caches.venue_to_client_id.len(),
6422 if terminal { 1 } else { 2 }
6423 );
6424
6425 if !terminal {
6426 assert_eq!(
6427 *caches.venue_to_client_id.get(&child).unwrap(),
6428 client_order_id
6429 );
6430 }
6431
6432 assert_eq!(caches.cid_to_client_order_id.len(), 1);
6433 }
6434 }
6435
6436 #[rstest]
6437 #[case::child(true, false)]
6438 #[case::child_after_reconnect(true, true)]
6439 #[case::parent(false, false)]
6440 #[case::parent_after_reconnect(false, true)]
6441 fn test_fill_confirms_child_without_regressing_parent(
6442 #[case] child_fill: bool,
6443 #[case] reconnect: bool,
6444 ) {
6445 let mut event: AxWsOrderEvent =
6446 serde_json::from_str(include_str!("../test_data/ws_order_partially_filled.json"))
6447 .unwrap();
6448
6449 let AxWsOrderEvent::PartiallyFilled(msg) = &mut event else {
6450 unreachable!()
6451 };
6452
6453 let parent = VenueOrderId::from(msg.o.oid.as_str());
6454 let child = VenueOrderId::from("OID-FILLED-CHILD");
6455 let client_order_id = ClientOrderId::from("O-FILL-CONFIRMATION");
6456 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
6457 let caches = test_caches();
6458 let mut metadata = test_metadata(client_order_id, instrument_id);
6459 metadata.venue_order_id = Some(parent);
6460 caches.orders_metadata.insert(client_order_id, metadata);
6461 caches.venue_to_client_id.insert(parent, client_order_id);
6462 caches
6463 .pending_modifies
6464 .insert(parent, (client_order_id, 1, false));
6465 record_replacement_proposal(&caches, client_order_id, parent, child);
6466 if child_fill {
6467 msg.o.oid = child.to_string();
6468 } else {
6469 assert!(record_replacement_venue_id(
6470 &caches,
6471 client_order_id,
6472 parent,
6473 child,
6474 false
6475 ));
6476 caches
6477 .pending_modifies
6478 .insert(child, (client_order_id, 1, false));
6479 }
6480
6481 if reconnect {
6482 cleanup_deferred_order_tracking(&caches);
6483 }
6484
6485 let instruments = AtomicMap::new();
6486 instruments.insert(
6487 Ustr::from("EURUSD-PERP"),
6488 test_perp_instrument("EURUSD-PERP"),
6489 );
6490 let rates = AtomicMap::new();
6491 rates.insert(
6492 Some(Ustr::from("fixture-account")),
6493 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
6494 );
6495 let clock = get_atomic_clock_realtime();
6496 let account_id = AccountId::from("AX-001");
6497 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6498
6499 let mut emitter = ExecutionEventEmitter::new(
6500 clock,
6501 TraderId::from("TRADER-001"),
6502 account_id,
6503 AccountType::Margin,
6504 None,
6505 );
6506 emitter.set_sender(tx);
6507 dispatch_order_event(
6508 event,
6509 &emitter,
6510 &caches,
6511 account_id,
6512 &instruments,
6513 &rates,
6514 clock,
6515 );
6516
6517 let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = rx.try_recv().unwrap() else {
6518 panic!("expected correlated fill")
6519 };
6520
6521 assert_eq!(fill.client_order_id, client_order_id);
6522 assert_eq!(fill.account_id, account_id);
6523 assert_eq!(fill.instrument_id, instrument_id);
6524 assert_eq!(fill.venue_order_id, if child_fill { child } else { parent });
6525 assert_eq!(fill.last_qty, Quantity::from("50"));
6526 assert_eq!(fill.last_px, Price::from("50000"));
6527 assert_eq!(
6528 caches
6529 .orders_metadata
6530 .get(&client_order_id)
6531 .unwrap()
6532 .venue_order_id,
6533 Some(child)
6534 );
6535 assert_eq!(
6536 *caches.venue_to_client_id.get(&child).unwrap(),
6537 client_order_id
6538 );
6539 assert_eq!(
6540 *caches.pending_modifies.get(&parent).unwrap(),
6541 (client_order_id, 1, false)
6542 );
6543 assert!(rx.try_recv().is_err());
6544 }
6545}