1use std::{
19 future::Future,
20 sync::Mutex,
21 time::{Duration, Instant},
22};
23
24use anyhow::Context;
25use async_trait::async_trait;
26use futures_util::{StreamExt, pin_mut};
27use nautilus_common::{
28 clients::ExecutionClient,
29 live::{get_runtime, runner::get_exec_event_sender},
30 messages::execution::{
31 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
32 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
33 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
34 },
35};
36use nautilus_core::{
37 AtomicMap, MUTEX_POISONED, UUID4, UnixNanos,
38 time::{AtomicTime, get_atomic_clock_realtime},
39};
40use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter};
41use nautilus_model::{
42 accounts::AccountAny,
43 enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderStatus, OrderType, TimeInForce},
44 events::{
45 OrderAccepted, OrderCancelRejected, OrderCanceled, OrderEventAny, OrderExpired,
46 OrderFilled, OrderRejected, OrderUpdated,
47 },
48 identifiers::{
49 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, TradeId, Venue, VenueOrderId,
50 },
51 instruments::{Instrument, InstrumentAny},
52 orders::{Order, OrderAny},
53 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
54 types::{AccountBalance, MarginBalance, Money, Price, Quantity},
55};
56use tokio::task::JoinHandle;
57use ustr::Ustr;
58
59use crate::{
60 common::{
61 consts::{
62 AX_ACCOUNT_REGISTRATION_TIMEOUT_SECS, AX_AUTH_TOKEN_TTL_EXEC_SECS, AX_POST_ONLY_REJECT,
63 AX_VENUE,
64 },
65 credential::Credential,
66 enums::{AxOrderSide, AxTimeInForce},
67 parse::{ax_timestamp_stn_to_unix_nanos, cid_to_client_order_id, quantity_to_contracts},
68 },
69 config::AxExecClientConfig,
70 http::{
71 client::AxHttpClient,
72 error::AxHttpError,
73 models::{AxOrderRejectReason, PreviewAggressiveLimitOrderRequest, ReplaceOrderRequest},
74 },
75 websocket::{
76 AxOrdersWsMessage, AxWsOrderEvent,
77 messages::{AxWsOrder, AxWsTradeExecution, OrderMetadata},
78 orders::{AxOrdersWebSocketClient, AxOrdersWsClientError, OrdersCaches},
79 },
80};
81
82#[derive(Debug)]
84pub struct AxExecutionClient {
85 core: ExecutionClientCore,
86 clock: &'static AtomicTime,
87 config: AxExecClientConfig,
88 emitter: ExecutionEventEmitter,
89 http_client: AxHttpClient,
90 ws_orders: AxOrdersWebSocketClient,
91 ws_stream_handle: Option<JoinHandle<()>>,
92 pending_tasks: Mutex<Vec<JoinHandle<()>>>,
93}
94
95impl AxExecutionClient {
96 pub fn new(core: ExecutionClientCore, config: AxExecClientConfig) -> anyhow::Result<Self> {
102 let http_client = AxHttpClient::with_credentials(
103 config.api_key.clone().unwrap_or_default(),
104 config.api_secret.clone().unwrap_or_default(),
105 Some(config.http_base_url()),
106 Some(config.orders_base_url()),
107 config.http_timeout_secs,
108 config.max_retries,
109 config.retry_delay_initial_ms,
110 config.retry_delay_max_ms,
111 config.proxy_url.clone(),
112 )?;
113
114 let clock = get_atomic_clock_realtime();
115 let trader_id = core.trader_id;
116 let account_id = core.account_id;
117 let emitter =
118 ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
119 let mut ws_url = config.ws_private_url();
120 if config.cancel_on_disconnect {
121 let separator = if ws_url.contains('?') { "&" } else { "?" };
122 ws_url.push_str(&format!("{separator}cancel_on_disconnect=true"));
123 }
124 let ws_orders = AxOrdersWebSocketClient::new(
125 ws_url,
126 account_id,
127 trader_id,
128 config.heartbeat_interval_secs,
129 config.transport_backend,
130 config.proxy_url.clone(),
131 );
132
133 Ok(Self {
134 core,
135 clock,
136 config,
137 emitter,
138 http_client,
139 ws_orders,
140 ws_stream_handle: None,
141 pending_tasks: Mutex::new(Vec::new()),
142 })
143 }
144
145 async fn authenticate(&self) -> anyhow::Result<String> {
146 let credential =
147 Credential::resolve(self.config.api_key.clone(), self.config.api_secret.clone())
148 .context("API credentials not configured")?;
149
150 self.http_client
151 .authenticate(
152 credential.api_key(),
153 credential.api_secret(),
154 AX_AUTH_TOKEN_TTL_EXEC_SECS,
155 )
156 .await
157 .map_err(|e| anyhow::anyhow!("Authentication failed: {e}"))
158 }
159
160 fn update_account_state(&self) {
161 let http_client = self.http_client.clone();
162 let account_id = self.core.account_id;
163 let emitter = self.emitter.clone();
164 let clock = self.clock;
165
166 self.spawn_task("query_account", async move {
167 let account_state = http_client
168 .request_account_state(account_id)
169 .await
170 .context("failed to request AX account state")?;
171 let ts_event = clock.get_time_ns();
172 emitter.emit_account_state(
173 account_state.balances.clone(),
174 account_state.margins.clone(),
175 account_state.is_reported,
176 ts_event,
177 );
178 Ok(())
179 });
180 }
181
182 fn submit_order_internal(&self, cmd: &SubmitOrder) -> anyhow::Result<()> {
183 let (
184 client_order_id,
185 strategy_id,
186 instrument_id,
187 order_side,
188 order_type,
189 quantity,
190 trigger_price,
191 time_in_force,
192 is_post_only,
193 limit_price,
194 ) = {
195 let cache = self.core.cache();
196 let order = cache.try_order(&cmd.client_order_id)?;
197 (
198 order.client_order_id(),
199 order.strategy_id(),
200 order.instrument_id(),
201 order.order_side(),
202 order.order_type(),
203 order.quantity(),
204 order.trigger_price(),
205 order.time_in_force(),
206 order.is_post_only(),
207 order.price(),
208 )
209 };
210
211 let ws_orders = self.ws_orders.clone();
212 let trader_id = self.core.trader_id;
213
214 let http_client = if order_type == OrderType::Market {
215 Some(self.http_client.clone())
216 } else {
217 None
218 };
219
220 self.spawn_task("submit_order", async move {
221 let result: anyhow::Result<()> = async {
222 let price = if order_type == OrderType::Market {
230 let symbol = instrument_id.symbol.inner();
231 let ax_side = AxOrderSide::try_from(order_side)
232 .map_err(|e| anyhow::anyhow!("Invalid order side: {e}"))?;
233 let qty_contracts = quantity_to_contracts(quantity)?;
234
235 let request =
236 PreviewAggressiveLimitOrderRequest::new(symbol, qty_contracts, ax_side);
237 let response = http_client
238 .expect("HTTP client should be set for market orders")
239 .inner
240 .preview_aggressive_limit_order(&request)
241 .await
242 .map_err(|e| {
243 anyhow::anyhow!("Failed to preview aggressive limit order: {e}")
244 })?;
245
246 if response.remaining_quantity > 0 {
247 log::warn!(
248 "Market order book depth insufficient: \
249 filled_qty={} remaining_qty={} for {instrument_id}",
250 response.filled_quantity,
251 response.remaining_quantity,
252 );
253 }
254
255 let limit_price_decimal = response.limit_price.ok_or_else(|| {
256 anyhow::anyhow!(
257 "No liquidity available for market order on {instrument_id}"
258 )
259 })?;
260
261 let price = Price::from(limit_price_decimal.to_string().as_str());
262 log::debug!("Market order take-through price: {price} for {instrument_id}",);
263 Some(price)
264 } else {
265 limit_price
266 };
267
268 ws_orders
269 .submit_order(
270 trader_id,
271 strategy_id,
272 instrument_id,
273 client_order_id,
274 order_side,
275 order_type,
276 quantity,
277 time_in_force,
278 price,
279 trigger_price,
280 is_post_only,
281 )
282 .await
283 .map_err(|e| anyhow::anyhow!("Submit order failed: {e}"))?;
284
285 Ok(())
286 }
287 .await;
288
289 if let Err(e) = result {
292 log::warn!(
293 "Ambiguous AX submit failure for {client_order_id}, awaiting reconciliation: {e:?}"
294 );
295 }
296
297 Ok(())
298 });
299
300 Ok(())
301 }
302
303 fn cancel_order_internal(&self, cmd: &CancelOrder) {
304 let ws_orders = self.ws_orders.clone();
305 let client_order_id = cmd.client_order_id;
306 let venue_order_id = cmd.venue_order_id;
307
308 self.spawn_task("cancel_order", async move {
311 if let Err(e) = ws_orders.cancel_order(client_order_id, venue_order_id).await {
312 match classify_ax_ws_failure(&e) {
313 AxCommandFailure::LocalValidation(reason) => {
314 log::warn!(
315 "Cancel command failed local validation for {client_order_id}: {reason}"
316 );
317 }
318 AxCommandFailure::Ambiguous(reason) => {
319 log::warn!(
320 "Ambiguous AX cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
321 );
322 }
323 }
324 }
325
326 Ok(())
327 });
328 }
329
330 fn spawn_task<F>(&self, description: &'static str, fut: F)
331 where
332 F: Future<Output = anyhow::Result<()>> + Send + 'static,
333 {
334 let runtime = get_runtime();
335 let handle = runtime.spawn(async move {
336 if let Err(e) = fut.await {
337 log::warn!("{description} failed: {e}");
338 }
339 });
340
341 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
342 tasks.retain(|handle| !handle.is_finished());
343 tasks.push(handle);
344 }
345
346 fn abort_pending_tasks(&self) {
347 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
348 for handle in tasks.drain(..) {
349 handle.abort();
350 }
351 }
352
353 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
355 let account_id = self.core.account_id;
356
357 if self.core.cache().account(&account_id).is_some() {
358 log::info!("Account {account_id} registered");
359 return Ok(());
360 }
361
362 let start = Instant::now();
363 let timeout = Duration::from_secs_f64(timeout_secs);
364 let interval = Duration::from_millis(10);
365
366 loop {
367 tokio::time::sleep(interval).await;
368
369 if self.core.cache().account(&account_id).is_some() {
370 log::info!("Account {account_id} registered");
371 return Ok(());
372 }
373
374 if start.elapsed() >= timeout {
375 anyhow::bail!(
376 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
377 );
378 }
379 }
380 }
381}
382
383#[async_trait(?Send)]
384impl ExecutionClient for AxExecutionClient {
385 fn is_connected(&self) -> bool {
386 self.core.is_connected()
387 }
388
389 fn client_id(&self) -> ClientId {
390 self.core.client_id
391 }
392
393 fn account_id(&self) -> AccountId {
394 self.core.account_id
395 }
396
397 fn venue(&self) -> Venue {
398 *AX_VENUE
399 }
400
401 fn oms_type(&self) -> OmsType {
402 self.core.oms_type
403 }
404
405 fn get_account(&self) -> Option<AccountAny> {
406 self.core.cache().account_owned(&self.core.account_id)
407 }
408
409 async fn connect(&mut self) -> anyhow::Result<()> {
410 if self.core.is_connected() {
411 return Ok(());
412 }
413
414 self.http_client.reset_cancellation_token();
416
417 if !self.core.instruments_initialized() {
418 let instruments = self
419 .http_client
420 .request_instruments(None, None)
421 .await
422 .context("failed to request AX instruments")?;
423
424 if instruments.is_empty() {
425 log::warn!("No instruments returned from AX");
426 } else {
427 log::debug!("Loaded {} instruments", instruments.len());
428 self.http_client.cache_instruments(&instruments);
429 self.ws_orders.cache_instruments(&instruments);
430 }
431 self.core.set_instruments_initialized();
432 }
433
434 let token = self.authenticate().await?;
435 self.ws_orders.connect(&token).await?;
436 log::debug!("Connected to orders WebSocket");
437
438 let should_spawn = match &self.ws_stream_handle {
439 None => true,
440 Some(handle) => handle.is_finished(),
441 };
442
443 if should_spawn {
444 let stream = self.ws_orders.stream();
445 let emitter = self.emitter.clone();
446 let caches = self.ws_orders.caches().clone();
447 let account_id = self.core.account_id;
448 let instruments_cache = self.ws_orders.instruments_cache();
449 let clock = self.clock;
450
451 let handle = get_runtime().spawn(async move {
452 pin_mut!(stream);
453 while let Some(message) = stream.next().await {
454 dispatch_ws_message(
455 message,
456 &emitter,
457 &caches,
458 account_id,
459 &instruments_cache,
460 clock,
461 );
462 }
463 });
464 self.ws_stream_handle = Some(handle);
465 }
466
467 let account_state = self
468 .http_client
469 .request_account_state(self.core.account_id)
470 .await
471 .context("failed to request AX account state")?;
472
473 if !account_state.balances.is_empty() {
474 log::debug!(
475 "Received account state with {} balance(s)",
476 account_state.balances.len()
477 );
478 }
479 self.emitter.send_account_state(account_state);
480
481 self.await_account_registered(AX_ACCOUNT_REGISTRATION_TIMEOUT_SECS)
482 .await?;
483
484 self.core.set_connected();
485 log::info!("Connected: client_id={}", self.core.client_id);
486 Ok(())
487 }
488
489 async fn disconnect(&mut self) -> anyhow::Result<()> {
490 if self.core.is_disconnected() {
491 return Ok(());
492 }
493
494 self.abort_pending_tasks();
495 self.http_client.cancel_all_requests();
496
497 self.ws_orders.close().await;
498
499 if let Some(handle) = self.ws_stream_handle.take() {
500 handle.abort();
501 }
502
503 self.core.set_disconnected();
504 log::info!("Disconnected: client_id={}", self.core.client_id);
505 Ok(())
506 }
507
508 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
509 self.update_account_state();
510 Ok(())
511 }
512
513 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
514 let http_client = self.http_client.clone();
515 let account_id = self.core.account_id;
516 let client_order_id = cmd.client_order_id;
517 let venue_order_id = cmd.venue_order_id;
518 let instrument_id = cmd.instrument_id;
519 let emitter = self.emitter.clone();
520
521 let (order_side, order_type, time_in_force) = {
523 let cache = self.core.cache();
524 match cache.order(&client_order_id) {
525 Some(order) => (
526 order.order_side(),
527 order.order_type(),
528 order.time_in_force(),
529 ),
530 None => (OrderSide::NoOrderSide, OrderType::Limit, TimeInForce::Gtc),
531 }
532 };
533
534 self.spawn_task("query_order", async move {
535 match http_client
536 .request_order_status(
537 account_id,
538 instrument_id,
539 Some(client_order_id),
540 venue_order_id,
541 order_side,
542 order_type,
543 time_in_force,
544 )
545 .await
546 {
547 Ok(report) => emitter.send_order_status_report(report),
548 Err(e) => log::error!("AX query order failed: {e}"),
549 }
550 Ok(())
551 });
552
553 Ok(())
554 }
555
556 fn generate_account_state(
557 &self,
558 balances: Vec<AccountBalance>,
559 margins: Vec<MarginBalance>,
560 reported: bool,
561 ts_event: UnixNanos,
562 ) -> anyhow::Result<()> {
563 self.emitter
564 .emit_account_state(balances, margins, reported, ts_event);
565 Ok(())
566 }
567
568 fn start(&mut self) -> anyhow::Result<()> {
569 if self.core.is_started() {
570 return Ok(());
571 }
572
573 self.emitter.set_sender(get_exec_event_sender());
574 self.core.set_started();
575 log::info!(
576 "Started: client_id={}, account_id={}, environment={}",
577 self.core.client_id,
578 self.core.account_id,
579 self.config.environment,
580 );
581 Ok(())
582 }
583
584 fn stop(&mut self) -> anyhow::Result<()> {
585 if self.core.is_stopped() {
586 return Ok(());
587 }
588
589 self.core.set_stopped();
590 self.core.set_disconnected();
591
592 if let Some(handle) = self.ws_stream_handle.take() {
593 handle.abort();
594 }
595 self.abort_pending_tasks();
596 log::info!("Stopped: client_id={}", self.core.client_id);
597 Ok(())
598 }
599
600 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
601 {
602 let cache = self.core.cache();
603 let order = cache.try_order(&cmd.client_order_id)?;
604
605 if order.is_closed() {
606 log::warn!("Cannot submit closed order {}", order.client_order_id());
607 return Ok(());
608 }
609
610 if let Err(e) = validate_order_for_ax_submit(&order) {
611 self.emitter.emit_order_denied(&order, &e.to_string());
612 return Ok(());
613 }
614
615 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
616 self.emitter.emit_order_submitted(&order);
617 }
618
619 self.submit_order_internal(&cmd)
620 }
621
622 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
623 for (client_order_id, order_init) in cmd
624 .order_list
625 .client_order_ids
626 .iter()
627 .zip(cmd.order_inits.iter())
628 {
629 let submit_cmd = SubmitOrder::new(
630 cmd.trader_id,
631 cmd.client_id,
632 cmd.strategy_id,
633 cmd.instrument_id,
634 *client_order_id,
635 order_init.clone(),
636 cmd.exec_algorithm_id,
637 cmd.position_id,
638 cmd.params.clone(),
639 UUID4::new(),
640 cmd.ts_init,
641 cmd.correlation_id,
642 );
643 self.submit_order(submit_cmd)?;
644 }
645 Ok(())
646 }
647
648 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
649 let venue_order_id = match cmd.venue_order_id {
650 Some(ref voi) => *voi,
651 None => {
652 log::warn!(
653 "Modify command failed local validation for {}: missing venue_order_id",
654 cmd.client_order_id
655 );
656 return Ok(());
657 }
658 };
659
660 let http_client = self.http_client.clone();
661 let caches = self.ws_orders.caches().clone();
662 let client_order_id = cmd.client_order_id;
663 let quantity = cmd.quantity;
664 let price = cmd.price;
665 let trigger_price = cmd.trigger_price;
666
667 self.spawn_task("modify_order", async move {
668 let mut request = ReplaceOrderRequest::new(venue_order_id.as_str());
669
670 if let Some(price) = price {
671 request = request.with_price(price.as_decimal());
672 }
673
674 if let Some(qty) = quantity {
675 let contracts = quantity_to_contracts(qty)?;
676 request = request.with_quantity(contracts);
677 }
678
679 if let Some(trigger) = trigger_price {
680 request = request.with_trigger_price(trigger.as_decimal());
681 }
682
683 match http_client.inner.replace_order(&request).await {
684 Ok(resp) => {
685 let new_venue_order_id = VenueOrderId::new(&resp.oid);
686 caches
687 .venue_to_client_id
688 .insert(new_venue_order_id, client_order_id);
689 if let Some(mut entry) = caches.orders_metadata.get_mut(&client_order_id) {
690 entry.venue_order_id = Some(new_venue_order_id);
691 entry.pending_trigger_price = trigger_price;
692 }
693 log::debug!("Order replaced: old={} new={}", request.oid, resp.oid);
694 }
695 Err(e) => match classify_ax_http_failure(&e) {
698 AxCommandFailure::LocalValidation(reason) => {
699 log::warn!(
700 "Modify command failed local validation for {client_order_id}: {reason}"
701 );
702 }
703 AxCommandFailure::Ambiguous(reason) => {
704 log::warn!(
705 "Ambiguous AX modify failure for {client_order_id}, awaiting reconciliation: {reason}"
706 );
707 }
708 },
709 }
710
711 Ok(())
712 });
713
714 Ok(())
715 }
716
717 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
718 self.cancel_order_internal(&cmd);
719 Ok(())
720 }
721
722 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
723 let http_client = self.http_client.clone();
724 let emitter = self.emitter.clone();
725 let clock = self.clock;
726 let instrument_id = cmd.instrument_id;
727 let account_id = self.core.account_id;
728 let trader_id = self.core.trader_id;
729
730 let open_orders: Vec<(ClientOrderId, Option<VenueOrderId>, StrategyId)> = {
732 let cache = self.core.cache();
733 cache
734 .orders_open(None, Some(&instrument_id), None, None, None)
735 .iter()
736 .map(|o| (o.client_order_id(), o.venue_order_id(), o.strategy_id()))
737 .collect()
738 };
739
740 let caches = self.ws_orders.caches().clone();
741
742 self.spawn_task("cancel_all_orders", async move {
743 match http_client.cancel_all_orders(instrument_id).await {
744 Ok(()) => {
745 log::debug!("Canceled all orders for {instrument_id}");
746
747 let ts_event = clock.get_time_ns();
751
752 for (client_order_id, venue_order_id, strategy_id) in &open_orders {
753 let event = OrderCanceled::new(
754 trader_id,
755 *strategy_id,
756 instrument_id,
757 *client_order_id,
758 UUID4::new(),
759 ts_event,
760 clock.get_time_ns(),
761 false,
762 *venue_order_id,
763 Some(account_id),
764 );
765 emitter.send_order_event(OrderEventAny::Canceled(event));
766
767 if let Some(voi) = venue_order_id {
768 caches.venue_to_client_id.remove(voi);
769 }
770 caches.orders_metadata.remove(client_order_id);
771 }
772 }
773 Err(e) => match classify_ax_http_failure(&e) {
776 AxCommandFailure::LocalValidation(reason) => {
777 log::warn!("Cancel-all for {instrument_id} failed local validation: {reason}");
778 }
779 AxCommandFailure::Ambiguous(reason) => {
780 log::warn!(
781 "Ambiguous AX cancel-all failure for {instrument_id}, awaiting reconciliation: {reason}"
782 );
783 }
784 },
785 }
786 Ok(())
787 });
788
789 Ok(())
790 }
791
792 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
793 for cancel in &cmd.cancels {
794 self.cancel_order_internal(cancel);
795 }
796 Ok(())
797 }
798
799 async fn generate_order_status_report(
800 &self,
801 cmd: &GenerateOrderStatusReport,
802 ) -> anyhow::Result<Option<OrderStatusReport>> {
803 let cid_map = self.ws_orders.cid_to_client_order_id().clone();
804 let cid_resolver = move |cid: u64| cid_map.get(&cid).map(|v| *v);
805
806 let mut reports = self
807 .http_client
808 .request_order_status_reports(self.core.account_id, Some(cid_resolver))
809 .await?;
810
811 if let Some(instrument_id) = cmd.instrument_id {
812 reports.retain(|report| report.instrument_id == instrument_id);
813 }
814
815 if let Some(client_order_id) = cmd.client_order_id {
816 reports.retain(|report| report.client_order_id == Some(client_order_id));
817 }
818
819 if let Some(venue_order_id) = cmd.venue_order_id {
820 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
821 }
822
823 Ok(reports.into_iter().next())
824 }
825
826 async fn generate_order_status_reports(
827 &self,
828 cmd: &GenerateOrderStatusReports,
829 ) -> anyhow::Result<Vec<OrderStatusReport>> {
830 let cid_map = self.ws_orders.cid_to_client_order_id().clone();
831 let cid_resolver = move |cid: u64| cid_map.get(&cid).map(|v| *v);
832
833 let mut reports = self
834 .http_client
835 .request_order_status_reports(self.core.account_id, Some(cid_resolver))
836 .await?;
837
838 if let Some(instrument_id) = cmd.instrument_id {
839 reports.retain(|report| report.instrument_id == instrument_id);
840 }
841
842 if cmd.open_only {
843 reports.retain(|r| r.order_status.is_open());
844 }
845
846 if let Some(start) = cmd.start {
847 reports.retain(|r| r.ts_last >= start);
848 }
849
850 if let Some(end) = cmd.end {
851 reports.retain(|r| r.ts_last <= end);
852 }
853
854 Ok(reports)
855 }
856
857 async fn generate_fill_reports(
858 &self,
859 cmd: GenerateFillReports,
860 ) -> anyhow::Result<Vec<FillReport>> {
861 let mut reports = self
862 .http_client
863 .request_fill_reports(self.core.account_id, cmd.start, cmd.end)
864 .await?;
865
866 if let Some(instrument_id) = cmd.instrument_id {
867 reports.retain(|report| report.instrument_id == instrument_id);
868 }
869
870 if let Some(venue_order_id) = cmd.venue_order_id {
871 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
872 }
873
874 Ok(reports)
875 }
876
877 async fn generate_position_status_reports(
878 &self,
879 cmd: &GeneratePositionStatusReports,
880 ) -> anyhow::Result<Vec<PositionStatusReport>> {
881 let mut reports = self
882 .http_client
883 .request_position_reports(self.core.account_id)
884 .await?;
885
886 if let Some(instrument_id) = cmd.instrument_id {
887 reports.retain(|report| report.instrument_id == instrument_id);
888 }
889
890 Ok(reports)
891 }
892
893 async fn generate_mass_status(
894 &self,
895 lookback_mins: Option<u64>,
896 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
897 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
898
899 let ts_now = self.clock.get_time_ns();
900
901 let start = lookback_mins.map(|mins| {
902 let lookback_ns = mins * 60 * 1_000_000_000;
903 UnixNanos::from(ts_now.as_u64().saturating_sub(lookback_ns))
904 });
905
906 let order_cmd = GenerateOrderStatusReports::new(
907 UUID4::new(),
908 ts_now,
909 false, None, start,
912 None, None, None, );
916
917 let fill_cmd = GenerateFillReports::new(
918 UUID4::new(),
919 ts_now,
920 None, None, start,
923 None, None, None, );
927
928 let position_cmd = GeneratePositionStatusReports::new(
929 UUID4::new(),
930 ts_now,
931 None, start,
933 None, None, None, );
937
938 let (order_reports, fill_reports, position_reports) = tokio::try_join!(
939 self.generate_order_status_reports(&order_cmd),
940 self.generate_fill_reports(fill_cmd),
941 self.generate_position_status_reports(&position_cmd),
942 )?;
943
944 log::info!("Received {} OrderStatusReports", order_reports.len());
945 log::info!("Received {} FillReports", fill_reports.len());
946 log::info!("Received {} PositionReports", position_reports.len());
947
948 let mut mass_status = ExecutionMassStatus::new(
949 self.core.client_id,
950 self.core.account_id,
951 *AX_VENUE,
952 ts_now,
953 None,
954 );
955
956 mass_status.add_order_reports(order_reports);
957 mass_status.add_fill_reports(fill_reports);
958 mass_status.add_position_reports(position_reports);
959
960 Ok(Some(mass_status))
961 }
962
963 fn register_external_order(
964 &self,
965 client_order_id: ClientOrderId,
966 venue_order_id: VenueOrderId,
967 instrument_id: InstrumentId,
968 strategy_id: StrategyId,
969 _ts_init: UnixNanos,
970 ) {
971 self.ws_orders.register_external_order(
972 client_order_id,
973 venue_order_id,
974 instrument_id,
975 strategy_id,
976 );
977 }
978}
979
980fn dispatch_ws_message(
982 message: AxOrdersWsMessage,
983 emitter: &ExecutionEventEmitter,
984 caches: &OrdersCaches,
985 account_id: AccountId,
986 instruments: &AtomicMap<Ustr, InstrumentAny>,
987 clock: &'static AtomicTime,
988) {
989 match message {
990 AxOrdersWsMessage::Event(event) => {
991 dispatch_order_event(*event, emitter, caches, account_id, instruments, clock);
992 }
993 AxOrdersWsMessage::PlaceOrderResponse(resp) => {
994 log::debug!(
995 "Place order response: rid={} oid={}",
996 resp.rid,
997 resp.res.oid
998 );
999 }
1000 AxOrdersWsMessage::CancelOrderResponse(resp) => {
1001 log::debug!(
1002 "Cancel order response: rid={} accepted={}",
1003 resp.rid,
1004 resp.res.cxl_rx
1005 );
1006 }
1007 AxOrdersWsMessage::OpenOrdersResponse(resp) => {
1008 log::debug!("Open orders response: {} orders", resp.res.len());
1009 }
1010 AxOrdersWsMessage::Error(err) => {
1011 log::warn!("WebSocket error: {}", err.message);
1012 }
1013 AxOrdersWsMessage::Reconnected => {
1014 log::info!("WebSocket reconnected");
1015 }
1016 AxOrdersWsMessage::Authenticated => {
1017 log::debug!("WebSocket authenticated");
1018 }
1019 }
1020}
1021
1022fn dispatch_order_event(
1023 event: AxWsOrderEvent,
1024 emitter: &ExecutionEventEmitter,
1025 caches: &OrdersCaches,
1026 account_id: AccountId,
1027 instruments: &AtomicMap<Ustr, InstrumentAny>,
1028 clock: &'static AtomicTime,
1029) {
1030 match event {
1031 AxWsOrderEvent::Heartbeat => {}
1032 AxWsOrderEvent::Acknowledged(msg) => {
1033 if let Some(event) =
1034 create_order_accepted(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1035 {
1036 emitter.send_order_event(OrderEventAny::Accepted(event));
1037 } else if let Some(report) = create_order_status_report(
1038 &msg.o,
1039 OrderStatus::Accepted,
1040 msg.ts,
1041 msg.tn,
1042 caches,
1043 account_id,
1044 instruments,
1045 clock,
1046 ) {
1047 emitter.send_order_status_report(report);
1048 }
1049 }
1050 AxWsOrderEvent::PartiallyFilled(msg) => {
1051 dispatch_fill_event(
1052 &msg.o,
1053 &msg.xs,
1054 msg.ts,
1055 msg.tn,
1056 emitter,
1057 caches,
1058 account_id,
1059 instruments,
1060 clock,
1061 );
1062 }
1063 AxWsOrderEvent::Filled(msg) => {
1064 dispatch_fill_event(
1065 &msg.o,
1066 &msg.xs,
1067 msg.ts,
1068 msg.tn,
1069 emitter,
1070 caches,
1071 account_id,
1072 instruments,
1073 clock,
1074 );
1075 cleanup_terminal_order_tracking(&msg.o, caches);
1076 }
1077 AxWsOrderEvent::Canceled(msg) => {
1078 if let Some(event) =
1079 create_order_canceled(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1080 {
1081 emitter.send_order_event(OrderEventAny::Canceled(event));
1082 } else if let Some(report) = create_order_status_report(
1083 &msg.o,
1084 OrderStatus::Canceled,
1085 msg.ts,
1086 msg.tn,
1087 caches,
1088 account_id,
1089 instruments,
1090 clock,
1091 ) {
1092 emitter.send_order_status_report(report);
1093 }
1094 cleanup_terminal_order_tracking(&msg.o, caches);
1095 }
1096 AxWsOrderEvent::Rejected(msg) => {
1097 let known_reason = msg.r.filter(|r| !matches!(r, AxOrderRejectReason::Unknown));
1098 let reason = known_reason
1099 .as_ref()
1100 .map(AsRef::as_ref)
1101 .or(msg.txt.as_deref())
1102 .unwrap_or("UNKNOWN");
1103
1104 if let Some(event) =
1105 create_order_rejected(&msg.o, reason, msg.ts, msg.tn, caches, account_id, clock)
1106 {
1107 emitter.send_order_event(OrderEventAny::Rejected(event));
1108 }
1109 cleanup_terminal_order_tracking(&msg.o, caches);
1110 }
1111 AxWsOrderEvent::Expired(msg) => {
1112 let as_canceled = matches!(msg.o.tif, AxTimeInForce::Ioc | AxTimeInForce::Fok);
1114 let event = if as_canceled {
1115 create_order_canceled(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1116 .map(OrderEventAny::Canceled)
1117 } else {
1118 create_order_expired(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1119 .map(OrderEventAny::Expired)
1120 };
1121
1122 if let Some(event) = event {
1123 emitter.send_order_event(event);
1124 } else if let Some(report) = create_order_status_report(
1125 &msg.o,
1126 if as_canceled {
1127 OrderStatus::Canceled
1128 } else {
1129 OrderStatus::Expired
1130 },
1131 msg.ts,
1132 msg.tn,
1133 caches,
1134 account_id,
1135 instruments,
1136 clock,
1137 ) {
1138 emitter.send_order_status_report(report);
1139 }
1140 cleanup_terminal_order_tracking(&msg.o, caches);
1141 }
1142 AxWsOrderEvent::Replaced(msg) => {
1143 if let Some(event) =
1144 create_order_updated(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1145 {
1146 emitter.send_order_event(OrderEventAny::Updated(event));
1147 } else if let Some(report) = create_order_status_report(
1148 &msg.o,
1149 OrderStatus::Accepted,
1150 msg.ts,
1151 msg.tn,
1152 caches,
1153 account_id,
1154 instruments,
1155 clock,
1156 ) {
1157 emitter.send_order_status_report(report);
1158 }
1159 }
1160 AxWsOrderEvent::DoneForDay(msg) => {
1161 if let Some(event) =
1162 create_order_expired(&msg.o, msg.ts, msg.tn, caches, account_id, clock)
1163 {
1164 emitter.send_order_event(OrderEventAny::Expired(event));
1165 } else if let Some(report) = create_order_status_report(
1166 &msg.o,
1167 OrderStatus::Expired,
1168 msg.ts,
1169 msg.tn,
1170 caches,
1171 account_id,
1172 instruments,
1173 clock,
1174 ) {
1175 emitter.send_order_status_report(report);
1176 }
1177 cleanup_terminal_order_tracking(&msg.o, caches);
1178 }
1179 AxWsOrderEvent::CancelRejected(msg) => {
1180 let venue_order_id = VenueOrderId::new(&msg.oid);
1181 if let Some(client_order_id) = caches.venue_to_client_id.get(&venue_order_id)
1182 && let Some(metadata) = caches.orders_metadata.get(&client_order_id)
1183 {
1184 let event = OrderCancelRejected::new(
1185 metadata.trader_id,
1186 metadata.strategy_id,
1187 metadata.instrument_id,
1188 metadata.client_order_id,
1189 Ustr::from(msg.r.as_ref()),
1190 UUID4::new(),
1191 clock.get_time_ns(),
1192 metadata.ts_init,
1193 false,
1194 Some(venue_order_id),
1195 Some(account_id),
1196 );
1197 emitter.send_order_event(OrderEventAny::CancelRejected(event));
1198 } else {
1199 log::warn!(
1200 "Could not find metadata for cancel rejected order {}",
1201 msg.oid
1202 );
1203 }
1204 }
1205 }
1206}
1207
1208#[expect(clippy::too_many_arguments)]
1209fn dispatch_fill_event(
1210 order: &AxWsOrder,
1211 execution: &AxWsTradeExecution,
1212 ts: i64,
1213 tn: i64,
1214 emitter: &ExecutionEventEmitter,
1215 caches: &OrdersCaches,
1216 account_id: AccountId,
1217 instruments: &AtomicMap<Ustr, InstrumentAny>,
1218 clock: &'static AtomicTime,
1219) {
1220 if let Some(event) = create_order_filled(order, execution, ts, tn, caches, account_id, clock) {
1221 emitter.send_order_event(OrderEventAny::Filled(event));
1222 } else if let Some(report) = create_fill_report(
1223 order,
1224 execution,
1225 ts,
1226 tn,
1227 caches,
1228 account_id,
1229 instruments,
1230 clock,
1231 ) {
1232 emitter.send_fill_report(report);
1233 }
1234}
1235
1236pub(crate) fn lookup_order_metadata<'a>(
1237 order: &AxWsOrder,
1238 caches: &'a OrdersCaches,
1239) -> Option<dashmap::mapref::one::Ref<'a, ClientOrderId, OrderMetadata>> {
1240 let venue_order_id = VenueOrderId::new(&order.oid);
1241
1242 if let Some(client_order_id) = caches.venue_to_client_id.get(&venue_order_id)
1243 && let Some(metadata) = caches.orders_metadata.get(&*client_order_id)
1244 {
1245 return Some(metadata);
1246 }
1247
1248 if let Some(cid) = order.cid
1249 && let Some(client_order_id) = caches.cid_to_client_order_id.get(&cid)
1250 && let Some(metadata) = caches.orders_metadata.get(&*client_order_id)
1251 {
1252 return Some(metadata);
1253 }
1254
1255 None
1256}
1257
1258pub(crate) fn create_order_accepted(
1259 order: &AxWsOrder,
1260 event_ts: i64,
1261 event_tn: i64,
1262 caches: &OrdersCaches,
1263 account_id: AccountId,
1264 clock: &'static AtomicTime,
1265) -> Option<OrderAccepted> {
1266 let venue_order_id = VenueOrderId::new(&order.oid);
1267 let metadata = lookup_order_metadata(order, caches)?;
1268
1269 let client_order_id = metadata.client_order_id;
1270 let trader_id = metadata.trader_id;
1271 let strategy_id = metadata.strategy_id;
1272 let instrument_id = metadata.instrument_id;
1273 drop(metadata);
1274
1275 caches
1276 .venue_to_client_id
1277 .insert(venue_order_id, client_order_id);
1278
1279 if let Some(mut entry) = caches.orders_metadata.get_mut(&client_order_id) {
1280 entry.venue_order_id = Some(venue_order_id);
1281 }
1282
1283 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1284 .map_err(|e| log::error!("{e}"))
1285 .ok()?;
1286
1287 Some(OrderAccepted::new(
1288 trader_id,
1289 strategy_id,
1290 instrument_id,
1291 client_order_id,
1292 venue_order_id,
1293 account_id,
1294 UUID4::new(),
1295 ts_event,
1296 clock.get_time_ns(),
1297 false,
1298 ))
1299}
1300
1301pub(crate) fn create_order_updated(
1302 order: &AxWsOrder,
1303 event_ts: i64,
1304 event_tn: i64,
1305 caches: &OrdersCaches,
1306 account_id: AccountId,
1307 clock: &'static AtomicTime,
1308) -> Option<OrderUpdated> {
1309 let metadata = lookup_order_metadata(order, caches)?;
1310
1311 let client_order_id = metadata.client_order_id;
1312 let trader_id = metadata.trader_id;
1313 let strategy_id = metadata.strategy_id;
1314 let instrument_id = metadata.instrument_id;
1315 let price_precision = metadata.price_precision;
1316 let size_precision = metadata.size_precision;
1317 let pending_trigger_price = metadata.pending_trigger_price;
1318 let venue_order_id = metadata
1321 .venue_order_id
1322 .unwrap_or_else(|| VenueOrderId::new(&order.oid));
1323 drop(metadata);
1324
1325 caches
1326 .venue_to_client_id
1327 .insert(venue_order_id, client_order_id);
1328
1329 if let Some(mut entry) = caches.orders_metadata.get_mut(&client_order_id) {
1331 entry.pending_trigger_price = None;
1332 }
1333
1334 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1335 .map_err(|e| log::error!("{e}"))
1336 .ok()?;
1337
1338 let quantity = Quantity::new(order.q as f64, size_precision);
1339 let price = Price::from_decimal_dp(order.p, price_precision).ok();
1340
1341 Some(OrderUpdated::new(
1342 trader_id,
1343 strategy_id,
1344 instrument_id,
1345 client_order_id,
1346 quantity,
1347 UUID4::new(),
1348 ts_event,
1349 clock.get_time_ns(),
1350 false,
1351 Some(venue_order_id),
1352 Some(account_id),
1353 price,
1354 pending_trigger_price,
1355 None, false,
1357 ))
1358}
1359
1360pub(crate) fn create_order_filled(
1361 order: &AxWsOrder,
1362 execution: &AxWsTradeExecution,
1363 event_ts: i64,
1364 event_tn: i64,
1365 caches: &OrdersCaches,
1366 account_id: AccountId,
1367 clock: &'static AtomicTime,
1368) -> Option<OrderFilled> {
1369 let venue_order_id = VenueOrderId::new(&order.oid);
1370 let metadata = lookup_order_metadata(order, caches)?;
1371
1372 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1373 .map_err(|e| log::error!("{e}"))
1374 .ok()?;
1375
1376 let last_qty = Quantity::new(execution.q as f64, metadata.size_precision);
1377 let last_px = Price::from_decimal_dp(execution.p, metadata.price_precision).ok()?;
1378
1379 let order_side: OrderSide = order.d.into();
1380
1381 let liquidity_side = if execution.agg {
1382 LiquiditySide::Taker
1383 } else {
1384 LiquiditySide::Maker
1385 };
1386
1387 Some(OrderFilled::new(
1388 metadata.trader_id,
1389 metadata.strategy_id,
1390 metadata.instrument_id,
1391 metadata.client_order_id,
1392 venue_order_id,
1393 account_id,
1394 TradeId::new(&execution.tid),
1395 order_side,
1396 OrderType::Limit,
1397 last_qty,
1398 last_px,
1399 metadata.quote_currency,
1400 liquidity_side,
1401 UUID4::new(),
1402 ts_event,
1403 clock.get_time_ns(),
1404 false,
1405 None,
1406 None,
1407 ))
1408}
1409
1410pub(crate) fn create_order_canceled(
1411 order: &AxWsOrder,
1412 event_ts: i64,
1413 event_tn: i64,
1414 caches: &OrdersCaches,
1415 account_id: AccountId,
1416 clock: &'static AtomicTime,
1417) -> Option<OrderCanceled> {
1418 let venue_order_id = VenueOrderId::new(&order.oid);
1419 let metadata = lookup_order_metadata(order, caches)?;
1420
1421 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1422 .map_err(|e| log::error!("{e}"))
1423 .ok()?;
1424
1425 Some(OrderCanceled::new(
1426 metadata.trader_id,
1427 metadata.strategy_id,
1428 metadata.instrument_id,
1429 metadata.client_order_id,
1430 UUID4::new(),
1431 ts_event,
1432 clock.get_time_ns(),
1433 false,
1434 Some(venue_order_id),
1435 Some(account_id),
1436 ))
1437}
1438
1439pub(crate) fn create_order_expired(
1440 order: &AxWsOrder,
1441 event_ts: i64,
1442 event_tn: i64,
1443 caches: &OrdersCaches,
1444 account_id: AccountId,
1445 clock: &'static AtomicTime,
1446) -> Option<OrderExpired> {
1447 let venue_order_id = VenueOrderId::new(&order.oid);
1448 let metadata = lookup_order_metadata(order, caches)?;
1449
1450 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1451 .map_err(|e| log::error!("{e}"))
1452 .ok()?;
1453
1454 Some(OrderExpired::new(
1455 metadata.trader_id,
1456 metadata.strategy_id,
1457 metadata.instrument_id,
1458 metadata.client_order_id,
1459 UUID4::new(),
1460 ts_event,
1461 clock.get_time_ns(),
1462 false,
1463 Some(venue_order_id),
1464 Some(account_id),
1465 ))
1466}
1467
1468pub(crate) fn create_order_rejected(
1469 order: &AxWsOrder,
1470 reason: &str,
1471 event_ts: i64,
1472 event_tn: i64,
1473 caches: &OrdersCaches,
1474 account_id: AccountId,
1475 clock: &'static AtomicTime,
1476) -> Option<OrderRejected> {
1477 let metadata = lookup_order_metadata(order, caches)?;
1478
1479 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1480 .map_err(|e| log::error!("{e}"))
1481 .ok()?;
1482 let due_post_only = reason.contains(AX_POST_ONLY_REJECT);
1483
1484 Some(OrderRejected::new(
1485 metadata.trader_id,
1486 metadata.strategy_id,
1487 metadata.instrument_id,
1488 metadata.client_order_id,
1489 account_id,
1490 Ustr::from(reason),
1491 UUID4::new(),
1492 ts_event,
1493 clock.get_time_ns(),
1494 false,
1495 due_post_only,
1496 ))
1497}
1498
1499pub(crate) fn cleanup_terminal_order_tracking(order: &AxWsOrder, caches: &OrdersCaches) {
1500 let venue_order_id = VenueOrderId::new(&order.oid);
1501 let client_order_id = caches
1502 .venue_to_client_id
1503 .remove(&venue_order_id)
1504 .map(|(_, v)| v)
1505 .or_else(|| {
1506 order
1507 .cid
1508 .and_then(|cid| caches.cid_to_client_order_id.remove(&cid).map(|(_, v)| v))
1509 });
1510
1511 if let Some(client_order_id) = client_order_id {
1512 caches.orders_metadata.remove(&client_order_id);
1513 }
1514
1515 if let Some(cid) = order.cid {
1516 caches.cid_to_client_order_id.remove(&cid);
1517 }
1518}
1519
1520#[expect(clippy::too_many_arguments)]
1521fn create_order_status_report(
1522 order: &AxWsOrder,
1523 order_status: OrderStatus,
1524 event_ts: i64,
1525 event_tn: i64,
1526 caches: &OrdersCaches,
1527 account_id: AccountId,
1528 instruments: &AtomicMap<Ustr, InstrumentAny>,
1529 clock: &'static AtomicTime,
1530) -> Option<OrderStatusReport> {
1531 let instruments_snap = instruments.load();
1532 let instrument = instruments_snap.get(&order.s)?;
1533 let venue_order_id = VenueOrderId::new(&order.oid);
1534 let instrument_id = instrument.id();
1535 let order_side = order.d.into();
1536 let time_in_force = order.tif.into();
1537
1538 let quantity = Quantity::new(order.q as f64, instrument.size_precision());
1539 let filled_qty = Quantity::new(order.xq as f64, instrument.size_precision());
1540
1541 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1542 .map_err(|e| log::error!("{e}"))
1543 .ok()?;
1544 let ts_init = clock.get_time_ns();
1545
1546 let client_order_id = order.cid.map(|cid| {
1547 caches
1548 .cid_to_client_order_id
1549 .get(&cid)
1550 .map_or_else(|| cid_to_client_order_id(cid), |v| *v)
1551 });
1552
1553 let mut report = OrderStatusReport::new(
1554 account_id,
1555 instrument_id,
1556 client_order_id,
1557 venue_order_id,
1558 order_side,
1559 OrderType::Limit,
1560 time_in_force,
1561 order_status,
1562 quantity,
1563 filled_qty,
1564 ts_event,
1565 ts_event,
1566 ts_init,
1567 Some(UUID4::new()),
1568 );
1569
1570 if let Ok(price) = Price::from_decimal_dp(order.p, instrument.price_precision()) {
1571 report = report.with_price(price);
1572 }
1573
1574 Some(report)
1575}
1576
1577#[expect(clippy::too_many_arguments)]
1578fn create_fill_report(
1579 order: &AxWsOrder,
1580 execution: &AxWsTradeExecution,
1581 event_ts: i64,
1582 event_tn: i64,
1583 caches: &OrdersCaches,
1584 account_id: AccountId,
1585 instruments: &AtomicMap<Ustr, InstrumentAny>,
1586 clock: &'static AtomicTime,
1587) -> Option<FillReport> {
1588 let instruments_snap = instruments.load();
1589 let instrument = instruments_snap.get(&order.s)?;
1590 let venue_order_id = VenueOrderId::new(&order.oid);
1591 let instrument_id = instrument.id();
1592 let order_side = order.d.into();
1593
1594 let last_qty = Quantity::new(execution.q as f64, instrument.size_precision());
1595 let last_px = Price::from_decimal_dp(execution.p, instrument.price_precision()).ok()?;
1596
1597 let liquidity_side = if execution.agg {
1598 LiquiditySide::Taker
1599 } else {
1600 LiquiditySide::Maker
1601 };
1602
1603 let ts_event = ax_timestamp_stn_to_unix_nanos(event_ts, event_tn)
1604 .map_err(|e| log::error!("{e}"))
1605 .ok()?;
1606 let ts_init = clock.get_time_ns();
1607
1608 let client_order_id = order.cid.map(|cid| {
1609 caches
1610 .cid_to_client_order_id
1611 .get(&cid)
1612 .map_or_else(|| cid_to_client_order_id(cid), |v| *v)
1613 });
1614
1615 let commission = Money::zero(instrument.quote_currency());
1619
1620 Some(FillReport::new(
1621 account_id,
1622 instrument_id,
1623 venue_order_id,
1624 TradeId::new(&execution.tid),
1625 order_side,
1626 last_qty,
1627 last_px,
1628 commission,
1629 liquidity_side,
1630 client_order_id,
1631 None,
1632 ts_event,
1633 ts_init,
1634 Some(UUID4::new()),
1635 ))
1636}
1637
1638fn validate_order_for_ax_submit(order: &OrderAny) -> anyhow::Result<()> {
1639 if !matches!(order.order_type(), OrderType::Market | OrderType::Limit) {
1641 anyhow::bail!(
1642 "Unsupported order type: {:?}, AX supports MARKET and LIMIT",
1643 order.order_type(),
1644 );
1645 }
1646
1647 if !matches!(
1649 order.time_in_force(),
1650 TimeInForce::Gtc | TimeInForce::Ioc | TimeInForce::Day
1651 ) {
1652 anyhow::bail!(
1653 "Unsupported time in force: {:?}, AX supports GTC, IOC and DAY",
1654 order.time_in_force(),
1655 );
1656 }
1657
1658 AxOrderSide::try_from(order.order_side())
1659 .map_err(|e| anyhow::anyhow!("Invalid order side: {e}"))?;
1660 quantity_to_contracts(order.quantity())?;
1661
1662 Ok(())
1663}
1664
1665#[derive(Debug)]
1666enum AxCommandFailure {
1667 LocalValidation(String),
1668 Ambiguous(String),
1669}
1670
1671fn classify_ax_http_failure(error: &AxHttpError) -> AxCommandFailure {
1675 let message = error.to_string();
1676 match error {
1677 AxHttpError::MissingCredentials
1678 | AxHttpError::MissingSessionToken
1679 | AxHttpError::ValidationError(_)
1680 | AxHttpError::BuildError(_) => AxCommandFailure::LocalValidation(message),
1681 AxHttpError::ApiError { .. }
1682 | AxHttpError::JsonError(_)
1683 | AxHttpError::Canceled(_)
1684 | AxHttpError::NetworkError(_)
1685 | AxHttpError::UnexpectedStatus { .. } => AxCommandFailure::Ambiguous(message),
1686 }
1687}
1688
1689fn classify_ax_ws_failure(error: &AxOrdersWsClientError) -> AxCommandFailure {
1690 match error {
1691 AxOrdersWsClientError::ClientError(message) => {
1692 AxCommandFailure::LocalValidation(message.clone())
1693 }
1694 AxOrdersWsClientError::Transport(_)
1695 | AxOrdersWsClientError::ChannelError(_)
1696 | AxOrdersWsClientError::AuthenticationError(_) => {
1697 AxCommandFailure::Ambiguous(error.to_string())
1698 }
1699 }
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704 use std::sync::Arc;
1705
1706 use dashmap::DashMap;
1707 use nautilus_common::messages::ExecutionEvent;
1708 use nautilus_core::time::get_atomic_clock_realtime;
1709 use nautilus_model::{
1710 identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
1711 orders::builder::OrderTestBuilder,
1712 types::{Currency, Price, Quantity},
1713 };
1714 use rstest::rstest;
1715 use rust_decimal::Decimal;
1716 use rust_decimal_macros::dec;
1717 use ustr::Ustr;
1718
1719 use super::*;
1720 use crate::{
1721 common::enums::{AxOrderSide, AxOrderStatus, AxTimeInForce},
1722 http::error::AxBuildError,
1723 websocket::{
1724 messages::{AxWsOrderExpired, AxWsTradeExecution, OrderMetadata},
1725 orders::OrdersCaches,
1726 },
1727 };
1728
1729 fn test_caches() -> OrdersCaches {
1730 OrdersCaches {
1731 orders_metadata: Arc::new(DashMap::new()),
1732 venue_to_client_id: Arc::new(DashMap::new()),
1733 cid_to_client_order_id: Arc::new(DashMap::new()),
1734 }
1735 }
1736
1737 fn test_ws_order(oid: &str, price: Decimal, qty: u64) -> AxWsOrder {
1738 AxWsOrder {
1739 oid: oid.to_string(),
1740 u: "user".to_string(),
1741 s: Ustr::from("BTC-PERP"),
1742 p: price,
1743 q: qty,
1744 xq: 0,
1745 rq: qty,
1746 o: AxOrderStatus::Accepted,
1747 d: AxOrderSide::Buy,
1748 tif: AxTimeInForce::Gtc,
1749 ts: 1609459200,
1750 tn: 0,
1751 cid: None,
1752 tag: None,
1753 txt: None,
1754 }
1755 }
1756
1757 #[rstest]
1758 fn test_create_order_updated_uses_cached_venue_order_id() {
1759 let caches = test_caches();
1760 let clock = get_atomic_clock_realtime();
1761 let account_id = AccountId::from("AX-001");
1762 let client_order_id = ClientOrderId::from("O-001");
1763 let new_venue_id = VenueOrderId::new("NEW-OID");
1764 let trigger = Price::from("49000.00");
1765
1766 let metadata = OrderMetadata {
1767 trader_id: TraderId::from("TRADER-001"),
1768 strategy_id: StrategyId::from("S-001"),
1769 instrument_id: InstrumentId::from("BTC-PERP.AX"),
1770 client_order_id,
1771 venue_order_id: Some(new_venue_id),
1772 ts_init: 0.into(),
1773 size_precision: 0,
1774 price_precision: 2,
1775 quote_currency: Currency::USD(),
1776 pending_trigger_price: Some(trigger),
1777 };
1778 caches.orders_metadata.insert(client_order_id, metadata);
1779 caches
1780 .venue_to_client_id
1781 .insert(new_venue_id, client_order_id);
1782
1783 let ws_order = test_ws_order("OLD-OID", dec!(50500.00), 100);
1785
1786 let cid_value = 42u64;
1789 caches
1790 .cid_to_client_order_id
1791 .insert(cid_value, client_order_id);
1792 let mut ws_order_with_cid = ws_order;
1793 ws_order_with_cid.cid = Some(cid_value);
1794
1795 let event = create_order_updated(
1796 &ws_order_with_cid,
1797 1609459200,
1798 0,
1799 &caches,
1800 account_id,
1801 clock,
1802 )
1803 .expect("should produce OrderUpdated");
1804
1805 assert_eq!(event.venue_order_id, Some(new_venue_id));
1807 assert_eq!(event.trigger_price, Some(trigger));
1808 assert_eq!(event.quantity, Quantity::new(100.0, 0));
1809 assert_eq!(event.price, Some(Price::from("50500.00")));
1810
1811 let meta = caches.orders_metadata.get(&client_order_id).unwrap();
1813 assert!(meta.pending_trigger_price.is_none());
1814 }
1815
1816 #[rstest]
1817 fn test_create_order_updated_falls_back_to_ws_oid() {
1818 let caches = test_caches();
1819 let clock = get_atomic_clock_realtime();
1820 let account_id = AccountId::from("AX-001");
1821 let client_order_id = ClientOrderId::from("O-002");
1822 let ws_oid = VenueOrderId::new("WS-OID");
1823
1824 let metadata = OrderMetadata {
1825 trader_id: TraderId::from("TRADER-001"),
1826 strategy_id: StrategyId::from("S-001"),
1827 instrument_id: InstrumentId::from("BTC-PERP.AX"),
1828 client_order_id,
1829 venue_order_id: None,
1830 ts_init: 0.into(),
1831 size_precision: 0,
1832 price_precision: 2,
1833 quote_currency: Currency::USD(),
1834 pending_trigger_price: None,
1835 };
1836 caches.orders_metadata.insert(client_order_id, metadata);
1837 caches.venue_to_client_id.insert(ws_oid, client_order_id);
1838
1839 let ws_order = test_ws_order("WS-OID", dec!(50500.00), 200);
1840
1841 let event = create_order_updated(&ws_order, 1609459200, 0, &caches, account_id, clock)
1842 .expect("should produce OrderUpdated");
1843
1844 assert_eq!(event.venue_order_id, Some(ws_oid));
1845 assert!(event.trigger_price.is_none());
1846 }
1847
1848 fn test_metadata(client_order_id: ClientOrderId, instrument_id: InstrumentId) -> OrderMetadata {
1849 OrderMetadata {
1850 trader_id: TraderId::from("TRADER-001"),
1851 strategy_id: StrategyId::from("S-001"),
1852 instrument_id,
1853 client_order_id,
1854 venue_order_id: None,
1855 ts_init: 0.into(),
1856 size_precision: 0,
1857 price_precision: 2,
1858 quote_currency: Currency::USD(),
1859 pending_trigger_price: None,
1860 }
1861 }
1862
1863 fn test_execution(tid: &str, price: Decimal, qty: u64, agg: bool) -> AxWsTradeExecution {
1864 AxWsTradeExecution {
1865 tid: tid.to_string(),
1866 s: Ustr::from("BTC-PERP"),
1867 q: qty,
1868 p: price,
1869 d: AxOrderSide::Buy,
1870 agg,
1871 }
1872 }
1873
1874 #[rstest]
1875 fn test_create_order_accepted_populates_cache_and_event() {
1876 let caches = test_caches();
1877 let clock = get_atomic_clock_realtime();
1878 let account_id = AccountId::from("AX-001");
1879 let client_order_id = ClientOrderId::from("O-ACK");
1880 let instrument_id = InstrumentId::from("BTC-PERP.AX");
1881 let venue_order_id = VenueOrderId::new("OID-ACK");
1882
1883 caches.orders_metadata.insert(
1884 client_order_id,
1885 test_metadata(client_order_id, instrument_id),
1886 );
1887 let cid_value = 7u64;
1888 caches
1889 .cid_to_client_order_id
1890 .insert(cid_value, client_order_id);
1891
1892 let mut ws_order = test_ws_order(venue_order_id.as_str(), dec!(50500.00), 100);
1893 ws_order.cid = Some(cid_value);
1894
1895 let event = create_order_accepted(&ws_order, 1609459200, 500, &caches, account_id, clock)
1896 .expect("should produce OrderAccepted");
1897
1898 assert_eq!(event.venue_order_id, venue_order_id);
1899 assert_eq!(event.client_order_id, client_order_id);
1900 assert_eq!(event.account_id, account_id);
1901 assert_eq!(event.instrument_id, instrument_id);
1902 assert_eq!(event.trader_id, TraderId::from("TRADER-001"));
1903 assert_eq!(event.strategy_id, StrategyId::from("S-001"));
1904 assert_eq!(
1905 event.ts_event,
1906 UnixNanos::from(1_609_459_200_000_000_500u64)
1907 );
1908
1909 assert_eq!(
1911 *caches.venue_to_client_id.get(&venue_order_id).unwrap(),
1912 client_order_id,
1913 );
1914 let meta = caches.orders_metadata.get(&client_order_id).unwrap();
1915 assert_eq!(meta.venue_order_id, Some(venue_order_id));
1916 }
1917
1918 #[rstest]
1919 fn test_create_order_accepted_returns_none_without_metadata() {
1920 let caches = test_caches();
1921 let clock = get_atomic_clock_realtime();
1922 let account_id = AccountId::from("AX-001");
1923 let ws_order = test_ws_order("OID-UNKNOWN", dec!(100.00), 10);
1924
1925 let result = create_order_accepted(&ws_order, 1609459200, 0, &caches, account_id, clock);
1926 assert!(result.is_none());
1927 assert!(caches.venue_to_client_id.is_empty());
1928 }
1929
1930 #[rstest]
1931 fn test_lookup_order_metadata_cid_fallback() {
1932 let caches = test_caches();
1933 let client_order_id = ClientOrderId::from("O-CID");
1934 let instrument_id = InstrumentId::from("BTC-PERP.AX");
1935 caches.orders_metadata.insert(
1936 client_order_id,
1937 test_metadata(client_order_id, instrument_id),
1938 );
1939 caches.cid_to_client_order_id.insert(99, client_order_id);
1940
1941 let mut ws_order = test_ws_order("UNKNOWN-OID", dec!(0), 0);
1942 ws_order.cid = Some(99);
1943
1944 let found = lookup_order_metadata(&ws_order, &caches).expect("cid fallback should find");
1945 assert_eq!(found.client_order_id, client_order_id);
1946 }
1947
1948 #[rstest]
1949 fn test_lookup_order_metadata_returns_none_when_unknown() {
1950 let caches = test_caches();
1951 let ws_order = test_ws_order("UNKNOWN-OID", dec!(0), 0);
1952 assert!(lookup_order_metadata(&ws_order, &caches).is_none());
1953 }
1954
1955 #[rstest]
1956 #[case(true, LiquiditySide::Taker)]
1957 #[case(false, LiquiditySide::Maker)]
1958 fn test_create_order_filled_maps_liquidity_side(
1959 #[case] agg: bool,
1960 #[case] expected: LiquiditySide,
1961 ) {
1962 let caches = test_caches();
1963 let clock = get_atomic_clock_realtime();
1964 let account_id = AccountId::from("AX-001");
1965 let client_order_id = ClientOrderId::from("O-FILL");
1966 let instrument_id = InstrumentId::from("BTC-PERP.AX");
1967 let venue_order_id = VenueOrderId::new("OID-FILL");
1968
1969 caches.orders_metadata.insert(
1970 client_order_id,
1971 test_metadata(client_order_id, instrument_id),
1972 );
1973 caches
1974 .venue_to_client_id
1975 .insert(venue_order_id, client_order_id);
1976
1977 let order = test_ws_order(venue_order_id.as_str(), dec!(50500.00), 100);
1978 let execution = test_execution("TID-1", dec!(50500.00), 25, agg);
1979
1980 let event = create_order_filled(
1981 &order, &execution, 1609459200, 0, &caches, account_id, clock,
1982 )
1983 .expect("should produce OrderFilled");
1984
1985 assert_eq!(event.venue_order_id, venue_order_id);
1986 assert_eq!(event.client_order_id, client_order_id);
1987 assert_eq!(event.trade_id, TradeId::new("TID-1"));
1988 assert_eq!(event.last_qty, Quantity::new(25.0, 0));
1989 assert_eq!(event.last_px, Price::from("50500.00"));
1990 assert_eq!(event.liquidity_side, expected);
1991 }
1992
1993 #[rstest]
1994 fn test_create_order_canceled_populates_identifiers() {
1995 let caches = test_caches();
1996 let clock = get_atomic_clock_realtime();
1997 let account_id = AccountId::from("AX-001");
1998 let client_order_id = ClientOrderId::from("O-CXL");
1999 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2000 let venue_order_id = VenueOrderId::new("OID-CXL");
2001
2002 caches.orders_metadata.insert(
2003 client_order_id,
2004 test_metadata(client_order_id, instrument_id),
2005 );
2006 caches
2007 .venue_to_client_id
2008 .insert(venue_order_id, client_order_id);
2009
2010 let order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
2011 let event = create_order_canceled(&order, 1609459200, 0, &caches, account_id, clock)
2012 .expect("should produce OrderCanceled");
2013
2014 assert_eq!(event.venue_order_id, Some(venue_order_id));
2015 assert_eq!(event.client_order_id, client_order_id);
2016 assert_eq!(event.account_id, Some(account_id));
2017 assert_eq!(event.instrument_id, instrument_id);
2018 }
2019
2020 #[rstest]
2021 fn test_create_order_expired_populates_identifiers() {
2022 let caches = test_caches();
2023 let clock = get_atomic_clock_realtime();
2024 let account_id = AccountId::from("AX-001");
2025 let client_order_id = ClientOrderId::from("O-EXP");
2026 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2027 let venue_order_id = VenueOrderId::new("OID-EXP");
2028
2029 caches.orders_metadata.insert(
2030 client_order_id,
2031 test_metadata(client_order_id, instrument_id),
2032 );
2033 caches
2034 .venue_to_client_id
2035 .insert(venue_order_id, client_order_id);
2036
2037 let order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
2038 let event = create_order_expired(&order, 1609459200, 0, &caches, account_id, clock)
2039 .expect("should produce OrderExpired");
2040
2041 assert_eq!(event.venue_order_id, Some(venue_order_id));
2042 assert_eq!(event.client_order_id, client_order_id);
2043 }
2044
2045 #[rstest]
2046 #[case(AxTimeInForce::Ioc, true)]
2047 #[case(AxTimeInForce::Fok, true)]
2048 #[case(AxTimeInForce::Day, false)]
2049 #[case(AxTimeInForce::Gtc, false)]
2050 fn test_dispatch_expired_maps_ioc_fok_to_canceled(
2051 #[case] tif: AxTimeInForce,
2052 #[case] expect_canceled: bool,
2053 ) {
2054 let clock = get_atomic_clock_realtime();
2055 let account_id = AccountId::from("AX-001");
2056 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2057 let mut emitter = ExecutionEventEmitter::new(
2058 clock,
2059 TraderId::from("TESTER-001"),
2060 account_id,
2061 AccountType::Margin,
2062 None,
2063 );
2064 emitter.set_sender(tx);
2065
2066 let caches = test_caches();
2067 let client_order_id = ClientOrderId::from("O-EXP");
2068 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2069 caches.orders_metadata.insert(
2070 client_order_id,
2071 test_metadata(client_order_id, instrument_id),
2072 );
2073 caches
2074 .venue_to_client_id
2075 .insert(VenueOrderId::new("OID-EXP"), client_order_id);
2076
2077 let mut order = test_ws_order("OID-EXP", dec!(100.00), 10);
2078 order.tif = tif;
2079 let event = AxWsOrderEvent::Expired(AxWsOrderExpired {
2080 ts: 1609459200,
2081 tn: 0,
2082 eid: "E-EXP".to_string(),
2083 o: order,
2084 });
2085
2086 let instruments: AtomicMap<Ustr, InstrumentAny> = AtomicMap::new();
2087 dispatch_order_event(event, &emitter, &caches, account_id, &instruments, clock);
2088
2089 match rx.try_recv().expect("an order event should be emitted") {
2090 ExecutionEvent::Order(OrderEventAny::Canceled(_)) => assert!(expect_canceled),
2091 ExecutionEvent::Order(OrderEventAny::Expired(_)) => assert!(!expect_canceled),
2092 other => panic!("unexpected event: {other:?}"),
2093 }
2094 }
2095
2096 #[rstest]
2097 fn test_create_order_rejected_sets_due_post_only_when_reason_matches() {
2098 let caches = test_caches();
2099 let clock = get_atomic_clock_realtime();
2100 let account_id = AccountId::from("AX-001");
2101 let client_order_id = ClientOrderId::from("O-REJ");
2102 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2103
2104 caches.orders_metadata.insert(
2105 client_order_id,
2106 test_metadata(client_order_id, instrument_id),
2107 );
2108 caches
2109 .venue_to_client_id
2110 .insert(VenueOrderId::new("OID-REJ"), client_order_id);
2111
2112 let order = test_ws_order("OID-REJ", dec!(100.00), 10);
2113 let reason = "post-only order would cross the book";
2115 let event =
2116 create_order_rejected(&order, reason, 1609459200, 0, &caches, account_id, clock)
2117 .expect("should produce OrderRejected");
2118
2119 assert!(event.due_post_only, "post-only reason should set flag");
2120 assert_eq!(event.reason, Ustr::from(reason));
2121 }
2122
2123 #[rstest]
2124 fn test_create_order_rejected_clears_due_post_only_for_other_reasons() {
2125 let caches = test_caches();
2126 let clock = get_atomic_clock_realtime();
2127 let account_id = AccountId::from("AX-001");
2128 let client_order_id = ClientOrderId::from("O-REJ-2");
2129 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2130
2131 caches.orders_metadata.insert(
2132 client_order_id,
2133 test_metadata(client_order_id, instrument_id),
2134 );
2135 caches
2136 .venue_to_client_id
2137 .insert(VenueOrderId::new("OID-REJ-2"), client_order_id);
2138
2139 let order = test_ws_order("OID-REJ-2", dec!(100.00), 10);
2140 let event = create_order_rejected(
2141 &order,
2142 "INSUFFICIENT_MARGIN",
2143 1609459200,
2144 0,
2145 &caches,
2146 account_id,
2147 clock,
2148 )
2149 .expect("should produce OrderRejected");
2150
2151 assert!(!event.due_post_only);
2152 assert_eq!(event.reason, Ustr::from("INSUFFICIENT_MARGIN"));
2153 }
2154
2155 #[rstest]
2156 fn test_cleanup_terminal_order_tracking_removes_all_caches() {
2157 let caches = test_caches();
2158 let client_order_id = ClientOrderId::from("O-CLEAN");
2159 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2160 let venue_order_id = VenueOrderId::new("OID-CLEAN");
2161 let cid_value = 123u64;
2162
2163 caches.orders_metadata.insert(
2164 client_order_id,
2165 test_metadata(client_order_id, instrument_id),
2166 );
2167 caches
2168 .venue_to_client_id
2169 .insert(venue_order_id, client_order_id);
2170 caches
2171 .cid_to_client_order_id
2172 .insert(cid_value, client_order_id);
2173
2174 let mut order = test_ws_order(venue_order_id.as_str(), dec!(100.00), 10);
2175 order.cid = Some(cid_value);
2176
2177 cleanup_terminal_order_tracking(&order, &caches);
2178
2179 assert!(caches.orders_metadata.is_empty());
2180 assert!(caches.venue_to_client_id.is_empty());
2181 assert!(caches.cid_to_client_order_id.is_empty());
2182 }
2183
2184 #[rstest]
2185 fn test_cleanup_terminal_order_tracking_via_cid_when_venue_missing() {
2186 let caches = test_caches();
2187 let client_order_id = ClientOrderId::from("O-CLEAN-CID");
2188 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2189 let cid_value = 321u64;
2190
2191 caches.orders_metadata.insert(
2192 client_order_id,
2193 test_metadata(client_order_id, instrument_id),
2194 );
2195 caches
2196 .cid_to_client_order_id
2197 .insert(cid_value, client_order_id);
2198
2199 let mut order = test_ws_order("OID-UNKNOWN", dec!(100.00), 10);
2201 order.cid = Some(cid_value);
2202
2203 cleanup_terminal_order_tracking(&order, &caches);
2204
2205 assert!(caches.orders_metadata.is_empty());
2206 assert!(caches.cid_to_client_order_id.is_empty());
2207 }
2208
2209 #[rstest]
2210 fn test_cleanup_terminal_order_tracking_noop_when_unknown() {
2211 let caches = test_caches();
2212 let other = ClientOrderId::from("OTHER");
2213 let instrument_id = InstrumentId::from("BTC-PERP.AX");
2214 caches
2215 .orders_metadata
2216 .insert(other, test_metadata(other, instrument_id));
2217
2218 let order = test_ws_order("OID-NOT-TRACKED", dec!(100.00), 10);
2219 cleanup_terminal_order_tracking(&order, &caches);
2220
2221 assert_eq!(caches.orders_metadata.len(), 1);
2223 }
2224
2225 #[rstest]
2226 fn test_cancel_on_disconnect_url_no_existing_query() {
2227 let mut url = "wss://example.com/orders/ws".to_string();
2228 let separator = if url.contains('?') { "&" } else { "?" };
2229 url.push_str(&format!("{separator}cancel_on_disconnect=true"));
2230 assert_eq!(url, "wss://example.com/orders/ws?cancel_on_disconnect=true");
2231 }
2232
2233 #[rstest]
2234 fn test_cancel_on_disconnect_url_with_existing_query() {
2235 let mut url = "wss://example.com/orders/ws?token=abc".to_string();
2236 let separator = if url.contains('?') { "&" } else { "?" };
2237 url.push_str(&format!("{separator}cancel_on_disconnect=true"));
2238 assert_eq!(
2239 url,
2240 "wss://example.com/orders/ws?token=abc&cancel_on_disconnect=true"
2241 );
2242 }
2243
2244 fn limit_order_for_validation(quantity: Quantity) -> OrderAny {
2245 OrderTestBuilder::new(OrderType::Limit)
2246 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
2247 .side(OrderSide::Buy)
2248 .quantity(quantity)
2249 .price(Price::from("1.10"))
2250 .build()
2251 }
2252
2253 #[rstest]
2254 fn test_validate_order_for_ax_submit_accepts_supported_limit_order() {
2255 let order = limit_order_for_validation(Quantity::from("10"));
2256
2257 assert!(validate_order_for_ax_submit(&order).is_ok());
2258 }
2259
2260 #[rstest]
2261 fn test_validate_order_for_ax_submit_denies_unsupported_order_type() {
2262 let order = OrderTestBuilder::new(OrderType::StopMarket)
2263 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
2264 .side(OrderSide::Buy)
2265 .quantity(Quantity::from("10"))
2266 .trigger_price(Price::from("1.10"))
2267 .build();
2268
2269 let err = validate_order_for_ax_submit(&order).unwrap_err();
2270
2271 assert!(err.to_string().contains("Unsupported order type"));
2272 }
2273
2274 #[rstest]
2275 fn test_validate_order_for_ax_submit_denies_gtd_time_in_force() {
2276 let order = OrderTestBuilder::new(OrderType::Limit)
2277 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
2278 .side(OrderSide::Buy)
2279 .quantity(Quantity::from("10"))
2280 .price(Price::from("1.10"))
2281 .time_in_force(TimeInForce::Gtd)
2282 .expire_time(UnixNanos::from(2_000_000_000_000_000_000u64))
2283 .build();
2284
2285 let err = validate_order_for_ax_submit(&order).unwrap_err();
2286
2287 assert!(err.to_string().contains("Unsupported time in force"));
2288 }
2289
2290 #[rstest]
2291 fn test_validate_order_for_ax_submit_denies_stop_limit_order() {
2292 let order = OrderTestBuilder::new(OrderType::StopLimit)
2293 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
2294 .side(OrderSide::Buy)
2295 .quantity(Quantity::from("10"))
2296 .price(Price::from("1.11"))
2297 .trigger_price(Price::from("1.10"))
2298 .build();
2299
2300 let err = validate_order_for_ax_submit(&order).unwrap_err();
2301
2302 assert!(err.to_string().contains("Unsupported order type"));
2303 }
2304
2305 #[rstest]
2306 fn test_validate_order_for_ax_submit_denies_fok_time_in_force() {
2307 let order = OrderTestBuilder::new(OrderType::Limit)
2308 .instrument_id(InstrumentId::from("EURUSD-PERP.AX"))
2309 .side(OrderSide::Buy)
2310 .quantity(Quantity::from("10"))
2311 .price(Price::from("1.10"))
2312 .time_in_force(TimeInForce::Fok)
2313 .build();
2314
2315 let err = validate_order_for_ax_submit(&order).unwrap_err();
2316
2317 assert!(err.to_string().contains("Unsupported time in force"));
2318 }
2319
2320 #[rstest]
2321 fn test_validate_order_for_ax_submit_denies_fractional_quantity() {
2322 let order = limit_order_for_validation(Quantity::from("10.5"));
2323
2324 let err = validate_order_for_ax_submit(&order).unwrap_err();
2325
2326 assert!(err.to_string().contains("whole contract"));
2327 }
2328
2329 #[rstest]
2330 #[case(AxHttpError::MissingCredentials, true)]
2331 #[case(AxHttpError::MissingSessionToken, true)]
2332 #[case(AxHttpError::ValidationError("bad param".to_string()), true)]
2333 #[case(AxHttpError::BuildError(AxBuildError::MissingOrderId), true)]
2334 #[case(AxHttpError::ApiError { message: "invalid modification".to_string() }, false)]
2335 #[case(AxHttpError::JsonError("parse failure".to_string()), false)]
2336 #[case(AxHttpError::Canceled("shutdown".to_string()), false)]
2337 #[case(AxHttpError::NetworkError("timeout".to_string()), false)]
2338 #[case(AxHttpError::UnexpectedStatus { status: 400, body: "invalid".to_string() }, false)]
2339 #[case(AxHttpError::UnexpectedStatus { status: 503, body: String::new() }, false)]
2340 fn test_classify_ax_http_failure(#[case] error: AxHttpError, #[case] expect_local: bool) {
2341 let failure = classify_ax_http_failure(&error);
2342
2343 assert_eq!(
2344 matches!(failure, AxCommandFailure::LocalValidation(_)),
2345 expect_local,
2346 "failure was: {failure:?}"
2347 );
2348 }
2349
2350 #[rstest]
2351 #[case(AxOrdersWsClientError::ClientError("missing venue_order_id".to_string()), true)]
2352 #[case(AxOrdersWsClientError::ChannelError("handler closed".to_string()), false)]
2353 #[case(AxOrdersWsClientError::Transport("connection reset".to_string()), false)]
2354 #[case(AxOrdersWsClientError::AuthenticationError("token expired".to_string()), false)]
2355 fn test_classify_ax_ws_failure(
2356 #[case] error: AxOrdersWsClientError,
2357 #[case] expect_local: bool,
2358 ) {
2359 let failure = classify_ax_ws_failure(&error);
2360
2361 assert_eq!(
2362 matches!(failure, AxCommandFailure::LocalValidation(_)),
2363 expect_local,
2364 "failure was: {failure:?}"
2365 );
2366 }
2367}