Skip to main content

nautilus_architect_ax/common/
parse.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//! Conversion functions that translate AX API schemas into Nautilus types.
17
18use std::sync::LazyLock;
19
20use ahash::RandomState;
21use nautilus_core::nanos::UnixNanos;
22pub use nautilus_core::serialization::{
23    deserialize_decimal_or_zero, deserialize_optional_decimal_from_str,
24    deserialize_optional_decimal_or_zero, deserialize_optional_decimal_str, parse_decimal,
25    parse_optional_decimal, serialize_decimal_as_str, serialize_optional_decimal_as_str,
26};
27use nautilus_model::{
28    data::BarSpecification,
29    identifiers::ClientOrderId,
30    types::{Quantity, fixed::FIXED_PRECISION, quantity::QuantityRaw},
31};
32
33use super::enums::AxCandleWidth;
34
35const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
36
37/// Converts an AX epoch-seconds timestamp to [`UnixNanos`].
38///
39/// # Panics
40///
41/// Panics if `seconds` is negative (malformed data from AX).
42#[must_use]
43pub fn ax_timestamp_s_to_unix_nanos(seconds: i64) -> UnixNanos {
44    assert!(
45        seconds >= 0,
46        "AX timestamp must be non-negative, was {seconds}"
47    );
48    UnixNanos::from(seconds as u64 * NANOSECONDS_IN_SECOND)
49}
50
51/// Converts an AX nanosecond timestamp to [`UnixNanos`].
52///
53/// # Panics
54///
55/// Panics if `nanos` is negative (malformed data from AX).
56#[must_use]
57pub fn ax_timestamp_ns_to_unix_nanos(nanos: i64) -> UnixNanos {
58    assert!(
59        nanos >= 0,
60        "AX timestamp_ns must be non-negative, was {nanos}"
61    );
62    UnixNanos::from(nanos as u64)
63}
64
65/// Cached hasher state for deterministic client order ID to cid conversion
66static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
67    RandomState::with_seeds(
68        0x517cc1b727220a95,
69        0x9b5c18c90c3c314d,
70        0x5851f42d4c957f2d,
71        0x14057b7ef767814f,
72    )
73});
74
75/// Maps a Nautilus [`BarSpecification`] to an [`AxCandleWidth`].
76///
77/// # Errors
78///
79/// Returns an error if the bar specification is not supported by Ax.
80pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
81    AxCandleWidth::try_from(spec)
82}
83
84/// Converts a [`Quantity`] to an i64 contract count for AX orders.
85///
86/// AX uses integer contracts only. Uses integer arithmetic to avoid
87/// floating-point precision issues.
88///
89/// # Errors
90///
91/// Returns an error if:
92/// - The quantity represents a fractional number of contracts.
93/// - The quantity is zero.
94pub fn quantity_to_contracts(quantity: Quantity) -> anyhow::Result<u64> {
95    let raw = quantity.raw;
96    let scale = 10_u64.pow(FIXED_PRECISION as u32) as QuantityRaw;
97
98    // AX requires whole contract quantities
99    if !raw.is_multiple_of(scale) {
100        anyhow::bail!(
101            "AX requires whole contract quantities, was {}",
102            quantity.as_f64()
103        );
104    }
105
106    #[allow(clippy::unnecessary_cast)]
107    let contracts = (raw / scale) as u64;
108    if contracts == 0 {
109        anyhow::bail!("Order quantity must be at least 1 contract");
110    }
111    Ok(contracts)
112}
113
114/// Converts a [`ClientOrderId`] to a 64-bit unsigned integer for AX `cid` field.
115///
116/// Uses a deterministic hash of the client order ID string to produce
117/// a u64 value that can be used for order correlation.
118#[must_use]
119pub fn client_order_id_to_cid(client_order_id: &ClientOrderId) -> u64 {
120    CID_HASHER.hash_one(client_order_id.inner())
121}
122
123/// Creates a [`ClientOrderId`] from a cid value.
124///
125/// Used when we receive an order with a cid but cannot resolve it to the
126/// original ClientOrderId (e.g., after restart when in-memory mapping is lost).
127#[must_use]
128pub fn cid_to_client_order_id(cid: u64) -> ClientOrderId {
129    ClientOrderId::new(format!("CID-{cid}"))
130}
131
132#[cfg(test)]
133mod tests {
134    use nautilus_model::{
135        enums::{BarAggregation, PriceType},
136        identifiers::ClientOrderId,
137        types::Quantity,
138    };
139    use rstest::rstest;
140
141    use super::*;
142
143    #[rstest]
144    fn test_client_order_id_to_cid_deterministic() {
145        let coid = ClientOrderId::new("O-20240101-000001");
146
147        // Must produce same result across multiple calls
148        let cid1 = client_order_id_to_cid(&coid);
149        let cid2 = client_order_id_to_cid(&coid);
150        let cid3 = client_order_id_to_cid(&coid);
151
152        assert_eq!(cid1, cid2);
153        assert_eq!(cid2, cid3);
154    }
155
156    #[rstest]
157    fn test_client_order_id_to_cid_different_ids() {
158        let coid1 = ClientOrderId::new("O-20240101-000001");
159        let coid2 = ClientOrderId::new("O-20240101-000002");
160
161        let cid1 = client_order_id_to_cid(&coid1);
162        let cid2 = client_order_id_to_cid(&coid2);
163
164        assert_ne!(cid1, cid2);
165    }
166
167    #[rstest]
168    fn test_quantity_to_contracts_valid_precision_zero() {
169        let qty = Quantity::new(10.0, 0);
170        let result = quantity_to_contracts(qty);
171        assert!(result.is_ok());
172        assert_eq!(result.unwrap(), 10);
173    }
174
175    #[rstest]
176    fn test_quantity_to_contracts_valid_with_precision() {
177        // Whole number with non-zero precision should work
178        let qty = Quantity::new(10.0, 2);
179        let result = quantity_to_contracts(qty);
180        assert!(result.is_ok());
181        assert_eq!(result.unwrap(), 10);
182    }
183
184    #[rstest]
185    fn test_quantity_to_contracts_fractional_rejects() {
186        let qty = Quantity::new(10.5, 1);
187        let result = quantity_to_contracts(qty);
188        assert!(result.is_err());
189    }
190
191    #[rstest]
192    fn test_quantity_to_contracts_zero_rejects() {
193        let qty = Quantity::new(0.0, 0);
194        let result = quantity_to_contracts(qty);
195        assert!(result.is_err());
196    }
197
198    #[rstest]
199    fn test_map_bar_spec_1_second() {
200        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
201        let result = map_bar_spec_to_candle_width(&spec);
202        assert!(result.is_ok());
203        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds1));
204    }
205
206    #[rstest]
207    fn test_map_bar_spec_5_second() {
208        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
209        let result = map_bar_spec_to_candle_width(&spec);
210        assert!(result.is_ok());
211        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds5));
212    }
213
214    #[rstest]
215    fn test_map_bar_spec_1_minute() {
216        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
217        let result = map_bar_spec_to_candle_width(&spec);
218        assert!(result.is_ok());
219        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes1));
220    }
221
222    #[rstest]
223    fn test_map_bar_spec_5_minute() {
224        let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Last);
225        let result = map_bar_spec_to_candle_width(&spec);
226        assert!(result.is_ok());
227        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes5));
228    }
229
230    #[rstest]
231    fn test_map_bar_spec_15_minute() {
232        let spec = BarSpecification::new(15, BarAggregation::Minute, PriceType::Last);
233        let result = map_bar_spec_to_candle_width(&spec);
234        assert!(result.is_ok());
235        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes15));
236    }
237
238    #[rstest]
239    fn test_map_bar_spec_1_hour() {
240        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
241        let result = map_bar_spec_to_candle_width(&spec);
242        assert!(result.is_ok());
243        assert!(matches!(result.unwrap(), AxCandleWidth::Hours1));
244    }
245
246    #[rstest]
247    fn test_map_bar_spec_1_day() {
248        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
249        let result = map_bar_spec_to_candle_width(&spec);
250        assert!(result.is_ok());
251        assert!(matches!(result.unwrap(), AxCandleWidth::Days1));
252    }
253
254    #[rstest]
255    fn test_map_bar_spec_unsupported_step() {
256        let spec = BarSpecification::new(3, BarAggregation::Minute, PriceType::Last);
257        let result = map_bar_spec_to_candle_width(&spec);
258        assert!(result.is_err());
259    }
260
261    #[rstest]
262    fn test_map_bar_spec_unsupported_aggregation() {
263        let spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
264        let result = map_bar_spec_to_candle_width(&spec);
265        assert!(result.is_err());
266    }
267}