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