Skip to main content

nautilus_architect_ax/python/
http.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//! Python bindings for the Ax HTTP client.
17
18use ahash::AHashMap;
19use jiff::Timestamp;
20use nautilus_core::{
21    datetime::datetime_to_unix_nanos, python::to_pyvalue_err, string::secret::SecretString,
22};
23use nautilus_model::{
24    data::BarType,
25    enums::{OrderSide, OrderType, TimeInForce},
26    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
27    python::instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
28    types::{Price, Quantity},
29};
30use pyo3::{IntoPyObjectExt, prelude::*, types::PyList};
31use rust_decimal::Decimal;
32
33use crate::{
34    common::{
35        enums::{AxCandleWidth, AxOrderSide},
36        parse::{client_order_id_to_cid, quantity_to_contracts},
37    },
38    http::{client::AxHttpClient, error::AxHttpError, models::PreviewAggressiveLimitOrderRequest},
39};
40
41#[pymethods]
42#[pyo3_stub_gen::derive::gen_stub_pymethods]
43impl AxHttpClient {
44    /// High-level HTTP client for the Ax REST API.
45    ///
46    /// This client wraps the underlying `AxRawHttpClient` to provide a convenient
47    /// interface for Python bindings and instrument caching.
48    #[new]
49    #[pyo3(signature = (
50        base_url=None,
51        orders_base_url=None,
52        timeout_secs=60,
53        max_retries=3,
54        retry_delay_ms=1000,
55        retry_delay_max_ms=10_000,
56        proxy_url=None,
57    ))]
58    fn py_new(
59        base_url: Option<String>,
60        orders_base_url: Option<String>,
61        timeout_secs: u64,
62        max_retries: u32,
63        retry_delay_ms: u64,
64        retry_delay_max_ms: u64,
65        proxy_url: Option<String>,
66    ) -> PyResult<Self> {
67        Self::new(
68            base_url,
69            orders_base_url,
70            timeout_secs,
71            max_retries,
72            retry_delay_ms,
73            retry_delay_max_ms,
74            proxy_url,
75        )
76        .map_err(to_pyvalue_err)
77    }
78
79    /// Creates a new `AxHttpClient` configured with credentials.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if the HTTP client cannot be created.
84    #[staticmethod]
85    #[pyo3(name = "with_credentials")]
86    #[pyo3(signature = (
87        api_key,
88        api_secret,
89        base_url=None,
90        orders_base_url=None,
91        timeout_secs=60,
92        max_retries=3,
93        retry_delay_ms=1000,
94        retry_delay_max_ms=10_000,
95        proxy_url=None,
96    ))]
97    #[expect(clippy::too_many_arguments)]
98    fn py_with_credentials(
99        api_key: String,
100        api_secret: String,
101        base_url: Option<String>,
102        orders_base_url: Option<String>,
103        timeout_secs: u64,
104        max_retries: u32,
105        retry_delay_ms: u64,
106        retry_delay_max_ms: u64,
107        proxy_url: Option<String>,
108    ) -> PyResult<Self> {
109        Self::with_credentials(
110            api_key,
111            api_secret,
112            base_url,
113            orders_base_url,
114            timeout_secs,
115            max_retries,
116            retry_delay_ms,
117            retry_delay_max_ms,
118            proxy_url,
119        )
120        .map_err(to_pyvalue_err)
121    }
122
123    /// Returns the base URL for this client.
124    #[getter]
125    #[pyo3(name = "base_url")]
126    #[must_use]
127    pub fn py_base_url(&self) -> &str {
128        self.base_url()
129    }
130
131    /// Returns a masked version of the API key for logging purposes.
132    #[getter]
133    #[pyo3(name = "api_key_masked")]
134    #[must_use]
135    pub fn py_api_key_masked(&self) -> String {
136        self.api_key_masked()
137    }
138
139    /// Cancel all pending HTTP requests.
140    #[pyo3(name = "cancel_all_requests")]
141    pub fn py_cancel_all_requests(&self) {
142        self.cancel_all_requests();
143    }
144
145    /// Cancels all open orders for an instrument.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if the request fails.
150    #[pyo3(name = "cancel_all_orders")]
151    pub fn py_cancel_all_orders<'py>(
152        &self,
153        py: Python<'py>,
154        instrument_id: InstrumentId,
155    ) -> PyResult<Bound<'py, PyAny>> {
156        let client = self.clone();
157        pyo3_async_runtimes::tokio::future_into_py(py, async move {
158            client
159                .cancel_all_orders(instrument_id)
160                .await
161                .map_err(to_pyvalue_err)
162        })
163    }
164
165    /// Caches a single instrument.
166    ///
167    /// Any existing instrument with the same symbol will be replaced.
168    #[pyo3(name = "cache_instrument")]
169    pub fn py_cache_instrument(&self, py: Python<'_>, instrument: Py<PyAny>) -> PyResult<()> {
170        self.cache_instrument(pyobject_to_instrument_any(py, instrument)?);
171        Ok(())
172    }
173
174    /// Authenticates with Ax using API credentials.
175    ///
176    /// On success, the session token is automatically stored for subsequent authenticated requests.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the HTTP request fails or credentials are invalid.
181    #[pyo3(name = "authenticate")]
182    #[pyo3(signature = (api_key, api_secret, expiration_seconds=86400))]
183    fn py_authenticate<'py>(
184        &self,
185        py: Python<'py>,
186        api_key: String,
187        api_secret: String,
188        expiration_seconds: i32,
189    ) -> PyResult<Bound<'py, PyAny>> {
190        let client = self.clone();
191
192        pyo3_async_runtimes::tokio::future_into_py(py, async move {
193            client
194                .authenticate(&api_key, &api_secret, expiration_seconds)
195                .await
196                .map(SecretString::into_inner)
197                .map_err(to_pyvalue_err)
198        })
199    }
200
201    /// Authenticates using stored credentials or environment variables.
202    ///
203    /// # Credential Resolution
204    ///
205    /// Credentials are resolved in the following order:
206    /// 1. Stored credentials (from `with_credentials` constructor)
207    /// 2. Environment variables (`AX_API_KEY` and `AX_API_SECRET`)
208    ///
209    /// On success, the session token is automatically stored for subsequent authenticated requests.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if:
214    /// - No credentials are available from either source
215    /// - The HTTP request fails
216    /// - The credentials are invalid
217    #[pyo3(name = "authenticate_auto")]
218    #[pyo3(signature = (expiration_seconds=86400))]
219    fn py_authenticate_auto<'py>(
220        &self,
221        py: Python<'py>,
222        expiration_seconds: i32,
223    ) -> PyResult<Bound<'py, PyAny>> {
224        let client = self.clone();
225
226        pyo3_async_runtimes::tokio::future_into_py(py, async move {
227            client
228                .authenticate_auto(expiration_seconds)
229                .await
230                .map(SecretString::into_inner)
231                .map_err(to_pyvalue_err)
232        })
233    }
234
235    /// Requests all instruments from Ax.
236    ///
237    /// Fee rates fall back to the rates last resolved from `GET /whoami`, and to zero when no
238    /// rates have been resolved.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the HTTP request fails or instrument parsing fails.
243    #[pyo3(name = "request_instruments")]
244    #[pyo3(signature = (maker_fee=None, taker_fee=None))]
245    fn py_request_instruments<'py>(
246        &self,
247        py: Python<'py>,
248        maker_fee: Option<Decimal>,
249        taker_fee: Option<Decimal>,
250    ) -> PyResult<Bound<'py, PyAny>> {
251        let client = self.clone();
252
253        pyo3_async_runtimes::tokio::future_into_py(py, async move {
254            let instruments = client
255                .request_instruments(maker_fee, taker_fee)
256                .await
257                .map_err(to_pyvalue_err)?;
258
259            Python::attach(|py| {
260                let py_instruments: PyResult<Vec<_>> = instruments
261                    .into_iter()
262                    .map(|inst| instrument_any_to_pyobject(py, inst))
263                    .collect();
264                let pylist = PyList::new(py, py_instruments?)?.into_any().unbind();
265                Ok(pylist)
266            })
267        })
268    }
269
270    /// Requests recent trades from Ax and parses them to Nautilus `TradeTick`.
271    ///
272    /// The AX trades endpoint does not accept time range parameters, so
273    /// `start` and `end` are applied as client-side filters after fetching.
274    ///
275    /// Requires the instrument to be cached.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if:
280    /// - The instrument is not found in the cache.
281    /// - The HTTP request fails.
282    /// - Trade parsing fails.
283    #[pyo3(name = "request_trade_ticks")]
284    #[pyo3(signature = (instrument_id, limit=None, start=None, end=None))]
285    fn py_request_trade_ticks<'py>(
286        &self,
287        py: Python<'py>,
288        instrument_id: InstrumentId,
289        limit: Option<i32>,
290        start: Option<Timestamp>,
291        end: Option<Timestamp>,
292    ) -> PyResult<Bound<'py, PyAny>> {
293        let client = self.clone();
294        let symbol = instrument_id.symbol.inner();
295        let start_nanos = datetime_to_unix_nanos(start);
296        let end_nanos = datetime_to_unix_nanos(end);
297
298        pyo3_async_runtimes::tokio::future_into_py(py, async move {
299            let trades = client
300                .request_trade_ticks(symbol, limit, start_nanos, end_nanos)
301                .await
302                .map_err(to_pyvalue_err)?;
303
304            Python::attach(|py| {
305                let py_trades: PyResult<Vec<_>> = trades
306                    .into_iter()
307                    .map(|trade| trade.into_py_any(py))
308                    .collect();
309                let pylist = PyList::new(py, py_trades?)?.into_any().unbind();
310                Ok(pylist)
311            })
312        })
313    }
314
315    /// Requests historical bars from Ax and parses them to Nautilus Bar types.
316    ///
317    /// Requires the instrument to be cached (call `request_instruments` first).
318    ///
319    /// # Errors
320    ///
321    /// Returns an error if:
322    /// - The instrument is not found in the cache.
323    /// - The HTTP request fails.
324    /// - Bar parsing fails.
325    #[pyo3(name = "request_bars")]
326    #[pyo3(signature = (bar_type, start=None, end=None))]
327    fn py_request_bars<'py>(
328        &self,
329        py: Python<'py>,
330        bar_type: BarType,
331        start: Option<Timestamp>,
332        end: Option<Timestamp>,
333    ) -> PyResult<Bound<'py, PyAny>> {
334        let client = self.clone();
335        let symbol = bar_type.instrument_id().symbol.inner();
336        let width = AxCandleWidth::try_from(&bar_type.spec()).map_err(to_pyvalue_err)?;
337
338        pyo3_async_runtimes::tokio::future_into_py(py, async move {
339            let bars = client
340                .request_bars(symbol, start, end, width)
341                .await
342                .map_err(to_pyvalue_err)?;
343
344            Python::attach(|py| {
345                let py_bars: PyResult<Vec<_>> =
346                    bars.into_iter().map(|bar| bar.into_py_any(py)).collect();
347                let pylist = PyList::new(py, py_bars?)?.into_any().unbind();
348                Ok(pylist)
349            })
350        })
351    }
352
353    /// Requests an order book snapshot from Ax and builds a Nautilus `OrderBook`.
354    ///
355    /// Requires the instrument to be cached.
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if:
360    /// - The instrument is not found in the cache.
361    /// - The HTTP request fails.
362    #[pyo3(name = "request_book_snapshot")]
363    #[pyo3(signature = (instrument_id, depth=None))]
364    fn py_request_book_snapshot<'py>(
365        &self,
366        py: Python<'py>,
367        instrument_id: InstrumentId,
368        depth: Option<u32>,
369    ) -> PyResult<Bound<'py, PyAny>> {
370        let client = self.clone();
371        let symbol = instrument_id.symbol.inner();
372
373        pyo3_async_runtimes::tokio::future_into_py(py, async move {
374            let book = client
375                .request_book_snapshot(symbol, depth.map(|value| value as usize))
376                .await
377                .map_err(to_pyvalue_err)?;
378
379            Python::attach(|py| book.into_py_any(py))
380        })
381    }
382
383    /// Requests funding rates from Ax and parses them to Nautilus types.
384    ///
385    /// Traverses the provider's cursor chain. This is a best-effort historical
386    /// read, not an atomic snapshot if AX corrects rows during the traversal.
387    ///
388    /// # Errors
389    ///
390    /// Returns an error if the HTTP request fails.
391    #[pyo3(name = "request_funding_rates")]
392    #[pyo3(signature = (instrument_id, start=None, end=None))]
393    fn py_request_funding_rates<'py>(
394        &self,
395        py: Python<'py>,
396        instrument_id: InstrumentId,
397        start: Option<Timestamp>,
398        end: Option<Timestamp>,
399    ) -> PyResult<Bound<'py, PyAny>> {
400        let client = self.clone();
401
402        pyo3_async_runtimes::tokio::future_into_py(py, async move {
403            let funding_rates = client
404                .request_funding_rates(instrument_id, start, end)
405                .await
406                .map_err(to_pyvalue_err)?;
407
408            Python::attach(|py| {
409                let py_rates: PyResult<Vec<_>> = funding_rates
410                    .into_iter()
411                    .map(|rate| rate.into_py_any(py))
412                    .collect();
413                let pylist = PyList::new(py, py_rates?)?.into_any().unbind();
414                Ok(pylist)
415            })
416        })
417    }
418
419    /// Requests account state from Ax and parses to a Nautilus `AccountState`.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if the HTTP request fails or parsing fails.
424    #[pyo3(name = "request_account_state")]
425    fn py_request_account_state<'py>(
426        &self,
427        py: Python<'py>,
428        account_id: AccountId,
429    ) -> PyResult<Bound<'py, PyAny>> {
430        let client = self.clone();
431
432        pyo3_async_runtimes::tokio::future_into_py(py, async move {
433            let account_state = client
434                .request_account_state(account_id)
435                .await
436                .map_err(to_pyvalue_err)?;
437
438            Python::attach(|py| account_state.into_py_any(py))
439        })
440    }
441
442    /// Queries a single order by venue order ID or client order ID using the
443    /// dedicated `/order-status` endpoint, which works for any order state.
444    ///
445    /// The caller must supply `order_side`, `order_type`, and `time_in_force`
446    /// because the endpoint does not return these fields.
447    ///
448    /// # Errors
449    ///
450    /// Returns an error if:
451    /// - Neither `venue_order_id` nor `client_order_id` is provided.
452    /// - The HTTP request fails.
453    #[pyo3(name = "request_order_status")]
454    #[pyo3(signature = (
455        account_id,
456        instrument_id,
457        order_side,
458        order_type,
459        time_in_force,
460        client_order_id=None,
461        venue_order_id=None,
462    ))]
463    #[expect(clippy::too_many_arguments)]
464    fn py_request_order_status<'py>(
465        &self,
466        py: Python<'py>,
467        account_id: AccountId,
468        instrument_id: InstrumentId,
469        order_side: OrderSide,
470        order_type: OrderType,
471        time_in_force: TimeInForce,
472        client_order_id: Option<ClientOrderId>,
473        venue_order_id: Option<VenueOrderId>,
474    ) -> PyResult<Bound<'py, PyAny>> {
475        let client = self.clone();
476
477        pyo3_async_runtimes::tokio::future_into_py(py, async move {
478            let report = client
479                .request_order_status(
480                    account_id,
481                    instrument_id,
482                    client_order_id,
483                    venue_order_id,
484                    Some(order_side),
485                    order_type,
486                    time_in_force,
487                )
488                .await
489                .map_err(to_pyvalue_err)?;
490
491            Python::attach(|py| report.into_py_any(py))
492        })
493    }
494
495    /// Requests open orders from Ax and parses them to Nautilus `OrderStatusReport`.
496    ///
497    /// Missing instruments are requested from Ax and cached before parsing order details.
498    ///
499    /// The `cid_resolver` parameter is an optional function that resolves a `cid` (u64)
500    /// to a `ClientOrderId`. This is needed for correlating orders submitted via WebSocket.
501    ///
502    /// # Errors
503    ///
504    /// Returns an error if:
505    /// - The HTTP request fails.
506    /// - An order's instrument cannot be fetched or parsed.
507    ///
508    /// # Notes
509    ///
510    /// Order parsing failures are skipped with a warning.
511    #[pyo3(name = "request_order_status_reports", signature = (account_id, client_order_ids=None))]
512    fn py_request_order_status_reports<'py>(
513        &self,
514        py: Python<'py>,
515        account_id: AccountId,
516        client_order_ids: Option<Vec<ClientOrderId>>,
517    ) -> PyResult<Bound<'py, PyAny>> {
518        let client = self.clone();
519        let cid_map = client_order_ids
520            .unwrap_or_default()
521            .into_iter()
522            .map(|client_order_id| (client_order_id_to_cid(&client_order_id), client_order_id))
523            .collect::<AHashMap<_, _>>();
524
525        pyo3_async_runtimes::tokio::future_into_py(py, async move {
526            let cid_resolver = move |cid: u64| cid_map.get(&cid).copied();
527            let reports = client
528                .request_order_status_reports(account_id, Some(cid_resolver))
529                .await
530                .map_err(to_pyvalue_err)?;
531
532            Python::attach(|py| {
533                let py_reports: PyResult<Vec<_>> = reports
534                    .into_iter()
535                    .map(|report| report.into_py_any(py))
536                    .collect();
537                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
538                Ok(pylist)
539            })
540        })
541    }
542
543    /// Requests fills from Ax and parses them to Nautilus `FillReport`.
544    ///
545    /// Missing instruments are requested from Ax and cached before parsing fill details.
546    /// Traverses the provider's cursor chain. This is a best-effort historical
547    /// read, not an atomic snapshot if AX corrects rows during the traversal.
548    ///
549    /// # Errors
550    ///
551    /// Returns an error if:
552    /// - The HTTP request fails.
553    /// - A fill's instrument cannot be fetched or parsed.
554    /// - Fill parsing fails.
555    #[pyo3(name = "request_fill_reports")]
556    fn py_request_fill_reports<'py>(
557        &self,
558        py: Python<'py>,
559        account_id: AccountId,
560    ) -> PyResult<Bound<'py, PyAny>> {
561        let client = self.clone();
562
563        pyo3_async_runtimes::tokio::future_into_py(py, async move {
564            let reports = client
565                .request_fill_reports(account_id, None, None)
566                .await
567                .map_err(to_pyvalue_err)?;
568
569            Python::attach(|py| {
570                let py_reports: PyResult<Vec<_>> = reports
571                    .into_iter()
572                    .map(|report| report.into_py_any(py))
573                    .collect();
574                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
575                Ok(pylist)
576            })
577        })
578    }
579
580    /// Requests positions from Ax and parses them to Nautilus `PositionStatusReport`.
581    ///
582    /// Missing instruments are requested from Ax and cached before parsing position details.
583    ///
584    /// # Errors
585    ///
586    /// Returns an error if:
587    /// - The HTTP request fails.
588    /// - A position's instrument cannot be fetched or parsed.
589    ///
590    /// # Notes
591    ///
592    /// Position parsing failures are skipped with a warning.
593    #[pyo3(name = "request_position_reports")]
594    fn py_request_position_reports<'py>(
595        &self,
596        py: Python<'py>,
597        account_id: AccountId,
598    ) -> PyResult<Bound<'py, PyAny>> {
599        let client = self.clone();
600
601        pyo3_async_runtimes::tokio::future_into_py(py, async move {
602            let reports = client
603                .request_position_reports(account_id)
604                .await
605                .map_err(to_pyvalue_err)?;
606
607            Python::attach(|py| {
608                let py_reports: PyResult<Vec<_>> = reports
609                    .into_iter()
610                    .map(|report| report.into_py_any(py))
611                    .collect();
612                let pylist = PyList::new(py, py_reports?)?.into_any().unbind();
613                Ok(pylist)
614            })
615        })
616    }
617
618    #[pyo3(name = "preview_aggressive_limit_order")]
619    fn py_preview_aggressive_limit_order<'py>(
620        &self,
621        py: Python<'py>,
622        instrument_id: InstrumentId,
623        quantity: Quantity,
624        side: OrderSide,
625    ) -> PyResult<Bound<'py, PyAny>> {
626        let symbol = instrument_id.symbol.inner();
627        let ax_side = AxOrderSide::from(side);
628        let qty_contracts = quantity_to_contracts(quantity).map_err(to_pyvalue_err)?;
629
630        let client = self.clone();
631
632        pyo3_async_runtimes::tokio::future_into_py(py, async move {
633            let request = PreviewAggressiveLimitOrderRequest::new(symbol, qty_contracts, ax_side);
634            let response = client
635                .inner
636                .preview_aggressive_limit_order(&request)
637                .await
638                .map_err(to_pyvalue_err)?;
639
640            let price = response
641                .limit_price
642                .map(|p| Price::from(p.to_string().as_str()));
643
644            Python::attach(|py| price.into_py_any(py))
645        })
646    }
647}
648
649impl From<AxHttpError> for PyErr {
650    fn from(error: AxHttpError) -> Self {
651        to_pyvalue_err(error)
652    }
653}