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