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