1use 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
88pub 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
97pub 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 #[must_use]
133 pub fn base_url(&self) -> &str {
134 &self.base_url
135 }
136
137 #[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 pub fn cancel_all_requests(&self) {
147 self.cancellation_token.read().cancel();
148 }
149
150 pub fn reset_cancellation_token(&self) {
152 *self.cancellation_token.write() = CancellationToken::new();
153 }
154
155 pub fn cancellation_token(&self) -> CancellationToken {
157 self.cancellation_token.read().clone()
158 }
159
160 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 #[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 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 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 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 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 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 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 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 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 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(¶ms), None, true)
516 .await
517 .map(|response| response.ticker)
518 }
519
520 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(¶ms), None, false)
531 .await
532 }
533
534 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 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 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 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 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 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 pub async fn get_open_orders(&self) -> Result<AxOpenOrdersResponse, AxHttpError> {
718 self.get_open_orders_page(&GetOpenOrdersParams::new()).await
719 }
720
721 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 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(¶ms).await
756 }
757
758 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 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(¶ms),
792 None,
793 true,
794 )
795 .await
796 }
797
798 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(¶ms),
817 None,
818 true,
819 )
820 .await?;
821 Ok(response.candle)
822 }
823
824 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(¶ms),
843 None,
844 true,
845 )
846 .await?;
847 Ok(response.candle)
848 }
849
850 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(¶ms).await
866 }
867
868 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 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 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 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 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(¶ms).await
973 }
974
975 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 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(¶ms), None, true)
1009 .await
1010 }
1011
1012 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 self.send_request::<AxBookResponse, _>(Method::GET, "/book", Some(¶ms), None, true)
1028 .await
1029 }
1030
1031 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(¶ms),
1049 None,
1050 true,
1051 )
1052 .await
1053 }
1054
1055 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(¶ms),
1073 None,
1074 true,
1075 )
1076 .await
1077 }
1078
1079 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 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#[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 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 #[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 #[must_use]
1238 pub fn base_url(&self) -> &str {
1239 self.inner.base_url()
1240 }
1241
1242 #[must_use]
1244 pub fn api_key_masked(&self) -> String {
1245 self.inner.api_key_masked()
1246 }
1247
1248 pub fn cancel_all_requests(&self) {
1250 self.inner.cancel_all_requests();
1251 }
1252
1253 pub fn reset_cancellation_token(&self) {
1255 self.inner.reset_cancellation_token();
1256 }
1257
1258 pub fn set_session_token(&self, token: SecretString) {
1262 self.inner.set_session_token(token);
1263 }
1264
1265 fn generate_ts_init(&self) -> UnixNanos {
1267 self.clock.get_time_ns()
1268 }
1269
1270 #[must_use]
1274 pub fn is_initialized(&self) -> bool {
1275 self.cache_initialized.load(Ordering::Acquire)
1276 }
1277
1278 #[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 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 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 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 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 pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1359 self.instruments_cache.get_cloned(symbol)
1360 }
1361
1362 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 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 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 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 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 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 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 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 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(¶ms).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 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(¶ms).await
1850 }
1851
1852 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 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 #[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 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(¶ms)
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 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(¶ms)
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 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 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(¶ms)
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 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 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 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}