nautilus_architect_ax/common/
parse.rs1use 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
40pub 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
53pub 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
69pub 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
82const TRADE_ID_DOMAIN: &[u8] = b"nautilus-architect-ax/trade-id/v1";
87
88pub fn create_architect_trade_id(
129 ts_event: UnixNanos,
130 price: Price,
131 quantity: Quantity,
132 aggressor_side: AggressorSide,
133) -> anyhow::Result<TradeId> {
134 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
161static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
163 RandomState::with_seeds(
164 0x517cc1b727220a95,
165 0x9b5c18c90c3c314d,
166 0x5851f42d4c957f2d,
167 0x14057b7ef767814f,
168 )
169});
170
171pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
177 AxCandleWidth::try_from(spec)
178}
179
180pub 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 if !raw.is_multiple_of(scale) {
196 anyhow::bail!(
197 "AX requires whole contract quantities, was {}",
198 quantity.as_f64()
199 );
200 }
201
202 #[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#[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#[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 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 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 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 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 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 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 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 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}