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 anyhow::Context;
22use nautilus_core::nanos::UnixNanos;
23pub use nautilus_core::serialization::{
24    deserialize_decimal_or_zero, deserialize_optional_decimal,
25    deserialize_optional_decimal_from_str, deserialize_optional_decimal_or_zero,
26    deserialize_optional_decimal_str, parse_decimal, parse_optional_decimal,
27    serialize_decimal_as_str, serialize_optional_decimal_as_str,
28};
29use nautilus_model::{
30    data::BarSpecification,
31    enums::AggressorSide,
32    identifiers::{ClientOrderId, TradeId},
33    types::{Price, Quantity, fixed::FIXED_PRECISION, quantity::QuantityRaw},
34};
35
36use super::enums::AxCandleWidth;
37
38const NANOSECONDS_IN_SECOND: u64 = 1_000_000_000;
39
40/// Converts an AX epoch-seconds timestamp to [`UnixNanos`].
41///
42/// # Errors
43///
44/// Returns an error if `seconds` is negative (malformed data from AX).
45pub fn ax_timestamp_s_to_unix_nanos(seconds: i64) -> anyhow::Result<UnixNanos> {
46    anyhow::ensure!(
47        seconds >= 0,
48        "AX timestamp must be non-negative, was {seconds}"
49    );
50    Ok(UnixNanos::from(seconds as u64 * NANOSECONDS_IN_SECOND))
51}
52
53/// Converts AX `ts` (seconds) + `tn` (nanoseconds) fields to [`UnixNanos`].
54///
55/// # Errors
56///
57/// Returns an error if `seconds` is negative (malformed data from AX).
58pub fn ax_timestamp_stn_to_unix_nanos(seconds: i64, nanos: i64) -> anyhow::Result<UnixNanos> {
59    anyhow::ensure!(
60        seconds >= 0,
61        "AX timestamp must be non-negative, was {seconds}"
62    );
63    let nanos_part = nanos.max(0) as u64;
64    Ok(UnixNanos::from(
65        seconds as u64 * NANOSECONDS_IN_SECOND + nanos_part,
66    ))
67}
68
69/// Converts an AX nanosecond timestamp to [`UnixNanos`].
70///
71/// # Errors
72///
73/// Returns an error if `nanos` is negative (malformed data from AX).
74pub fn ax_timestamp_ns_to_unix_nanos(nanos: i64) -> anyhow::Result<UnixNanos> {
75    anyhow::ensure!(
76        nanos >= 0,
77        "AX timestamp_ns must be non-negative, was {nanos}"
78    );
79    Ok(UnixNanos::from(nanos as u64))
80}
81
82/// Domain separator for the market-data trade identity digest.
83///
84/// Changing this invalidates every AX `TradeId` already published or persisted, so bump the
85/// version suffix only as a deliberate decision.
86const TRADE_ID_DOMAIN: &[u8] = b"nautilus-architect-ax/trade-id/v1";
87
88/// Creates a [`TradeId`] for an AX market-data trade.
89///
90/// AX publishes no trade identifier for market data: `GET /trades` and the market-data WebSocket
91/// both carry only `ts`, `tn`, `s`, `p`, `q`, and `d`, and `tn` is the nanosecond component of the
92/// timestamp rather than a sequence number. The composed timestamp alone is not unique either,
93/// because one aggressor sweeping several levels reports multiple prints at an identical `ts` and
94/// `tn`. Across 100 sandbox trades on 2026-07-25, `GBPUSD-PERP` yielded only 64 distinct
95/// timestamps.
96///
97/// The identity is therefore the composed timestamp plus a digest over the price, quantity, and
98/// aggressor side, which separated 99 of those 100 prints. Two prints identical in all five
99/// fields remain indistinguishable; nothing the venue publishes separates them. A sandbox run on
100/// 2026-07-25 observed exactly that, two `JPYUSD-PERP` prints agreeing on timestamp, price,
101/// quantity, and side, so the residual is real rather than theoretical at roughly 1 to 4 percent
102/// of prints.
103///
104/// That residual is accepted because a duplicate here cannot reach an execution. Live fills carry
105/// the venue's own identifiers, `fill.trade_id` on REST and `execution.tid` on the orders
106/// WebSocket, and the backtest matching engine mints its own trade IDs with a per-timestamp
107/// counter. This function is called only from the two market-data `parse_trade_tick` functions, so
108/// a duplicate is a market-data fidelity limit and never an execution or position-accounting risk.
109/// Only a consumer that itself deduplicates on [`TradeId`] is affected; nothing in the Nautilus
110/// data path compares them.
111///
112/// Both transports must call this so the same trade fetched historically and received live gets
113/// one identity. The digest covers the parsed [`Price`] and [`Quantity`] rather than the wire
114/// text, because AX sends the same price as `"1.339700000000"` over REST and in a shorter form
115/// over the WebSocket.
116///
117/// Parity relies on both transports reporting the aggressor side. `GET /trades` always does, and
118/// no sandbox WebSocket trade has omitted it, but `AxMdTrade::d` is modelled as optional and an
119/// omitted side resolves to [`AggressorSide::NoAggressor`], which would not match the REST
120/// identity for that trade.
121///
122/// The result is exactly 36 characters, which is [`TradeId`]'s maximum, so a timestamp beyond
123/// 19 digits (year 2262) returns an error rather than silently truncating.
124///
125/// # Errors
126///
127/// Returns an error if the composed identity is not a valid [`TradeId`].
128pub fn create_architect_trade_id(
129    ts_event: UnixNanos,
130    price: Price,
131    quantity: Quantity,
132    aggressor_side: AggressorSide,
133) -> anyhow::Result<TradeId> {
134    // Normalized decimals, not raw fixed-point, so the identity survives a precision change
135    let price = price.as_decimal().normalize();
136    let quantity = quantity.as_decimal().normalize();
137
138    let side = match aggressor_side {
139        AggressorSide::NoAggressor => b'N',
140        AggressorSide::Buy => b'B',
141        AggressorSide::Sell => b'S',
142    };
143
144    let mut hasher = blake3::Hasher::new();
145    hasher.update(TRADE_ID_DOMAIN);
146    hasher.update(&ts_event.as_u64().to_be_bytes());
147    hasher.update(&price.mantissa().to_be_bytes());
148    hasher.update(&price.scale().to_be_bytes());
149    hasher.update(&quantity.mantissa().to_be_bytes());
150    hasher.update(&quantity.scale().to_be_bytes());
151    hasher.update(&[side]);
152
153    let mut digest = [0u8; 8];
154    digest.copy_from_slice(&hasher.finalize().as_bytes()[..8]);
155    let suffix = u64::from_be_bytes(digest);
156
157    TradeId::new_checked(format!("{}-{suffix:016x}", ts_event.as_u64()))
158        .context("Failed to create TradeId")
159}
160
161/// Cached hasher state for deterministic client order ID to cid conversion
162static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
163    RandomState::with_seeds(
164        0x517cc1b727220a95,
165        0x9b5c18c90c3c314d,
166        0x5851f42d4c957f2d,
167        0x14057b7ef767814f,
168    )
169});
170
171/// Maps a Nautilus [`BarSpecification`] to an [`AxCandleWidth`].
172///
173/// # Errors
174///
175/// Returns an error if the bar specification is not supported by Ax.
176pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
177    AxCandleWidth::try_from(spec)
178}
179
180/// Converts a [`Quantity`] to an i64 contract count for AX orders.
181///
182/// AX uses integer contracts only. Uses integer arithmetic to avoid
183/// floating-point precision issues.
184///
185/// # Errors
186///
187/// Returns an error if:
188/// - The quantity represents a fractional number of contracts.
189/// - The quantity is zero.
190pub fn quantity_to_contracts(quantity: Quantity) -> anyhow::Result<u64> {
191    let raw = quantity.raw;
192    let scale = 10_u64.pow(FIXED_PRECISION as u32) as QuantityRaw;
193
194    // AX requires whole contract quantities
195    if !raw.is_multiple_of(scale) {
196        anyhow::bail!(
197            "AX requires whole contract quantities, was {}",
198            quantity.as_f64()
199        );
200    }
201
202    // QuantityRaw is u128 under the `high-precision` feature and u64 otherwise,
203    // so the narrowing cast is conditional on the active feature set.
204    #[allow(clippy::unnecessary_cast)]
205    let contracts = (raw / scale) as u64;
206    if contracts == 0 {
207        anyhow::bail!("Order quantity must be at least 1 contract");
208    }
209    Ok(contracts)
210}
211
212/// Converts a [`ClientOrderId`] to a deterministic AX `cid` in the non-negative `int64` range.
213///
214/// Inbound WebSocket `cid` values remain `u64` because venue messages can exceed `int64`.
215#[must_use]
216pub fn client_order_id_to_cid(client_order_id: &ClientOrderId) -> u64 {
217    CID_HASHER.hash_one(client_order_id.inner()) & i64::MAX as u64
218}
219
220/// Creates a [`ClientOrderId`] from a cid value.
221///
222/// Used when we receive an order with a cid but cannot resolve it to the
223/// original ClientOrderId (e.g., after restart when in-memory mapping is lost).
224#[must_use]
225pub fn cid_to_client_order_id(cid: u64) -> ClientOrderId {
226    ClientOrderId::new(format!("CID-{cid}"))
227}
228
229#[cfg(test)]
230mod tests {
231    use nautilus_model::{
232        enums::{BarAggregation, PriceType},
233        identifiers::ClientOrderId,
234        types::Quantity,
235    };
236    use rstest::rstest;
237    use rust_decimal::Decimal;
238    use rust_decimal_macros::dec;
239
240    use super::*;
241
242    /// The composed timestamp of the captured `EURUSD-PERP` trade shared by the REST and
243    /// WebSocket fixtures.
244    const CAPTURED_TS_EVENT: u64 = 1_766_193_240_334_589_144;
245
246    fn captured_trade_id() -> TradeId {
247        create_architect_trade_id(
248            UnixNanos::from(CAPTURED_TS_EVENT),
249            Price::from_decimal_dp(dec!(1.1719), 4).unwrap(),
250            Quantity::from_decimal_dp(dec!(400), 0).unwrap(),
251            AggressorSide::Buy,
252        )
253        .unwrap()
254    }
255
256    #[rstest]
257    fn test_create_architect_trade_id_format() {
258        let trade_id = captured_trade_id().to_string();
259
260        let (timestamp, digest) = trade_id.split_once('-').unwrap();
261
262        assert_eq!(trade_id.len(), 36);
263        assert_eq!(timestamp, CAPTURED_TS_EVENT.to_string());
264        assert_eq!(digest.len(), 16);
265        assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
266    }
267
268    #[rstest]
269    fn test_create_architect_trade_id_is_deterministic() {
270        assert_eq!(captured_trade_id(), captured_trade_id());
271    }
272
273    #[rstest]
274    fn test_create_architect_trade_id_ignores_trailing_wire_zeros() {
275        // AX sends this price as "1.1719" on the WebSocket and "1.171900000000" on REST
276        let padded = create_architect_trade_id(
277            UnixNanos::from(CAPTURED_TS_EVENT),
278            Price::from_decimal_dp(dec!(1.17190000), 4).unwrap(),
279            Quantity::from_decimal_dp(dec!(400.00), 0).unwrap(),
280            AggressorSide::Buy,
281        )
282        .unwrap();
283
284        assert_eq!(padded, captured_trade_id());
285    }
286
287    #[rstest]
288    fn test_create_architect_trade_id_ignores_instrument_precision() {
289        // A venue tick size change between a historical request and the live stream must not
290        // split one trade into two identities.
291        let wider = create_architect_trade_id(
292            UnixNanos::from(CAPTURED_TS_EVENT),
293            Price::from_decimal_dp(dec!(1.1719), 6).unwrap(),
294            Quantity::from_decimal_dp(dec!(400), 2).unwrap(),
295            AggressorSide::Buy,
296        )
297        .unwrap();
298
299        assert_eq!(wider, captured_trade_id());
300    }
301
302    #[rstest]
303    #[case(dec!(1.1720), dec!(400), AggressorSide::Buy)]
304    #[case(dec!(1.1719), dec!(100), AggressorSide::Buy)]
305    #[case(dec!(1.1719), dec!(400), AggressorSide::Sell)]
306    #[case(dec!(1.1719), dec!(400), AggressorSide::NoAggressor)]
307    fn test_create_architect_trade_id_separates_prints_within_one_timestamp(
308        #[case] price: Decimal,
309        #[case] quantity: Decimal,
310        #[case] aggressor_side: AggressorSide,
311    ) {
312        let other = create_architect_trade_id(
313            UnixNanos::from(CAPTURED_TS_EVENT),
314            Price::from_decimal_dp(price, 4).unwrap(),
315            Quantity::from_decimal_dp(quantity, 0).unwrap(),
316            aggressor_side,
317        )
318        .unwrap();
319
320        assert_ne!(other, captured_trade_id());
321    }
322
323    #[rstest]
324    fn test_create_architect_trade_id_rejects_timestamp_beyond_capacity() {
325        // `u64::MAX` is 20 digits, one past what the 36-character format allows
326        let error = create_architect_trade_id(
327            UnixNanos::from(u64::MAX),
328            Price::from_decimal_dp(dec!(1.1719), 4).unwrap(),
329            Quantity::from_decimal_dp(dec!(400), 0).unwrap(),
330            AggressorSide::Buy,
331        )
332        .unwrap_err();
333
334        assert_eq!(error.to_string(), "Failed to create TradeId");
335    }
336
337    #[rstest]
338    fn test_client_order_id_to_cid_deterministic() {
339        let coid = ClientOrderId::new("O-20240101-000001");
340
341        // Must produce same result across multiple calls
342        let cid1 = client_order_id_to_cid(&coid);
343        let cid2 = client_order_id_to_cid(&coid);
344        let cid3 = client_order_id_to_cid(&coid);
345
346        assert_eq!(cid1, cid2);
347        assert_eq!(cid2, cid3);
348    }
349
350    #[rstest]
351    fn test_client_order_id_to_cid_different_ids() {
352        let coid1 = ClientOrderId::new("O-20240101-000001");
353        let coid2 = ClientOrderId::new("O-20240101-000002");
354
355        let cid1 = client_order_id_to_cid(&coid1);
356        let cid2 = client_order_id_to_cid(&coid2);
357
358        assert_ne!(cid1, cid2);
359    }
360
361    #[rstest]
362    fn test_client_order_id_to_cid_fits_signed_64_bit_range() {
363        let coid = ClientOrderId::new("O-20260720-055815-001-001-1");
364
365        let cid = client_order_id_to_cid(&coid);
366
367        assert!(i64::try_from(cid).is_ok());
368    }
369
370    #[rstest]
371    #[case("O-1")]
372    #[case("O-SHORT")]
373    #[case("O-20240101-000001")]
374    #[case("Order-with-dashes-and-digits-12345")]
375    #[case("SINGLE")]
376    #[case("a")]
377    #[case("X")]
378    #[case("LONG-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789")]
379    fn test_client_order_id_to_cid_stable_across_varied_inputs(#[case] value: &str) {
380        // The hash must be deterministic for any valid ClientOrderId, and the
381        // recovered ClientOrderId via cid_to_client_order_id must match the
382        // "CID-{cid}" format so reconciliation can resolve lost mappings.
383        let coid = ClientOrderId::new(value);
384        let cid_a = client_order_id_to_cid(&coid);
385        let cid_b = client_order_id_to_cid(&coid);
386        assert_eq!(cid_a, cid_b, "hash must be deterministic");
387
388        let recovered = cid_to_client_order_id(cid_a);
389        assert!(
390            recovered.inner().as_str().starts_with("CID-"),
391            "recovered id should have CID prefix: {recovered}",
392        );
393        assert!(
394            !recovered.inner().as_str().is_empty(),
395            "recovered id should not be empty",
396        );
397    }
398
399    #[rstest]
400    fn test_client_order_id_to_cid_collision_resistance_small_corpus() {
401        // Collision-free over a handful of distinct client order IDs.
402        let values = [
403            "O-1",
404            "O-2",
405            "O-10",
406            "O-11",
407            "O-20240101-000001",
408            "O-20240101-000002",
409            "strategy-a/1",
410            "strategy-a/2",
411            "strategy-b/1",
412        ];
413
414        let mut seen = std::collections::HashSet::new();
415        for v in values {
416            let coid = ClientOrderId::new(v);
417            let cid = client_order_id_to_cid(&coid);
418            assert!(seen.insert(cid), "cid collision for {v}");
419        }
420    }
421
422    #[rstest]
423    fn test_quantity_to_contracts_valid_precision_zero() {
424        let qty = Quantity::new(10.0, 0);
425        let result = quantity_to_contracts(qty);
426        assert!(result.is_ok());
427        assert_eq!(result.unwrap(), 10);
428    }
429
430    #[rstest]
431    fn test_quantity_to_contracts_valid_with_precision() {
432        // Whole number with non-zero precision should work
433        let qty = Quantity::new(10.0, 2);
434        let result = quantity_to_contracts(qty);
435        assert!(result.is_ok());
436        assert_eq!(result.unwrap(), 10);
437    }
438
439    #[rstest]
440    fn test_quantity_to_contracts_fractional_rejects() {
441        let qty = Quantity::new(10.5, 1);
442        let result = quantity_to_contracts(qty);
443        assert!(result.is_err());
444    }
445
446    #[rstest]
447    fn test_quantity_to_contracts_zero_rejects() {
448        let qty = Quantity::new(0.0, 0);
449        let result = quantity_to_contracts(qty);
450        assert!(result.is_err());
451    }
452
453    #[rstest]
454    fn test_map_bar_spec_1_second() {
455        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
456        let result = map_bar_spec_to_candle_width(&spec);
457        assert!(result.is_ok());
458        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds1));
459    }
460
461    #[rstest]
462    fn test_map_bar_spec_5_second() {
463        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
464        let result = map_bar_spec_to_candle_width(&spec);
465        assert!(result.is_ok());
466        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds5));
467    }
468
469    #[rstest]
470    fn test_map_bar_spec_1_minute() {
471        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
472        let result = map_bar_spec_to_candle_width(&spec);
473        assert!(result.is_ok());
474        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes1));
475    }
476
477    #[rstest]
478    fn test_map_bar_spec_5_minute() {
479        let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Last);
480        let result = map_bar_spec_to_candle_width(&spec);
481        assert!(result.is_ok());
482        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes5));
483    }
484
485    #[rstest]
486    fn test_map_bar_spec_15_minute() {
487        let spec = BarSpecification::new(15, BarAggregation::Minute, PriceType::Last);
488        let result = map_bar_spec_to_candle_width(&spec);
489        assert!(result.is_ok());
490        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes15));
491    }
492
493    #[rstest]
494    fn test_map_bar_spec_1_hour() {
495        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
496        let result = map_bar_spec_to_candle_width(&spec);
497        assert!(result.is_ok());
498        assert!(matches!(result.unwrap(), AxCandleWidth::Hours1));
499    }
500
501    #[rstest]
502    fn test_map_bar_spec_1_day() {
503        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
504        let result = map_bar_spec_to_candle_width(&spec);
505        assert!(result.is_ok());
506        assert!(matches!(result.unwrap(), AxCandleWidth::Days1));
507    }
508
509    #[rstest]
510    fn test_map_bar_spec_unsupported_step() {
511        let spec = BarSpecification::new(3, BarAggregation::Minute, PriceType::Last);
512        let result = map_bar_spec_to_candle_width(&spec);
513        assert!(result.is_err());
514    }
515
516    #[rstest]
517    fn test_map_bar_spec_unsupported_aggregation() {
518        let spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
519        let result = map_bar_spec_to_candle_width(&spec);
520        assert!(result.is_err());
521    }
522
523    #[rstest]
524    fn test_ax_timestamp_s_to_unix_nanos_valid() {
525        let result = ax_timestamp_s_to_unix_nanos(1_000).unwrap();
526        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
527    }
528
529    #[rstest]
530    fn test_ax_timestamp_s_to_unix_nanos_zero() {
531        let result = ax_timestamp_s_to_unix_nanos(0).unwrap();
532        assert_eq!(result, UnixNanos::from(0u64));
533    }
534
535    #[rstest]
536    fn test_ax_timestamp_s_to_unix_nanos_negative_errors() {
537        assert!(ax_timestamp_s_to_unix_nanos(-1).is_err());
538    }
539
540    #[rstest]
541    fn test_ax_timestamp_ns_to_unix_nanos_valid() {
542        let result = ax_timestamp_ns_to_unix_nanos(1_000_000_000).unwrap();
543        assert_eq!(result, UnixNanos::from(1_000_000_000u64));
544    }
545
546    #[rstest]
547    fn test_ax_timestamp_ns_to_unix_nanos_negative_errors() {
548        assert!(ax_timestamp_ns_to_unix_nanos(-1).is_err());
549    }
550
551    #[rstest]
552    fn test_ax_timestamp_stn_to_unix_nanos_combines_seconds_and_nanos() {
553        let result = ax_timestamp_stn_to_unix_nanos(1_000, 500).unwrap();
554        assert_eq!(result, UnixNanos::from(1_000_000_000_500u64));
555    }
556
557    #[rstest]
558    fn test_ax_timestamp_stn_to_unix_nanos_zero_nanos() {
559        let result = ax_timestamp_stn_to_unix_nanos(1_000, 0).unwrap();
560        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
561    }
562
563    #[rstest]
564    fn test_ax_timestamp_stn_to_unix_nanos_negative_seconds_errors() {
565        assert!(ax_timestamp_stn_to_unix_nanos(-1, 0).is_err());
566    }
567
568    #[rstest]
569    fn test_ax_timestamp_stn_to_unix_nanos_negative_nanos_clamps_to_zero() {
570        let result = ax_timestamp_stn_to_unix_nanos(1_000, -1).unwrap();
571        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
572    }
573}