Skip to main content

nautilus_architect_ax/http/
client.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//! Provides the HTTP client integration for the Ax REST API.
17
18use std::{
19    collections::{HashMap, HashSet},
20    fmt::Debug,
21    num::NonZeroU32,
22    sync::{
23        Arc, LazyLock,
24        atomic::{AtomicBool, Ordering},
25    },
26};
27
28use anyhow::Context;
29use arc_swap::ArcSwapOption;
30use http::Method;
31use jiff::{Timestamp, civil::Date};
32use nautilus_core::{
33    AtomicMap, AtomicTime, UUID4, nanos::UnixNanos, string::secret::SecretString,
34    time::get_atomic_clock_realtime,
35};
36use nautilus_model::{
37    data::{Bar, BookOrder, FundingRateUpdate, TradeTick},
38    enums::{BookType, OrderSide, OrderType, TimeInForce},
39    events::AccountState,
40    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
41    instruments::{Instrument, any::InstrumentAny},
42    orderbook::OrderBook,
43    reports::{FillReport, OrderStatusReport, PositionStatusReport},
44    types::{Price, Quantity},
45};
46use nautilus_network::{
47    http::{HttpClient, HttpRedirectPolicy, create_standard_nautilus_headers},
48    ratelimiter::quota::Quota,
49    retry::{RetryConfig, RetryError, RetryManager},
50};
51use parking_lot::RwLock;
52use rust_decimal::Decimal;
53use serde::{Serialize, de::DeserializeOwned};
54use tokio_util::sync::CancellationToken;
55use ustr::Ustr;
56
57use super::{
58    error::AxHttpError,
59    models::{
60        AuthenticateApiKeyRequest, AxAuthenticateResponse, AxBalancesResponse, AxBookResponse,
61        AxCancelAllOrdersResponse, AxCancelOrderResponse, AxCandle, AxCandleResponse,
62        AxCandlesResponse, AxFillsResponse, AxFundingRatesResponse, AxFundingSlotsResponse,
63        AxInitialMarginRequirementResponse, AxInstrument, AxInstrumentsResponse,
64        AxOpenOrdersResponse, AxOrderStatusQueryResponse, AxOrdersResponse, AxPlaceOrderResponse,
65        AxPositionsResponse, AxPreviewAggressiveLimitOrderResponse, AxReplaceOrderResponse,
66        AxRiskSnapshotResponse, AxTicker, AxTickerResponse, AxTickersResponse, AxTradesResponse,
67        AxTransactionsResponse, AxWhoAmI, CancelAllOrdersRequest, CancelOrderRequest,
68        PlaceOrderRequest, PreviewAggressiveLimitOrderRequest, ReplaceOrderRequest,
69    },
70    parse::{
71        parse_account_state, parse_bar, parse_fill_report, parse_funding_rate, parse_instrument,
72        parse_order_detail_status_report, parse_order_status_report, parse_position_status_report,
73        parse_trade_tick,
74    },
75    query::{
76        GetBookParams, GetCandleParams, GetCandlesParams, GetFillsParams, GetFundingRatesParams,
77        GetFundingSlotsParams, GetInstrumentParams, GetOpenOrdersParams, GetOrderStatusParams,
78        GetOrdersParams, GetTickerParams, GetTickersParams, GetTradesParams, GetTransactionsParams,
79    },
80};
81use crate::common::{
82    consts::{AX_FILLS_MAX_LOOKBACK_DAYS, AX_HTTP_URL, AX_ORDERS_URL},
83    credential::Credential,
84    enums::{AxCandleWidth, AxInstrumentState},
85    parse::{ax_timestamp_stn_to_unix_nanos, cid_to_client_order_id, client_order_id_to_cid},
86};
87
88/// Default Ax REST API rate limit.
89///
90/// Conservative default of 10 requests per second.
91pub static AX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
92    Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
93});
94
95const AX_GLOBAL_RATE_KEY: &str = "architect:global";
96
97/// Raw HTTP client for low-level AX Exchange API operations.
98///
99/// This client handles request/response operations with the AX Exchange API,
100/// returning venue-specific response types. It does not parse to Nautilus domain types.
101pub struct AxRawHttpClient {
102    base_url: String,
103    orders_base_url: String,
104    client: HttpClient,
105    credential: Option<Credential>,
106    session_token: RwLock<Option<SecretString>>,
107    retry_manager: RetryManager<AxHttpError>,
108    cancellation_token: RwLock<CancellationToken>,
109}
110
111impl Default for AxRawHttpClient {
112    fn default() -> Self {
113        Self::new(None, None, 60, 3, 1000, 10_000, None)
114            .expect("Failed to create default AxRawHttpClient")
115    }
116}
117
118impl Debug for AxRawHttpClient {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        let has_session_token = self.session_token.read().is_some();
121        f.debug_struct(stringify!(AxRawHttpClient))
122            .field("base_url", &self.base_url)
123            .field("orders_base_url", &self.orders_base_url)
124            .field("has_credentials", &self.credential.is_some())
125            .field("has_session_token", &has_session_token)
126            .finish()
127    }
128}
129
130impl AxRawHttpClient {
131    /// Returns the base URL for this client.
132    #[must_use]
133    pub fn base_url(&self) -> &str {
134        &self.base_url
135    }
136
137    /// Returns a masked version of the API key for logging purposes.
138    #[must_use]
139    pub fn api_key_masked(&self) -> String {
140        self.credential
141            .as_ref()
142            .map_or_else(|| "None".to_string(), |c| c.masked_api_key())
143    }
144
145    /// Cancel all pending HTTP requests.
146    pub fn cancel_all_requests(&self) {
147        self.cancellation_token.read().cancel();
148    }
149
150    /// Replaces the cancelled token so new requests can proceed after reconnect.
151    pub fn reset_cancellation_token(&self) {
152        *self.cancellation_token.write() = CancellationToken::new();
153    }
154
155    /// Get a clone of the current cancellation token.
156    pub fn cancellation_token(&self) -> CancellationToken {
157        self.cancellation_token.read().clone()
158    }
159
160    /// Creates a new [`AxRawHttpClient`] using the default Ax HTTP URL.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the retry manager cannot be created.
165    pub fn new(
166        base_url: Option<String>,
167        orders_base_url: Option<String>,
168        timeout_secs: u64,
169        max_retries: u32,
170        retry_delay_ms: u64,
171        retry_delay_max_ms: u64,
172        proxy_url: Option<String>,
173    ) -> Result<Self, AxHttpError> {
174        let retry_config = RetryConfig {
175            max_retries,
176            initial_delay_ms: retry_delay_ms,
177            max_delay_ms: retry_delay_max_ms,
178            backoff_factor: 2.0,
179            jitter_ms: 1000,
180            operation_timeout_ms: Some(60_000),
181            immediate_first: false,
182            max_elapsed_ms: Some(180_000),
183        };
184
185        let retry_manager = RetryManager::new(retry_config);
186
187        Ok(Self {
188            base_url: base_url.unwrap_or_else(|| AX_HTTP_URL.to_string()),
189            orders_base_url: orders_base_url.unwrap_or_else(|| AX_ORDERS_URL.to_string()),
190            client: HttpClient::builder()
191                .redirect_policy(HttpRedirectPolicy::Reject)
192                .headers(Self::default_headers())
193                .keyed_quotas(Self::rate_limiter_quotas())
194                .default_quota(*AX_REST_QUOTA)
195                .timeout_secs(timeout_secs)
196                .maybe_proxy_url(proxy_url)
197                .build()
198                .map_err(|e| {
199                    AxHttpError::NetworkError(format!("Failed to create HTTP client: {e}"))
200                })?,
201            credential: None,
202            session_token: RwLock::new(None),
203            retry_manager,
204            cancellation_token: RwLock::new(CancellationToken::new()),
205        })
206    }
207
208    /// Creates a new [`AxRawHttpClient`] configured with credentials.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if the HTTP client cannot be created.
213    #[expect(clippy::too_many_arguments)]
214    pub fn with_credentials(
215        api_key: String,
216        api_secret: String,
217        base_url: Option<String>,
218        orders_base_url: Option<String>,
219        timeout_secs: u64,
220        max_retries: u32,
221        retry_delay_ms: u64,
222        retry_delay_max_ms: u64,
223        proxy_url: Option<String>,
224    ) -> Result<Self, AxHttpError> {
225        let retry_config = RetryConfig {
226            max_retries,
227            initial_delay_ms: retry_delay_ms,
228            max_delay_ms: retry_delay_max_ms,
229            backoff_factor: 2.0,
230            jitter_ms: 1000,
231            operation_timeout_ms: Some(60_000),
232            immediate_first: false,
233            max_elapsed_ms: Some(180_000),
234        };
235
236        let retry_manager = RetryManager::new(retry_config);
237
238        Ok(Self {
239            base_url: base_url.unwrap_or_else(|| AX_HTTP_URL.to_string()),
240            orders_base_url: orders_base_url.unwrap_or_else(|| AX_ORDERS_URL.to_string()),
241            client: HttpClient::builder()
242                .redirect_policy(HttpRedirectPolicy::Reject)
243                .headers(Self::default_headers())
244                .keyed_quotas(Self::rate_limiter_quotas())
245                .default_quota(*AX_REST_QUOTA)
246                .timeout_secs(timeout_secs)
247                .maybe_proxy_url(proxy_url)
248                .build()
249                .map_err(|e| {
250                    AxHttpError::NetworkError(format!("Failed to create HTTP client: {e}"))
251                })?,
252            credential: Some(Credential::new(api_key, api_secret)),
253            session_token: RwLock::new(None),
254            retry_manager,
255            cancellation_token: RwLock::new(CancellationToken::new()),
256        })
257    }
258
259    /// Sets the session token for authenticated requests.
260    ///
261    /// The session token is obtained through the login flow and used for bearer token authentication.
262    pub fn set_session_token(&self, token: SecretString) {
263        *self.session_token.write() = Some(token);
264    }
265
266    pub(crate) fn has_session_token(&self) -> bool {
267        self.session_token.read().is_some()
268    }
269
270    fn default_headers() -> HashMap<String, String> {
271        let mut headers: HashMap<String, String> =
272            create_standard_nautilus_headers().into_iter().collect();
273        headers.insert("Accept".to_string(), "application/json".to_string());
274        headers
275    }
276
277    fn rate_limiter_quotas() -> Vec<(String, Quota)> {
278        vec![(AX_GLOBAL_RATE_KEY.to_string(), *AX_REST_QUOTA)]
279    }
280
281    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
282        let normalized = endpoint.split('?').next().unwrap_or(endpoint);
283        let route = format!("architect:{normalized}");
284
285        vec![AX_GLOBAL_RATE_KEY.to_string(), route]
286    }
287
288    fn auth_headers(&self) -> Result<HashMap<String, String>, AxHttpError> {
289        let guard = self.session_token.read();
290        let session_token = guard.as_ref().ok_or(AxHttpError::MissingSessionToken)?;
291
292        let mut headers = HashMap::new();
293        headers.insert(
294            "Authorization".to_string(),
295            format!("Bearer {}", session_token.expose_secret()),
296        );
297
298        Ok(headers)
299    }
300
301    async fn send_request<T: DeserializeOwned, P: Serialize>(
302        &self,
303        method: Method,
304        endpoint: &str,
305        params: Option<&P>,
306        body: Option<Vec<u8>>,
307        authenticate: bool,
308    ) -> Result<T, AxHttpError> {
309        self.send_request_to_url(&self.base_url, method, endpoint, params, body, authenticate)
310            .await
311    }
312
313    async fn send_request_to_url<T: DeserializeOwned, P: Serialize>(
314        &self,
315        base_url: &str,
316        method: Method,
317        endpoint: &str,
318        params: Option<&P>,
319        body: Option<Vec<u8>>,
320        authenticate: bool,
321    ) -> Result<T, AxHttpError> {
322        let endpoint = endpoint.to_string();
323        let url = format!("{base_url}{endpoint}");
324
325        let params_str = if method == Method::GET || method == Method::DELETE {
326            params
327                .map(serde_urlencoded::to_string)
328                .transpose()
329                .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize params: {e}")))?
330        } else {
331            None
332        };
333
334        let operation = || {
335            let url = url.clone();
336            let method = method.clone();
337            let endpoint = endpoint.clone();
338            let params_str = params_str.clone();
339            let body = body.clone();
340
341            async move {
342                let mut headers = Self::default_headers();
343
344                if authenticate {
345                    let auth_headers = self.auth_headers()?;
346                    headers.extend(auth_headers);
347                }
348
349                if body.is_some() {
350                    headers.insert("Content-Type".to_string(), "application/json".to_string());
351                }
352
353                let full_url = if let Some(ref query) = params_str {
354                    if query.is_empty() {
355                        url
356                    } else {
357                        format!("{url}?{query}")
358                    }
359                } else {
360                    url
361                };
362
363                let rate_limit_keys = Self::rate_limit_keys(&endpoint);
364
365                let response = self
366                    .client
367                    .request(
368                        method,
369                        full_url,
370                        None,
371                        Some(headers),
372                        body,
373                        None,
374                        Some(rate_limit_keys),
375                    )
376                    .await?;
377
378                let status = response.status;
379                let response_body = String::from_utf8_lossy(&response.body).to_string();
380
381                if !status.is_success() {
382                    return Err(AxHttpError::UnexpectedStatus {
383                        status: status.as_u16(),
384                        body: response_body,
385                    });
386                }
387
388                serde_json::from_str(&response_body).map_err(|e| {
389                    AxHttpError::JsonError(format!(
390                        "Failed to deserialize response: {e}\nBody: {response_body}"
391                    ))
392                })
393            }
394        };
395
396        // Only retry idempotent methods to avoid duplicate orders/cancels
397        let is_idempotent = matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
398        let should_retry = |error: &AxHttpError| -> bool { is_idempotent && error.is_retryable() };
399
400        let create_error = |error: RetryError| -> AxHttpError {
401            match error {
402                RetryError::Canceled => {
403                    AxHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
404                }
405                error => AxHttpError::NetworkError(error.to_string()),
406            }
407        };
408
409        let cancel_token = self.cancellation_token.read().clone();
410
411        self.retry_manager
412            .invocation(endpoint.as_str(), operation, should_retry, create_error)
413            .cancellation_token(&cancel_token)
414            .execute()
415            .await
416    }
417
418    /// Fetches the current authenticated user information.
419    ///
420    /// # Endpoint
421    /// `GET /whoami`
422    ///
423    /// # Errors
424    ///
425    /// Returns an error if the request fails or the response cannot be parsed.
426    pub async fn get_whoami(&self) -> Result<AxWhoAmI, AxHttpError> {
427        self.send_request::<AxWhoAmI, ()>(Method::GET, "/whoami", None, None, true)
428            .await
429    }
430
431    /// Fetches all available instruments.
432    ///
433    /// # Endpoint
434    /// `GET /instruments`
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if the request fails or the response cannot be parsed.
439    pub async fn get_instruments(&self) -> Result<AxInstrumentsResponse, AxHttpError> {
440        self.send_request::<AxInstrumentsResponse, ()>(
441            Method::GET,
442            "/instruments",
443            None,
444            None,
445            false,
446        )
447        .await
448    }
449
450    /// Fetches all account balances for the authenticated user.
451    ///
452    /// # Endpoint
453    /// `GET /balances`
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if the request fails or the response cannot be parsed.
458    pub async fn get_balances(&self) -> Result<AxBalancesResponse, AxHttpError> {
459        self.send_request::<AxBalancesResponse, ()>(Method::GET, "/balances", None, None, true)
460            .await
461    }
462
463    /// Fetches all open positions for the authenticated user.
464    ///
465    /// # Endpoint
466    /// `GET /positions`
467    ///
468    /// # Errors
469    ///
470    /// Returns an error if the request fails or the response cannot be parsed.
471    pub async fn get_positions(&self) -> Result<AxPositionsResponse, AxHttpError> {
472        self.send_request::<AxPositionsResponse, ()>(Method::GET, "/positions", None, None, true)
473            .await
474    }
475
476    /// Fetches all tickers.
477    ///
478    /// # Endpoint
479    /// `GET /tickers`
480    ///
481    /// # Errors
482    ///
483    /// Returns an error if the request fails or the response cannot be parsed.
484    pub async fn get_tickers(&self) -> Result<AxTickersResponse, AxHttpError> {
485        self.send_request::<AxTickersResponse, ()>(Method::GET, "/tickers", None, None, true)
486            .await
487    }
488
489    /// Fetches tickers with optional pagination and sorting.
490    ///
491    /// # Endpoint
492    /// `GET /tickers`
493    ///
494    /// # Errors
495    ///
496    /// Returns an error if the request fails or the response cannot be parsed.
497    pub async fn get_tickers_with_params(
498        &self,
499        params: &GetTickersParams,
500    ) -> Result<AxTickersResponse, AxHttpError> {
501        self.send_request::<AxTickersResponse, _>(Method::GET, "/tickers", Some(params), None, true)
502            .await
503    }
504
505    /// Fetches a single ticker by symbol.
506    ///
507    /// # Endpoint
508    /// `GET /ticker?symbol=<symbol>`
509    ///
510    /// # Errors
511    ///
512    /// Returns an error if the request fails or the response cannot be parsed.
513    pub async fn get_ticker(&self, symbol: Ustr) -> Result<AxTicker, AxHttpError> {
514        let params = GetTickerParams::new(symbol);
515        self.send_request::<AxTickerResponse, _>(Method::GET, "/ticker", Some(&params), None, true)
516            .await
517            .map(|response| response.ticker)
518    }
519
520    /// Fetches a single instrument by symbol.
521    ///
522    /// # Endpoint
523    /// `GET /instrument?symbol=<symbol>`
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if the request fails or the response cannot be parsed.
528    pub async fn get_instrument(&self, symbol: Ustr) -> Result<AxInstrument, AxHttpError> {
529        let params = GetInstrumentParams::new(symbol);
530        self.send_request::<AxInstrument, _>(Method::GET, "/instrument", Some(&params), None, false)
531            .await
532    }
533
534    /// Authenticates using API key and secret to obtain a session token.
535    ///
536    /// # Endpoint
537    /// `POST /authenticate`
538    ///
539    /// # Errors
540    ///
541    /// Returns an error if the request fails or the response cannot be parsed.
542    pub async fn authenticate(
543        &self,
544        api_key: &str,
545        api_secret: &str,
546        expiration_seconds: i32,
547    ) -> Result<AxAuthenticateResponse, AxHttpError> {
548        let request = AuthenticateApiKeyRequest::new(api_key, api_secret, expiration_seconds);
549
550        let body = serde_json::to_vec(&request)
551            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
552
553        self.send_request::<AxAuthenticateResponse, ()>(
554            Method::POST,
555            "/authenticate",
556            None,
557            Some(body),
558            false,
559        )
560        .await
561    }
562
563    /// Authenticates using stored credentials or environment variables.
564    ///
565    /// # Credential Resolution
566    ///
567    /// Credentials are resolved in the following order:
568    /// 1. Stored credentials (from `with_credentials` constructor)
569    /// 2. Environment variables (`AX_API_KEY` and `AX_API_SECRET`)
570    ///
571    /// # Errors
572    ///
573    /// Returns an error if:
574    /// - No credentials are available from either source
575    /// - The HTTP request fails
576    /// - The credentials are invalid
577    pub async fn authenticate_auto(
578        &self,
579        expiration_seconds: i32,
580    ) -> Result<AxAuthenticateResponse, AxHttpError> {
581        let (api_key, api_secret) = self
582            .resolve_credentials()
583            .ok_or(AxHttpError::MissingCredentials)?;
584
585        self.authenticate(
586            api_key.expose_secret(),
587            api_secret.expose_secret(),
588            expiration_seconds,
589        )
590        .await
591    }
592
593    fn resolve_credentials(&self) -> Option<(SecretString, SecretString)> {
594        if let Some(cred) = &self.credential {
595            return Some((
596                SecretString::from(cred.api_key()),
597                SecretString::from(cred.api_secret()),
598            ));
599        }
600
601        let cred = Credential::resolve(None, None)?;
602        Some((
603            SecretString::from(cred.api_key()),
604            SecretString::from(cred.api_secret()),
605        ))
606    }
607
608    /// Places a new order.
609    ///
610    /// # Endpoint
611    /// `POST /place-order` (orders base URL)
612    ///
613    /// # Errors
614    ///
615    /// Returns an error if the request fails or the response cannot be parsed.
616    pub async fn place_order(
617        &self,
618        request: &PlaceOrderRequest,
619    ) -> Result<AxPlaceOrderResponse, AxHttpError> {
620        let body = serde_json::to_vec(request)
621            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
622        self.send_request_to_url::<AxPlaceOrderResponse, ()>(
623            &self.orders_base_url,
624            Method::POST,
625            "/place-order",
626            None,
627            Some(body),
628            true,
629        )
630        .await
631    }
632
633    /// Cancels an existing order.
634    ///
635    /// # Endpoint
636    /// `POST /cancel-order` (orders base URL)
637    ///
638    /// # Errors
639    ///
640    /// Returns an error if the request fails or the response cannot be parsed.
641    pub async fn cancel_order(&self, order_id: &str) -> Result<AxCancelOrderResponse, AxHttpError> {
642        let request = CancelOrderRequest::new(order_id);
643        let body = serde_json::to_vec(&request)
644            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
645        self.send_request_to_url::<AxCancelOrderResponse, ()>(
646            &self.orders_base_url,
647            Method::POST,
648            "/cancel-order",
649            None,
650            Some(body),
651            true,
652        )
653        .await
654    }
655
656    /// Replaces (amends) an existing order.
657    ///
658    /// The exchange cancels the original order and creates a new one with the
659    /// updated fields. Unspecified optional fields inherit from the original.
660    ///
661    /// # Endpoint
662    /// `POST /replace-order` (orders base URL)
663    ///
664    /// # Errors
665    ///
666    /// Returns an error if the request fails or the response cannot be parsed.
667    pub async fn replace_order(
668        &self,
669        request: &ReplaceOrderRequest,
670    ) -> Result<AxReplaceOrderResponse, AxHttpError> {
671        let body = serde_json::to_vec(request)
672            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
673        self.send_request_to_url::<AxReplaceOrderResponse, ()>(
674            &self.orders_base_url,
675            Method::POST,
676            "/replace-order",
677            None,
678            Some(body),
679            true,
680        )
681        .await
682    }
683
684    /// Cancels all open orders, optionally filtered by account or symbol.
685    ///
686    /// # Endpoint
687    /// `POST /cancel-all-orders` (orders base URL)
688    ///
689    /// # Errors
690    ///
691    /// Returns an error if the request fails or the response cannot be parsed.
692    pub async fn cancel_all_orders(
693        &self,
694        request: &CancelAllOrdersRequest,
695    ) -> Result<AxCancelAllOrdersResponse, AxHttpError> {
696        let body = serde_json::to_vec(request)
697            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
698        self.send_request_to_url::<AxCancelAllOrdersResponse, ()>(
699            &self.orders_base_url,
700            Method::POST,
701            "/cancel-all-orders",
702            None,
703            Some(body),
704            true,
705        )
706        .await
707    }
708
709    /// Fetches all open orders.
710    ///
711    /// # Endpoint
712    /// `GET /open-orders` (orders base URL)
713    ///
714    /// # Errors
715    ///
716    /// Returns an error if the request fails or the response cannot be parsed.
717    pub async fn get_open_orders(&self) -> Result<AxOpenOrdersResponse, AxHttpError> {
718        self.get_open_orders_page(&GetOpenOrdersParams::new()).await
719    }
720
721    /// Fetches one page of open orders.
722    ///
723    /// # Errors
724    ///
725    /// Returns an error if the request fails or the response cannot be parsed.
726    pub async fn get_open_orders_page(
727        &self,
728        params: &GetOpenOrdersParams,
729    ) -> Result<AxOpenOrdersResponse, AxHttpError> {
730        self.send_request_to_url::<AxOpenOrdersResponse, _>(
731            &self.orders_base_url,
732            Method::GET,
733            "/open-orders",
734            Some(params),
735            None,
736            true,
737        )
738        .await
739    }
740
741    /// Fetches the default page of fills/trades.
742    ///
743    /// # Endpoint
744    /// `GET /fills`
745    ///
746    /// # Errors
747    ///
748    /// Returns an error if the request fails or the response cannot be parsed.
749    pub async fn get_fills(
750        &self,
751        start_timestamp_ns: i64,
752        end_timestamp_ns: i64,
753    ) -> Result<AxFillsResponse, AxHttpError> {
754        let params = GetFillsParams::new(start_timestamp_ns, end_timestamp_ns);
755        self.get_fills_page(&params).await
756    }
757
758    /// Fetches one page of fills/trades.
759    ///
760    /// # Errors
761    ///
762    /// Returns an error if the request fails or the response cannot be parsed.
763    pub async fn get_fills_page(
764        &self,
765        params: &GetFillsParams,
766    ) -> Result<AxFillsResponse, AxHttpError> {
767        self.send_request::<AxFillsResponse, _>(Method::GET, "/fills", Some(params), None, true)
768            .await
769    }
770
771    /// Fetches historical candles.
772    ///
773    /// # Endpoint
774    /// `GET /candles`
775    ///
776    /// # Errors
777    ///
778    /// Returns an error if the request fails or the response cannot be parsed.
779    pub async fn get_candles(
780        &self,
781        symbol: Ustr,
782        start_timestamp_ns: i64,
783        end_timestamp_ns: i64,
784        candle_width: AxCandleWidth,
785    ) -> Result<AxCandlesResponse, AxHttpError> {
786        let params =
787            GetCandlesParams::new(symbol, start_timestamp_ns, end_timestamp_ns, candle_width);
788        self.send_request::<AxCandlesResponse, _>(
789            Method::GET,
790            "/candles",
791            Some(&params),
792            None,
793            true,
794        )
795        .await
796    }
797
798    /// Fetches the current (incomplete) candle.
799    ///
800    /// # Endpoint
801    /// `GET /candles/current`
802    ///
803    /// # Errors
804    ///
805    /// Returns an error if the request fails or the response cannot be parsed.
806    pub async fn get_current_candle(
807        &self,
808        symbol: Ustr,
809        candle_width: AxCandleWidth,
810    ) -> Result<AxCandle, AxHttpError> {
811        let params = GetCandleParams::new(symbol, candle_width);
812        let response = self
813            .send_request::<AxCandleResponse, _>(
814                Method::GET,
815                "/candles/current",
816                Some(&params),
817                None,
818                true,
819            )
820            .await?;
821        Ok(response.candle)
822    }
823
824    /// Fetches the last completed candle.
825    ///
826    /// # Endpoint
827    /// `GET /candles/last`
828    ///
829    /// # Errors
830    ///
831    /// Returns an error if the request fails or the response cannot be parsed.
832    pub async fn get_last_candle(
833        &self,
834        symbol: Ustr,
835        candle_width: AxCandleWidth,
836    ) -> Result<AxCandle, AxHttpError> {
837        let params = GetCandleParams::new(symbol, candle_width);
838        let response = self
839            .send_request::<AxCandleResponse, _>(
840                Method::GET,
841                "/candles/last",
842                Some(&params),
843                None,
844                true,
845            )
846            .await?;
847        Ok(response.candle)
848    }
849
850    /// Fetches the default page of funding rates for a symbol.
851    ///
852    /// # Endpoint
853    /// `GET /funding-rates`
854    ///
855    /// # Errors
856    ///
857    /// Returns an error if the request fails or the response cannot be parsed.
858    pub async fn get_funding_rates(
859        &self,
860        symbol: Ustr,
861        start_timestamp_ns: i64,
862        end_timestamp_ns: i64,
863    ) -> Result<AxFundingRatesResponse, AxHttpError> {
864        let params = GetFundingRatesParams::new(symbol, start_timestamp_ns, end_timestamp_ns);
865        self.get_funding_rates_page(&params).await
866    }
867
868    /// Fetches one page of funding rates for a symbol.
869    ///
870    /// # Errors
871    ///
872    /// Returns an error if the request fails or the response cannot be parsed.
873    pub async fn get_funding_rates_page(
874        &self,
875        params: &GetFundingRatesParams,
876    ) -> Result<AxFundingRatesResponse, AxHttpError> {
877        self.send_request::<AxFundingRatesResponse, _>(
878            Method::GET,
879            "/funding-rates",
880            Some(params),
881            None,
882            true,
883        )
884        .await
885    }
886
887    /// Fetches the funding-slot schedule for a symbol on a trading day.
888    ///
889    /// # Endpoint
890    /// `GET /funding-slots`
891    ///
892    /// # Errors
893    ///
894    /// Returns an error if the request fails or the response cannot be parsed.
895    pub async fn get_funding_slots(
896        &self,
897        params: &GetFundingSlotsParams,
898    ) -> Result<AxFundingSlotsResponse, AxHttpError> {
899        self.send_request::<AxFundingSlotsResponse, _>(
900            Method::GET,
901            "/funding-slots",
902            Some(params),
903            None,
904            true,
905        )
906        .await
907    }
908
909    /// Fetches the current risk snapshot.
910    ///
911    /// # Endpoint
912    /// `GET /risk-snapshot`
913    ///
914    /// # Errors
915    ///
916    /// Returns an error if the request fails or the response cannot be parsed.
917    pub async fn get_risk_snapshot(&self) -> Result<AxRiskSnapshotResponse, AxHttpError> {
918        self.send_request::<AxRiskSnapshotResponse, ()>(
919            Method::GET,
920            "/risk-snapshot",
921            None,
922            None,
923            true,
924        )
925        .await
926    }
927
928    /// Previews an aggressive limit order to get the "take through" price.
929    ///
930    /// This endpoint calculates the price needed to sweep the order book for a given
931    /// quantity, which is used to simulate market orders on AX (which only supports
932    /// limit orders natively).
933    ///
934    /// # Endpoint
935    /// `POST /preview-aggressive-limit-order`
936    ///
937    /// # Errors
938    ///
939    /// Returns an error if the request fails or the response cannot be parsed.
940    pub async fn preview_aggressive_limit_order(
941        &self,
942        request: &PreviewAggressiveLimitOrderRequest,
943    ) -> Result<AxPreviewAggressiveLimitOrderResponse, AxHttpError> {
944        let body = serde_json::to_vec(request)
945            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
946        self.send_request::<AxPreviewAggressiveLimitOrderResponse, ()>(
947            Method::POST,
948            "/preview-aggressive-limit-order",
949            None,
950            Some(body),
951            true,
952        )
953        .await
954    }
955
956    /// Fetches the default page of transactions filtered by type.
957    ///
958    /// # Endpoint
959    /// `GET /transactions`
960    ///
961    /// # Errors
962    ///
963    /// Returns an error if the request fails or the response cannot be parsed.
964    pub async fn get_transactions(
965        &self,
966        transaction_types: Vec<String>,
967        start_timestamp_ns: i64,
968        end_timestamp_ns: i64,
969    ) -> Result<AxTransactionsResponse, AxHttpError> {
970        let params =
971            GetTransactionsParams::new(transaction_types, start_timestamp_ns, end_timestamp_ns);
972        self.get_transactions_page(&params).await
973    }
974
975    /// Fetches one page of transactions.
976    ///
977    /// # Errors
978    ///
979    /// Returns an error if the request fails or the response cannot be parsed.
980    pub async fn get_transactions_page(
981        &self,
982        params: &GetTransactionsParams,
983    ) -> Result<AxTransactionsResponse, AxHttpError> {
984        self.send_request::<AxTransactionsResponse, _>(
985            Method::GET,
986            "/transactions",
987            Some(params),
988            None,
989            true,
990        )
991        .await
992    }
993
994    /// Fetches recent trades for a symbol.
995    ///
996    /// # Endpoint
997    /// `GET /trades`
998    ///
999    /// # Errors
1000    ///
1001    /// Returns an error if the request fails or the response cannot be parsed.
1002    pub async fn get_trades(
1003        &self,
1004        symbol: Ustr,
1005        limit: Option<i32>,
1006    ) -> Result<AxTradesResponse, AxHttpError> {
1007        let params = GetTradesParams::new(symbol, limit);
1008        self.send_request::<AxTradesResponse, _>(Method::GET, "/trades", Some(&params), None, true)
1009            .await
1010    }
1011
1012    /// Fetches an order book snapshot for a symbol.
1013    ///
1014    /// # Endpoint
1015    /// `GET /book`
1016    ///
1017    /// # Errors
1018    ///
1019    /// Returns an error if the request fails or the response cannot be parsed.
1020    pub async fn get_book(
1021        &self,
1022        symbol: Ustr,
1023        level: Option<i32>,
1024    ) -> Result<AxBookResponse, AxHttpError> {
1025        let params = GetBookParams::new(symbol, level);
1026        // The AX sandbox requires authentication for `/book` despite the public schema
1027        self.send_request::<AxBookResponse, _>(Method::GET, "/book", Some(&params), None, true)
1028            .await
1029    }
1030
1031    /// Fetches the status of a single order by order ID.
1032    ///
1033    /// # Endpoint
1034    /// `GET /order-status` (orders base URL)
1035    ///
1036    /// # Errors
1037    ///
1038    /// Returns an error if the request fails or the response cannot be parsed.
1039    pub async fn get_order_status_by_id(
1040        &self,
1041        order_id: &str,
1042    ) -> Result<AxOrderStatusQueryResponse, AxHttpError> {
1043        let params = GetOrderStatusParams::by_order_id(order_id);
1044        self.send_request_to_url::<AxOrderStatusQueryResponse, _>(
1045            &self.orders_base_url,
1046            Method::GET,
1047            "/order-status",
1048            Some(&params),
1049            None,
1050            true,
1051        )
1052        .await
1053    }
1054
1055    /// Fetches the status of a single order by client order ID.
1056    ///
1057    /// # Endpoint
1058    /// `GET /order-status` (orders base URL)
1059    ///
1060    /// # Errors
1061    ///
1062    /// Returns an error if the request fails or the response cannot be parsed.
1063    pub async fn get_order_status_by_cid(
1064        &self,
1065        client_order_id: u64,
1066    ) -> Result<AxOrderStatusQueryResponse, AxHttpError> {
1067        let params = GetOrderStatusParams::by_client_order_id(client_order_id);
1068        self.send_request_to_url::<AxOrderStatusQueryResponse, _>(
1069            &self.orders_base_url,
1070            Method::GET,
1071            "/order-status",
1072            Some(&params),
1073            None,
1074            true,
1075        )
1076        .await
1077    }
1078
1079    /// Fetches historical orders with optional filters.
1080    ///
1081    /// # Endpoint
1082    /// `GET /orders` (orders base URL)
1083    ///
1084    /// # Errors
1085    ///
1086    /// Returns an error if the request fails or the response cannot be parsed.
1087    pub async fn get_orders(
1088        &self,
1089        params: &GetOrdersParams,
1090    ) -> Result<AxOrdersResponse, AxHttpError> {
1091        self.send_request_to_url::<AxOrdersResponse, _>(
1092            &self.orders_base_url,
1093            Method::GET,
1094            "/orders",
1095            Some(params),
1096            None,
1097            true,
1098        )
1099        .await
1100    }
1101
1102    /// Checks the initial margin requirement for a proposed order.
1103    ///
1104    /// # Endpoint
1105    /// `POST /initial-margin-requirement` (orders base URL)
1106    ///
1107    /// # Errors
1108    ///
1109    /// Returns an error if the request fails or the response cannot be parsed.
1110    pub async fn check_initial_margin(
1111        &self,
1112        request: &PlaceOrderRequest,
1113    ) -> Result<AxInitialMarginRequirementResponse, AxHttpError> {
1114        let body = serde_json::to_vec(request)
1115            .map_err(|e| AxHttpError::JsonError(format!("Failed to serialize request: {e}")))?;
1116        self.send_request_to_url::<AxInitialMarginRequirementResponse, ()>(
1117            &self.orders_base_url,
1118            Method::POST,
1119            "/initial-margin-requirement",
1120            None,
1121            Some(body),
1122            true,
1123        )
1124        .await
1125    }
1126}
1127
1128/// High-level HTTP client for the Ax REST API.
1129///
1130/// This client wraps the underlying [`AxRawHttpClient`] to provide a convenient
1131/// interface for Python bindings and instrument caching.
1132#[derive(Debug)]
1133#[cfg_attr(
1134    feature = "python",
1135    pyo3::pyclass(module = "nautilus_trader.adapters.architect_ax", from_py_object)
1136)]
1137#[cfg_attr(
1138    feature = "python",
1139    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.architect_ax")
1140)]
1141pub struct AxHttpClient {
1142    pub(crate) inner: Arc<AxRawHttpClient>,
1143    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1144    clock: &'static AtomicTime,
1145    cache_initialized: Arc<AtomicBool>,
1146    account_fees: Arc<ArcSwapOption<(Decimal, Decimal)>>,
1147}
1148
1149impl Clone for AxHttpClient {
1150    fn clone(&self) -> Self {
1151        Self {
1152            inner: self.inner.clone(),
1153            instruments_cache: self.instruments_cache.clone(),
1154            cache_initialized: self.cache_initialized.clone(),
1155            clock: self.clock,
1156            account_fees: self.account_fees.clone(),
1157        }
1158    }
1159}
1160
1161impl Default for AxHttpClient {
1162    fn default() -> Self {
1163        Self::new(None, None, 60, 3, 1000, 10_000, None)
1164            .expect("Failed to create default AxHttpClient")
1165    }
1166}
1167
1168impl AxHttpClient {
1169    /// Creates a new [`AxHttpClient`] using the default Ax HTTP URL.
1170    ///
1171    /// # Errors
1172    ///
1173    /// Returns an error if the retry manager cannot be created.
1174    pub fn new(
1175        base_url: Option<String>,
1176        orders_base_url: Option<String>,
1177        timeout_secs: u64,
1178        max_retries: u32,
1179        retry_delay_ms: u64,
1180        retry_delay_max_ms: u64,
1181        proxy_url: Option<String>,
1182    ) -> Result<Self, AxHttpError> {
1183        Ok(Self {
1184            inner: Arc::new(AxRawHttpClient::new(
1185                base_url,
1186                orders_base_url,
1187                timeout_secs,
1188                max_retries,
1189                retry_delay_ms,
1190                retry_delay_max_ms,
1191                proxy_url,
1192            )?),
1193            instruments_cache: Arc::new(AtomicMap::new()),
1194            cache_initialized: Arc::new(AtomicBool::new(false)),
1195            clock: get_atomic_clock_realtime(),
1196            account_fees: Arc::new(ArcSwapOption::empty()),
1197        })
1198    }
1199
1200    /// Creates a new [`AxHttpClient`] configured with credentials.
1201    ///
1202    /// # Errors
1203    ///
1204    /// Returns an error if the HTTP client cannot be created.
1205    #[expect(clippy::too_many_arguments)]
1206    pub fn with_credentials(
1207        api_key: String,
1208        api_secret: String,
1209        base_url: Option<String>,
1210        orders_base_url: Option<String>,
1211        timeout_secs: u64,
1212        max_retries: u32,
1213        retry_delay_ms: u64,
1214        retry_delay_max_ms: u64,
1215        proxy_url: Option<String>,
1216    ) -> Result<Self, AxHttpError> {
1217        Ok(Self {
1218            inner: Arc::new(AxRawHttpClient::with_credentials(
1219                api_key,
1220                api_secret,
1221                base_url,
1222                orders_base_url,
1223                timeout_secs,
1224                max_retries,
1225                retry_delay_ms,
1226                retry_delay_max_ms,
1227                proxy_url,
1228            )?),
1229            instruments_cache: Arc::new(AtomicMap::new()),
1230            cache_initialized: Arc::new(AtomicBool::new(false)),
1231            clock: get_atomic_clock_realtime(),
1232            account_fees: Arc::new(ArcSwapOption::empty()),
1233        })
1234    }
1235
1236    /// Returns the base URL for this client.
1237    #[must_use]
1238    pub fn base_url(&self) -> &str {
1239        self.inner.base_url()
1240    }
1241
1242    /// Returns a masked version of the API key for logging purposes.
1243    #[must_use]
1244    pub fn api_key_masked(&self) -> String {
1245        self.inner.api_key_masked()
1246    }
1247
1248    /// Cancel all pending HTTP requests.
1249    pub fn cancel_all_requests(&self) {
1250        self.inner.cancel_all_requests();
1251    }
1252
1253    /// Replaces the cancelled token so new requests can proceed after reconnect.
1254    pub fn reset_cancellation_token(&self) {
1255        self.inner.reset_cancellation_token();
1256    }
1257
1258    /// Sets the session token for authenticated requests.
1259    ///
1260    /// The session token is obtained through the login flow and used for bearer token authentication.
1261    pub fn set_session_token(&self, token: SecretString) {
1262        self.inner.set_session_token(token);
1263    }
1264
1265    /// Generates a timestamp for initialization.
1266    fn generate_ts_init(&self) -> UnixNanos {
1267        self.clock.get_time_ns()
1268    }
1269
1270    /// Checks if the client is initialized.
1271    ///
1272    /// The client is considered initialized if any instruments have been cached from the venue.
1273    #[must_use]
1274    pub fn is_initialized(&self) -> bool {
1275        self.cache_initialized.load(Ordering::Acquire)
1276    }
1277
1278    /// Returns a snapshot of all instrument symbols currently held in the internal cache.
1279    #[must_use]
1280    pub fn get_cached_symbols(&self) -> Vec<String> {
1281        self.instruments_cache
1282            .load()
1283            .keys()
1284            .map(|k| k.to_string())
1285            .collect()
1286    }
1287
1288    /// Caches multiple instruments.
1289    ///
1290    /// Any existing instruments with the same symbols will be replaced.
1291    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1292        self.instruments_cache.rcu(|m| {
1293            for inst in instruments {
1294                m.insert(inst.raw_symbol().inner(), inst.clone());
1295            }
1296        });
1297        self.cache_initialized.store(true, Ordering::Release);
1298    }
1299
1300    /// Caches a single instrument.
1301    ///
1302    /// Any existing instrument with the same symbol will be replaced.
1303    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1304        self.instruments_cache
1305            .insert(instrument.raw_symbol().inner(), instrument);
1306        self.cache_initialized.store(true, Ordering::Release);
1307    }
1308
1309    /// Authenticates with Ax using API credentials.
1310    ///
1311    /// On success, the session token is automatically stored for subsequent authenticated requests.
1312    ///
1313    /// # Errors
1314    ///
1315    /// Returns an error if the HTTP request fails or credentials are invalid.
1316    pub async fn authenticate(
1317        &self,
1318        api_key: &str,
1319        api_secret: &str,
1320        expiration_seconds: i32,
1321    ) -> Result<SecretString, AxHttpError> {
1322        let resp = self
1323            .inner
1324            .authenticate(api_key, api_secret, expiration_seconds)
1325            .await?;
1326        let token = resp.into_token();
1327        self.inner.set_session_token(token.clone());
1328        Ok(token)
1329    }
1330
1331    /// Authenticates using stored credentials or environment variables.
1332    ///
1333    /// # Credential Resolution
1334    ///
1335    /// Credentials are resolved in the following order:
1336    /// 1. Stored credentials (from `with_credentials` constructor)
1337    /// 2. Environment variables (`AX_API_KEY` and `AX_API_SECRET`)
1338    ///
1339    /// On success, the session token is automatically stored for subsequent authenticated requests.
1340    ///
1341    /// # Errors
1342    ///
1343    /// Returns an error if:
1344    /// - No credentials are available from either source
1345    /// - The HTTP request fails
1346    /// - The credentials are invalid
1347    pub async fn authenticate_auto(
1348        &self,
1349        expiration_seconds: i32,
1350    ) -> Result<SecretString, AxHttpError> {
1351        let resp = self.inner.authenticate_auto(expiration_seconds).await?;
1352        let token = resp.into_token();
1353        self.inner.set_session_token(token.clone());
1354        Ok(token)
1355    }
1356
1357    /// Gets an instrument from the cache by symbol.
1358    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1359        self.instruments_cache.get_cloned(symbol)
1360    }
1361
1362    /// Resolves the maker and taker fee rates for the account behind the current credentials.
1363    ///
1364    /// AX reports fee rates per account rather than per user, and returns the accounts the
1365    /// credentials can act on. The first entry is used, which is the account AX resolves when a
1366    /// request carries no explicit selector. The rates are retained so later instrument requests,
1367    /// including the periodic refresh, keep reporting them.
1368    ///
1369    /// Requires an authenticated client.
1370    ///
1371    /// # Errors
1372    ///
1373    /// Returns an error if the request fails, the response carries no accounts, or the selected
1374    /// account supplies no fee rates. An absent rate is not treated as zero, because a zero rate
1375    /// is itself valid and a silent zero would outlive the response that caused it.
1376    pub async fn request_account_fees(&self) -> anyhow::Result<(Decimal, Decimal)> {
1377        let whoami = self
1378            .inner
1379            .get_whoami()
1380            .await
1381            .map_err(|e| anyhow::anyhow!(e))
1382            .context("failed to request AX whoami")?;
1383
1384        let Some(account) = whoami.accounts.first() else {
1385            anyhow::bail!("AX whoami returned no accounts to resolve fees from");
1386        };
1387
1388        if whoami.accounts.len() > 1 {
1389            log::warn!(
1390                "AX credentials cover {} accounts, using fee rates from {}",
1391                whoami.accounts.len(),
1392                account.id,
1393            );
1394        }
1395
1396        let (Some(maker_fee), Some(taker_fee)) = (account.maker_fee, account.taker_fee) else {
1397            anyhow::bail!("AX whoami account {} supplied no fee rates", account.id);
1398        };
1399
1400        let fees = (maker_fee, taker_fee);
1401        self.account_fees.store(Some(Arc::new(fees)));
1402
1403        Ok(fees)
1404    }
1405
1406    /// Requests all instruments from Ax.
1407    ///
1408    /// Fee rates fall back to the rates last resolved from `GET /whoami`, and to zero when no
1409    /// rates have been resolved.
1410    ///
1411    /// # Errors
1412    ///
1413    /// Returns an error if the HTTP request fails or instrument parsing fails.
1414    pub async fn request_instruments(
1415        &self,
1416        maker_fee: Option<Decimal>,
1417        taker_fee: Option<Decimal>,
1418    ) -> anyhow::Result<Vec<InstrumentAny>> {
1419        let resp = self
1420            .inner
1421            .get_instruments()
1422            .await
1423            .map_err(|e| anyhow::anyhow!(e))?;
1424
1425        let (maker_fee, taker_fee) = self.resolve_fees(maker_fee, taker_fee);
1426        let ts_init = self.generate_ts_init();
1427
1428        let mut instruments: Vec<InstrumentAny> = Vec::new();
1429        for inst in &resp.instruments {
1430            if inst.state == AxInstrumentState::Delisted {
1431                log::debug!("Skipping delisted instrument: {}", inst.symbol);
1432                continue;
1433            }
1434
1435            // Skip test instruments (not real tradable products)
1436            if inst.symbol.starts_with("TEST") {
1437                log::debug!("Skipping test instrument: {}", inst.symbol);
1438                continue;
1439            }
1440
1441            match parse_instrument(inst, maker_fee, taker_fee, ts_init, ts_init) {
1442                Ok(instrument) => instruments.push(instrument),
1443                Err(e) => {
1444                    log::warn!("Failed to parse instrument {}: {e}", inst.symbol);
1445                }
1446            }
1447        }
1448
1449        Ok(instruments)
1450    }
1451
1452    /// Requests a single instrument from Ax by symbol.
1453    ///
1454    /// Fee rates fall back to the rates last resolved from `GET /whoami`, and to zero when no
1455    /// rates have been resolved.
1456    ///
1457    /// # Errors
1458    ///
1459    /// Returns an error if the HTTP request fails or instrument parsing fails.
1460    pub async fn request_instrument(
1461        &self,
1462        symbol: Ustr,
1463        maker_fee: Option<Decimal>,
1464        taker_fee: Option<Decimal>,
1465    ) -> anyhow::Result<InstrumentAny> {
1466        let resp = self
1467            .inner
1468            .get_instrument(symbol)
1469            .await
1470            .map_err(|e| anyhow::anyhow!(e))?;
1471
1472        let (maker_fee, taker_fee) = self.resolve_fees(maker_fee, taker_fee);
1473        let ts_init = self.generate_ts_init();
1474
1475        parse_instrument(&resp, maker_fee, taker_fee, ts_init, ts_init)
1476    }
1477
1478    fn resolve_fees(
1479        &self,
1480        maker_fee: Option<Decimal>,
1481        taker_fee: Option<Decimal>,
1482    ) -> (Decimal, Decimal) {
1483        let resolved = self.account_fees.load();
1484
1485        let Some(&(resolved_maker, resolved_taker)) = resolved.as_deref() else {
1486            // Either rate missing becomes zero, so warn on a partial argument too
1487            if (maker_fee.is_none() || taker_fee.is_none()) && self.inner.has_session_token() {
1488                log::warn!(
1489                    "Building instruments with zero fees: authenticated but account fee rates \
1490                     were never resolved"
1491                );
1492            }
1493
1494            return (
1495                maker_fee.unwrap_or(Decimal::ZERO),
1496                taker_fee.unwrap_or(Decimal::ZERO),
1497            );
1498        };
1499
1500        (
1501            maker_fee.unwrap_or(resolved_maker),
1502            taker_fee.unwrap_or(resolved_taker),
1503        )
1504    }
1505
1506    /// Requests an order book snapshot from Ax and builds a Nautilus [`OrderBook`].
1507    ///
1508    /// Requires the instrument to be cached.
1509    ///
1510    /// # Errors
1511    ///
1512    /// Returns an error if:
1513    /// - The instrument is not found in the cache.
1514    /// - The HTTP request fails.
1515    pub async fn request_book_snapshot(
1516        &self,
1517        symbol: Ustr,
1518        depth: Option<usize>,
1519    ) -> anyhow::Result<OrderBook> {
1520        let instrument = self
1521            .get_instrument(&symbol)
1522            .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not found in cache"))?;
1523
1524        let resp = self
1525            .inner
1526            .get_book(symbol, Some(2))
1527            .await
1528            .map_err(|e| anyhow::anyhow!(e))?;
1529
1530        let instrument_id = instrument.id();
1531        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1532
1533        let price_precision = instrument.price_precision();
1534        let size_precision = instrument.size_precision();
1535        let ts_event = ax_timestamp_stn_to_unix_nanos(resp.book.ts, resp.book.tn)?;
1536
1537        for (i, level) in resp.book.b.iter().enumerate() {
1538            if depth.is_some_and(|d| i >= d) {
1539                break;
1540            }
1541            let price = Price::from_decimal_dp(level.p, price_precision).with_context(|| {
1542                format!(
1543                    "Failed to convert AX book bid price {} for {symbol}",
1544                    level.p
1545                )
1546            })?;
1547            let size = Quantity::new(level.q as f64, size_precision);
1548            let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1549            book.add(order, 0, i as u64, ts_event);
1550        }
1551
1552        let bids_len = resp.book.b.len();
1553        for (i, level) in resp.book.a.iter().enumerate() {
1554            if depth.is_some_and(|d| i >= d) {
1555                break;
1556            }
1557            let price = Price::from_decimal_dp(level.p, price_precision).with_context(|| {
1558                format!(
1559                    "Failed to convert AX book ask price {} for {symbol}",
1560                    level.p
1561                )
1562            })?;
1563            let size = Quantity::new(level.q as f64, size_precision);
1564            let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
1565            book.add(order, 0, (bids_len + i) as u64, ts_event);
1566        }
1567
1568        Ok(book)
1569    }
1570
1571    /// Requests recent trades from Ax and parses them to Nautilus [`TradeTick`].
1572    ///
1573    /// The AX trades endpoint does not accept time range parameters, so
1574    /// `start` and `end` are applied as client-side filters after fetching.
1575    ///
1576    /// Requires the instrument to be cached.
1577    ///
1578    /// # Errors
1579    ///
1580    /// Returns an error if:
1581    /// - The instrument is not found in the cache.
1582    /// - The HTTP request fails.
1583    /// - Trade parsing fails.
1584    pub async fn request_trade_ticks(
1585        &self,
1586        symbol: Ustr,
1587        limit: Option<i32>,
1588        start: Option<UnixNanos>,
1589        end: Option<UnixNanos>,
1590    ) -> anyhow::Result<Vec<TradeTick>> {
1591        let instrument = self
1592            .get_instrument(&symbol)
1593            .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not found in cache"))?;
1594
1595        let resp = self
1596            .inner
1597            .get_trades(symbol, limit)
1598            .await
1599            .map_err(|e| anyhow::anyhow!(e))?;
1600
1601        let ts_init = self.generate_ts_init();
1602        let mut ticks = Vec::with_capacity(resp.trades.len());
1603
1604        for trade in &resp.trades {
1605            match parse_trade_tick(trade, &instrument, ts_init) {
1606                Ok(tick) => {
1607                    if start.is_some_and(|s| tick.ts_event < s) {
1608                        continue;
1609                    }
1610
1611                    if end.is_some_and(|e| tick.ts_event > e) {
1612                        continue;
1613                    }
1614                    ticks.push(tick);
1615                }
1616                Err(e) => {
1617                    log::warn!("Failed to parse trade for {symbol}: {e}");
1618                }
1619            }
1620        }
1621
1622        Ok(ticks)
1623    }
1624
1625    /// Requests historical bars from Ax and parses them to Nautilus Bar types.
1626    ///
1627    /// Requires the instrument to be cached (call `request_instruments` first).
1628    ///
1629    /// # Errors
1630    ///
1631    /// Returns an error if:
1632    /// - The instrument is not found in the cache.
1633    /// - The HTTP request fails.
1634    /// - Bar parsing fails.
1635    pub async fn request_bars(
1636        &self,
1637        symbol: Ustr,
1638        start: Option<Timestamp>,
1639        end: Option<Timestamp>,
1640        width: AxCandleWidth,
1641    ) -> anyhow::Result<Vec<Bar>> {
1642        let instrument = self
1643            .get_instrument(&symbol)
1644            .ok_or_else(|| anyhow::anyhow!("Instrument {symbol} not found in cache"))?;
1645
1646        let start_ns = start
1647            .and_then(|dt| i64::try_from(dt.as_nanosecond()).ok())
1648            .unwrap_or(0);
1649        let end_ns = end
1650            .and_then(|dt| i64::try_from(dt.as_nanosecond()).ok())
1651            .unwrap_or_else(|| self.generate_ts_init().as_i64());
1652        let resp = self
1653            .inner
1654            .get_candles(symbol, start_ns, end_ns, width)
1655            .await
1656            .map_err(|e| anyhow::anyhow!(e))?;
1657
1658        let ts_init = self.generate_ts_init();
1659        let mut bars = Vec::with_capacity(resp.candles.len());
1660
1661        for candle in &resp.candles {
1662            match parse_bar(candle, &instrument, ts_init) {
1663                Ok(bar) => bars.push(bar),
1664                Err(e) => {
1665                    log::warn!("Failed to parse bar for {symbol}: {e}");
1666                }
1667            }
1668        }
1669
1670        Ok(bars)
1671    }
1672
1673    /// Requests funding rates from Ax and parses them to Nautilus types.
1674    ///
1675    /// Traverses the provider's cursor chain. This is a best-effort historical
1676    /// read, not an atomic snapshot if AX corrects rows during the traversal.
1677    ///
1678    /// # Errors
1679    ///
1680    /// Returns an error if the HTTP request fails.
1681    pub async fn request_funding_rates(
1682        &self,
1683        instrument_id: InstrumentId,
1684        start: Option<Timestamp>,
1685        end: Option<Timestamp>,
1686    ) -> Result<Vec<FundingRateUpdate>, AxHttpError> {
1687        const PAGE_SIZE: i32 = 100;
1688
1689        let symbol = instrument_id.symbol.inner();
1690        let start_ns = start
1691            .and_then(|dt| i64::try_from(dt.as_nanosecond()).ok())
1692            .unwrap_or(0);
1693        let end_ns = end
1694            .and_then(|dt| i64::try_from(dt.as_nanosecond()).ok())
1695            .unwrap_or_else(|| self.generate_ts_init().as_i64());
1696        let mut params = GetFundingRatesParams::new(symbol, start_ns, end_ns);
1697        params.limit = Some(PAGE_SIZE);
1698        params.sort_ts = Some("desc".to_string());
1699
1700        let mut funding_rates = Vec::new();
1701        let mut seen_rows = HashSet::new();
1702        let mut seen_cursors = HashSet::new();
1703        let mut expected_total = None;
1704
1705        loop {
1706            let response = self.inner.get_funding_rates_page(&params).await?;
1707            let page_len = response.funding_rates.len();
1708
1709            if page_len > PAGE_SIZE as usize {
1710                return Err(format!(
1711                    "AX funding-rates page length {page_len} exceeds requested limit {PAGE_SIZE}"
1712                )
1713                .into());
1714            }
1715
1716            if let Some(limit) = response.limit {
1717                if !(0..=PAGE_SIZE).contains(&limit) {
1718                    return Err(format!(
1719                        "AX funding-rates applied limit must be between 0 and {PAGE_SIZE}, was {limit}"
1720                    )
1721                    .into());
1722                }
1723
1724                if page_len > limit as usize {
1725                    return Err(format!(
1726                        "AX funding-rates page length {page_len} exceeds applied limit {limit}"
1727                    )
1728                    .into());
1729                }
1730            }
1731
1732            if let Some(total_count) = response.total_count {
1733                if total_count < 0 {
1734                    return Err(format!(
1735                        "AX funding-rates total_count must be non-negative, was {total_count}"
1736                    )
1737                    .into());
1738                }
1739
1740                if let Some(expected) = expected_total {
1741                    if total_count != expected {
1742                        return Err(format!(
1743                            "AX funding-rates total_count changed during pagination: expected {expected}, was {total_count}"
1744                        )
1745                        .into());
1746                    }
1747                } else {
1748                    expected_total = Some(total_count);
1749                }
1750            }
1751
1752            for rate in response.funding_rates {
1753                let identity = (
1754                    rate.symbol,
1755                    rate.timestamp_ns,
1756                    rate.funding_rate,
1757                    rate.funding_amount,
1758                    rate.benchmark_price,
1759                    rate.settlement_price,
1760                );
1761
1762                if !seen_rows.insert(identity) {
1763                    return Err(format!(
1764                        "AX funding-rates pagination returned an exact duplicate row for {} at {}",
1765                        rate.symbol, rate.timestamp_ns
1766                    )
1767                    .into());
1768                }
1769                funding_rates.push(rate);
1770            }
1771
1772            if let Some(total_count) = expected_total
1773                && funding_rates.len() as i64 > total_count
1774            {
1775                return Err(format!(
1776                    "AX funding-rates pagination returned more unique rows ({}) than total_count {total_count}",
1777                    funding_rates.len()
1778                )
1779                .into());
1780            }
1781
1782            match response.next_cursor {
1783                Some(next_cursor) => {
1784                    if next_cursor.is_empty() {
1785                        return Err("AX funding-rates returned an empty next_cursor"
1786                            .to_string()
1787                            .into());
1788                    }
1789
1790                    if page_len == 0 {
1791                        return Err("AX funding-rates returned an empty page with a next_cursor"
1792                            .to_string()
1793                            .into());
1794                    }
1795
1796                    if !seen_cursors.insert(next_cursor.clone()) {
1797                        return Err(format!(
1798                            "AX funding-rates pagination repeated cursor {next_cursor:?}"
1799                        )
1800                        .into());
1801                    }
1802                    params.cursor = Some(next_cursor);
1803                }
1804                None => break,
1805            }
1806        }
1807
1808        if let Some(total_count) = expected_total
1809            && funding_rates.len() as i64 != total_count
1810        {
1811            return Err(format!(
1812                "AX funding-rates pagination returned {} unique rows, expected {total_count}",
1813                funding_rates.len()
1814            )
1815            .into());
1816        }
1817
1818        let ts_init = self.generate_ts_init();
1819        let updates = funding_rates
1820            .iter()
1821            .map(|r| parse_funding_rate(r, instrument_id, ts_init))
1822            .collect::<anyhow::Result<Vec<_>>>()
1823            .map_err(|e| AxHttpError::from(e.to_string()))?;
1824
1825        Ok(updates)
1826    }
1827
1828    /// Requests the funding-slot schedule for a symbol on a trading day.
1829    ///
1830    /// AX returns a single response covering the whole trading day, so there
1831    /// is no pagination. The schedule has no Nautilus domain equivalent, so
1832    /// the venue response is returned verbatim.
1833    ///
1834    /// # Errors
1835    ///
1836    /// Returns an error if the HTTP request fails or the response cannot be parsed.
1837    pub async fn request_funding_slots(
1838        &self,
1839        instrument_id: InstrumentId,
1840        date: Option<Date>,
1841    ) -> Result<AxFundingSlotsResponse, AxHttpError> {
1842        let symbol = instrument_id.symbol.inner();
1843        let mut params = GetFundingSlotsParams::new(symbol);
1844
1845        if let Some(date) = date {
1846            params.date = Some(date.strftime("%Y-%m-%d").to_string());
1847        }
1848
1849        self.inner.get_funding_slots(&params).await
1850    }
1851
1852    /// Requests account state from Ax and parses to a Nautilus [`AccountState`].
1853    ///
1854    /// # Errors
1855    ///
1856    /// Returns an error if the HTTP request fails or parsing fails.
1857    pub async fn request_account_state(
1858        &self,
1859        account_id: AccountId,
1860    ) -> anyhow::Result<AccountState> {
1861        let response = self
1862            .inner
1863            .get_balances()
1864            .await
1865            .map_err(|e| anyhow::anyhow!(e))?;
1866
1867        let ts_init = self.generate_ts_init();
1868        parse_account_state(&response, account_id, ts_init, ts_init)
1869    }
1870
1871    /// Checks the initial margin requirement for a proposed order.
1872    ///
1873    /// # Errors
1874    ///
1875    /// Returns an error if the HTTP request fails.
1876    pub async fn check_initial_margin(
1877        &self,
1878        request: &PlaceOrderRequest,
1879    ) -> anyhow::Result<Decimal> {
1880        let resp = self
1881            .inner
1882            .check_initial_margin(request)
1883            .await
1884            .map_err(|e| anyhow::anyhow!(e))?;
1885        Ok(resp.im)
1886    }
1887
1888    /// Queries a single order by venue order ID or client order ID using the
1889    /// dedicated `/order-status` endpoint, which works for any order state.
1890    ///
1891    /// The caller must supply `order_side`, `order_type`, and `time_in_force`
1892    /// because the endpoint does not return these fields.
1893    ///
1894    /// # Errors
1895    ///
1896    /// Returns an error if:
1897    /// - Neither `venue_order_id` nor `client_order_id` is provided.
1898    /// - The HTTP request fails.
1899    #[expect(clippy::too_many_arguments)]
1900    pub async fn request_order_status(
1901        &self,
1902        account_id: AccountId,
1903        instrument_id: InstrumentId,
1904        client_order_id: Option<ClientOrderId>,
1905        venue_order_id: Option<VenueOrderId>,
1906        order_side: Option<OrderSide>,
1907        order_type: OrderType,
1908        time_in_force: TimeInForce,
1909    ) -> anyhow::Result<OrderStatusReport> {
1910        let resp = if let Some(ref voi) = venue_order_id {
1911            self.inner.get_order_status_by_id(voi.as_str()).await
1912        } else if let Some(ref coid) = client_order_id {
1913            let cid = client_order_id_to_cid(coid);
1914            self.inner.get_order_status_by_cid(cid).await
1915        } else {
1916            anyhow::bail!("Either venue_order_id or client_order_id must be provided")
1917        }
1918        .map_err(|e| anyhow::anyhow!(e))?;
1919
1920        let detail = resp.status;
1921        let size_precision = self
1922            .get_instrument(&detail.symbol)
1923            .map_or(0, |i| i.size_precision());
1924
1925        let voi = VenueOrderId::new(&detail.order_id);
1926        let order_status = detail.state.into();
1927        let filled = detail.filled_quantity.unwrap_or(0);
1928        let remaining = detail.remaining_quantity.unwrap_or(0);
1929        let quantity = Quantity::new((filled + remaining) as f64, size_precision);
1930        let filled_qty = Quantity::new(filled as f64, size_precision);
1931        let ts_init = self.generate_ts_init();
1932
1933        let resolved_coid = client_order_id.or_else(|| detail.clord_id.map(cid_to_client_order_id));
1934
1935        Ok(OrderStatusReport::new(
1936            account_id,
1937            instrument_id,
1938            resolved_coid,
1939            voi,
1940            order_side,
1941            order_type,
1942            time_in_force,
1943            order_status,
1944            quantity,
1945            filled_qty,
1946            ts_init,
1947            ts_init,
1948            ts_init,
1949            Some(UUID4::new()),
1950        ))
1951    }
1952
1953    /// Requests open orders from Ax and parses them to Nautilus [`OrderStatusReport`].
1954    ///
1955    /// Missing instruments are requested from Ax and cached before parsing order details.
1956    ///
1957    /// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
1958    /// to a `ClientOrderId`. This is needed for correlating orders submitted via WebSocket.
1959    ///
1960    /// # Errors
1961    ///
1962    /// Returns an error if:
1963    /// - The HTTP request fails.
1964    /// - An order's instrument cannot be fetched or parsed.
1965    ///
1966    /// # Notes
1967    ///
1968    /// Order parsing failures are skipped with a warning.
1969    pub async fn request_order_status_reports<F>(
1970        &self,
1971        account_id: AccountId,
1972        cid_resolver: Option<F>,
1973    ) -> anyhow::Result<Vec<OrderStatusReport>>
1974    where
1975        F: Fn(u64) -> Option<ClientOrderId>,
1976    {
1977        const PAGE_SIZE: i32 = 100;
1978
1979        let mut orders = Vec::new();
1980        let mut seen_order_ids = HashSet::new();
1981        let mut offset = 0_i64;
1982        let mut expected_total = None;
1983
1984        loop {
1985            let request_offset = i32::try_from(offset)
1986                .context("AX open-orders offset exceeds the documented int32 range")?;
1987            let params = GetOpenOrdersParams {
1988                account_id: None,
1989                limit: Some(PAGE_SIZE),
1990                offset: Some(request_offset),
1991                sort_ts: Some("desc".to_string()),
1992            };
1993            let response = self
1994                .inner
1995                .get_open_orders_page(&params)
1996                .await
1997                .map_err(|e| anyhow::anyhow!(e))?;
1998
1999            anyhow::ensure!(
2000                response.total_count >= 0,
2001                "AX open-orders total_count must be non-negative, was {}",
2002                response.total_count
2003            );
2004            anyhow::ensure!(
2005                response.limit >= 0 && response.limit <= PAGE_SIZE,
2006                "AX open-orders applied limit must be between 0 and {PAGE_SIZE}, was {}",
2007                response.limit
2008            );
2009            anyhow::ensure!(
2010                i64::from(response.offset) == offset,
2011                "AX open-orders response offset mismatch: requested {offset}, was {}",
2012                response.offset
2013            );
2014
2015            let total_count = *expected_total.get_or_insert(response.total_count);
2016            anyhow::ensure!(
2017                response.total_count == total_count,
2018                "AX open-orders total_count changed during pagination: expected {total_count}, was {}",
2019                response.total_count
2020            );
2021
2022            let page_len = i64::try_from(response.orders.len())
2023                .context("AX open-orders page length exceeds i64")?;
2024            anyhow::ensure!(
2025                page_len <= i64::from(response.limit),
2026                "AX open-orders page length {page_len} exceeds applied limit {}",
2027                response.limit
2028            );
2029            let next_offset = offset
2030                .checked_add(page_len)
2031                .context("AX open-orders offset overflow")?;
2032            anyhow::ensure!(
2033                next_offset <= total_count,
2034                "AX open-orders page exceeds total_count: next offset {next_offset}, total {total_count}"
2035            );
2036
2037            if total_count == 0 {
2038                anyhow::ensure!(
2039                    response.orders.is_empty(),
2040                    "AX open-orders returned rows with total_count zero"
2041                );
2042                break;
2043            }
2044
2045            anyhow::ensure!(
2046                !response.orders.is_empty(),
2047                "AX open-orders returned an empty page before offset {offset} reached total {total_count}"
2048            );
2049
2050            for order in response.orders {
2051                anyhow::ensure!(
2052                    seen_order_ids.insert(order.oid.clone()),
2053                    "AX open-orders pagination returned duplicate order ID {}",
2054                    order.oid
2055                );
2056                orders.push(order);
2057            }
2058
2059            if next_offset == total_count {
2060                break;
2061            }
2062
2063            offset = next_offset;
2064        }
2065
2066        anyhow::ensure!(
2067            i64::try_from(orders.len()).context("AX open-orders result length exceeds i64")?
2068                == expected_total.unwrap_or_default(),
2069            "AX open-orders pagination did not return the advertised number of unique orders"
2070        );
2071
2072        let ts_init = self.generate_ts_init();
2073        let mut reports = Vec::with_capacity(orders.len());
2074
2075        for order in &orders {
2076            let instrument = self.resolve_report_instrument(order.s).await?;
2077
2078            match parse_order_status_report(
2079                order,
2080                account_id,
2081                &instrument,
2082                ts_init,
2083                cid_resolver.as_ref(),
2084            ) {
2085                Ok(report) => reports.push(report),
2086                Err(e) => {
2087                    log::warn!("Failed to parse order {}: {e}", order.oid);
2088                }
2089            }
2090        }
2091
2092        Ok(reports)
2093    }
2094
2095    /// Requests historical orders from Ax and parses them to Nautilus
2096    /// [`OrderStatusReport`].
2097    ///
2098    /// Missing instruments are requested from Ax and cached before parsing order details.
2099    ///
2100    /// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
2101    /// to a `ClientOrderId`. This is needed for correlating orders submitted via WebSocket.
2102    ///
2103    /// # Errors
2104    ///
2105    /// Returns an error if:
2106    /// - The HTTP request or pagination contract fails.
2107    /// - An order's instrument cannot be fetched or parsed.
2108    ///
2109    /// # Notes
2110    ///
2111    /// Order parsing failures are skipped with a warning.
2112    pub async fn request_historical_order_status_reports<F>(
2113        &self,
2114        account_id: AccountId,
2115        start: Option<UnixNanos>,
2116        end: Option<UnixNanos>,
2117        cid_resolver: Option<F>,
2118    ) -> anyhow::Result<Vec<OrderStatusReport>>
2119    where
2120        F: Fn(u64) -> Option<ClientOrderId>,
2121    {
2122        const PAGE_SIZE: i32 = 100;
2123
2124        let mut params = GetOrdersParams {
2125            start_timestamp_ns: start.map(|timestamp| timestamp.as_i64()),
2126            end_timestamp_ns: end.map(|timestamp| timestamp.as_i64()),
2127            limit: Some(PAGE_SIZE),
2128            ..Default::default()
2129        };
2130        let mut orders = Vec::new();
2131        let mut seen_cursors = HashSet::new();
2132        let mut seen_order_ids = HashSet::new();
2133
2134        loop {
2135            let response = self
2136                .inner
2137                .get_orders(&params)
2138                .await
2139                .map_err(|e| anyhow::anyhow!(e))?;
2140
2141            for order in response.orders {
2142                anyhow::ensure!(
2143                    seen_order_ids.insert(order.oid.clone()),
2144                    "AX orders pagination returned duplicate order ID {}",
2145                    order.oid
2146                );
2147                orders.push(order);
2148            }
2149
2150            match response.next_cursor {
2151                Some(next_cursor) => {
2152                    anyhow::ensure!(
2153                        seen_cursors.insert(next_cursor.clone()),
2154                        "AX orders pagination repeated cursor {next_cursor:?}"
2155                    );
2156                    params.cursor = Some(next_cursor);
2157                }
2158                None => break,
2159            }
2160        }
2161
2162        let ts_init = self.generate_ts_init();
2163        let mut reports = Vec::with_capacity(orders.len());
2164
2165        for order in &orders {
2166            let instrument = self.resolve_report_instrument(order.s).await?;
2167
2168            match parse_order_detail_status_report(
2169                order,
2170                account_id,
2171                &instrument,
2172                ts_init,
2173                cid_resolver.as_ref(),
2174            ) {
2175                Ok(report) => reports.push(report),
2176                Err(e) => {
2177                    log::warn!("Failed to parse order {}: {e}", order.oid);
2178                }
2179            }
2180        }
2181
2182        Ok(reports)
2183    }
2184
2185    /// Requests fills from Ax and parses them to Nautilus [`FillReport`].
2186    ///
2187    /// Missing instruments are requested from Ax and cached before parsing fill details.
2188    /// Traverses the provider's cursor chain. This is a best-effort historical
2189    /// read, not an atomic snapshot if AX corrects rows during the traversal.
2190    ///
2191    /// # Errors
2192    ///
2193    /// Returns an error if:
2194    /// - The HTTP request fails.
2195    /// - A fill's instrument cannot be fetched or parsed.
2196    /// - Fill parsing fails.
2197    pub async fn request_fill_reports(
2198        &self,
2199        account_id: AccountId,
2200        start: Option<UnixNanos>,
2201        end: Option<UnixNanos>,
2202    ) -> anyhow::Result<Vec<FillReport>> {
2203        const PAGE_SIZE: i32 = 100;
2204
2205        // The AX `/fills` endpoint requires a bounded time range and caps the span at 7 days
2206        let max_span_ns = AX_FILLS_MAX_LOOKBACK_DAYS * 24 * 60 * 60 * 1_000_000_000;
2207        let end_ns = end.map_or_else(|| self.generate_ts_init().as_i64(), |e| e.as_i64());
2208        let floor_ns = end_ns - max_span_ns;
2209        let start_ns = start.map_or(floor_ns, |s| s.as_i64().max(floor_ns));
2210        let mut params = GetFillsParams::new(start_ns, end_ns);
2211        params.limit = Some(PAGE_SIZE);
2212        params.sort_ts = Some("desc".to_string());
2213
2214        let mut fills = Vec::new();
2215        let mut seen_trade_ids = HashSet::new();
2216        let mut seen_cursors = HashSet::new();
2217        let mut expected_total = None;
2218
2219        loop {
2220            let response = self
2221                .inner
2222                .get_fills_page(&params)
2223                .await
2224                .map_err(|e| anyhow::anyhow!(e))?;
2225            let page_len = response.fills.len();
2226
2227            anyhow::ensure!(
2228                page_len <= PAGE_SIZE as usize,
2229                "AX fills page length {page_len} exceeds requested limit {PAGE_SIZE}"
2230            );
2231
2232            if let Some(limit) = response.limit {
2233                anyhow::ensure!(
2234                    (0..=PAGE_SIZE).contains(&limit),
2235                    "AX fills applied limit must be between 0 and {PAGE_SIZE}, was {limit}"
2236                );
2237                anyhow::ensure!(
2238                    page_len <= limit as usize,
2239                    "AX fills page length {page_len} exceeds applied limit {limit}"
2240                );
2241            }
2242
2243            if let Some(total_count) = response.total_count {
2244                anyhow::ensure!(
2245                    total_count >= 0,
2246                    "AX fills total_count must be non-negative, was {total_count}"
2247                );
2248
2249                if let Some(expected) = expected_total {
2250                    anyhow::ensure!(
2251                        total_count == expected,
2252                        "AX fills total_count changed during pagination: expected {expected}, was {total_count}"
2253                    );
2254                } else {
2255                    expected_total = Some(total_count);
2256                }
2257            }
2258
2259            for fill in response.fills {
2260                anyhow::ensure!(
2261                    seen_trade_ids.insert(fill.trade_id.clone()),
2262                    "AX fills pagination returned duplicate trade ID {}",
2263                    fill.trade_id
2264                );
2265                fills.push(fill);
2266            }
2267
2268            if let Some(total_count) = expected_total {
2269                anyhow::ensure!(
2270                    fills.len() as i64 <= total_count,
2271                    "AX fills pagination returned more unique rows ({}) than total_count {total_count}",
2272                    fills.len()
2273                );
2274            }
2275
2276            match response.next_cursor {
2277                Some(next_cursor) => {
2278                    anyhow::ensure!(
2279                        !next_cursor.is_empty(),
2280                        "AX fills returned an empty next_cursor"
2281                    );
2282                    anyhow::ensure!(
2283                        page_len > 0,
2284                        "AX fills returned an empty page with a next_cursor"
2285                    );
2286                    anyhow::ensure!(
2287                        seen_cursors.insert(next_cursor.clone()),
2288                        "AX fills pagination repeated cursor {next_cursor:?}"
2289                    );
2290                    params.cursor = Some(next_cursor);
2291                }
2292                None => break,
2293            }
2294        }
2295
2296        if let Some(total_count) = expected_total {
2297            anyhow::ensure!(
2298                fills.len() as i64 == total_count,
2299                "AX fills pagination returned {} unique rows, expected {total_count}",
2300                fills.len()
2301            );
2302        }
2303
2304        let ts_init = self.generate_ts_init();
2305        let mut reports = Vec::with_capacity(fills.len());
2306
2307        for fill in &fills {
2308            let instrument = self.resolve_report_instrument(fill.symbol).await?;
2309            let report = parse_fill_report(fill, account_id, &instrument, ts_init)
2310                .with_context(|| format!("Failed to parse AX fill {}", fill.trade_id))?;
2311            reports.push(report);
2312        }
2313
2314        Ok(reports)
2315    }
2316
2317    /// Requests positions from Ax and parses them to Nautilus [`PositionStatusReport`].
2318    ///
2319    /// Missing instruments are requested from Ax and cached before parsing position details.
2320    ///
2321    /// # Errors
2322    ///
2323    /// Returns an error if:
2324    /// - The HTTP request fails.
2325    /// - A position's instrument cannot be fetched or parsed.
2326    ///
2327    /// # Notes
2328    ///
2329    /// Position parsing failures are skipped with a warning.
2330    pub async fn request_position_reports(
2331        &self,
2332        account_id: AccountId,
2333    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2334        let response = self
2335            .inner
2336            .get_positions()
2337            .await
2338            .map_err(|e| anyhow::anyhow!(e))?;
2339
2340        let ts_init = self.generate_ts_init();
2341        let mut reports = Vec::with_capacity(response.positions.len());
2342
2343        for position in &response.positions {
2344            // Skip flat positions (zero quantity)
2345            if position.signed_quantity == 0 {
2346                continue;
2347            }
2348
2349            let instrument = self.resolve_report_instrument(position.symbol).await?;
2350
2351            match parse_position_status_report(position, account_id, &instrument, ts_init) {
2352                Ok(report) => reports.push(report),
2353                Err(e) => {
2354                    log::warn!("Failed to parse position for {}: {e}", position.symbol);
2355                }
2356            }
2357        }
2358
2359        Ok(reports)
2360    }
2361
2362    async fn resolve_report_instrument(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
2363        if let Some(instrument) = self.get_instrument(&symbol) {
2364            return Ok(instrument);
2365        }
2366
2367        let instrument = self
2368            .request_instrument(symbol, None, None)
2369            .await
2370            .map_err(|e| {
2371                anyhow::anyhow!("Failed to resolve AX instrument {symbol} via GET /instrument: {e}")
2372            })?;
2373        self.cache_instrument(instrument.clone());
2374        Ok(instrument)
2375    }
2376
2377    /// Cancels all open orders for an instrument.
2378    ///
2379    /// # Errors
2380    ///
2381    /// Returns an error if the request fails.
2382    pub async fn cancel_all_orders(&self, instrument_id: InstrumentId) -> Result<(), AxHttpError> {
2383        let request = CancelAllOrdersRequest::new().with_symbol(instrument_id.symbol.inner());
2384        self.inner.cancel_all_orders(&request).await?;
2385        Ok(())
2386    }
2387}
2388
2389#[cfg(test)]
2390mod tests {
2391    use nautilus_testkit::http::assert_http_redirect_rejected;
2392    use rstest::rstest;
2393
2394    use super::*;
2395
2396    #[rstest]
2397    #[case::credentials(true)]
2398    #[case::session_token(false)]
2399    #[tokio::test]
2400    async fn test_authenticated_client_rejects_redirects(#[case] credentials: bool) {
2401        let client = if credentials {
2402            AxRawHttpClient::with_credentials(
2403                "key".into(),
2404                "secret".into(),
2405                None,
2406                None,
2407                3,
2408                0,
2409                1,
2410                1,
2411                None,
2412            )
2413            .unwrap()
2414        } else {
2415            AxRawHttpClient::new(None, None, 3, 0, 1, 1, None).unwrap()
2416        }
2417        .client;
2418        assert_http_redirect_rejected(|url| async move {
2419            client
2420                .get(url, None, None, Some(3), None)
2421                .await
2422                .unwrap()
2423                .status
2424                .as_u16()
2425        })
2426        .await;
2427    }
2428}