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/// # Errors
40///
41/// Returns an error if `seconds` is negative (malformed data from AX).
42pub fn ax_timestamp_s_to_unix_nanos(seconds: i64) -> anyhow::Result<UnixNanos> {
43    anyhow::ensure!(
44        seconds >= 0,
45        "AX timestamp must be non-negative, was {seconds}"
46    );
47    Ok(UnixNanos::from(seconds as u64 * NANOSECONDS_IN_SECOND))
48}
49
50/// Converts AX `ts` (seconds) + `tn` (nanoseconds) fields to [`UnixNanos`].
51///
52/// # Errors
53///
54/// Returns an error if `seconds` is negative (malformed data from AX).
55pub fn ax_timestamp_stn_to_unix_nanos(seconds: i64, nanos: i64) -> anyhow::Result<UnixNanos> {
56    anyhow::ensure!(
57        seconds >= 0,
58        "AX timestamp must be non-negative, was {seconds}"
59    );
60    let nanos_part = nanos.max(0) as u64;
61    Ok(UnixNanos::from(
62        seconds as u64 * NANOSECONDS_IN_SECOND + nanos_part,
63    ))
64}
65
66/// Converts an AX nanosecond timestamp to [`UnixNanos`].
67///
68/// # Errors
69///
70/// Returns an error if `nanos` is negative (malformed data from AX).
71pub fn ax_timestamp_ns_to_unix_nanos(nanos: i64) -> anyhow::Result<UnixNanos> {
72    anyhow::ensure!(
73        nanos >= 0,
74        "AX timestamp_ns must be non-negative, was {nanos}"
75    );
76    Ok(UnixNanos::from(nanos as u64))
77}
78
79/// Cached hasher state for deterministic client order ID to cid conversion
80static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
81    RandomState::with_seeds(
82        0x517cc1b727220a95,
83        0x9b5c18c90c3c314d,
84        0x5851f42d4c957f2d,
85        0x14057b7ef767814f,
86    )
87});
88
89/// Maps a Nautilus [`BarSpecification`] to an [`AxCandleWidth`].
90///
91/// # Errors
92///
93/// Returns an error if the bar specification is not supported by Ax.
94pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
95    AxCandleWidth::try_from(spec)
96}
97
98/// Converts a [`Quantity`] to an i64 contract count for AX orders.
99///
100/// AX uses integer contracts only. Uses integer arithmetic to avoid
101/// floating-point precision issues.
102///
103/// # Errors
104///
105/// Returns an error if:
106/// - The quantity represents a fractional number of contracts.
107/// - The quantity is zero.
108pub fn quantity_to_contracts(quantity: Quantity) -> anyhow::Result<u64> {
109    let raw = quantity.raw;
110    let scale = 10_u64.pow(FIXED_PRECISION as u32) as QuantityRaw;
111
112    // AX requires whole contract quantities
113    if !raw.is_multiple_of(scale) {
114        anyhow::bail!(
115            "AX requires whole contract quantities, was {}",
116            quantity.as_f64()
117        );
118    }
119
120    // QuantityRaw is u128 under the `high-precision` feature and u64 otherwise,
121    // so the narrowing cast is conditional on the active feature set.
122    #[allow(clippy::unnecessary_cast)]
123    let contracts = (raw / scale) as u64;
124    if contracts == 0 {
125        anyhow::bail!("Order quantity must be at least 1 contract");
126    }
127    Ok(contracts)
128}
129
130/// Converts a [`ClientOrderId`] to a 64-bit unsigned integer for AX `cid` field.
131///
132/// Uses a deterministic hash of the client order ID string to produce
133/// a u64 value that can be used for order correlation.
134#[must_use]
135pub fn client_order_id_to_cid(client_order_id: &ClientOrderId) -> u64 {
136    CID_HASHER.hash_one(client_order_id.inner())
137}
138
139/// Creates a [`ClientOrderId`] from a cid value.
140///
141/// Used when we receive an order with a cid but cannot resolve it to the
142/// original ClientOrderId (e.g., after restart when in-memory mapping is lost).
143#[must_use]
144pub fn cid_to_client_order_id(cid: u64) -> ClientOrderId {
145    ClientOrderId::new(format!("CID-{cid}"))
146}
147
148#[cfg(test)]
149mod tests {
150    use nautilus_model::{
151        enums::{BarAggregation, PriceType},
152        identifiers::ClientOrderId,
153        types::Quantity,
154    };
155    use rstest::rstest;
156
157    use super::*;
158
159    #[rstest]
160    fn test_client_order_id_to_cid_deterministic() {
161        let coid = ClientOrderId::new("O-20240101-000001");
162
163        // Must produce same result across multiple calls
164        let cid1 = client_order_id_to_cid(&coid);
165        let cid2 = client_order_id_to_cid(&coid);
166        let cid3 = client_order_id_to_cid(&coid);
167
168        assert_eq!(cid1, cid2);
169        assert_eq!(cid2, cid3);
170    }
171
172    #[rstest]
173    fn test_client_order_id_to_cid_different_ids() {
174        let coid1 = ClientOrderId::new("O-20240101-000001");
175        let coid2 = ClientOrderId::new("O-20240101-000002");
176
177        let cid1 = client_order_id_to_cid(&coid1);
178        let cid2 = client_order_id_to_cid(&coid2);
179
180        assert_ne!(cid1, cid2);
181    }
182
183    #[rstest]
184    #[case("O-1")]
185    #[case("O-SHORT")]
186    #[case("O-20240101-000001")]
187    #[case("Order-with-dashes-and-digits-12345")]
188    #[case("SINGLE")]
189    #[case("a")]
190    #[case("X")]
191    #[case("LONG-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789")]
192    fn test_client_order_id_to_cid_stable_across_varied_inputs(#[case] value: &str) {
193        // The hash must be deterministic for any valid ClientOrderId, and the
194        // recovered ClientOrderId via cid_to_client_order_id must match the
195        // "CID-{cid}" format so reconciliation can resolve lost mappings.
196        let coid = ClientOrderId::new(value);
197        let cid_a = client_order_id_to_cid(&coid);
198        let cid_b = client_order_id_to_cid(&coid);
199        assert_eq!(cid_a, cid_b, "hash must be deterministic");
200
201        let recovered = cid_to_client_order_id(cid_a);
202        assert!(
203            recovered.inner().as_str().starts_with("CID-"),
204            "recovered id should have CID prefix: {recovered}",
205        );
206        assert!(
207            !recovered.inner().as_str().is_empty(),
208            "recovered id should not be empty",
209        );
210    }
211
212    #[rstest]
213    fn test_client_order_id_to_cid_collision_resistance_small_corpus() {
214        // Collision-free over a handful of distinct client order IDs.
215        let values = [
216            "O-1",
217            "O-2",
218            "O-10",
219            "O-11",
220            "O-20240101-000001",
221            "O-20240101-000002",
222            "strategy-a/1",
223            "strategy-a/2",
224            "strategy-b/1",
225        ];
226
227        let mut seen = std::collections::HashSet::new();
228        for v in values {
229            let coid = ClientOrderId::new(v);
230            let cid = client_order_id_to_cid(&coid);
231            assert!(seen.insert(cid), "cid collision for {v}");
232        }
233    }
234
235    #[rstest]
236    fn test_quantity_to_contracts_valid_precision_zero() {
237        let qty = Quantity::new(10.0, 0);
238        let result = quantity_to_contracts(qty);
239        assert!(result.is_ok());
240        assert_eq!(result.unwrap(), 10);
241    }
242
243    #[rstest]
244    fn test_quantity_to_contracts_valid_with_precision() {
245        // Whole number with non-zero precision should work
246        let qty = Quantity::new(10.0, 2);
247        let result = quantity_to_contracts(qty);
248        assert!(result.is_ok());
249        assert_eq!(result.unwrap(), 10);
250    }
251
252    #[rstest]
253    fn test_quantity_to_contracts_fractional_rejects() {
254        let qty = Quantity::new(10.5, 1);
255        let result = quantity_to_contracts(qty);
256        assert!(result.is_err());
257    }
258
259    #[rstest]
260    fn test_quantity_to_contracts_zero_rejects() {
261        let qty = Quantity::new(0.0, 0);
262        let result = quantity_to_contracts(qty);
263        assert!(result.is_err());
264    }
265
266    #[rstest]
267    fn test_map_bar_spec_1_second() {
268        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
269        let result = map_bar_spec_to_candle_width(&spec);
270        assert!(result.is_ok());
271        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds1));
272    }
273
274    #[rstest]
275    fn test_map_bar_spec_5_second() {
276        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
277        let result = map_bar_spec_to_candle_width(&spec);
278        assert!(result.is_ok());
279        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds5));
280    }
281
282    #[rstest]
283    fn test_map_bar_spec_1_minute() {
284        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
285        let result = map_bar_spec_to_candle_width(&spec);
286        assert!(result.is_ok());
287        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes1));
288    }
289
290    #[rstest]
291    fn test_map_bar_spec_5_minute() {
292        let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Last);
293        let result = map_bar_spec_to_candle_width(&spec);
294        assert!(result.is_ok());
295        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes5));
296    }
297
298    #[rstest]
299    fn test_map_bar_spec_15_minute() {
300        let spec = BarSpecification::new(15, BarAggregation::Minute, PriceType::Last);
301        let result = map_bar_spec_to_candle_width(&spec);
302        assert!(result.is_ok());
303        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes15));
304    }
305
306    #[rstest]
307    fn test_map_bar_spec_1_hour() {
308        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
309        let result = map_bar_spec_to_candle_width(&spec);
310        assert!(result.is_ok());
311        assert!(matches!(result.unwrap(), AxCandleWidth::Hours1));
312    }
313
314    #[rstest]
315    fn test_map_bar_spec_1_day() {
316        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
317        let result = map_bar_spec_to_candle_width(&spec);
318        assert!(result.is_ok());
319        assert!(matches!(result.unwrap(), AxCandleWidth::Days1));
320    }
321
322    #[rstest]
323    fn test_map_bar_spec_unsupported_step() {
324        let spec = BarSpecification::new(3, BarAggregation::Minute, PriceType::Last);
325        let result = map_bar_spec_to_candle_width(&spec);
326        assert!(result.is_err());
327    }
328
329    #[rstest]
330    fn test_map_bar_spec_unsupported_aggregation() {
331        let spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
332        let result = map_bar_spec_to_candle_width(&spec);
333        assert!(result.is_err());
334    }
335
336    #[rstest]
337    fn test_ax_timestamp_s_to_unix_nanos_valid() {
338        let result = ax_timestamp_s_to_unix_nanos(1_000).unwrap();
339        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
340    }
341
342    #[rstest]
343    fn test_ax_timestamp_s_to_unix_nanos_zero() {
344        let result = ax_timestamp_s_to_unix_nanos(0).unwrap();
345        assert_eq!(result, UnixNanos::from(0u64));
346    }
347
348    #[rstest]
349    fn test_ax_timestamp_s_to_unix_nanos_negative_errors() {
350        assert!(ax_timestamp_s_to_unix_nanos(-1).is_err());
351    }
352
353    #[rstest]
354    fn test_ax_timestamp_ns_to_unix_nanos_valid() {
355        let result = ax_timestamp_ns_to_unix_nanos(1_000_000_000).unwrap();
356        assert_eq!(result, UnixNanos::from(1_000_000_000u64));
357    }
358
359    #[rstest]
360    fn test_ax_timestamp_ns_to_unix_nanos_negative_errors() {
361        assert!(ax_timestamp_ns_to_unix_nanos(-1).is_err());
362    }
363
364    #[rstest]
365    fn test_ax_timestamp_stn_to_unix_nanos_combines_seconds_and_nanos() {
366        let result = ax_timestamp_stn_to_unix_nanos(1_000, 500).unwrap();
367        assert_eq!(result, UnixNanos::from(1_000_000_000_500u64));
368    }
369
370    #[rstest]
371    fn test_ax_timestamp_stn_to_unix_nanos_zero_nanos() {
372        let result = ax_timestamp_stn_to_unix_nanos(1_000, 0).unwrap();
373        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
374    }
375
376    #[rstest]
377    fn test_ax_timestamp_stn_to_unix_nanos_negative_seconds_errors() {
378        assert!(ax_timestamp_stn_to_unix_nanos(-1, 0).is_err());
379    }
380
381    #[rstest]
382    fn test_ax_timestamp_stn_to_unix_nanos_negative_nanos_clamps_to_zero() {
383        let result = ax_timestamp_stn_to_unix_nanos(1_000, -1).unwrap();
384        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
385    }
386}