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/// Both REST and WebSocket trade models require the aggressor side, preserving identity across
118/// transports for the same print.
119///
120/// The result is exactly 36 characters, which is [`TradeId`]'s maximum, so a timestamp beyond
121/// 19 digits (year 2262) returns an error rather than silently truncating.
122///
123/// # Errors
124///
125/// Returns an error if the composed identity is not a valid [`TradeId`].
126pub fn create_architect_trade_id(
127    ts_event: UnixNanos,
128    price: Price,
129    quantity: Quantity,
130    aggressor_side: AggressorSide,
131) -> anyhow::Result<TradeId> {
132    // Normalized decimals, not raw fixed-point, so the identity survives a precision change
133    let price = price.as_decimal().normalize();
134    let quantity = quantity.as_decimal().normalize();
135
136    let side = match aggressor_side {
137        AggressorSide::NoAggressor => b'N',
138        AggressorSide::Buy => b'B',
139        AggressorSide::Sell => b'S',
140    };
141
142    let mut hasher = blake3::Hasher::new();
143    hasher.update(TRADE_ID_DOMAIN);
144    hasher.update(&ts_event.as_u64().to_be_bytes());
145    hasher.update(&price.mantissa().to_be_bytes());
146    hasher.update(&price.scale().to_be_bytes());
147    hasher.update(&quantity.mantissa().to_be_bytes());
148    hasher.update(&quantity.scale().to_be_bytes());
149    hasher.update(&[side]);
150
151    let mut digest = [0u8; 8];
152    digest.copy_from_slice(&hasher.finalize().as_bytes()[..8]);
153    let suffix = u64::from_be_bytes(digest);
154
155    TradeId::new_checked(format!("{}-{suffix:016x}", ts_event.as_u64()))
156        .context("Failed to create TradeId")
157}
158
159/// Cached hasher state for deterministic client order ID to cid conversion
160static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
161    RandomState::with_seeds(
162        0x517cc1b727220a95,
163        0x9b5c18c90c3c314d,
164        0x5851f42d4c957f2d,
165        0x14057b7ef767814f,
166    )
167});
168
169/// Maps a Nautilus [`BarSpecification`] to an [`AxCandleWidth`].
170///
171/// # Errors
172///
173/// Returns an error if the bar specification is not supported by Ax.
174pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
175    AxCandleWidth::try_from(spec)
176}
177
178/// Converts a [`Quantity`] to an i64 contract count for AX orders.
179///
180/// AX uses integer contracts only. Uses integer arithmetic to avoid
181/// floating-point precision issues.
182///
183/// # Errors
184///
185/// Returns an error if:
186/// - The quantity represents a fractional number of contracts.
187/// - The quantity is zero.
188pub fn quantity_to_contracts(quantity: Quantity) -> anyhow::Result<u64> {
189    let raw = quantity.raw();
190    let scale = 10_u64.pow(FIXED_PRECISION as u32) as QuantityRaw;
191
192    // AX requires whole contract quantities
193    if !raw.is_multiple_of(scale) {
194        anyhow::bail!(
195            "AX requires whole contract quantities, was {}",
196            quantity.as_f64()
197        );
198    }
199
200    // QuantityRaw is u128 under the `high-precision` feature and u64 otherwise,
201    // so the narrowing cast is conditional on the active feature set.
202    #[allow(clippy::unnecessary_cast)]
203    let contracts = (raw / scale) as u64;
204    if contracts == 0 {
205        anyhow::bail!("Order quantity must be at least 1 contract");
206    }
207    Ok(contracts)
208}
209
210/// Converts a [`ClientOrderId`] to a deterministic AX `cid` in the non-negative `int64` range.
211///
212/// Inbound WebSocket `cid` values remain `u64` because venue messages can exceed `int64`.
213#[must_use]
214pub fn client_order_id_to_cid(client_order_id: &ClientOrderId) -> u64 {
215    CID_HASHER.hash_one(client_order_id.inner()) & i64::MAX as u64
216}
217
218/// Creates a [`ClientOrderId`] from a cid value.
219///
220/// Used when we receive an order with a cid but cannot resolve it to the
221/// original ClientOrderId (e.g., after restart when in-memory mapping is lost).
222#[must_use]
223pub fn cid_to_client_order_id(cid: u64) -> ClientOrderId {
224    ClientOrderId::new(format!("CID-{cid}"))
225}
226
227#[cfg(test)]
228mod tests {
229    use nautilus_model::{
230        enums::{BarAggregation, PriceType},
231        identifiers::ClientOrderId,
232        types::Quantity,
233    };
234    use rstest::rstest;
235    use rust_decimal::Decimal;
236    use rust_decimal_macros::dec;
237
238    use super::*;
239
240    /// The composed timestamp of the captured `EURUSD-PERP` trade shared by the REST and
241    /// WebSocket fixtures.
242    const CAPTURED_TS_EVENT: u64 = 1_766_193_240_334_589_144;
243
244    fn captured_trade_id() -> TradeId {
245        create_architect_trade_id(
246            UnixNanos::from(CAPTURED_TS_EVENT),
247            Price::from_decimal_dp(dec!(1.1719), 4).unwrap(),
248            Quantity::from_decimal_dp(dec!(400), 0).unwrap(),
249            AggressorSide::Buy,
250        )
251        .unwrap()
252    }
253
254    #[rstest]
255    fn test_create_architect_trade_id_format() {
256        let trade_id = captured_trade_id().to_string();
257
258        let (timestamp, digest) = trade_id.split_once('-').unwrap();
259
260        assert_eq!(trade_id.len(), 36);
261        assert_eq!(timestamp, CAPTURED_TS_EVENT.to_string());
262        assert_eq!(digest.len(), 16);
263        assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
264    }
265
266    #[rstest]
267    fn test_create_architect_trade_id_is_deterministic() {
268        assert_eq!(captured_trade_id(), captured_trade_id());
269    }
270
271    #[rstest]
272    fn test_create_architect_trade_id_ignores_trailing_wire_zeros() {
273        // AX sends this price as "1.1719" on the WebSocket and "1.171900000000" on REST
274        let padded = create_architect_trade_id(
275            UnixNanos::from(CAPTURED_TS_EVENT),
276            Price::from_decimal_dp(dec!(1.17190000), 4).unwrap(),
277            Quantity::from_decimal_dp(dec!(400.00), 0).unwrap(),
278            AggressorSide::Buy,
279        )
280        .unwrap();
281
282        assert_eq!(padded, captured_trade_id());
283    }
284
285    #[rstest]
286    fn test_create_architect_trade_id_ignores_instrument_precision() {
287        // A venue tick size change between a historical request and the live stream must not
288        // split one trade into two identities.
289        let wider = create_architect_trade_id(
290            UnixNanos::from(CAPTURED_TS_EVENT),
291            Price::from_decimal_dp(dec!(1.1719), 6).unwrap(),
292            Quantity::from_decimal_dp(dec!(400), 2).unwrap(),
293            AggressorSide::Buy,
294        )
295        .unwrap();
296
297        assert_eq!(wider, captured_trade_id());
298    }
299
300    #[rstest]
301    #[case(dec!(1.1720), dec!(400), AggressorSide::Buy)]
302    #[case(dec!(1.1719), dec!(100), AggressorSide::Buy)]
303    #[case(dec!(1.1719), dec!(400), AggressorSide::Sell)]
304    #[case(dec!(1.1719), dec!(400), AggressorSide::NoAggressor)]
305    fn test_create_architect_trade_id_separates_prints_within_one_timestamp(
306        #[case] price: Decimal,
307        #[case] quantity: Decimal,
308        #[case] aggressor_side: AggressorSide,
309    ) {
310        let other = create_architect_trade_id(
311            UnixNanos::from(CAPTURED_TS_EVENT),
312            Price::from_decimal_dp(price, 4).unwrap(),
313            Quantity::from_decimal_dp(quantity, 0).unwrap(),
314            aggressor_side,
315        )
316        .unwrap();
317
318        assert_ne!(other, captured_trade_id());
319    }
320
321    #[rstest]
322    fn test_create_architect_trade_id_rejects_timestamp_beyond_capacity() {
323        // `u64::MAX` is 20 digits, one past what the 36-character format allows
324        let error = create_architect_trade_id(
325            UnixNanos::from(u64::MAX),
326            Price::from_decimal_dp(dec!(1.1719), 4).unwrap(),
327            Quantity::from_decimal_dp(dec!(400), 0).unwrap(),
328            AggressorSide::Buy,
329        )
330        .unwrap_err();
331
332        assert_eq!(error.to_string(), "Failed to create TradeId");
333    }
334
335    #[rstest]
336    fn test_client_order_id_to_cid_deterministic() {
337        let coid = ClientOrderId::new("O-20240101-000001");
338
339        // Must produce same result across multiple calls
340        let cid1 = client_order_id_to_cid(&coid);
341        let cid2 = client_order_id_to_cid(&coid);
342        let cid3 = client_order_id_to_cid(&coid);
343
344        assert_eq!(cid1, cid2);
345        assert_eq!(cid2, cid3);
346    }
347
348    #[rstest]
349    fn test_client_order_id_to_cid_different_ids() {
350        let coid1 = ClientOrderId::new("O-20240101-000001");
351        let coid2 = ClientOrderId::new("O-20240101-000002");
352
353        let cid1 = client_order_id_to_cid(&coid1);
354        let cid2 = client_order_id_to_cid(&coid2);
355
356        assert_ne!(cid1, cid2);
357    }
358
359    #[rstest]
360    fn test_client_order_id_to_cid_fits_signed_64_bit_range() {
361        let coid = ClientOrderId::new("O-20260720-055815-001-001-1");
362
363        let cid = client_order_id_to_cid(&coid);
364
365        assert!(i64::try_from(cid).is_ok());
366    }
367
368    #[rstest]
369    #[case("O-1")]
370    #[case("O-SHORT")]
371    #[case("O-20240101-000001")]
372    #[case("Order-with-dashes-and-digits-12345")]
373    #[case("SINGLE")]
374    #[case("a")]
375    #[case("X")]
376    #[case("LONG-ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789")]
377    fn test_client_order_id_to_cid_stable_across_varied_inputs(#[case] value: &str) {
378        // The hash must be deterministic for any valid ClientOrderId, and the
379        // recovered ClientOrderId via cid_to_client_order_id must match the
380        // "CID-{cid}" format so reconciliation can resolve lost mappings.
381        let coid = ClientOrderId::new(value);
382        let cid_a = client_order_id_to_cid(&coid);
383        let cid_b = client_order_id_to_cid(&coid);
384        assert_eq!(cid_a, cid_b, "hash must be deterministic");
385
386        let recovered = cid_to_client_order_id(cid_a);
387        assert!(
388            recovered.inner().starts_with("CID-"),
389            "recovered id should have CID prefix: {recovered}",
390        );
391        assert!(
392            !recovered.inner().is_empty(),
393            "recovered id should not be empty",
394        );
395    }
396
397    #[rstest]
398    fn test_client_order_id_to_cid_collision_resistance_small_corpus() {
399        // Collision-free over a handful of distinct client order IDs.
400        let values = [
401            "O-1",
402            "O-2",
403            "O-10",
404            "O-11",
405            "O-20240101-000001",
406            "O-20240101-000002",
407            "strategy-a/1",
408            "strategy-a/2",
409            "strategy-b/1",
410        ];
411
412        let mut seen = std::collections::HashSet::new();
413        for v in values {
414            let coid = ClientOrderId::new(v);
415            let cid = client_order_id_to_cid(&coid);
416            assert!(seen.insert(cid), "cid collision for {v}");
417        }
418    }
419
420    #[rstest]
421    fn test_quantity_to_contracts_valid_precision_zero() {
422        let qty = Quantity::new(10.0, 0);
423        let result = quantity_to_contracts(qty);
424        assert!(result.is_ok());
425        assert_eq!(result.unwrap(), 10);
426    }
427
428    #[rstest]
429    fn test_quantity_to_contracts_valid_with_precision() {
430        // Whole number with non-zero precision should work
431        let qty = Quantity::new(10.0, 2);
432        let result = quantity_to_contracts(qty);
433        assert!(result.is_ok());
434        assert_eq!(result.unwrap(), 10);
435    }
436
437    #[rstest]
438    fn test_quantity_to_contracts_fractional_rejects() {
439        let qty = Quantity::new(10.5, 1);
440        let result = quantity_to_contracts(qty);
441        assert!(result.is_err());
442    }
443
444    #[rstest]
445    fn test_quantity_to_contracts_zero_rejects() {
446        let qty = Quantity::new(0.0, 0);
447        let result = quantity_to_contracts(qty);
448        assert!(result.is_err());
449    }
450
451    #[rstest]
452    fn test_map_bar_spec_1_second() {
453        let spec = BarSpecification::new(1, BarAggregation::Second, PriceType::Last);
454        let result = map_bar_spec_to_candle_width(&spec);
455        assert!(result.is_ok());
456        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds1));
457    }
458
459    #[rstest]
460    fn test_map_bar_spec_5_second() {
461        let spec = BarSpecification::new(5, BarAggregation::Second, PriceType::Last);
462        let result = map_bar_spec_to_candle_width(&spec);
463        assert!(result.is_ok());
464        assert!(matches!(result.unwrap(), AxCandleWidth::Seconds5));
465    }
466
467    #[rstest]
468    fn test_map_bar_spec_1_minute() {
469        let spec = BarSpecification::new(1, BarAggregation::Minute, PriceType::Last);
470        let result = map_bar_spec_to_candle_width(&spec);
471        assert!(result.is_ok());
472        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes1));
473    }
474
475    #[rstest]
476    fn test_map_bar_spec_5_minute() {
477        let spec = BarSpecification::new(5, BarAggregation::Minute, PriceType::Last);
478        let result = map_bar_spec_to_candle_width(&spec);
479        assert!(result.is_ok());
480        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes5));
481    }
482
483    #[rstest]
484    fn test_map_bar_spec_15_minute() {
485        let spec = BarSpecification::new(15, BarAggregation::Minute, PriceType::Last);
486        let result = map_bar_spec_to_candle_width(&spec);
487        assert!(result.is_ok());
488        assert!(matches!(result.unwrap(), AxCandleWidth::Minutes15));
489    }
490
491    #[rstest]
492    fn test_map_bar_spec_1_hour() {
493        let spec = BarSpecification::new(1, BarAggregation::Hour, PriceType::Last);
494        let result = map_bar_spec_to_candle_width(&spec);
495        assert!(result.is_ok());
496        assert!(matches!(result.unwrap(), AxCandleWidth::Hours1));
497    }
498
499    #[rstest]
500    fn test_map_bar_spec_1_day() {
501        let spec = BarSpecification::new(1, BarAggregation::Day, PriceType::Last);
502        let result = map_bar_spec_to_candle_width(&spec);
503        assert!(result.is_ok());
504        assert!(matches!(result.unwrap(), AxCandleWidth::Days1));
505    }
506
507    #[rstest]
508    fn test_map_bar_spec_unsupported_step() {
509        let spec = BarSpecification::new(3, BarAggregation::Minute, PriceType::Last);
510        let result = map_bar_spec_to_candle_width(&spec);
511        assert!(result.is_err());
512    }
513
514    #[rstest]
515    fn test_map_bar_spec_unsupported_aggregation() {
516        let spec = BarSpecification::new(1, BarAggregation::Tick, PriceType::Last);
517        let result = map_bar_spec_to_candle_width(&spec);
518        assert!(result.is_err());
519    }
520
521    #[rstest]
522    fn test_ax_timestamp_s_to_unix_nanos_valid() {
523        let result = ax_timestamp_s_to_unix_nanos(1_000).unwrap();
524        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
525    }
526
527    #[rstest]
528    fn test_ax_timestamp_s_to_unix_nanos_zero() {
529        let result = ax_timestamp_s_to_unix_nanos(0).unwrap();
530        assert_eq!(result, UnixNanos::from(0u64));
531    }
532
533    #[rstest]
534    fn test_ax_timestamp_s_to_unix_nanos_negative_errors() {
535        assert!(ax_timestamp_s_to_unix_nanos(-1).is_err());
536    }
537
538    #[rstest]
539    fn test_ax_timestamp_ns_to_unix_nanos_valid() {
540        let result = ax_timestamp_ns_to_unix_nanos(1_000_000_000).unwrap();
541        assert_eq!(result, UnixNanos::from(1_000_000_000u64));
542    }
543
544    #[rstest]
545    fn test_ax_timestamp_ns_to_unix_nanos_negative_errors() {
546        assert!(ax_timestamp_ns_to_unix_nanos(-1).is_err());
547    }
548
549    #[rstest]
550    fn test_ax_timestamp_stn_to_unix_nanos_combines_seconds_and_nanos() {
551        let result = ax_timestamp_stn_to_unix_nanos(1_000, 500).unwrap();
552        assert_eq!(result, UnixNanos::from(1_000_000_000_500u64));
553    }
554
555    #[rstest]
556    fn test_ax_timestamp_stn_to_unix_nanos_zero_nanos() {
557        let result = ax_timestamp_stn_to_unix_nanos(1_000, 0).unwrap();
558        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
559    }
560
561    #[rstest]
562    fn test_ax_timestamp_stn_to_unix_nanos_negative_seconds_errors() {
563        assert!(ax_timestamp_stn_to_unix_nanos(-1, 0).is_err());
564    }
565
566    #[rstest]
567    fn test_ax_timestamp_stn_to_unix_nanos_negative_nanos_clamps_to_zero() {
568        let result = ax_timestamp_stn_to_unix_nanos(1_000, -1).unwrap();
569        assert_eq!(result, UnixNanos::from(1_000_000_000_000u64));
570    }
571}