Skip to main content

nautilus_architect_ax/
execution.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Live execution client implementation for the AX Exchange adapter.
17
18use 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/// Live execution client for the AX Exchange.
92#[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    /// Creates a new [`AxExecutionClient`].
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the client fails to initialize.
111    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            // AX emulates market orders with preview-priced IOC limits, so book moves
250            // between preview and submission can produce partial fills.
251            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                    // AX classifies no send failure as a venue rejection (see
343                    // `classify_ax_ws_failure`); both terminal-valid classes reject the same way.
344                    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        // `OrderCancelRejected` comes only from the WS CancelRejected event;
373        // local send failures leave the outcome to reconciliation.
374        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    /// Polls the cache until the account is registered or timeout is reached.
463    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        // Reset so requests work after a previous disconnect
542        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        // Read immutable order fields from cache before spawning
689        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                // No replace failure is an unambiguous rejection (see
935                // `classify_ax_http_failure`); leave the order pending.
936                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            // AX cancel-all has no side parameter, so select matching open
970            // orders and cancel their explicit IDs through the batch path.
971            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        // Snapshot open orders so we can emit cancel events after the HTTP request
1019        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                    // AX does not push WS cancel confirmations for HTTP-initiated
1036                    // cancels, so emit OrderCanceled events locally and clean up
1037                    // tracking state to prevent duplicates if WS events arrive
1038                    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                // A whole-request failure has no per-order venue results and
1068                // must not fan out per-order rejections.
1069                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        // Floor the declared window at the /fills span cap so it does not overstate coverage
1211        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, // open_only
1219            None,  // instrument_id
1220            start,
1221            None, // end
1222            None, // params
1223            None, // correlation_id
1224        );
1225
1226        let fill_cmd = GenerateFillReports::new(
1227            UUID4::new(),
1228            ts_now,
1229            None, // instrument_id
1230            None, // venue_order_id
1231            start,
1232            None, // end
1233            None, // params
1234            None, // correlation_id
1235        );
1236
1237        let position_cmd = GeneratePositionStatusReports::new(
1238            UUID4::new(),
1239            ts_now,
1240            None, // instrument_id
1241            start,
1242            None, // end
1243            None, // params
1244            None, // correlation_id
1245        );
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        // Declare the window so these fills are bounded history, not live fills
1266        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
1312/// Dispatches a WebSocket message using the event emitter.
1313fn 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            // AX reports an unfilled IOC/FOK as EXPIRED; Nautilus models those as canceled
1454            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    // Keep notional in Decimal so sub-cent rounding happens only after applying the rate
1651    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, // trigger_price
1781        None, // protection_price
1782        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    // AX accepts only GTC, IOC, and DAY; deny others locally to avoid an opaque venue error
2139    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
2190// The AX HTTP API documents only a bare 400 with no error schema, so no venue
2191// failure can be allowlisted as an unambiguous rejection; those arrive via the
2192// orders WS `Rejected` and `CancelRejected` events instead. AX therefore never
2193// classifies a send failure as `CommandFailure::VenueRejected`.
2194fn 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        // Side effects on caches
2523        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        // Use the literal venue reason text so this guards the AX_POST_ONLY_REJECT constant
2992        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        // Venue id missing from cache
3082        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        // Unrelated metadata still present
3104        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        // Asserting the exact variant also pins that AX never classifies a venue rejection
3571        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        // Asserting the exact variant also pins that AX never classifies a venue rejection
3593        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}