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