1use std::{
19 future::Future,
20 time::{Duration, Instant},
21};
22
23use anyhow::Context;
24use async_trait::async_trait;
25use futures_util::{StreamExt, pin_mut};
26use nautilus_common::{
27 clients::ExecutionClient,
28 live::runner::get_exec_event_sender,
29 messages::execution::{
30 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
31 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
32 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
33 },
34};
35use nautilus_core::{
36 AtomicMap, DurationNanos, Params, UUID4, UnixNanos,
37 string::secret::SecretString,
38 time::{AtomicTime, get_atomic_clock_realtime},
39};
40use nautilus_live::{
41 ExecutionClientCore, ExecutionEventEmitter, SocketControl,
42 execution::{failure::CommandFailure, reports::retain_order_status_reports},
43 task::{TaskGroup, TaskGroupGuard},
44};
45use nautilus_model::{
46 accounts::AccountAny,
47 enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, TimeInForce},
48 events::{
49 OrderAccepted, OrderCancelRejected, OrderCanceled, OrderEventAny, OrderExpired,
50 OrderFilled, OrderInitialized, OrderRejected, OrderUpdated,
51 },
52 fees::MakerTakerFeeRates,
53 identifiers::{
54 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, Venue, VenueOrderId,
55 },
56 instruments::{Instrument, InstrumentAny},
57 orders::{Order, OrderAny},
58 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
59 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
60};
61use rust_decimal::Decimal;
62use ustr::Ustr;
63
64use crate::{
65 common::{
66 auth::run_auth_token_refresh,
67 consts::{
68 AX_ACCOUNT_REGISTRATION_TIMEOUT_SECS, AX_AUTH_TOKEN_TTL_SECS,
69 AX_FILLS_MAX_LOOKBACK_DAYS, AX_POST_ONLY_REJECT, AX_VENUE,
70 },
71 credential::Credential,
72 enums::{AxOrderSide, AxOrderStatus, AxTimeInForce},
73 parse::{
74 ax_timestamp_stn_to_unix_nanos, cid_to_client_order_id, client_order_id_to_cid,
75 quantity_to_contracts,
76 },
77 },
78 config::AxExecutionClientConfig,
79 http::{
80 client::AxHttpClient,
81 error::AxHttpError,
82 models::{AxOrderRejectReason, PreviewAggressiveLimitOrderRequest, ReplaceOrderRequest},
83 },
84 websocket::{
85 AxOrdersWsMessage, AxWsOrderEvent,
86 messages::{AxWsOrder, AxWsTradeExecution, OrderMetadata},
87 orders::{AxOrdersWebSocketClient, AxOrdersWsClientError, OrdersCaches},
88 },
89};
90
91#[derive(Debug)]
93pub struct AxExecutionClient {
94 core: ExecutionClientCore,
95 clock: &'static AtomicTime,
96 config: AxExecutionClientConfig,
97 emitter: ExecutionEventEmitter,
98 http_client: AxHttpClient,
99 ws_orders: AxOrdersWebSocketClient,
100 session_tasks: TaskGroup,
101 pending_tasks: TaskGroup,
102 shutdown_errors: Vec<String>,
103}
104
105impl AxExecutionClient {
106 pub fn new(core: ExecutionClientCore, config: AxExecutionClientConfig) -> anyhow::Result<Self> {
112 let http_client = AxHttpClient::with_credentials(
113 config
114 .api_key
115 .clone()
116 .map(|value| value.into_inner())
117 .unwrap_or_default(),
118 config
119 .api_secret
120 .clone()
121 .map(|value| value.into_inner())
122 .unwrap_or_default(),
123 Some(config.http_base_url()),
124 Some(config.orders_base_url()),
125 config.http_timeout_secs,
126 config.max_retries,
127 config.retry_delay_initial_ms,
128 config.retry_delay_max_ms,
129 config
130 .proxy_url
131 .as_ref()
132 .map(|url| url.expose_secret().to_owned()),
133 )?;
134
135 let clock = get_atomic_clock_realtime();
136 let trader_id = core.trader_id;
137 let account_id = core.account_id;
138 let emitter =
139 ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
140 let mut ws_url = config.ws_private_url();
141 if config.cancel_on_disconnect {
142 let separator = if ws_url.contains('?') { "&" } else { "?" };
143 ws_url.push_str(&format!("{separator}cancel_on_disconnect=true"));
144 }
145 let ws_orders = AxOrdersWebSocketClient::new(
146 ws_url,
147 account_id,
148 trader_id,
149 config.heartbeat_interval_secs,
150 config.transport_backend,
151 config
152 .proxy_url
153 .as_ref()
154 .map(|url| url.expose_secret().to_owned()),
155 )
156 .with_socket_control(SocketControl::new(
157 core.client_id,
158 Some(*AX_VENUE),
159 "architect-ax-user-streams",
160 ));
161
162 let session_tasks = TaskGroup::new();
163 let pending_tasks = TaskGroup::new();
164
165 Ok(Self {
166 core,
167 clock,
168 config,
169 emitter,
170 http_client,
171 ws_orders,
172 session_tasks,
173 pending_tasks,
174 shutdown_errors: Vec::new(),
175 })
176 }
177
178 async fn authenticate(&self, credential: &Credential) -> anyhow::Result<SecretString> {
179 self.http_client
180 .authenticate(
181 credential.api_key(),
182 credential.api_secret(),
183 AX_AUTH_TOKEN_TTL_SECS,
184 )
185 .await
186 .map_err(|e| anyhow::anyhow!("Authentication failed: {e}"))
187 }
188
189 fn update_account_state(&self) {
190 let http_client = self.http_client.clone();
191 let account_id = self.core.account_id;
192 let emitter = self.emitter.clone();
193 let clock = self.clock;
194
195 self.spawn_task("query_account", async move {
196 let account_state = http_client
197 .request_account_state(account_id)
198 .await
199 .context("failed to request AX account state")?;
200 let ts_event = clock.get_time_ns();
201 emitter.emit_account_state(
202 account_state.balances.clone(),
203 account_state.margins.clone(),
204 account_state.is_reported,
205 ts_event,
206 account_state.info,
207 );
208 Ok(())
209 });
210 }
211
212 fn submit_order_internal(&self, cmd: &SubmitOrder) -> anyhow::Result<()> {
213 let (
214 order_for_task,
215 client_order_id,
216 strategy_id,
217 instrument_id,
218 order_side,
219 order_type,
220 quantity,
221 time_in_force,
222 is_post_only,
223 limit_price,
224 ) = {
225 let cache = self.core.cache();
226 let order = cache.try_order(&cmd.client_order_id)?;
227 (
228 order.clone(),
229 order.client_order_id(),
230 order.strategy_id(),
231 order.instrument_id(),
232 order.order_side(),
233 order.order_type(),
234 order.quantity(),
235 order.time_in_force(),
236 order.is_post_only(),
237 order.price(),
238 )
239 };
240
241 let ws_orders = self.ws_orders.clone();
242 let trader_id = self.core.trader_id;
243 let emitter = self.emitter.clone();
244 let clock = self.clock;
245
246 let http_client = self.http_client.clone();
247
248 self.spawn_task("submit_order", async move {
249 let (price, submit_time_in_force, submit_post_only) = if order_type
252 == OrderType::Market
253 {
254 let preview_result: anyhow::Result<Price> = async {
255 let symbol = instrument_id.symbol.inner();
256 let ax_side = AxOrderSide::from(order_side);
257 let qty_contracts = quantity_to_contracts(quantity)?;
258
259 let instrument = http_client.get_instrument(&symbol).ok_or_else(|| {
260 anyhow::anyhow!("Instrument {instrument_id} not found in cache")
261 })?;
262
263 let request =
264 PreviewAggressiveLimitOrderRequest::new(symbol, qty_contracts, ax_side);
265 let response = http_client
266 .inner
267 .preview_aggressive_limit_order(&request)
268 .await
269 .map_err(|e| {
270 anyhow::anyhow!("Failed to preview aggressive limit order: {e}")
271 })?;
272
273 if response.remaining_quantity > 0 {
274 log::warn!(
275 "Market order book depth insufficient: \
276 filled_qty={} remaining_qty={} for {instrument_id}",
277 response.filled_quantity,
278 response.remaining_quantity,
279 );
280 }
281
282 let limit_price_decimal = response.limit_price.ok_or_else(|| {
283 anyhow::anyhow!(
284 "No liquidity available for market order on {instrument_id}"
285 )
286 })?;
287
288 let price =
289 Price::from_decimal_dp(limit_price_decimal, instrument.price_precision())
290 .with_context(|| {
291 format!(
292 "Failed to convert AX take-through price {limit_price_decimal} for {instrument_id}"
293 )
294 })?;
295 log::debug!("Market order take-through price: {price} for {instrument_id}",);
296 Ok(price)
297 }
298 .await;
299
300 let price = match preview_result {
301 Ok(price) => price,
302 Err(e) => {
303 let reason = e.to_string();
304 log::warn!(
305 "AX market order preview failed for {client_order_id}: {reason}"
306 );
307 emitter.emit_order_rejected(
308 &order_for_task,
309 &reason,
310 clock.get_time_ns(),
311 false,
312 );
313 return Ok(());
314 }
315 };
316
317 (price, TimeInForce::Ioc, false)
318 } else {
319 (
320 limit_price.context("AX limit order is missing a price")?,
321 time_in_force,
322 is_post_only,
323 )
324 };
325
326 let result = ws_orders
327 .submit_order(
328 trader_id,
329 strategy_id,
330 instrument_id,
331 client_order_id,
332 order_side,
333 quantity,
334 submit_time_in_force,
335 price,
336 submit_post_only,
337 )
338 .await;
339
340 if let Err(e) = result {
341 match classify_ax_ws_failure(&e) {
342 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
345 log::warn!("AX submit failed for {client_order_id}: {reason}");
346 emitter.emit_order_rejected(
347 &order_for_task,
348 &reason,
349 clock.get_time_ns(),
350 false,
351 );
352 }
353 CommandFailure::Ambiguous(reason) => {
354 log::warn!(
355 "Ambiguous AX submit failure for {client_order_id}, awaiting reconciliation: {reason}"
356 );
357 }
358 }
359 }
360
361 Ok(())
362 });
363
364 Ok(())
365 }
366
367 fn cancel_order_internal(&self, cmd: &CancelOrder) {
368 let ws_orders = self.ws_orders.clone();
369 let client_order_id = cmd.client_order_id;
370 let venue_order_id = cmd.venue_order_id;
371
372 self.spawn_task("cancel_order", async move {
375 if let Err(e) = ws_orders.cancel_order(client_order_id, venue_order_id).await {
376 match classify_ax_ws_failure(&e) {
377 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
378 log::warn!("Cancel command failed for {client_order_id}: {reason}");
379 }
380 CommandFailure::Ambiguous(reason) => {
381 log::warn!(
382 "Ambiguous AX cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
383 );
384 }
385 }
386 }
387
388 Ok(())
389 });
390 }
391
392 fn spawn_task<F>(&self, description: &'static str, fut: F)
393 where
394 F: Future<Output = anyhow::Result<()>> + Send + 'static,
395 {
396 let future = async move {
397 if let Err(e) = fut.await {
398 log::warn!("{description} failed: {e}");
399 }
400 };
401
402 if let Err(e) = self.pending_tasks.spawn(future) {
403 log::warn!("Skipping AX {description} after shutdown began: {e}");
404 }
405 }
406
407 fn abort_pending_tasks(&self) {
408 self.pending_tasks.begin_shutdown();
409 }
410
411 fn abort_session_tasks(&self) {
412 self.session_tasks.begin_shutdown();
413 self.ws_orders.begin_shutdown();
414 }
415
416 async fn await_pending_tasks(&self) -> anyhow::Result<()> {
417 self.pending_tasks.begin_shutdown();
418 self.pending_tasks
419 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
420 .await
421 .map_err(|e| anyhow::anyhow!("Failed to terminate AX execution tasks: {e}"))?;
422 Ok(())
423 }
424
425 async fn await_session_tasks(&self) -> anyhow::Result<()> {
426 self.session_tasks.begin_shutdown();
427 self.session_tasks
428 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
429 .await
430 .map_err(|e| anyhow::anyhow!("Failed to terminate AX execution session tasks: {e}"))?;
431 Ok(())
432 }
433
434 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
435 self.abort_session_tasks();
436 self.abort_pending_tasks();
437 self.http_client.cancel_all_requests();
438
439 if let Err(e) = self.ws_orders.close().await {
440 self.shutdown_errors
441 .push(format!("AX orders WebSocket shutdown failed: {e}"));
442 }
443
444 let (session_result, pending_result) =
445 tokio::join!(self.await_session_tasks(), self.await_pending_tasks());
446 self.core.set_disconnected();
447
448 if let Err(e) = session_result {
449 self.shutdown_errors.push(e.to_string());
450 }
451
452 if let Err(e) = pending_result {
453 self.shutdown_errors.push(e.to_string());
454 }
455
456 if !self.shutdown_errors.is_empty() {
457 anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
458 }
459 Ok(())
460 }
461
462 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
464 let account_id = self.core.account_id;
465
466 if self.core.cache().account(&account_id).is_some() {
467 log::info!("Account {account_id} registered");
468 return Ok(());
469 }
470
471 let start = Instant::now();
472 let timeout = Duration::from_secs_f64(timeout_secs);
473 let interval = Duration::from_millis(10);
474
475 loop {
476 tokio::time::sleep(interval).await;
477
478 if self.core.cache().account(&account_id).is_some() {
479 log::info!("Account {account_id} registered");
480 return Ok(());
481 }
482
483 if start.elapsed() >= timeout {
484 anyhow::bail!(
485 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
486 );
487 }
488 }
489 }
490}
491
492#[async_trait(?Send)]
493impl ExecutionClient for AxExecutionClient {
494 fn is_connected(&self) -> bool {
495 self.core.is_connected()
496 }
497
498 fn client_id(&self) -> ClientId {
499 self.core.client_id
500 }
501
502 fn account_id(&self) -> AccountId {
503 self.core.account_id
504 }
505
506 fn venue(&self) -> Venue {
507 *AX_VENUE
508 }
509
510 fn oms_type(&self) -> OmsType {
511 self.core.oms_type
512 }
513
514 fn get_account(&self) -> Option<AccountAny> {
515 self.core.cache().account_owned(&self.core.account_id)
516 }
517
518 async fn connect(&mut self) -> anyhow::Result<()> {
519 if self.core.is_connected() && self.pending_tasks.is_open() && self.session_tasks.is_open()
520 {
521 return Ok(());
522 }
523
524 if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
525 self.teardown_partial_connect().await?;
526 self.pending_tasks.start_generation().map_err(|e| {
527 anyhow::anyhow!("Failed to start AX execution task generation: {e}")
528 })?;
529 self.session_tasks.start_generation().map_err(|e| {
530 anyhow::anyhow!("Failed to start AX execution session generation: {e}")
531 })?;
532 }
533 let http_client = self.http_client.clone();
534 let ws_orders = self.ws_orders.clone();
535 let setup_guard =
536 TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
537 http_client.cancel_all_requests();
538 ws_orders.begin_shutdown();
539 });
540
541 self.http_client.reset_cancellation_token();
543
544 let credential = Credential::resolve(
545 self.config.api_key.clone().map(|value| value.into_inner()),
546 self.config
547 .api_secret
548 .clone()
549 .map(|value| value.into_inner()),
550 )
551 .context("API credentials not configured")?;
552 let token = self.authenticate(&credential).await?;
553
554 self.http_client
555 .request_account_fees()
556 .await
557 .context("failed to resolve AX account fee rates")?;
558
559 if !self.core.instruments_initialized() {
560 let instruments = self
561 .http_client
562 .request_instruments()
563 .await
564 .context("failed to request AX instruments")?;
565
566 if instruments.is_empty() {
567 log::warn!("No instruments returned from AX");
568 } else {
569 log::debug!("Loaded {} instruments", instruments.len());
570 self.http_client.cache_instruments(&instruments);
571 self.ws_orders.cache_instruments(&instruments);
572 }
573 self.core.set_instruments_initialized();
574 }
575
576 self.ws_orders.connect(token.expose_secret()).await?;
577 log::debug!("Connected to orders WebSocket");
578
579 let stream = self.ws_orders.stream();
580 let emitter = self.emitter.clone();
581 let caches = self.ws_orders.caches().clone();
582 let account_id = self.core.account_id;
583 let instruments_cache = self.ws_orders.instruments_cache();
584 let account_fee_rates = self.http_client.account_fee_rates.clone();
585 let clock = self.clock;
586
587 if let Err(e) = self.session_tasks.spawn(async move {
588 pin_mut!(stream);
589 while let Some(message) = stream.next().await {
590 dispatch_ws_message(
591 message,
592 &emitter,
593 &caches,
594 account_id,
595 &instruments_cache,
596 &account_fee_rates,
597 clock,
598 );
599 }
600 }) {
601 if let Err(teardown_error) = self.teardown_partial_connect().await {
602 return Err(anyhow::Error::new(e).context(format!(
603 "AX execution startup teardown failed: {teardown_error}"
604 )));
605 }
606 return Err(e.into());
607 }
608
609 let session_result = async {
610 let account_state = self
611 .http_client
612 .request_account_state(self.core.account_id)
613 .await
614 .context("failed to request AX account state")?;
615
616 if !account_state.balances.is_empty() {
617 log::debug!(
618 "Received account state with {} balance(s)",
619 account_state.balances.len()
620 );
621 }
622 self.emitter.send_account_state(account_state);
623
624 self.await_account_registered(AX_ACCOUNT_REGISTRATION_TIMEOUT_SECS)
625 .await?;
626
627 let ws_orders = self.ws_orders.clone();
628 self.session_tasks.spawn(run_auth_token_refresh(
629 self.http_client.clone(),
630 credential,
631 move |token| ws_orders.update_auth_token(token.expose_secret()),
632 ))?;
633 Ok::<(), anyhow::Error>(())
634 }
635 .await;
636
637 if let Err(e) = session_result {
638 if let Err(teardown_error) = self.teardown_partial_connect().await {
639 return Err(e.context(format!(
640 "AX execution startup teardown failed: {teardown_error}"
641 )));
642 }
643 return Err(e);
644 }
645
646 self.core.set_connected();
647 setup_guard.disarm();
648 log::info!("Connected: client_id={}", self.core.client_id);
649 Ok(())
650 }
651
652 async fn disconnect(&mut self) -> anyhow::Result<()> {
653 self.abort_session_tasks();
654 self.abort_pending_tasks();
655 self.http_client.cancel_all_requests();
656
657 let ws_result = self.ws_orders.close().await;
658 let (session_result, pending_result) =
659 tokio::join!(self.await_session_tasks(), self.await_pending_tasks());
660
661 self.core.set_disconnected();
662 ws_result?;
663 session_result?;
664 pending_result?;
665 log::info!("Disconnected: client_id={}", self.core.client_id);
666 Ok(())
667 }
668
669 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
670 self.update_account_state();
671 Ok(())
672 }
673
674 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
675 let http_client = self.http_client.clone();
676 let account_id = self.core.account_id;
677 let client_order_id = cmd.client_order_id;
678 let venue_order_id = cmd.venue_order_id.or_else(|| {
679 self.ws_orders
680 .orders_metadata()
681 .get(&client_order_id)
682 .and_then(|metadata| metadata.venue_order_id)
683 });
684 let instrument_id = cmd.instrument_id;
685 let emitter = self.emitter.clone();
686 let caches = self.ws_orders.caches().clone();
687
688 let (order_side, order_type, time_in_force) = {
690 let cache = self.core.cache();
691 match cache.order(&client_order_id) {
692 Some(order) => (
693 Some(order.order_side()),
694 order.order_type(),
695 order.time_in_force(),
696 ),
697 None => (None, OrderType::Limit, TimeInForce::Gtc),
698 }
699 };
700
701 self.spawn_task("query_order", async move {
702 match http_client
703 .request_order_status(
704 account_id,
705 instrument_id,
706 Some(client_order_id),
707 venue_order_id,
708 order_side,
709 order_type,
710 time_in_force,
711 )
712 .await
713 {
714 Ok(report) => {
715 cleanup_closed_order_status_report(&report, &caches);
716 emitter.send_order_status_report(report);
717 }
718 Err(e) => log::warn!("AX query order failed: {e}"),
719 }
720 Ok(())
721 });
722
723 Ok(())
724 }
725
726 fn generate_account_state(
727 &self,
728 balances: Vec<AccountBalance>,
729 margins: Vec<MarginBalance>,
730 reported: bool,
731 ts_event: UnixNanos,
732 info: Option<Params>,
733 ) -> anyhow::Result<()> {
734 self.emitter
735 .emit_account_state(balances, margins, reported, ts_event, info);
736 Ok(())
737 }
738
739 fn start(&mut self) -> anyhow::Result<()> {
740 if self.core.is_started() {
741 return Ok(());
742 }
743
744 self.emitter.set_sender(get_exec_event_sender());
745 self.core.set_started();
746 log::info!(
747 "Started: client_id={}, account_id={}, environment={}",
748 self.core.client_id,
749 self.core.account_id,
750 self.config.environment,
751 );
752 Ok(())
753 }
754
755 fn stop(&mut self) -> anyhow::Result<()> {
756 if self.core.is_stopped() {
757 return Ok(());
758 }
759
760 self.core.set_stopped();
761 self.core.set_disconnected();
762
763 self.abort_session_tasks();
764 self.abort_pending_tasks();
765 log::info!("Stopped: client_id={}", self.core.client_id);
766 Ok(())
767 }
768
769 fn reset(&mut self) -> anyhow::Result<()> {
770 self.abort_session_tasks();
771 self.abort_pending_tasks();
772 self.core.set_disconnected();
773 Ok(())
774 }
775
776 fn dispose(&mut self) -> anyhow::Result<()> {
777 self.abort_session_tasks();
778 self.abort_pending_tasks();
779 self.core.set_disconnected();
780 Ok(())
781 }
782
783 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
784 {
785 let cache = self.core.cache();
786 let order = cache.try_order(&cmd.client_order_id)?;
787
788 if order.is_closed() {
789 log::warn!("Cannot submit closed order {}", order.client_order_id());
790 return Ok(());
791 }
792
793 if let Err(e) = validate_order_for_ax_submit(&order)
794 .and_then(|()| validate_order_init_instructions(&cmd.order_init))
795 {
796 self.emitter.emit_order_denied(&order, &e.to_string());
797 return Ok(());
798 }
799
800 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
801 self.emitter.emit_order_submitted(&order);
802 }
803
804 self.submit_order_internal(&cmd)
805 }
806
807 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
808 for (client_order_id, order_init) in cmd
809 .order_list
810 .client_order_ids
811 .iter()
812 .zip(cmd.order_inits.iter())
813 {
814 let submit_cmd = SubmitOrder::new(
815 cmd.trader_id,
816 cmd.client_id,
817 cmd.strategy_id,
818 cmd.instrument_id,
819 *client_order_id,
820 order_init.clone(),
821 cmd.exec_algorithm_id,
822 cmd.position_id,
823 cmd.params.clone(),
824 UUID4::new(),
825 cmd.ts_init,
826 cmd.correlation_id,
827 );
828 self.submit_order(submit_cmd)?;
829 }
830 Ok(())
831 }
832
833 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
834 if cmd.trigger_price.is_some() {
835 emit_ax_modify_rejected(
836 &self.emitter,
837 self.clock,
838 cmd.strategy_id,
839 cmd.instrument_id,
840 cmd.client_order_id,
841 cmd.venue_order_id,
842 "AX does not support venue-native trigger prices",
843 );
844 return Ok(());
845 }
846
847 let venue_order_id = match cmd.venue_order_id {
848 Some(ref voi) => *voi,
849 None => {
850 emit_ax_modify_rejected(
851 &self.emitter,
852 self.clock,
853 cmd.strategy_id,
854 cmd.instrument_id,
855 cmd.client_order_id,
856 None,
857 "missing venue_order_id",
858 );
859 return Ok(());
860 }
861 };
862
863 let quantity = match cmd.quantity {
864 Some(quantity) => match quantity_to_contracts(quantity) {
865 Ok(contracts) => Some(contracts),
866 Err(e) => {
867 emit_ax_modify_rejected(
868 &self.emitter,
869 self.clock,
870 cmd.strategy_id,
871 cmd.instrument_id,
872 cmd.client_order_id,
873 Some(venue_order_id),
874 &e.to_string(),
875 );
876 return Ok(());
877 }
878 },
879 None => None,
880 };
881
882 if !self.core.is_connected() {
883 emit_ax_modify_rejected(
884 &self.emitter,
885 self.clock,
886 cmd.strategy_id,
887 cmd.instrument_id,
888 cmd.client_order_id,
889 Some(venue_order_id),
890 "AX execution client is not connected",
891 );
892 return Ok(());
893 }
894
895 let http_client = self.http_client.clone();
896 let emitter = self.emitter.clone();
897 let clock = self.clock;
898 let strategy_id = cmd.strategy_id;
899 let instrument_id = cmd.instrument_id;
900 let caches = self.ws_orders.caches().clone();
901 let client_order_id = cmd.client_order_id;
902 let price = cmd.price;
903
904 self.spawn_task("modify_order", async move {
905 let mut request = ReplaceOrderRequest::new(venue_order_id.as_str());
906
907 if let Some(price) = price {
908 request = request.with_price(price.as_decimal());
909 }
910
911 if let Some(contracts) = quantity {
912 request = request.with_quantity(contracts);
913 }
914
915 match http_client.inner.replace_order(&request).await {
916 Ok(resp) => {
917 let new_venue_order_id = match VenueOrderId::new_checked(&resp.oid) {
918 Ok(venue_order_id) => venue_order_id,
919 Err(e) => {
920 log::warn!(
921 "AX replace returned invalid venue order ID for {client_order_id}, awaiting reconciliation: {e}"
922 );
923 return Ok(());
924 }
925 };
926 record_replacement_venue_id(
927 &caches,
928 client_order_id,
929 new_venue_order_id,
930 false,
931 );
932 log::debug!("Order replaced: old={} new={}", request.oid, resp.oid);
933 }
934 Err(e) => match classify_ax_http_failure(&e) {
937 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
938 emit_ax_modify_rejected(
939 &emitter,
940 clock,
941 strategy_id,
942 instrument_id,
943 client_order_id,
944 Some(venue_order_id),
945 &reason,
946 );
947 }
948 CommandFailure::Ambiguous(reason) => {
949 log::warn!(
950 "Ambiguous AX modify failure for {client_order_id}, awaiting reconciliation: {reason}"
951 );
952 }
953 },
954 }
955
956 Ok(())
957 });
958
959 Ok(())
960 }
961
962 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
963 self.cancel_order_internal(&cmd);
964 Ok(())
965 }
966
967 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
968 if cmd.order_side.is_some() {
969 let cancels: Vec<CancelOrder> = {
972 let cache = self.core.cache();
973 cache
974 .orders_open(None, Some(&cmd.instrument_id), None, None, cmd.order_side)
975 .iter()
976 .map(|order| CancelOrder {
977 trader_id: order.trader_id(),
978 client_id: cmd.client_id,
979 strategy_id: order.strategy_id(),
980 instrument_id: order.instrument_id(),
981 client_order_id: order.client_order_id(),
982 venue_order_id: order.venue_order_id(),
983 command_id: cmd.command_id,
984 ts_init: cmd.ts_init,
985 params: cmd.params.clone(),
986 correlation_id: cmd.correlation_id,
987 causation_id: cmd.causation_id,
988 })
989 .collect()
990 };
991
992 if cancels.is_empty() {
993 log::debug!("No open orders to cancel for {}", cmd.instrument_id);
994 return Ok(());
995 }
996
997 return self.batch_cancel_orders(BatchCancelOrders {
998 trader_id: cmd.trader_id,
999 client_id: cmd.client_id,
1000 strategy_id: cmd.strategy_id,
1001 instrument_id: cmd.instrument_id,
1002 cancels,
1003 command_id: cmd.command_id,
1004 ts_init: cmd.ts_init,
1005 params: cmd.params,
1006 correlation_id: cmd.correlation_id,
1007 causation_id: cmd.causation_id,
1008 });
1009 }
1010
1011 let http_client = self.http_client.clone();
1012 let emitter = self.emitter.clone();
1013 let clock = self.clock;
1014 let instrument_id = cmd.instrument_id;
1015 let account_id = self.core.account_id;
1016 let trader_id = self.core.trader_id;
1017
1018 let open_orders: Vec<(ClientOrderId, Option<VenueOrderId>, StrategyId)> = {
1020 let cache = self.core.cache();
1021 cache
1022 .orders_open(None, Some(&instrument_id), None, None, None)
1023 .iter()
1024 .map(|o| (o.client_order_id(), o.venue_order_id(), o.strategy_id()))
1025 .collect()
1026 };
1027
1028 let caches = self.ws_orders.caches().clone();
1029
1030 self.spawn_task("cancel_all_orders", async move {
1031 match http_client.cancel_all_orders(instrument_id).await {
1032 Ok(()) => {
1033 log::debug!("Canceled all orders for {instrument_id}");
1034
1035 let ts_event = clock.get_time_ns();
1039
1040 for (client_order_id, venue_order_id, strategy_id) in &open_orders {
1041 let event = OrderCanceled::new(
1042 trader_id,
1043 *strategy_id,
1044 instrument_id,
1045 *client_order_id,
1046 UUID4::new(),
1047 ts_event,
1048 clock.get_time_ns(),
1049 false,
1050 *venue_order_id,
1051 Some(account_id),
1052 None,
1053 );
1054 emitter.send_order_event(OrderEventAny::Canceled(event));
1055
1056 if let Some(voi) = venue_order_id {
1057 caches.venue_to_client_id.remove(voi);
1058 }
1059 caches.orders_metadata.remove(client_order_id);
1060 caches
1061 .cid_to_client_order_id
1062 .retain(|_, mapped_client_order_id| {
1063 mapped_client_order_id != client_order_id
1064 });
1065 }
1066 }
1067 Err(e) => match classify_ax_http_failure(&e) {
1070 CommandFailure::NotSent(reason) | CommandFailure::VenueRejected(reason) => {
1071 log::warn!("Cancel-all for {instrument_id} failed: {reason}");
1072 }
1073 CommandFailure::Ambiguous(reason) => {
1074 log::warn!(
1075 "Ambiguous AX cancel-all failure for {instrument_id}, awaiting reconciliation: {reason}"
1076 );
1077 }
1078 },
1079 }
1080 Ok(())
1081 });
1082
1083 Ok(())
1084 }
1085
1086 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
1087 for cancel in &cmd.cancels {
1088 self.cancel_order_internal(cancel);
1089 }
1090 Ok(())
1091 }
1092
1093 async fn generate_order_status_report(
1094 &self,
1095 cmd: &GenerateOrderStatusReport,
1096 ) -> anyhow::Result<Option<OrderStatusReport>> {
1097 let caches = self.ws_orders.caches().clone();
1098 let cid_map = caches.cid_to_client_order_id.clone();
1099 let cid_resolver = move |cid: u64| cid_map.get(&cid).map(|v| *v);
1100
1101 let mut reports = self
1102 .http_client
1103 .request_order_status_reports(self.core.account_id, Some(cid_resolver))
1104 .await?;
1105
1106 if let Some(instrument_id) = cmd.instrument_id {
1107 reports.retain(|report| report.instrument_id == instrument_id);
1108 }
1109
1110 if let Some(client_order_id) = cmd.client_order_id {
1111 reports.retain(|report| report.client_order_id == Some(client_order_id));
1112 }
1113
1114 if let Some(venue_order_id) = cmd.venue_order_id {
1115 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
1116 }
1117
1118 let report = reports.into_iter().next();
1119 if let Some(report) = &report {
1120 cleanup_closed_order_status_report(report, &caches);
1121 }
1122
1123 Ok(report)
1124 }
1125
1126 async fn generate_order_status_reports(
1127 &self,
1128 cmd: &GenerateOrderStatusReports,
1129 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1130 let caches = self.ws_orders.caches().clone();
1131 let cid_map = caches.cid_to_client_order_id.clone();
1132 let cid_resolver = move |cid: u64| cid_map.get(&cid).map(|v| *v);
1133
1134 let mut reports = if cmd.open_only {
1135 self.http_client
1136 .request_order_status_reports(self.core.account_id, Some(cid_resolver))
1137 .await?
1138 } else {
1139 self.http_client
1140 .request_historical_order_status_reports(
1141 self.core.account_id,
1142 cmd.start,
1143 cmd.end,
1144 Some(cid_resolver),
1145 )
1146 .await?
1147 };
1148
1149 if let Some(instrument_id) = cmd.instrument_id {
1150 reports.retain(|report| report.instrument_id == instrument_id);
1151 }
1152
1153 retain_order_status_reports(&mut reports, cmd);
1154 for report in &reports {
1155 cleanup_closed_order_status_report(report, &caches);
1156 }
1157
1158 Ok(reports)
1159 }
1160
1161 async fn generate_fill_reports(
1162 &self,
1163 cmd: GenerateFillReports,
1164 ) -> anyhow::Result<Vec<FillReport>> {
1165 let mut reports = self
1166 .http_client
1167 .request_fill_reports(self.core.account_id, cmd.start, cmd.end)
1168 .await?;
1169
1170 if let Some(instrument_id) = cmd.instrument_id {
1171 reports.retain(|report| report.instrument_id == instrument_id);
1172 }
1173
1174 if let Some(venue_order_id) = cmd.venue_order_id {
1175 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
1176 }
1177
1178 Ok(reports)
1179 }
1180
1181 async fn generate_position_status_reports(
1182 &self,
1183 cmd: &GeneratePositionStatusReports,
1184 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1185 let mut reports = self
1186 .http_client
1187 .request_position_reports(self.core.account_id)
1188 .await?;
1189
1190 if let Some(instrument_id) = cmd.instrument_id {
1191 reports.retain(|report| report.instrument_id == instrument_id);
1192 }
1193
1194 Ok(reports)
1195 }
1196
1197 async fn generate_mass_status(
1198 &self,
1199 lookback_mins: Option<u64>,
1200 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1201 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
1202
1203 let ts_now = self.clock.get_time_ns();
1204
1205 let start = lookback_mins
1206 .map(DurationNanos::try_from_mins)
1207 .transpose()?
1208 .map(|lookback| ts_now.saturating_sub(lookback));
1209
1210 let fills_span = DurationNanos::try_from_days(u64::try_from(AX_FILLS_MAX_LOOKBACK_DAYS)?)?;
1212 let fills_floor = ts_now.saturating_sub(fills_span);
1213 let declared_start = start.map(|start| start.max(fills_floor));
1214
1215 let order_cmd = GenerateOrderStatusReports::new(
1216 UUID4::new(),
1217 ts_now,
1218 false, None, start,
1221 None, None, None, );
1225
1226 let fill_cmd = GenerateFillReports::new(
1227 UUID4::new(),
1228 ts_now,
1229 None, None, start,
1232 None, None, None, );
1236
1237 let position_cmd = GeneratePositionStatusReports::new(
1238 UUID4::new(),
1239 ts_now,
1240 None, start,
1242 None, None, None, );
1246
1247 let (order_reports, fill_reports, position_reports) = tokio::try_join!(
1248 self.generate_order_status_reports(&order_cmd),
1249 self.generate_fill_reports(fill_cmd),
1250 self.generate_position_status_reports(&position_cmd),
1251 )?;
1252
1253 log::info!("Received {} OrderStatusReports", order_reports.len());
1254 log::info!("Received {} FillReports", fill_reports.len());
1255 log::info!("Received {} PositionReports", position_reports.len());
1256
1257 let mut mass_status = ExecutionMassStatus::new(
1258 self.core.client_id,
1259 self.core.account_id,
1260 *AX_VENUE,
1261 ts_now,
1262 None,
1263 );
1264
1265 mass_status.set_report_window(declared_start, true);
1267
1268 mass_status.add_order_reports(order_reports);
1269 mass_status.add_fill_reports(fill_reports);
1270 mass_status.add_position_reports(position_reports);
1271
1272 Ok(Some(mass_status))
1273 }
1274
1275 fn register_external_order(
1276 &self,
1277 client_order_id: ClientOrderId,
1278 venue_order_id: VenueOrderId,
1279 instrument_id: InstrumentId,
1280 strategy_id: StrategyId,
1281 _ts_init: UnixNanos,
1282 ) {
1283 self.ws_orders.register_external_order(
1284 client_order_id,
1285 venue_order_id,
1286 instrument_id,
1287 strategy_id,
1288 );
1289 }
1290}
1291
1292fn emit_ax_modify_rejected(
1293 emitter: &ExecutionEventEmitter,
1294 clock: &'static AtomicTime,
1295 strategy_id: StrategyId,
1296 instrument_id: InstrumentId,
1297 client_order_id: ClientOrderId,
1298 venue_order_id: Option<VenueOrderId>,
1299 reason: &str,
1300) {
1301 log::warn!("Modify command failed local validation for {client_order_id}: {reason}");
1302 emitter.emit_order_modify_rejected_event(
1303 strategy_id,
1304 instrument_id,
1305 client_order_id,
1306 venue_order_id,
1307 reason,
1308 clock.get_time_ns(),
1309 );
1310}
1311
1312fn dispatch_ws_message(
1314 message: AxOrdersWsMessage,
1315 emitter: &ExecutionEventEmitter,
1316 caches: &OrdersCaches,
1317 account_id: AccountId,
1318 instruments: &AtomicMap<Ustr, InstrumentAny>,
1319 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
1320 clock: &'static AtomicTime,
1321) {
1322 match message {
1323 AxOrdersWsMessage::Event(event) => {
1324 dispatch_order_event(
1325 *event,
1326 emitter,
1327 caches,
1328 account_id,
1329 instruments,
1330 account_fee_rates,
1331 clock,
1332 );
1333 }
1334 AxOrdersWsMessage::PlaceOrderResponse(resp) => {
1335 log::debug!(
1336 "Place order response: rid={} oid={}",
1337 resp.rid,
1338 resp.res.oid
1339 );
1340 }
1341 AxOrdersWsMessage::CancelOrderResponse(resp) => {
1342 log::debug!(
1343 "Cancel order response: rid={} accepted={}",
1344 resp.rid,
1345 resp.res.cxl_rx
1346 );
1347 }
1348 AxOrdersWsMessage::OpenOrdersResponse(resp) => {
1349 log::debug!("Open orders response: {} orders", resp.res.orders.len());
1350 }
1351 AxOrdersWsMessage::Error(err) => {
1352 log::debug!("WebSocket error: {}", err.message);
1353 }
1354 AxOrdersWsMessage::Reconnected => {
1355 log::info!("WebSocket reconnected");
1356 }
1357 AxOrdersWsMessage::Authenticated => {
1358 log::debug!("WebSocket authenticated");
1359 }
1360 }
1361}
1362
1363fn dispatch_order_event(
1364 event: AxWsOrderEvent,
1365 emitter: &ExecutionEventEmitter,
1366 caches: &OrdersCaches,
1367 account_id: AccountId,
1368 instruments: &AtomicMap<Ustr, InstrumentAny>,
1369 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
1370 clock: &'static AtomicTime,
1371) {
1372 match event {
1373 AxWsOrderEvent::Heartbeat => {}
1374 AxWsOrderEvent::Acknowledged(msg) => {
1375 if let Some(event) =
1376 create_order_accepted(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1377 {
1378 emitter.send_order_event(OrderEventAny::Accepted(event));
1379 } else if let Some(report) = create_order_status_report(
1380 &msg.o,
1381 OrderStatus::Accepted,
1382 msg.ts,
1383 msg.tn,
1384 caches,
1385 account_id,
1386 instruments,
1387 clock,
1388 ) {
1389 emitter.send_order_status_report(report);
1390 }
1391 }
1392 AxWsOrderEvent::PartiallyFilled(msg) => {
1393 dispatch_fill_event(
1394 &msg.o,
1395 &msg.xs,
1396 msg.ts,
1397 msg.tn,
1398 emitter,
1399 caches,
1400 account_id,
1401 instruments,
1402 account_fee_rates,
1403 false,
1404 clock,
1405 );
1406 }
1407 AxWsOrderEvent::Filled(msg) => {
1408 dispatch_fill_event(
1409 &msg.o,
1410 &msg.xs,
1411 msg.ts,
1412 msg.tn,
1413 emitter,
1414 caches,
1415 account_id,
1416 instruments,
1417 account_fee_rates,
1418 true,
1419 clock,
1420 );
1421 }
1422 AxWsOrderEvent::Canceled(msg) => {
1423 if let Some(event) =
1424 create_order_canceled(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1425 {
1426 emitter.send_order_event(OrderEventAny::Canceled(event));
1427 } else if let Some(report) = create_order_status_report(
1428 &msg.o,
1429 OrderStatus::Canceled,
1430 msg.ts,
1431 msg.tn,
1432 caches,
1433 account_id,
1434 instruments,
1435 clock,
1436 ) {
1437 emitter.send_order_status_report(report);
1438 }
1439 cleanup_terminal_order_tracking(&msg.o, caches);
1440 }
1441 AxWsOrderEvent::Rejected(msg) => {
1442 let reason = AxOrderRejectReason::reason_str(msg.r, msg.txt.as_deref())
1443 .unwrap_or_else(|| "UNKNOWN".to_string());
1444
1445 if let Some(event) =
1446 create_order_rejected(&msg.o, &reason, msg.ts, msg.tn, caches, account_id, clock)
1447 {
1448 emitter.send_order_event(OrderEventAny::Rejected(event));
1449 }
1450 cleanup_terminal_order_tracking(&msg.o, caches);
1451 }
1452 AxWsOrderEvent::Expired(msg) => {
1453 let as_canceled = matches!(msg.o.tif, AxTimeInForce::Ioc | AxTimeInForce::Fok);
1455 let event = if as_canceled {
1456 create_order_canceled(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1457 .map(OrderEventAny::Canceled)
1458 } else {
1459 create_order_expired(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1460 .map(OrderEventAny::Expired)
1461 };
1462
1463 if let Some(event) = event {
1464 emitter.send_order_event(event);
1465 } else if let Some(report) = create_order_status_report(
1466 &msg.o,
1467 if as_canceled {
1468 OrderStatus::Canceled
1469 } else {
1470 OrderStatus::Expired
1471 },
1472 msg.ts,
1473 msg.tn,
1474 caches,
1475 account_id,
1476 instruments,
1477 clock,
1478 ) {
1479 emitter.send_order_status_report(report);
1480 }
1481 cleanup_terminal_order_tracking(&msg.o, caches);
1482 }
1483 AxWsOrderEvent::Replaced(msg) => {
1484 let replacement_venue_order_id = match replacement_venue_order_id(&msg) {
1485 Ok(venue_order_id) => venue_order_id,
1486 Err(e) => {
1487 log::warn!("Invalid AX replace event, awaiting reconciliation: {e}");
1488 return;
1489 }
1490 };
1491
1492 let order = msg.no.as_deref().unwrap_or(&msg.ro);
1493
1494 if let Some(event) = create_order_updated(
1495 order,
1496 &msg.ro,
1497 replacement_venue_order_id,
1498 (msg.ts, msg.tn),
1499 caches,
1500 account_id,
1501 clock,
1502 ) {
1503 emitter.send_order_event(OrderEventAny::Updated(event));
1504 } else if let Some(report) = create_order_status_report(
1505 order,
1506 OrderStatus::Accepted,
1507 msg.ts,
1508 msg.tn,
1509 caches,
1510 account_id,
1511 instruments,
1512 clock,
1513 ) {
1514 emitter.send_order_status_report(report);
1515 }
1516 }
1517 AxWsOrderEvent::DoneForDay(msg) => {
1518 if let Some(event) =
1519 create_order_expired(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1520 {
1521 emitter.send_order_event(OrderEventAny::Expired(event));
1522 } else if let Some(report) = create_order_status_report(
1523 &msg.o,
1524 OrderStatus::Expired,
1525 msg.ts,
1526 msg.tn,
1527 caches,
1528 account_id,
1529 instruments,
1530 clock,
1531 ) {
1532 emitter.send_order_status_report(report);
1533 }
1534 cleanup_terminal_order_tracking(&msg.o, caches);
1535 }
1536 AxWsOrderEvent::CancelRejected(msg) => {
1537 let venue_order_id = VenueOrderId::new(&msg.oid);
1538 if let Some(client_order_id) = caches.venue_to_client_id.get(&venue_order_id)
1539 && let Some(metadata) = caches.orders_metadata.get(&client_order_id)
1540 {
1541 let event = OrderCancelRejected::new(
1542 metadata.trader_id,
1543 metadata.strategy_id,
1544 metadata.instrument_id,
1545 metadata.client_order_id,
1546 Ustr::from(msg.r.as_ref()),
1547 UUID4::new(),
1548 clock.get_time_ns(),
1549 metadata.ts_init,
1550 false,
1551 Some(venue_order_id),
1552 Some(account_id),
1553 );
1554 emitter.send_order_event(OrderEventAny::CancelRejected(event));
1555 } else {
1556 log::debug!(
1557 "Could not find metadata for cancel rejected order {}",
1558 msg.oid
1559 );
1560 }
1561 }
1562 }
1563}
1564
1565#[expect(clippy::too_many_arguments)]
1566fn dispatch_fill_event(
1567 order: &AxWsOrder,
1568 execution: &AxWsTradeExecution,
1569 ts: i64,
1570 tn: i64,
1571 emitter: &ExecutionEventEmitter,
1572 caches: &OrdersCaches,
1573 account_id: AccountId,
1574 instruments: &AtomicMap<Ustr, InstrumentAny>,
1575 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
1576 complete: bool,
1577 clock: &'static AtomicTime,
1578) {
1579 if order.o == AxOrderStatus::Unknown || order.tif == AxTimeInForce::Unknown {
1580 log::warn!(
1581 "Unknown AX order classification in fill, order_id={}, instrument={}, trade_id={}, state={}, time_in_force={}",
1582 order.oid,
1583 order.s,
1584 execution.tid,
1585 order.o,
1586 order.tif,
1587 );
1588 }
1589
1590 let commission =
1591 match calculate_fill_commission(order, execution, instruments, account_fee_rates) {
1592 Ok(commission) => commission,
1593 Err(e) => {
1594 log::warn!(
1595 "Cannot calculate AX commission for trade_id={}: {e}",
1596 execution.tid
1597 );
1598 return;
1599 }
1600 };
1601
1602 if let Some(event) = create_order_filled(
1603 order, execution, ts, tn, caches, account_id, commission, clock,
1604 ) {
1605 emitter.send_order_event(OrderEventAny::Filled(event));
1606 } else if let Some(report) = create_fill_report(
1607 order,
1608 execution,
1609 ts,
1610 tn,
1611 caches,
1612 account_id,
1613 instruments,
1614 commission,
1615 clock,
1616 ) {
1617 emitter.send_fill_report(report);
1618 } else {
1619 return;
1620 }
1621
1622 if complete {
1623 cleanup_terminal_order_tracking(order, caches);
1624 }
1625}
1626
1627fn calculate_fill_commission(
1628 order: &AxWsOrder,
1629 execution: &AxWsTradeExecution,
1630 instruments: &AtomicMap<Ustr, InstrumentAny>,
1631 account_fee_rates: &AtomicMap<Option<Ustr>, MakerTakerFeeRates>,
1632) -> anyhow::Result<Money> {
1633 let instruments = instruments.load();
1634 let instrument = instruments
1635 .get(&order.s)
1636 .context("AX fill instrument is not cached")?;
1637 let rates = account_fee_rates.load();
1638 let rates = rates
1639 .get(&execution.aid.or(order.aid))
1640 .context("AX fill account fee rates are not cached")?;
1641
1642 let liquidity_side = if execution.agg {
1643 LiquiditySide::Taker
1644 } else {
1645 LiquiditySide::Maker
1646 };
1647
1648 let rate = rates.rate_for(liquidity_side)?;
1649
1650 let commission = Decimal::from(execution.q)
1652 .checked_mul(execution.p.abs())
1653 .and_then(|notional| notional.checked_mul(instrument.multiplier().as_decimal()))
1654 .and_then(|notional| notional.checked_mul(rate))
1655 .context("AX fill commission calculation overflow")?;
1656
1657 Money::from_decimal(commission, Currency::USD())
1658 .context("failed to convert AX fill commission to Money")
1659}
1660
1661pub(crate) fn lookup_order_metadata<'a>(
1662 order: &AxWsOrder,
1663 caches: &'a OrdersCaches,
1664) -> Option<dashmap::mapref::one::Ref<'a, ClientOrderId, OrderMetadata>> {
1665 let venue_order_id = VenueOrderId::new(&order.oid);
1666
1667 if let Some(client_order_id) = caches.venue_to_client_id.get(&venue_order_id)
1668 && let Some(metadata) = caches.orders_metadata.get(&*client_order_id)
1669 {
1670 return Some(metadata);
1671 }
1672
1673 if let Some(cid) = order.cid
1674 && let Some(client_order_id) = caches.cid_to_client_order_id.get(&cid)
1675 && let Some(metadata) = caches.orders_metadata.get(&*client_order_id)
1676 {
1677 return Some(metadata);
1678 }
1679
1680 None
1681}
1682
1683pub(crate) fn replacement_venue_order_id(
1684 message: &crate::websocket::messages::AxWsOrderReplaced,
1685) -> anyhow::Result<VenueOrderId> {
1686 match (&message.noid, &message.no) {
1687 (Some(noid), Some(order)) if noid == &order.oid => {
1688 VenueOrderId::new_checked(noid).map_err(anyhow::Error::from)
1689 }
1690 (None, None) => VenueOrderId::new_checked(&message.ro.oid).map_err(anyhow::Error::from),
1691 _ => anyhow::bail!("Inconsistent AX replacement order identity"),
1692 }
1693}
1694
1695pub(crate) fn create_order_accepted(
1696 order: &AxWsOrder,
1697 event_ts: i64,
1698 event_tn: i64,
1699 caches: &OrdersCaches,
1700 account_id: AccountId,
1701 clock: &'static AtomicTime,
1702) -> Option<OrderAccepted> {
1703 let venue_order_id = VenueOrderId::new(&order.oid);
1704 let metadata = lookup_order_metadata(order, caches)?;
1705
1706 let client_order_id = metadata.client_order_id;
1707 let trader_id = metadata.trader_id;
1708 let strategy_id = metadata.strategy_id;
1709 let instrument_id = metadata.instrument_id;
1710 drop(metadata);
1711
1712 caches
1713 .venue_to_client_id
1714 .insert(venue_order_id, client_order_id);
1715
1716 if let Some(mut entry) = caches.orders_metadata.get_mut(&client_order_id) {
1717 entry.venue_order_id = Some(venue_order_id);
1718 }
1719
1720 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1721 .map_err(|e| log::error!("{e}"))
1722 .ok()?;
1723
1724 Some(OrderAccepted::new(
1725 trader_id,
1726 strategy_id,
1727 instrument_id,
1728 client_order_id,
1729 venue_order_id,
1730 account_id,
1731 UUID4::new(),
1732 ts_event,
1733 clock.get_time_ns(),
1734 false,
1735 ))
1736}
1737
1738pub(crate) fn create_order_updated(
1739 order: &AxWsOrder,
1740 replaced_order: &AxWsOrder,
1741 replacement_venue_order_id: VenueOrderId,
1742 event_timestamp: (i64, i64),
1743 caches: &OrdersCaches,
1744 account_id: AccountId,
1745 clock: &'static AtomicTime,
1746) -> Option<OrderUpdated> {
1747 let metadata = lookup_order_metadata(order, caches)
1748 .or_else(|| lookup_order_metadata(replaced_order, caches))?;
1749
1750 let client_order_id = metadata.client_order_id;
1751 let trader_id = metadata.trader_id;
1752 let strategy_id = metadata.strategy_id;
1753 let instrument_id = metadata.instrument_id;
1754 let price_precision = metadata.price_precision;
1755 let size_precision = metadata.size_precision;
1756 drop(metadata);
1757
1758 record_replacement_venue_id(caches, client_order_id, replacement_venue_order_id, true);
1759
1760 let ts_event = ax_timestamp_stn_to_unix_nanos(event_timestamp.0, event_timestamp.1)
1761 .map_err(|e| log::error!("{e}"))
1762 .ok()?;
1763
1764 let quantity = Quantity::from_decimal_dp(Decimal::from(order.q), size_precision).ok()?;
1765 let price = Price::from_decimal_dp(order.p, price_precision).ok();
1766
1767 Some(OrderUpdated::new(
1768 trader_id,
1769 strategy_id,
1770 instrument_id,
1771 client_order_id,
1772 quantity,
1773 UUID4::new(),
1774 ts_event,
1775 clock.get_time_ns(),
1776 false,
1777 Some(replacement_venue_order_id),
1778 Some(account_id),
1779 price,
1780 None, None, false,
1783 ))
1784}
1785
1786#[expect(clippy::too_many_arguments)]
1787pub(crate) fn create_order_filled(
1788 order: &AxWsOrder,
1789 execution: &AxWsTradeExecution,
1790 event_ts: i64,
1791 event_tn: i64,
1792 caches: &OrdersCaches,
1793 account_id: AccountId,
1794 commission: Money,
1795 clock: &'static AtomicTime,
1796) -> Option<OrderFilled> {
1797 let venue_order_id = VenueOrderId::new(&order.oid);
1798 let metadata = lookup_order_metadata(order, caches)?;
1799
1800 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1801 .map_err(|e| log::error!("{e}"))
1802 .ok()?;
1803
1804 let last_qty =
1805 Quantity::from_decimal_dp(Decimal::from(execution.q), metadata.size_precision).ok()?;
1806 let last_px = Price::from_decimal_dp(execution.p, metadata.price_precision).ok()?;
1807
1808 let order_side = OrderSide::from(order.d);
1809
1810 let liquidity_side = if execution.agg {
1811 LiquiditySide::Taker
1812 } else {
1813 LiquiditySide::Maker
1814 };
1815
1816 Some(OrderFilled::new(
1817 metadata.trader_id,
1818 metadata.strategy_id,
1819 metadata.instrument_id,
1820 metadata.client_order_id,
1821 venue_order_id,
1822 account_id,
1823 TradeId::new(&execution.tid),
1824 order_side,
1825 OrderType::Limit,
1826 last_qty,
1827 last_px,
1828 metadata.quote_currency,
1829 liquidity_side,
1830 UUID4::new(),
1831 ts_event,
1832 clock.get_time_ns(),
1833 false,
1834 None,
1835 Some(commission),
1836 None,
1837 ))
1838}
1839
1840pub(crate) fn create_order_canceled(
1841 order: &AxWsOrder,
1842 event_ts: i64,
1843 event_tn: i64,
1844 caches: &OrdersCaches,
1845 account_id: AccountId,
1846 clock: &'static AtomicTime,
1847) -> Option<OrderCanceled> {
1848 let venue_order_id = VenueOrderId::new(&order.oid);
1849 let metadata = lookup_order_metadata(order, caches)?;
1850
1851 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1852 .map_err(|e| log::error!("{e}"))
1853 .ok()?;
1854
1855 Some(OrderCanceled::new(
1856 metadata.trader_id,
1857 metadata.strategy_id,
1858 metadata.instrument_id,
1859 metadata.client_order_id,
1860 UUID4::new(),
1861 ts_event,
1862 clock.get_time_ns(),
1863 false,
1864 Some(venue_order_id),
1865 Some(account_id),
1866 None,
1867 ))
1868}
1869
1870pub(crate) fn create_order_expired(
1871 order: &AxWsOrder,
1872 event_ts: i64,
1873 event_tn: i64,
1874 caches: &OrdersCaches,
1875 account_id: AccountId,
1876 clock: &'static AtomicTime,
1877) -> Option<OrderExpired> {
1878 let venue_order_id = VenueOrderId::new(&order.oid);
1879 let metadata = lookup_order_metadata(order, caches)?;
1880
1881 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1882 .map_err(|e| log::error!("{e}"))
1883 .ok()?;
1884
1885 Some(OrderExpired::new(
1886 metadata.trader_id,
1887 metadata.strategy_id,
1888 metadata.instrument_id,
1889 metadata.client_order_id,
1890 UUID4::new(),
1891 ts_event,
1892 clock.get_time_ns(),
1893 false,
1894 Some(venue_order_id),
1895 Some(account_id),
1896 ))
1897}
1898
1899pub(crate) fn create_order_rejected(
1900 order: &AxWsOrder,
1901 reason: &str,
1902 event_ts: i64,
1903 event_tn: i64,
1904 caches: &OrdersCaches,
1905 account_id: AccountId,
1906 clock: &'static AtomicTime,
1907) -> Option<OrderRejected> {
1908 let metadata = lookup_order_metadata(order, caches)?;
1909
1910 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1911 .map_err(|e| log::error!("{e}"))
1912 .ok()?;
1913 let due_post_only = reason.contains(AX_POST_ONLY_REJECT);
1914
1915 Some(OrderRejected::new(
1916 metadata.trader_id,
1917 metadata.strategy_id,
1918 metadata.instrument_id,
1919 metadata.client_order_id,
1920 account_id,
1921 Ustr::from(reason),
1922 UUID4::new(),
1923 ts_event,
1924 clock.get_time_ns(),
1925 false,
1926 due_post_only,
1927 ))
1928}
1929
1930fn cleanup_closed_order_status_report(report: &OrderStatusReport, caches: &OrdersCaches) {
1931 if !report.order_status.is_closed() {
1932 return;
1933 }
1934
1935 cleanup_terminal_order_tracking_ids(
1936 caches,
1937 Some(&report.venue_order_id),
1938 report.client_order_id.as_ref().map(client_order_id_to_cid),
1939 report.client_order_id,
1940 );
1941}
1942
1943pub(crate) fn cleanup_terminal_order_tracking(order: &AxWsOrder, caches: &OrdersCaches) {
1944 let venue_order_id = VenueOrderId::new(&order.oid);
1945 cleanup_terminal_order_tracking_ids(caches, Some(&venue_order_id), order.cid, None);
1946}
1947
1948fn cleanup_terminal_order_tracking_ids(
1949 caches: &OrdersCaches,
1950 venue_order_id: Option<&VenueOrderId>,
1951 cid: Option<u64>,
1952 known_client_order_id: Option<ClientOrderId>,
1953) {
1954 let client_order_id = venue_order_id
1955 .and_then(|venue_order_id| {
1956 caches
1957 .venue_to_client_id
1958 .remove(venue_order_id)
1959 .map(|(_, v)| v)
1960 })
1961 .or_else(|| cid.and_then(|cid| caches.cid_to_client_order_id.remove(&cid).map(|(_, v)| v)))
1962 .or(known_client_order_id);
1963
1964 if let Some(client_order_id) = client_order_id {
1965 caches.orders_metadata.remove(&client_order_id);
1966 caches
1967 .venue_to_client_id
1968 .retain(|_, mapped_client_order_id| *mapped_client_order_id != client_order_id);
1969 caches
1970 .cid_to_client_order_id
1971 .retain(|_, mapped_client_order_id| *mapped_client_order_id != client_order_id);
1972 }
1973
1974 if let Some(cid) = cid {
1975 caches.cid_to_client_order_id.remove(&cid);
1976 }
1977}
1978
1979fn record_replacement_venue_id(
1980 caches: &OrdersCaches,
1981 client_order_id: ClientOrderId,
1982 venue_order_id: VenueOrderId,
1983 remove_previous_venue_ids: bool,
1984) {
1985 caches
1986 .venue_to_client_id
1987 .insert(venue_order_id, client_order_id);
1988 if let Some(mut entry) = caches.orders_metadata.get_mut(&client_order_id) {
1989 entry.venue_order_id = Some(venue_order_id);
1990 }
1991
1992 if remove_previous_venue_ids {
1993 caches
1994 .venue_to_client_id
1995 .retain(|mapped_venue_order_id, mapped_client_order_id| {
1996 *mapped_client_order_id != client_order_id
1997 || *mapped_venue_order_id == venue_order_id
1998 });
1999 }
2000}
2001
2002#[expect(clippy::too_many_arguments)]
2003fn create_order_status_report(
2004 order: &AxWsOrder,
2005 order_status: OrderStatus,
2006 event_ts: i64,
2007 event_tn: i64,
2008 caches: &OrdersCaches,
2009 account_id: AccountId,
2010 instruments: &AtomicMap<Ustr, InstrumentAny>,
2011 clock: &'static AtomicTime,
2012) -> Option<OrderStatusReport> {
2013 let instruments_snap = instruments.load();
2014 let instrument = instruments_snap.get(&order.s)?;
2015 let venue_order_id = VenueOrderId::new(&order.oid);
2016 let instrument_id = instrument.id();
2017 let order_side = OrderSide::from(order.d);
2018
2019 let time_in_force = TimeInForce::try_from(order.tif)
2020 .map_err(|e| {
2021 log::warn!(
2022 "Cannot map AX order time in force, order_id={}, instrument={}, error={e}",
2023 order.oid,
2024 order.s
2025 );
2026 })
2027 .ok()?;
2028
2029 let quantity =
2030 Quantity::from_decimal_dp(Decimal::from(order.q), instrument.size_precision()).ok()?;
2031 let filled_qty =
2032 Quantity::from_decimal_dp(Decimal::from(order.xq), instrument.size_precision()).ok()?;
2033
2034 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2035 .map_err(|e| log::error!("{e}"))
2036 .ok()?;
2037 let ts_init = clock.get_time_ns();
2038
2039 let client_order_id = order.cid.map(|cid| {
2040 caches
2041 .cid_to_client_order_id
2042 .get(&cid)
2043 .map_or_else(|| cid_to_client_order_id(cid), |v| *v)
2044 });
2045
2046 let mut report = OrderStatusReport::new(
2047 account_id,
2048 instrument_id,
2049 client_order_id,
2050 venue_order_id,
2051 order_side.into(),
2052 OrderType::Limit,
2053 time_in_force,
2054 order_status,
2055 quantity,
2056 filled_qty,
2057 ts_event,
2058 ts_event,
2059 ts_init,
2060 Some(UUID4::new()),
2061 );
2062
2063 if let Ok(price) = Price::from_decimal_dp(order.p, instrument.price_precision()) {
2064 report = report.with_price(price);
2065 }
2066
2067 report = report.with_post_only(order.po);
2068
2069 Some(report)
2070}
2071
2072#[expect(clippy::too_many_arguments)]
2073fn create_fill_report(
2074 order: &AxWsOrder,
2075 execution: &AxWsTradeExecution,
2076 event_ts: i64,
2077 event_tn: i64,
2078 caches: &OrdersCaches,
2079 account_id: AccountId,
2080 instruments: &AtomicMap<Ustr, InstrumentAny>,
2081 commission: Money,
2082 clock: &'static AtomicTime,
2083) -> Option<FillReport> {
2084 let instruments_snap = instruments.load();
2085 let instrument = instruments_snap.get(&order.s)?;
2086 let venue_order_id = VenueOrderId::new(&order.oid);
2087 let instrument_id = instrument.id();
2088 let order_side = order.d.into();
2089
2090 let last_qty =
2091 Quantity::from_decimal_dp(Decimal::from(execution.q), instrument.size_precision()).ok()?;
2092 let last_px = Price::from_decimal_dp(execution.p, instrument.price_precision()).ok()?;
2093
2094 let liquidity_side = if execution.agg {
2095 LiquiditySide::Taker
2096 } else {
2097 LiquiditySide::Maker
2098 };
2099
2100 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
2101 .map_err(|e| log::error!("{e}"))
2102 .ok()?;
2103 let ts_init = clock.get_time_ns();
2104
2105 let client_order_id = order.cid.map(|cid| {
2106 caches
2107 .cid_to_client_order_id
2108 .get(&cid)
2109 .map_or_else(|| cid_to_client_order_id(cid), |v| *v)
2110 });
2111
2112 Some(FillReport::new(
2113 account_id,
2114 instrument_id,
2115 venue_order_id,
2116 TradeId::new(&execution.tid),
2117 order_side,
2118 last_qty,
2119 last_px,
2120 commission,
2121 liquidity_side,
2122 client_order_id,
2123 None,
2124 ts_event,
2125 ts_init,
2126 Some(UUID4::new()),
2127 ))
2128}
2129
2130fn validate_order_for_ax_submit(order: &OrderAny) -> anyhow::Result<()> {
2131 if !matches!(order.order_type(), OrderType::Market | OrderType::Limit) {
2132 anyhow::bail!(
2133 "Unsupported order type: {:?}, the Architect AX adapter accepts Nautilus MARKET and LIMIT orders",
2134 order.order_type(),
2135 );
2136 }
2137
2138 if !matches!(
2140 order.time_in_force(),
2141 TimeInForce::Gtc | TimeInForce::Ioc | TimeInForce::Day
2142 ) {
2143 anyhow::bail!(
2144 "Unsupported time in force: {:?}, AX supports GTC, IOC, and DAY",
2145 order.time_in_force(),
2146 );
2147 }
2148
2149 validate_order_instructions(
2150 order.is_reduce_only(),
2151 order.is_quote_quantity(),
2152 order.display_qty().is_some(),
2153 )?;
2154
2155 quantity_to_contracts(order.quantity())?;
2156
2157 Ok(())
2158}
2159
2160fn validate_order_init_instructions(order_init: &OrderInitialized) -> anyhow::Result<()> {
2161 validate_order_instructions(
2162 order_init.reduce_only,
2163 order_init.quote_quantity,
2164 order_init.display_qty.is_some(),
2165 )
2166}
2167
2168fn validate_order_instructions(
2169 reduce_only: bool,
2170 quote_quantity: bool,
2171 has_display_qty: bool,
2172) -> anyhow::Result<()> {
2173 if reduce_only {
2174 anyhow::bail!("AX does not support reduce-only orders");
2175 }
2176
2177 if quote_quantity {
2178 anyhow::bail!(
2179 "Architect AX adapter cannot encode quote_quantity; submit a base quantity instead"
2180 );
2181 }
2182
2183 if has_display_qty {
2184 anyhow::bail!("Architect AX adapter cannot encode display_qty iceberg instructions");
2185 }
2186
2187 Ok(())
2188}
2189
2190fn classify_ax_http_failure(error: &AxHttpError) -> CommandFailure {
2195 let message = error.to_string();
2196 match error {
2197 AxHttpError::MissingCredentials
2198 | AxHttpError::MissingSessionToken
2199 | AxHttpError::ValidationError(_)
2200 | AxHttpError::BuildError(_) => CommandFailure::NotSent(message),
2201 AxHttpError::ApiError { .. }
2202 | AxHttpError::JsonError(_)
2203 | AxHttpError::Canceled(_)
2204 | AxHttpError::NetworkError(_)
2205 | AxHttpError::UnexpectedStatus { .. } => CommandFailure::Ambiguous(message),
2206 }
2207}
2208
2209fn classify_ax_ws_failure(error: &AxOrdersWsClientError) -> CommandFailure {
2210 match error {
2211 AxOrdersWsClientError::ClientError(message) => CommandFailure::NotSent(message.clone()),
2212 AxOrdersWsClientError::Transport(_)
2213 | AxOrdersWsClientError::ChannelError(_)
2214 | AxOrdersWsClientError::AuthenticationError(_) => {
2215 CommandFailure::Ambiguous(error.to_string())
2216 }
2217 }
2218}
2219
2220#[cfg(test)]
2221mod tests {
2222 use std::{cell::RefCell, net::SocketAddr, rc::Rc, sync::Arc, time::Duration};
2223
2224 use dashmap::DashMap;
2225 use nautilus_common::{
2226 cache::Cache,
2227 messages::{ExecutionEvent, ExecutionReport},
2228 };
2229 use nautilus_core::time::get_atomic_clock_realtime;
2230 use nautilus_live::ExecutionClientCore;
2231 use nautilus_model::{
2232 enums::AssetClass,
2233 identifiers::{
2234 AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
2235 },
2236 instruments::{InstrumentAny, PerpetualContract},
2237 orders::builder::OrderTestBuilder,
2238 types::{Currency, Price, Quantity},
2239 };
2240 use rstest::rstest;
2241 use rust_decimal::Decimal;
2242 use rust_decimal_macros::dec;
2243 use ustr::Ustr;
2244
2245 use super::*;
2246 use crate::{
2247 common::{
2248 consts::{AX_CLIENT_ID, AX_VENUE},
2249 enums::{AxEnvironment, AxOrderSide, AxOrderStatus, AxTimeInForce},
2250 },
2251 config::AxExecutionClientConfig,
2252 http::error::AxBuildError,
2253 websocket::{
2254 messages::{AxWsOrderExpired, AxWsOrderFilled, AxWsTradeExecution, OrderMetadata},
2255 orders::OrdersCaches,
2256 },
2257 };
2258
2259 fn test_caches() -> OrdersCaches {
2260 OrdersCaches {
2261 orders_metadata: Arc::new(DashMap::new()),
2262 venue_to_client_id: Arc::new(DashMap::new()),
2263 cid_to_client_order_id: Arc::new(DashMap::new()),
2264 }
2265 }
2266
2267 fn test_ws_order(oid: &str, price: Decimal, qty: u64) -> AxWsOrder {
2268 AxWsOrder {
2269 aid: None,
2270 po: false,
2271 rb: None,
2272 r: None,
2273 oid: oid.to_string(),
2274 u: Ustr::from("user"),
2275 s: Ustr::from("BTC-PERP"),
2276 p: price,
2277 q: qty,
2278 xq: 0,
2279 rq: qty,
2280 o: AxOrderStatus::Accepted,
2281 d: AxOrderSide::Buy,
2282 tif: AxTimeInForce::Gtc,
2283 ts: 1609459200,
2284 tn: 0,
2285 cid: None,
2286 tag: None,
2287 txt: None,
2288 }
2289 }
2290
2291 #[rstest]
2292 fn test_create_order_updated_uses_ws_replacement_id_before_http_response() {
2293 let caches = test_caches();
2294 let clock = get_atomic_clock_realtime();
2295 let account_id = AccountId::from("AX-001");
2296 let client_order_id = ClientOrderId::from("O-WS-FIRST");
2297 let old_venue_order_id = VenueOrderId::new("OLD-OID");
2298 let new_venue_order_id = VenueOrderId::new("NEW-OID");
2299
2300 let mut metadata = test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX"));
2301 metadata.venue_order_id = Some(old_venue_order_id);
2302 caches.orders_metadata.insert(client_order_id, metadata);
2303 caches
2304 .venue_to_client_id
2305 .insert(old_venue_order_id, client_order_id);
2306
2307 let old_order = test_ws_order(old_venue_order_id.as_str(), dec!(50000.00), 100);
2308 let new_order = test_ws_order(new_venue_order_id.as_str(), dec!(50001.00), 100);
2309 let event = create_order_updated(
2310 &new_order,
2311 &old_order,
2312 new_venue_order_id,
2313 (1609459200, 0),
2314 &caches,
2315 account_id,
2316 clock,
2317 )
2318 .expect("should produce OrderUpdated");
2319
2320 assert_eq!(event.venue_order_id, Some(new_venue_order_id));
2321 assert_eq!(
2322 caches
2323 .orders_metadata
2324 .get(&client_order_id)
2325 .unwrap()
2326 .venue_order_id,
2327 Some(new_venue_order_id),
2328 );
2329 assert!(!caches.venue_to_client_id.contains_key(&old_venue_order_id));
2330 assert_eq!(
2331 *caches.venue_to_client_id.get(&new_venue_order_id).unwrap(),
2332 client_order_id,
2333 );
2334 }
2335
2336 #[rstest]
2337 fn test_record_http_replacement_retains_previous_venue_id_until_ws_event() {
2338 let caches = test_caches();
2339 let client_order_id = ClientOrderId::from("O-HTTP-FIRST");
2340 let old_venue_order_id = VenueOrderId::new("OLD-OID");
2341 let new_venue_order_id = VenueOrderId::new("NEW-OID");
2342 let mut metadata = test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX"));
2343 metadata.venue_order_id = Some(old_venue_order_id);
2344 caches.orders_metadata.insert(client_order_id, metadata);
2345 caches
2346 .venue_to_client_id
2347 .insert(old_venue_order_id, client_order_id);
2348
2349 record_replacement_venue_id(&caches, client_order_id, new_venue_order_id, false);
2350
2351 assert!(caches.venue_to_client_id.contains_key(&old_venue_order_id));
2352 assert!(caches.venue_to_client_id.contains_key(&new_venue_order_id));
2353 assert_eq!(
2354 caches
2355 .orders_metadata
2356 .get(&client_order_id)
2357 .unwrap()
2358 .venue_order_id,
2359 Some(new_venue_order_id),
2360 );
2361
2362 record_replacement_venue_id(&caches, client_order_id, new_venue_order_id, true);
2363
2364 assert!(!caches.venue_to_client_id.contains_key(&old_venue_order_id));
2365 assert!(caches.venue_to_client_id.contains_key(&new_venue_order_id));
2366 }
2367
2368 fn test_metadata(client_order_id: ClientOrderId, instrument_id: InstrumentId) -> OrderMetadata {
2369 OrderMetadata {
2370 trader_id: TraderId::from("TRADER-001"),
2371 strategy_id: StrategyId::from("S-001"),
2372 instrument_id,
2373 client_order_id,
2374 venue_order_id: None,
2375 ts_init: 0.into(),
2376 size_precision: 0,
2377 price_precision: 2,
2378 quote_currency: Currency::USD(),
2379 }
2380 }
2381
2382 fn test_execution(tid: &str, price: Decimal, qty: u64, agg: bool) -> AxWsTradeExecution {
2383 AxWsTradeExecution {
2384 aid: None,
2385 tid: tid.to_string(),
2386 s: Ustr::from("BTC-PERP"),
2387 q: qty,
2388 p: price,
2389 d: AxOrderSide::Buy,
2390 agg,
2391 }
2392 }
2393
2394 fn seed_terminal_tracking(
2395 caches: &OrdersCaches,
2396 client_order_id: ClientOrderId,
2397 venue_order_id: VenueOrderId,
2398 stale_venue_order_id: VenueOrderId,
2399 cid_value: u64,
2400 ) {
2401 caches.orders_metadata.insert(
2402 client_order_id,
2403 test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX")),
2404 );
2405 caches
2406 .venue_to_client_id
2407 .insert(venue_order_id, client_order_id);
2408 caches
2409 .venue_to_client_id
2410 .insert(stale_venue_order_id, client_order_id);
2411 caches
2412 .cid_to_client_order_id
2413 .insert(cid_value, client_order_id);
2414 }
2415
2416 fn test_perp_instrument(symbol: &str) -> InstrumentAny {
2417 let symbol = Symbol::new(symbol);
2418 let instrument = PerpetualContract::builder()
2419 .instrument_id(InstrumentId::new(symbol, *AX_VENUE))
2420 .raw_symbol(symbol)
2421 .underlying(Ustr::from("BTC"))
2422 .asset_class(AssetClass::Cryptocurrency)
2423 .quote_currency(Currency::USD())
2424 .settlement_currency(Currency::USD())
2425 .is_inverse(false)
2426 .price_precision(2)
2427 .size_precision(0)
2428 .price_increment(Price::from("0.01"))
2429 .size_increment(Quantity::from("1"))
2430 .margin_init(Decimal::new(1, 2))
2431 .margin_maint(Decimal::new(5, 3))
2432 .ts_event(0.into())
2433 .ts_init(0.into())
2434 .build()
2435 .unwrap();
2436 InstrumentAny::PerpetualContract(instrument)
2437 }
2438
2439 async fn start_orders_report_server(body: serde_json::Value) -> SocketAddr {
2440 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2441 .await
2442 .expect("bind report server");
2443 let addr = listener.local_addr().expect("report server addr");
2444
2445 tokio::spawn(async move {
2446 let app = axum::Router::new().route(
2447 "/orders",
2448 axum::routing::get(move || async { axum::Json(body) }),
2449 );
2450 axum::serve(listener, app).await.unwrap();
2451 });
2452
2453 for _ in 0..50 {
2454 if tokio::net::TcpStream::connect(addr).await.is_ok() {
2455 break;
2456 }
2457
2458 tokio::time::sleep(Duration::from_millis(10)).await;
2459 }
2460
2461 addr
2462 }
2463
2464 fn test_status_report(
2465 client_order_id: Option<ClientOrderId>,
2466 venue_order_id: VenueOrderId,
2467 order_status: OrderStatus,
2468 ) -> OrderStatusReport {
2469 OrderStatusReport::new(
2470 AccountId::from("AX-001"),
2471 InstrumentId::from("BTC-PERP.AX"),
2472 client_order_id,
2473 venue_order_id,
2474 Some(OrderSide::Buy),
2475 OrderType::Limit,
2476 TimeInForce::Gtc,
2477 order_status,
2478 Quantity::from("1"),
2479 Quantity::from("1"),
2480 0.into(),
2481 0.into(),
2482 0.into(),
2483 None,
2484 )
2485 }
2486
2487 #[rstest]
2488 fn test_create_order_accepted_populates_cache_and_event() {
2489 let caches = test_caches();
2490 let clock = get_atomic_clock_realtime();
2491 let account_id = AccountId::from("AX-001");
2492 let client_order_id = ClientOrderId::from("O-ACK");
2493 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2494 let venue_order_id = VenueOrderId::new("OID-ACK");
2495
2496 caches.orders_metadata.insert(
2497 client_order_id,
2498 test_metadata(client_order_id, instrument_id),
2499 );
2500 let cid_value = 7u64;
2501 caches
2502 .cid_to_client_order_id
2503 .insert(cid_value, client_order_id);
2504
2505 let mut ws_order = test_ws_order(venue_order_id.as_str(), dec!(50500.00), 100);
2506 ws_order.cid = Some(cid_value);
2507
2508 let event = create_order_accepted(&ws_order, 1609459200, 500, &caches, account_id, clock)
2509 .expect("should produce OrderAccepted");
2510
2511 assert_eq!(event.venue_order_id, venue_order_id);
2512 assert_eq!(event.client_order_id, client_order_id);
2513 assert_eq!(event.account_id, account_id);
2514 assert_eq!(event.instrument_id, instrument_id);
2515 assert_eq!(event.trader_id, TraderId::from("TRADER-001"));
2516 assert_eq!(event.strategy_id, StrategyId::from("S-001"));
2517 assert_eq!(
2518 event.ts_event,
2519 UnixNanos::from(1_609_459_200_000_000_500u64)
2520 );
2521
2522 assert_eq!(
2524 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
2525 client_order_id,
2526 );
2527 let meta = caches.orders_metadata.get(&client_order_id).unwrap();
2528 assert_eq!(meta.venue_order_id, Some(venue_order_id));
2529 }
2530
2531 #[rstest]
2532 fn test_create_order_accepted_returns_none_without_metadata() {
2533 let caches = test_caches();
2534 let clock = get_atomic_clock_realtime();
2535 let account_id = AccountId::from("AX-001");
2536 let ws_order = test_ws_order("OID-UNKNOWN", dec!(100.00), 10);
2537
2538 let result = create_order_accepted(&ws_order, 1609459200, 0, &caches, account_id, clock);
2539 assert!(result.is_none());
2540 assert!(caches.venue_to_client_id.is_empty());
2541 }
2542
2543 #[rstest]
2544 fn test_lookup_order_metadata_cid_fallback() {
2545 let caches = test_caches();
2546 let client_order_id = ClientOrderId::from("O-CID");
2547 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2548 caches.orders_metadata.insert(
2549 client_order_id,
2550 test_metadata(client_order_id, instrument_id),
2551 );
2552 caches.cid_to_client_order_id.insert(99, client_order_id);
2553
2554 let mut ws_order = test_ws_order("UNKNOWN-OID", dec!(0), 0);
2555 ws_order.cid = Some(99);
2556
2557 let found = lookup_order_metadata(&ws_order, &caches).expect("cid fallback should find");
2558 assert_eq!(found.client_order_id, client_order_id);
2559 }
2560
2561 #[rstest]
2562 fn test_lookup_order_metadata_returns_none_when_unknown() {
2563 let caches = test_caches();
2564 let ws_order = test_ws_order("UNKNOWN-OID", dec!(0), 0);
2565 assert!(lookup_order_metadata(&ws_order, &caches).is_none());
2566 }
2567
2568 #[rstest]
2569 #[case(true, LiquiditySide::Taker)]
2570 #[case(false, LiquiditySide::Maker)]
2571 fn test_create_order_filled_maps_liquidity_side(
2572 #[case] agg: bool,
2573 #[case] expected: LiquiditySide,
2574 ) {
2575 let caches = test_caches();
2576 let clock = get_atomic_clock_realtime();
2577 let account_id = AccountId::from("AX-001");
2578 let client_order_id = ClientOrderId::from("O-FILL");
2579 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2580 let venue_order_id = VenueOrderId::new("OID-FILL");
2581
2582 caches.orders_metadata.insert(
2583 client_order_id,
2584 test_metadata(client_order_id, instrument_id),
2585 );
2586 caches
2587 .venue_to_client_id
2588 .insert(venue_order_id, client_order_id);
2589
2590 let order = test_ws_order(venue_order_id.as_str(), dec!(50500.00), 100);
2591 let execution = test_execution("TID-1", dec!(50500.00), 25, agg);
2592
2593 let event = create_order_filled(
2594 &order,
2595 &execution,
2596 1609459200,
2597 0,
2598 &caches,
2599 account_id,
2600 Money::from("12.34 USD"),
2601 clock,
2602 )
2603 .expect("should produce OrderFilled");
2604
2605 assert_eq!(event.venue_order_id, venue_order_id);
2606 assert_eq!(event.client_order_id, client_order_id);
2607 assert_eq!(event.trade_id, TradeId::new("TID-1"));
2608 assert_eq!(event.last_qty, Quantity::new(25.0, 0));
2609 assert_eq!(event.last_px, Price::from("50500.00"));
2610 assert_eq!(event.liquidity_side, expected);
2611 assert_eq!(event.commission, Some(Money::from("12.34 USD")));
2612 }
2613
2614 #[rstest]
2615 #[case::maker(false, dec!(0.0002), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "1.00 USD")]
2616 #[case::taker(true, dec!(0.0002), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "12.53 USD")]
2617 #[case::zero(false, dec!(0), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "0.00 USD")]
2618 #[case::rebate(false, dec!(-0.0002), dec!(0.0025), dec!(100.25), 20, dec!(2.5), "-1.00 USD")]
2619 #[case::negative_price(false, dec!(0.0002), dec!(0.0025), dec!(-100.25), 20, dec!(2.5), "1.00 USD")]
2620 #[case::sub_cent_notional(false, dec!(0.0002), dec!(0.0025), dec!(0.007499), 10_001, dec!(1), "0.01 USD")]
2621 fn test_calculate_fill_commission(
2622 #[case] agg: bool,
2623 #[case] maker: Decimal,
2624 #[case] taker: Decimal,
2625 #[case] price: Decimal,
2626 #[case] quantity: u64,
2627 #[case] multiplier: Decimal,
2628 #[case] expected: &str,
2629 ) {
2630 let mut instrument = test_perp_instrument("BTC-PERP");
2631 if let InstrumentAny::PerpetualContract(ref mut instrument) = instrument {
2632 instrument.multiplier = Quantity::from_decimal(multiplier).unwrap();
2633 }
2634
2635 let instruments = AtomicMap::new();
2636 instruments.insert(Ustr::from("BTC-PERP"), instrument);
2637 let rates = AtomicMap::new();
2638 rates.insert(None, MakerTakerFeeRates::new(maker, taker));
2639 let order = test_ws_order("OID-FILL", price, quantity);
2640 let execution = test_execution("TID-FILL", price, quantity, agg);
2641
2642 let commission =
2643 calculate_fill_commission(&order, &execution, &instruments, &rates).unwrap();
2644
2645 assert_eq!(commission, Money::from(expected));
2646 }
2647
2648 #[rstest]
2649 #[case::default(None, None, "0.05 USD")]
2650 #[case::order_account(Some("order-account"), None, "0.10 USD")]
2651 #[case::execution_account(None, Some("execution-account"), "0.15 USD")]
2652 #[case::execution_account_precedence(
2653 Some("order-account"),
2654 Some("execution-account"),
2655 "0.15 USD"
2656 )]
2657 fn test_calculate_fill_commission_resolves_account(
2658 #[case] order_account: Option<&str>,
2659 #[case] execution_account: Option<&str>,
2660 #[case] expected: &str,
2661 ) {
2662 let instruments = AtomicMap::new();
2663 instruments.insert(Ustr::from("BTC-PERP"), test_perp_instrument("BTC-PERP"));
2664 let rates = AtomicMap::new();
2665 rates.insert(None, MakerTakerFeeRates::new(dec!(0.0001), dec!(0.0002)));
2666 rates.insert(
2667 Some(Ustr::from("order-account")),
2668 MakerTakerFeeRates::new(dec!(0.0003), dec!(0.0004)),
2669 );
2670 rates.insert(
2671 Some(Ustr::from("execution-account")),
2672 MakerTakerFeeRates::new(dec!(0.0005), dec!(0.0006)),
2673 );
2674 let mut order = test_ws_order("OID-FILL", dec!(10), 25);
2675 order.aid = order_account.map(Ustr::from);
2676 let mut execution = test_execution("TID-FILL", dec!(10), 25, true);
2677 execution.aid = execution_account.map(Ustr::from);
2678
2679 let commission =
2680 calculate_fill_commission(&order, &execution, &instruments, &rates).unwrap();
2681
2682 assert_eq!(commission, Money::from(expected));
2683 }
2684
2685 #[rstest]
2686 #[case::tracked_partial(true, false, "500.00 USD")]
2687 #[case::tracked_full(true, true, "1000.00 USD")]
2688 #[case::untracked_partial(false, false, "500.00 USD")]
2689 #[case::untracked_full(false, true, "1000.00 USD")]
2690 fn test_dispatch_stream_fill_carries_commission(
2691 #[case] tracked: bool,
2692 #[case] complete: bool,
2693 #[case] expected: &str,
2694 ) {
2695 let raw = if complete {
2696 include_str!("../test_data/ws_order_filled.json")
2697 } else {
2698 include_str!("../test_data/ws_order_partially_filled.json")
2699 };
2700
2701 let event: AxWsOrderEvent = serde_json::from_str(raw).unwrap();
2702 let clock = get_atomic_clock_realtime();
2703 let account_id = AccountId::from("AX-001");
2704 let client_order_id = ClientOrderId::from("O-FILL");
2705 let venue_order_id = VenueOrderId::new("O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
2706 let instrument_id = InstrumentId::from("EURUSD-PERP.AX");
2707 let caches = test_caches();
2708 if tracked {
2709 caches.orders_metadata.insert(
2710 client_order_id,
2711 test_metadata(client_order_id, instrument_id),
2712 );
2713 caches
2714 .venue_to_client_id
2715 .insert(venue_order_id, client_order_id);
2716 }
2717
2718 let instruments = AtomicMap::new();
2719 instruments.insert(
2720 Ustr::from("EURUSD-PERP"),
2721 test_perp_instrument("EURUSD-PERP"),
2722 );
2723 let rates = AtomicMap::new();
2724 rates.insert(
2725 Some(Ustr::from("fixture-account")),
2726 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
2727 );
2728 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2729
2730 let mut emitter = ExecutionEventEmitter::new(
2731 clock,
2732 TraderId::from("TESTER-001"),
2733 account_id,
2734 AccountType::Margin,
2735 None,
2736 );
2737 emitter.set_sender(tx);
2738
2739 dispatch_order_event(
2740 event,
2741 &emitter,
2742 &caches,
2743 account_id,
2744 &instruments,
2745 &rates,
2746 clock,
2747 );
2748
2749 let expected_qty = if complete { dec!(100) } else { dec!(50) };
2750
2751 match rx.try_recv().unwrap() {
2752 ExecutionEvent::Order(OrderEventAny::Filled(fill)) => {
2753 assert!(tracked);
2754 assert_eq!(fill.commission, Some(Money::from(expected)));
2755 assert_eq!(fill.client_order_id, client_order_id);
2756 assert_eq!(fill.venue_order_id, venue_order_id);
2757 assert_eq!(fill.trade_id.as_str(), "T-01ARZ3NDEKTSV4RRFFQ69G5FAV");
2758 assert_eq!(fill.last_qty.as_decimal(), expected_qty);
2759 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
2760 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
2761 }
2762 ExecutionEvent::Report(report) => {
2763 let ExecutionReport::Fill(fill) = report else {
2764 panic!("expected fill report");
2765 };
2766
2767 assert!(!tracked);
2768 assert_eq!(fill.commission, Money::from(expected));
2769 assert_eq!(fill.instrument_id, instrument_id);
2770 assert_eq!(fill.venue_order_id, venue_order_id);
2771 assert_eq!(fill.last_qty.as_decimal(), expected_qty);
2772 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
2773 assert_eq!(fill.liquidity_side, LiquiditySide::Maker);
2774 }
2775 other => panic!("unexpected fill event: {other:?}"),
2776 }
2777
2778 assert!(rx.try_recv().is_err());
2779 assert_eq!(
2780 caches.orders_metadata.len(),
2781 usize::from(tracked && !complete)
2782 );
2783 assert_eq!(
2784 caches.venue_to_client_id.len(),
2785 usize::from(tracked && !complete)
2786 );
2787 }
2788
2789 #[rstest]
2790 #[case::missing_account(Some("unknown"), dec!(100), "AX fill account fee rates are not cached")]
2791 #[case::overflow(None, Decimal::MAX, "AX fill commission calculation overflow")]
2792 fn test_dispatch_fill_fee_error_retains_tracking(
2793 #[case] account: Option<&str>,
2794 #[case] price: Decimal,
2795 #[case] expected: &str,
2796 ) {
2797 let clock = get_atomic_clock_realtime();
2798 let account_id = AccountId::from("AX-001");
2799 let client_order_id = ClientOrderId::from("O-FILL");
2800 let venue_order_id = VenueOrderId::new("OID-FILL");
2801 let caches = test_caches();
2802 caches.orders_metadata.insert(
2803 client_order_id,
2804 test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX")),
2805 );
2806 caches
2807 .venue_to_client_id
2808 .insert(venue_order_id, client_order_id);
2809 let instruments = AtomicMap::new();
2810 instruments.insert(Ustr::from("BTC-PERP"), test_perp_instrument("BTC-PERP"));
2811 let rates = AtomicMap::new();
2812 rates.insert(None, MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)));
2813 let mut order = test_ws_order("OID-FILL", dec!(100), 25);
2814 order.aid = account.map(Ustr::from);
2815 let execution = test_execution("TID-FILL", price, 25, true);
2816 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2817
2818 let mut emitter = ExecutionEventEmitter::new(
2819 clock,
2820 TraderId::from("TESTER-001"),
2821 account_id,
2822 AccountType::Margin,
2823 None,
2824 );
2825 emitter.set_sender(tx);
2826
2827 let event = AxWsOrderEvent::Filled(AxWsOrderFilled {
2828 ts: 1609459200,
2829 tn: 0,
2830 eid: "E-FILL".to_owned(),
2831 o: order.clone(),
2832 xs: execution.clone(),
2833 });
2834
2835 let error =
2836 calculate_fill_commission(&order, &execution, &instruments, &rates).unwrap_err();
2837 dispatch_order_event(
2838 event,
2839 &emitter,
2840 &caches,
2841 account_id,
2842 &instruments,
2843 &rates,
2844 clock,
2845 );
2846
2847 assert_eq!(error.to_string(), expected);
2848 assert!(rx.try_recv().is_err());
2849 assert_eq!(
2850 caches
2851 .orders_metadata
2852 .get(&client_order_id)
2853 .unwrap()
2854 .client_order_id,
2855 client_order_id
2856 );
2857 assert_eq!(
2858 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
2859 client_order_id
2860 );
2861 }
2862
2863 #[rstest]
2864 fn test_create_order_canceled_populates_identifiers() {
2865 let caches = test_caches();
2866 let clock = get_atomic_clock_realtime();
2867 let account_id = AccountId::from("AX-001");
2868 let client_order_id = ClientOrderId::from("O-CXL");
2869 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2870 let venue_order_id = VenueOrderId::new("OID-CXL");
2871
2872 caches.orders_metadata.insert(
2873 client_order_id,
2874 test_metadata(client_order_id, instrument_id),
2875 );
2876 caches
2877 .venue_to_client_id
2878 .insert(venue_order_id, client_order_id);
2879
2880 let order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
2881 let event = create_order_canceled(&order, 1609459200, 0, &caches, account_id, clock)
2882 .expect("should produce OrderCanceled");
2883
2884 assert_eq!(event.venue_order_id, Some(venue_order_id));
2885 assert_eq!(event.client_order_id, client_order_id);
2886 assert_eq!(event.account_id, Some(account_id));
2887 assert_eq!(event.instrument_id, instrument_id);
2888 }
2889
2890 #[rstest]
2891 fn test_create_order_expired_populates_identifiers() {
2892 let caches = test_caches();
2893 let clock = get_atomic_clock_realtime();
2894 let account_id = AccountId::from("AX-001");
2895 let client_order_id = ClientOrderId::from("O-EXP");
2896 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2897 let venue_order_id = VenueOrderId::new("OID-EXP");
2898
2899 caches.orders_metadata.insert(
2900 client_order_id,
2901 test_metadata(client_order_id, instrument_id),
2902 );
2903 caches
2904 .venue_to_client_id
2905 .insert(venue_order_id, client_order_id);
2906
2907 let order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
2908 let event = create_order_expired(&order, 1609459200, 0, &caches, account_id, clock)
2909 .expect("should produce OrderExpired");
2910
2911 assert_eq!(event.venue_order_id, Some(venue_order_id));
2912 assert_eq!(event.client_order_id, client_order_id);
2913 }
2914
2915 #[rstest]
2916 #[case(AxTimeInForce::Ioc, true)]
2917 #[case(AxTimeInForce::Fok, true)]
2918 #[case(AxTimeInForce::Day, false)]
2919 #[case(AxTimeInForce::Gtc, false)]
2920 fn test_dispatch_expired_maps_ioc_fok_to_canceled(
2921 #[case] tif: AxTimeInForce,
2922 #[case] expect_canceled: bool,
2923 ) {
2924 let clock = get_atomic_clock_realtime();
2925 let account_id = AccountId::from("AX-001");
2926 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2927 let mut emitter = ExecutionEventEmitter::new(
2928 clock,
2929 TraderId::from("TESTER-001"),
2930 account_id,
2931 AccountType::Margin,
2932 None,
2933 );
2934 emitter.set_sender(tx);
2935
2936 let caches = test_caches();
2937 let client_order_id = ClientOrderId::from("O-EXP");
2938 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2939 caches.orders_metadata.insert(
2940 client_order_id,
2941 test_metadata(client_order_id, instrument_id),
2942 );
2943 caches
2944 .venue_to_client_id
2945 .insert(VenueOrderId::new("OID-EXP"), client_order_id);
2946
2947 let mut order = test_ws_order("OID-EXP", dec!(100.00), 10);
2948 order.tif = tif;
2949 let event = AxWsOrderEvent::Expired(AxWsOrderExpired {
2950 ts: 1609459200,
2951 tn: 0,
2952 eid: "E-EXP".to_string(),
2953 o: order,
2954 });
2955
2956 let instruments: AtomicMap<Ustr, InstrumentAny> = AtomicMap::new();
2957 dispatch_order_event(
2958 event,
2959 &emitter,
2960 &caches,
2961 account_id,
2962 &instruments,
2963 &AtomicMap::new(),
2964 clock,
2965 );
2966
2967 match rx.try_recv().expect("an order event should be emitted") {
2968 ExecutionEvent::Order(OrderEventAny::Canceled(_)) => assert!(expect_canceled),
2969 ExecutionEvent::Order(OrderEventAny::Expired(_)) => assert!(!expect_canceled),
2970 other => panic!("unexpected event: {other:?}"),
2971 }
2972 }
2973
2974 #[rstest]
2975 fn test_create_order_rejected_sets_due_post_only_when_reason_matches() {
2976 let caches = test_caches();
2977 let clock = get_atomic_clock_realtime();
2978 let account_id = AccountId::from("AX-001");
2979 let client_order_id = ClientOrderId::from("O-REJ");
2980 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2981
2982 caches.orders_metadata.insert(
2983 client_order_id,
2984 test_metadata(client_order_id, instrument_id),
2985 );
2986 caches
2987 .venue_to_client_id
2988 .insert(VenueOrderId::new("OID-REJ"), client_order_id);
2989
2990 let order = test_ws_order("OID-REJ", dec!(100.00), 10);
2991 let reason = "post-only order would cross the book";
2993 let event =
2994 create_order_rejected(&order, reason, 1609459200, 0, &caches, account_id, clock)
2995 .expect("should produce OrderRejected");
2996
2997 assert!(event.due_post_only, "post-only reason should set flag");
2998 assert_eq!(event.reason, Ustr::from(reason));
2999 }
3000
3001 #[rstest]
3002 fn test_create_order_rejected_clears_due_post_only_for_other_reasons() {
3003 let caches = test_caches();
3004 let clock = get_atomic_clock_realtime();
3005 let account_id = AccountId::from("AX-001");
3006 let client_order_id = ClientOrderId::from("O-REJ-2");
3007 let instrument_id = InstrumentId::from("BTC-PERP.AX");
3008
3009 caches.orders_metadata.insert(
3010 client_order_id,
3011 test_metadata(client_order_id, instrument_id),
3012 );
3013 caches
3014 .venue_to_client_id
3015 .insert(VenueOrderId::new("OID-REJ-2"), client_order_id);
3016
3017 let order = test_ws_order("OID-REJ-2", dec!(100.00), 10);
3018 let event = create_order_rejected(
3019 &order,
3020 "INSUFFICIENT_MARGIN",
3021 1609459200,
3022 0,
3023 &caches,
3024 account_id,
3025 clock,
3026 )
3027 .expect("should produce OrderRejected");
3028
3029 assert!(!event.due_post_only);
3030 assert_eq!(event.reason, Ustr::from("INSUFFICIENT_MARGIN"));
3031 }
3032
3033 #[rstest]
3034 fn test_cleanup_terminal_order_tracking_removes_all_caches() {
3035 let caches = test_caches();
3036 let client_order_id = ClientOrderId::from("O-CLEAN");
3037 let instrument_id = InstrumentId::from("BTC-PERP.AX");
3038 let venue_order_id = VenueOrderId::new("OID-CLEAN");
3039 let stale_venue_order_id = VenueOrderId::new("OID-CLEAN-OLD");
3040 let cid_value = 123u64;
3041
3042 caches.orders_metadata.insert(
3043 client_order_id,
3044 test_metadata(client_order_id, instrument_id),
3045 );
3046 caches
3047 .venue_to_client_id
3048 .insert(venue_order_id, client_order_id);
3049 caches
3050 .venue_to_client_id
3051 .insert(stale_venue_order_id, client_order_id);
3052 caches
3053 .cid_to_client_order_id
3054 .insert(cid_value, client_order_id);
3055
3056 let mut order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
3057 order.cid = Some(cid_value);
3058
3059 cleanup_terminal_order_tracking(&order, &caches);
3060
3061 assert!(caches.orders_metadata.is_empty());
3062 assert!(caches.venue_to_client_id.is_empty());
3063 assert!(caches.cid_to_client_order_id.is_empty());
3064 }
3065
3066 #[rstest]
3067 fn test_cleanup_terminal_order_tracking_via_cid_when_venue_missing() {
3068 let caches = test_caches();
3069 let client_order_id = ClientOrderId::from("O-CLEAN-CID");
3070 let instrument_id = InstrumentId::from("BTC-PERP.AX");
3071 let cid_value = 321u64;
3072
3073 caches.orders_metadata.insert(
3074 client_order_id,
3075 test_metadata(client_order_id, instrument_id),
3076 );
3077 caches
3078 .cid_to_client_order_id
3079 .insert(cid_value, client_order_id);
3080
3081 let mut order = test_ws_order("OID-UNKNOWN", dec!(100.00), 10);
3083 order.cid = Some(cid_value);
3084
3085 cleanup_terminal_order_tracking(&order, &caches);
3086
3087 assert!(caches.orders_metadata.is_empty());
3088 assert!(caches.cid_to_client_order_id.is_empty());
3089 }
3090
3091 #[rstest]
3092 fn test_cleanup_terminal_order_tracking_noop_when_unknown() {
3093 let caches = test_caches();
3094 let other = ClientOrderId::from("OTHER");
3095 let instrument_id = InstrumentId::from("BTC-PERP.AX");
3096 caches
3097 .orders_metadata
3098 .insert(other, test_metadata(other, instrument_id));
3099
3100 let order = test_ws_order("OID-NOT-TRACKED", dec!(100.00), 10);
3101 cleanup_terminal_order_tracking(&order, &caches);
3102
3103 assert_eq!(caches.orders_metadata.len(), 1);
3105 }
3106
3107 #[rstest]
3108 fn test_cleanup_closed_order_status_report_removes_all_caches() {
3109 let caches = test_caches();
3110 let client_order_id = ClientOrderId::from("O-RECON-FILL");
3111 let venue_order_id = VenueOrderId::new("OID-RECON-FILL");
3112 let stale_venue_order_id = VenueOrderId::new("OID-RECON-FILL-OLD");
3113 let cid_value = client_order_id_to_cid(&client_order_id);
3114
3115 seed_terminal_tracking(
3116 &caches,
3117 client_order_id,
3118 venue_order_id,
3119 stale_venue_order_id,
3120 cid_value,
3121 );
3122
3123 let report = test_status_report(Some(client_order_id), venue_order_id, OrderStatus::Filled);
3124 cleanup_closed_order_status_report(&report, &caches);
3125
3126 assert!(caches.orders_metadata.is_empty());
3127 assert!(caches.venue_to_client_id.is_empty());
3128 assert!(caches.cid_to_client_order_id.is_empty());
3129 }
3130
3131 #[rstest]
3132 fn test_cleanup_closed_order_status_report_via_venue_when_client_id_absent() {
3133 let caches = test_caches();
3134 let client_order_id = ClientOrderId::from("O-RECON-CANCEL");
3135 let venue_order_id = VenueOrderId::new("OID-RECON-CANCEL");
3136 let stale_venue_order_id = VenueOrderId::new("OID-RECON-CANCEL-OLD");
3137 let cid_value = 654u64;
3138
3139 seed_terminal_tracking(
3140 &caches,
3141 client_order_id,
3142 venue_order_id,
3143 stale_venue_order_id,
3144 cid_value,
3145 );
3146
3147 let report = test_status_report(None, venue_order_id, OrderStatus::Canceled);
3148 cleanup_closed_order_status_report(&report, &caches);
3149
3150 assert!(caches.orders_metadata.is_empty());
3151 assert!(caches.venue_to_client_id.is_empty());
3152 assert!(caches.cid_to_client_order_id.is_empty());
3153 }
3154
3155 #[rstest]
3156 fn test_cleanup_closed_order_status_report_prefers_venue_identity() {
3157 let caches = test_caches();
3158 let client_order_id = ClientOrderId::from("O-EXT-RECON");
3159 let venue_order_id = VenueOrderId::new("OID-EXT-RECON");
3160 let stale_venue_order_id = VenueOrderId::new("OID-EXT-RECON-OLD");
3161
3162 caches.orders_metadata.insert(
3163 client_order_id,
3164 test_metadata(client_order_id, InstrumentId::from("BTC-PERP.AX")),
3165 );
3166 caches
3167 .venue_to_client_id
3168 .insert(venue_order_id, client_order_id);
3169 caches
3170 .venue_to_client_id
3171 .insert(stale_venue_order_id, client_order_id);
3172
3173 let report = test_status_report(
3174 Some(cid_to_client_order_id(99)),
3175 venue_order_id,
3176 OrderStatus::Filled,
3177 );
3178 cleanup_closed_order_status_report(&report, &caches);
3179
3180 assert!(caches.orders_metadata.is_empty());
3181 assert!(caches.venue_to_client_id.is_empty());
3182 assert!(caches.cid_to_client_order_id.is_empty());
3183 }
3184
3185 #[rstest]
3186 fn test_cleanup_open_order_status_report_retains_caches() {
3187 let caches = test_caches();
3188 let client_order_id = ClientOrderId::from("O-RECON-OPEN");
3189 let venue_order_id = VenueOrderId::new("OID-RECON-OPEN");
3190 let stale_venue_order_id = VenueOrderId::new("OID-RECON-OPEN-OLD");
3191 let cid_value = client_order_id_to_cid(&client_order_id);
3192
3193 seed_terminal_tracking(
3194 &caches,
3195 client_order_id,
3196 venue_order_id,
3197 stale_venue_order_id,
3198 cid_value,
3199 );
3200
3201 let report =
3202 test_status_report(Some(client_order_id), venue_order_id, OrderStatus::Accepted);
3203 cleanup_closed_order_status_report(&report, &caches);
3204
3205 assert!(caches.orders_metadata.contains_key(&client_order_id));
3206 assert_eq!(
3207 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
3208 client_order_id
3209 );
3210 assert_eq!(
3211 *caches.cid_to_client_order_id.get(&cid_value).unwrap(),
3212 client_order_id
3213 );
3214 }
3215
3216 #[rstest]
3217 fn test_cleanup_closed_order_status_reports_only_cleans_terminal() {
3218 let caches = test_caches();
3219 let filled_client_order_id = ClientOrderId::from("O-RECON-BATCH-FILL");
3220 let filled_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-FILL");
3221 let filled_stale_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-FILL-OLD");
3222 let filled_cid = client_order_id_to_cid(&filled_client_order_id);
3223 let open_client_order_id = ClientOrderId::from("O-RECON-BATCH-OPEN");
3224 let open_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-OPEN");
3225 let open_stale_venue_order_id = VenueOrderId::new("OID-RECON-BATCH-OPEN-OLD");
3226 let open_cid = client_order_id_to_cid(&open_client_order_id);
3227
3228 seed_terminal_tracking(
3229 &caches,
3230 filled_client_order_id,
3231 filled_venue_order_id,
3232 filled_stale_venue_order_id,
3233 filled_cid,
3234 );
3235 seed_terminal_tracking(
3236 &caches,
3237 open_client_order_id,
3238 open_venue_order_id,
3239 open_stale_venue_order_id,
3240 open_cid,
3241 );
3242
3243 let reports = vec![
3244 test_status_report(
3245 Some(filled_client_order_id),
3246 filled_venue_order_id,
3247 OrderStatus::Filled,
3248 ),
3249 test_status_report(
3250 Some(open_client_order_id),
3251 open_venue_order_id,
3252 OrderStatus::Accepted,
3253 ),
3254 ];
3255
3256 for report in &reports {
3257 cleanup_closed_order_status_report(report, &caches);
3258 }
3259
3260 assert!(!caches.orders_metadata.contains_key(&filled_client_order_id));
3261 assert!(
3262 !caches
3263 .venue_to_client_id
3264 .contains_key(&filled_venue_order_id)
3265 );
3266 assert!(!caches.cid_to_client_order_id.contains_key(&filled_cid));
3267 assert!(caches.orders_metadata.contains_key(&open_client_order_id));
3268 assert_eq!(
3269 *caches.venue_to_client_id.get(&open_venue_order_id).unwrap(),
3270 open_client_order_id
3271 );
3272 assert_eq!(
3273 *caches.cid_to_client_order_id.get(&open_cid).unwrap(),
3274 open_client_order_id
3275 );
3276 }
3277
3278 #[rstest]
3279 #[tokio::test]
3280 async fn test_generate_order_status_reports_cleans_terminal_tracking() {
3281 let filled_client_order_id = ClientOrderId::from("O-RECON-HTTP-FILL");
3282 let filled_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-FILL");
3283 let filled_stale_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-FILL-OLD");
3284 let filled_cid = client_order_id_to_cid(&filled_client_order_id);
3285 let open_client_order_id = ClientOrderId::from("O-RECON-HTTP-OPEN");
3286 let open_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-OPEN");
3287 let open_stale_venue_order_id = VenueOrderId::new("OID-RECON-HTTP-OPEN-OLD");
3288 let open_cid = client_order_id_to_cid(&open_client_order_id);
3289
3290 let addr = start_orders_report_server(serde_json::json!({
3291 "orders": [
3292 {
3293 "ts": 1_704_067_200,
3294 "tn": 500_000_000,
3295 "oid": filled_venue_order_id.as_str(),
3296 "u": "u",
3297 "s": "BTC-PERP",
3298 "p": "100.00",
3299 "q": 10,
3300 "xq": 10,
3301 "rq": 0,
3302 "o": "FILLED",
3303 "d": "B",
3304 "tif": "GTC",
3305 "cid": filled_cid,
3306 "po": false
3307 },
3308 {
3309 "ts": 1_704_067_201,
3310 "tn": 500_000_000,
3311 "oid": open_venue_order_id.as_str(),
3312 "u": "u",
3313 "s": "BTC-PERP",
3314 "p": "100.00",
3315 "q": 10,
3316 "xq": 0,
3317 "rq": 10,
3318 "o": "ACCEPTED",
3319 "d": "B",
3320 "tif": "GTC",
3321 "cid": open_cid,
3322 "po": false
3323 }
3324 ]
3325 }))
3326 .await;
3327
3328 let cache = Rc::new(RefCell::new(Cache::default()));
3329
3330 let core = ExecutionClientCore::new(
3331 TraderId::from("TESTER-001"),
3332 *AX_CLIENT_ID,
3333 *AX_VENUE,
3334 OmsType::Netting,
3335 AccountId::from("AX-001"),
3336 AccountType::Margin,
3337 None,
3338 cache,
3339 );
3340
3341 let config = AxExecutionClientConfig {
3342 api_key: Some("test_api_key".into()),
3343 api_secret: Some("test_api_secret".into()),
3344 environment: AxEnvironment::Sandbox,
3345 base_url_http: Some(format!("http://{addr}")),
3346 base_url_orders: Some(format!("http://{addr}")),
3347 base_url_ws_private: Some(format!("ws://{addr}/orders/ws")),
3348 http_timeout_secs: 5,
3349 max_retries: 1,
3350 retry_delay_initial_ms: 10,
3351 retry_delay_max_ms: 10,
3352 ..Default::default()
3353 };
3354
3355 let client = AxExecutionClient::new(core, config).expect("create exec client");
3356 client.http_client.set_session_token("test-token".into());
3357 client
3358 .http_client
3359 .cache_instrument(test_perp_instrument("BTC-PERP"));
3360
3361 seed_terminal_tracking(
3362 client.ws_orders.caches(),
3363 filled_client_order_id,
3364 filled_venue_order_id,
3365 filled_stale_venue_order_id,
3366 filled_cid,
3367 );
3368 seed_terminal_tracking(
3369 client.ws_orders.caches(),
3370 open_client_order_id,
3371 open_venue_order_id,
3372 open_stale_venue_order_id,
3373 open_cid,
3374 );
3375
3376 let cmd = GenerateOrderStatusReports::new(
3377 UUID4::new(),
3378 UnixNanos::default(),
3379 false,
3380 None,
3381 None,
3382 None,
3383 None,
3384 None,
3385 );
3386 let reports = client
3387 .generate_order_status_reports(&cmd)
3388 .await
3389 .expect("generate_order_status_reports");
3390
3391 assert_eq!(reports.len(), 2);
3392 let filled = reports
3393 .iter()
3394 .find(|report| report.venue_order_id == filled_venue_order_id)
3395 .expect("filled report");
3396 let open = reports
3397 .iter()
3398 .find(|report| report.venue_order_id == open_venue_order_id)
3399 .expect("open report");
3400 assert_eq!(filled.order_status, OrderStatus::Filled);
3401 assert_eq!(filled.client_order_id, Some(filled_client_order_id));
3402 assert_eq!(open.order_status, OrderStatus::Accepted);
3403 assert_eq!(open.client_order_id, Some(open_client_order_id));
3404
3405 let caches = client.ws_orders.caches();
3406 assert!(!caches.orders_metadata.contains_key(&filled_client_order_id));
3407 assert!(
3408 !caches
3409 .venue_to_client_id
3410 .contains_key(&filled_venue_order_id)
3411 );
3412 assert!(!caches.cid_to_client_order_id.contains_key(&filled_cid));
3413 assert!(caches.orders_metadata.contains_key(&open_client_order_id));
3414 assert_eq!(
3415 *caches.venue_to_client_id.get(&open_venue_order_id).unwrap(),
3416 open_client_order_id
3417 );
3418 assert_eq!(
3419 *caches.cid_to_client_order_id.get(&open_cid).unwrap(),
3420 open_client_order_id
3421 );
3422 }
3423
3424 #[rstest]
3425 fn test_cancel_on_disconnect_url_no_existing_query() {
3426 let mut url = "wss://example.com/orders/ws".to_string();
3427 let separator = if url.contains('?') { "&" } else { "?" };
3428 url.push_str(&format!("{separator}cancel_on_disconnect=true"));
3429 assert_eq!(url, "wss://example.com/orders/ws?cancel_on_disconnect=true");
3430 }
3431
3432 #[rstest]
3433 fn test_cancel_on_disconnect_url_with_existing_query() {
3434 let mut url = "wss://example.com/orders/ws?token=abc".to_string();
3435 let separator = if url.contains('?') { "&" } else { "?" };
3436 url.push_str(&format!("{separator}cancel_on_disconnect=true"));
3437 assert_eq!(
3438 url,
3439 "wss://example.com/orders/ws?token=abc&cancel_on_disconnect=true"
3440 );
3441 }
3442
3443 fn limit_order_for_validation(quantity: Quantity) -> OrderAny {
3444 OrderTestBuilder::new(OrderType::Limit)
3445 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3446 .side(OrderSide::Buy)
3447 .quantity(quantity)
3448 .price(Price::from("1.10"))
3449 .build()
3450 }
3451
3452 #[rstest]
3453 fn test_validate_order_for_ax_submit_accepts_supported_limit_order() {
3454 let order = limit_order_for_validation(Quantity::from("10"));
3455
3456 assert!(validate_order_for_ax_submit(&order).is_ok());
3457 }
3458
3459 #[rstest]
3460 fn test_validate_order_for_ax_submit_denies_unsupported_order_type() {
3461 let order = OrderTestBuilder::new(OrderType::StopMarket)
3462 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3463 .side(OrderSide::Buy)
3464 .quantity(Quantity::from("10"))
3465 .trigger_price(Price::from("1.10"))
3466 .build();
3467
3468 let err = validate_order_for_ax_submit(&order).unwrap_err();
3469
3470 assert!(err.to_string().contains("Unsupported order type"));
3471 }
3472
3473 #[rstest]
3474 fn test_validate_order_for_ax_submit_denies_gtd_time_in_force() {
3475 let order = OrderTestBuilder::new(OrderType::Limit)
3476 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3477 .side(OrderSide::Buy)
3478 .quantity(Quantity::from("10"))
3479 .price(Price::from("1.10"))
3480 .time_in_force(TimeInForce::Gtd)
3481 .expire_time(UnixNanos::from(2_000_000_000_000_000_000u64))
3482 .build();
3483
3484 let err = validate_order_for_ax_submit(&order).unwrap_err();
3485
3486 assert!(err.to_string().contains("Unsupported time in force"));
3487 }
3488
3489 #[rstest]
3490 fn test_validate_order_for_ax_submit_denies_stop_limit_order() {
3491 let order = OrderTestBuilder::new(OrderType::StopLimit)
3492 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3493 .side(OrderSide::Buy)
3494 .quantity(Quantity::from("10"))
3495 .price(Price::from("1.11"))
3496 .trigger_price(Price::from("1.10"))
3497 .build();
3498
3499 let err = validate_order_for_ax_submit(&order).unwrap_err();
3500
3501 assert!(err.to_string().contains("Unsupported order type"));
3502 }
3503
3504 #[rstest]
3505 fn test_validate_order_for_ax_submit_denies_fok_time_in_force() {
3506 let order = OrderTestBuilder::new(OrderType::Limit)
3507 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3508 .side(OrderSide::Buy)
3509 .quantity(Quantity::from("10"))
3510 .price(Price::from("1.10"))
3511 .time_in_force(TimeInForce::Fok)
3512 .build();
3513
3514 let err = validate_order_for_ax_submit(&order).unwrap_err();
3515
3516 assert!(err.to_string().contains("Unsupported time in force"));
3517 }
3518
3519 #[rstest]
3520 fn test_validate_order_for_ax_submit_denies_fractional_quantity() {
3521 let order = limit_order_for_validation(Quantity::from("10.5"));
3522
3523 let err = validate_order_for_ax_submit(&order).unwrap_err();
3524
3525 assert!(err.to_string().contains("whole contract"));
3526 }
3527
3528 #[rstest]
3529 fn test_validate_order_for_ax_submit_denies_quote_quantity() {
3530 let order = OrderTestBuilder::new(OrderType::Market)
3531 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3532 .side(OrderSide::Buy)
3533 .quantity(Quantity::from("10"))
3534 .time_in_force(TimeInForce::Ioc)
3535 .quote_quantity(true)
3536 .build();
3537
3538 let err = validate_order_for_ax_submit(&order).unwrap_err();
3539
3540 assert!(err.to_string().contains("quote_quantity"));
3541 }
3542
3543 #[rstest]
3544 fn test_validate_order_for_ax_submit_denies_display_quantity() {
3545 let order = OrderTestBuilder::new(OrderType::Limit)
3546 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
3547 .side(OrderSide::Buy)
3548 .quantity(Quantity::from("10"))
3549 .price(Price::from("1.10"))
3550 .display_qty(Quantity::from("5"))
3551 .build();
3552
3553 let err = validate_order_for_ax_submit(&order).unwrap_err();
3554
3555 assert!(err.to_string().contains("display_qty"));
3556 }
3557
3558 #[rstest]
3559 #[case(AxHttpError::MissingCredentials, true)]
3560 #[case(AxHttpError::MissingSessionToken, true)]
3561 #[case(AxHttpError::ValidationError("bad param".to_string()), true)]
3562 #[case(AxHttpError::BuildError(AxBuildError::MissingOrderId), true)]
3563 #[case(AxHttpError::ApiError { message: "invalid modification".to_string() }, false)]
3564 #[case(AxHttpError::JsonError("parse failure".to_string()), false)]
3565 #[case(AxHttpError::Canceled("shutdown".to_string()), false)]
3566 #[case(AxHttpError::NetworkError("timeout".to_string()), false)]
3567 #[case(AxHttpError::UnexpectedStatus { status: 400, body: "invalid".to_string() }, false)]
3568 #[case(AxHttpError::UnexpectedStatus { status: 503, body: String::new() }, false)]
3569 fn test_classify_ax_http_failure(#[case] error: AxHttpError, #[case] expect_not_sent: bool) {
3570 let expected = if expect_not_sent {
3572 CommandFailure::NotSent(error.to_string())
3573 } else {
3574 CommandFailure::Ambiguous(error.to_string())
3575 };
3576
3577 assert_eq!(classify_ax_http_failure(&error), expected);
3578 }
3579
3580 #[rstest]
3581 #[case(
3582 AxOrdersWsClientError::ClientError("missing venue_order_id".to_string()),
3583 Some("missing venue_order_id")
3584 )]
3585 #[case(AxOrdersWsClientError::ChannelError("handler closed".to_string()), None)]
3586 #[case(AxOrdersWsClientError::Transport("connection reset".to_string()), None)]
3587 #[case(AxOrdersWsClientError::AuthenticationError("token expired".to_string()), None)]
3588 fn test_classify_ax_ws_failure(
3589 #[case] error: AxOrdersWsClientError,
3590 #[case] not_sent_reason: Option<&str>,
3591 ) {
3592 let expected = match not_sent_reason {
3594 Some(reason) => CommandFailure::NotSent(reason.to_string()),
3595 None => CommandFailure::Ambiguous(error.to_string()),
3596 };
3597
3598 assert_eq!(classify_ax_ws_failure(&error), expected);
3599 }
3600
3601 #[rstest]
3602 #[case(include_str!("../test_data/ws_order_filled_unknown_state.json"))]
3603 #[case(include_str!("../test_data/ws_order_filled_unknown_tif.json"))]
3604 fn test_dispatch_fill_with_unknown_sibling_classification(#[case] raw: &str) {
3605 let event: AxWsOrderEvent = serde_json::from_str(raw).unwrap();
3606 let clock = get_atomic_clock_realtime();
3607 let account_id = AccountId::from("AX-001");
3608 let client_order_id = ClientOrderId::from("O-UNKNOWN-STATE");
3609 let venue_order_id = VenueOrderId::new("O-01ARZ3NDEKTSV4RRFFQ69G5FAV");
3610 let caches = test_caches();
3611 caches.orders_metadata.insert(
3612 client_order_id,
3613 test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX")),
3614 );
3615 caches
3616 .venue_to_client_id
3617 .insert(venue_order_id, client_order_id);
3618 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3619
3620 let mut emitter = ExecutionEventEmitter::new(
3621 clock,
3622 TraderId::from("TESTER-001"),
3623 account_id,
3624 AccountType::Margin,
3625 None,
3626 );
3627 emitter.set_sender(tx);
3628 let instruments = AtomicMap::new();
3629 instruments.insert(
3630 Ustr::from("EURUSD-PERP"),
3631 test_perp_instrument("EURUSD-PERP"),
3632 );
3633 let account_fee_rates = AtomicMap::new();
3634 account_fee_rates.insert(
3635 Some(Ustr::from("fixture-account")),
3636 MakerTakerFeeRates::new(dec!(0.0002), dec!(0.0025)),
3637 );
3638
3639 dispatch_order_event(
3640 event,
3641 &emitter,
3642 &caches,
3643 account_id,
3644 &instruments,
3645 &account_fee_rates,
3646 clock,
3647 );
3648
3649 let ExecutionEvent::Order(OrderEventAny::Filled(fill)) = rx.try_recv().unwrap() else {
3650 panic!("expected fill")
3651 };
3652
3653 assert_eq!(fill.venue_order_id, venue_order_id);
3654 assert_eq!(fill.client_order_id, client_order_id);
3655 assert_eq!(fill.trade_id.as_str(), "T-01ARZ3NDEKTSV4RRFFQ69G5FAV");
3656 assert_eq!(fill.last_qty.as_decimal(), dec!(100));
3657 assert_eq!(fill.last_px.as_decimal(), dec!(50000));
3658 assert_eq!(fill.ts_event, UnixNanos::from(1609459200123456789));
3659 assert!(rx.try_recv().is_err());
3660 assert!(caches.orders_metadata.is_empty());
3661 assert!(caches.venue_to_client_id.is_empty());
3662 }
3663
3664 #[rstest]
3665 fn test_amendment_keeps_venue_identity_and_updates_quantity() {
3666 let message: crate::websocket::messages::AxWsOrderReplaced =
3667 serde_json::from_str(include_str!("../test_data/ws_order_amended.json")).unwrap();
3668 let venue_order_id = VenueOrderId::new(&message.ro.oid);
3669 let client_order_id = ClientOrderId::from("O-AMEND");
3670 let caches = test_caches();
3671 caches.orders_metadata.insert(
3672 client_order_id,
3673 test_metadata(client_order_id, InstrumentId::from("EURUSD-PERP.AX")),
3674 );
3675 caches
3676 .venue_to_client_id
3677 .insert(venue_order_id, client_order_id);
3678 let clock = get_atomic_clock_realtime();
3679 let account_id = AccountId::from("AX-001");
3680 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3681
3682 let mut emitter = ExecutionEventEmitter::new(
3683 clock,
3684 TraderId::from("TESTER-001"),
3685 account_id,
3686 AccountType::Margin,
3687 None,
3688 );
3689 emitter.set_sender(tx);
3690 let instruments = AtomicMap::new();
3691
3692 dispatch_order_event(
3693 AxWsOrderEvent::Replaced(message),
3694 &emitter,
3695 &caches,
3696 account_id,
3697 &instruments,
3698 &AtomicMap::new(),
3699 clock,
3700 );
3701
3702 let ExecutionEvent::Order(OrderEventAny::Updated(update)) = rx.try_recv().unwrap() else {
3703 panic!("expected update")
3704 };
3705
3706 assert_eq!(update.venue_order_id, Some(venue_order_id));
3707 assert_eq!(update.client_order_id, client_order_id);
3708 assert_eq!(update.quantity.as_decimal(), dec!(150));
3709 assert_eq!(
3710 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
3711 client_order_id
3712 );
3713 assert_eq!(caches.venue_to_client_id.len(), 1);
3714 assert!(rx.try_recv().is_err());
3715 }
3716
3717 #[rstest]
3718 #[case(true, false)]
3719 #[case(false, true)]
3720 #[case(true, true)]
3721 fn test_replacement_rejects_inconsistent_identity(
3722 #[case] has_id: bool,
3723 #[case] has_order: bool,
3724 ) {
3725 let mut message: crate::websocket::messages::AxWsOrderReplaced =
3726 serde_json::from_str(include_str!("../test_data/ws_order_replaced_live.json")).unwrap();
3727 message.noid = has_id.then(|| "MISMATCHED".to_owned());
3728
3729 if !has_order {
3730 message.no = None;
3731 }
3732
3733 assert_eq!(
3734 replacement_venue_order_id(&message)
3735 .unwrap_err()
3736 .to_string(),
3737 "Inconsistent AX replacement order identity"
3738 );
3739 }
3740}