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