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(
127 ts_event: UnixNanos,
128 price: Price,
129 quantity: Quantity,
130 aggressor_side: AggressorSide,
131) -> anyhow::Result<TradeId> {
132 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
159static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
161 RandomState::with_seeds(
162 0x517cc1b727220a95,
163 0x9b5c18c90c3c314d,
164 0x5851f42d4c957f2d,
165 0x14057b7ef767814f,
166 )
167});
168
169pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
175 AxCandleWidth::try_from(spec)
176}
177
178pub 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 if !raw.is_multiple_of(scale) {
194 anyhow::bail!(
195 "AX requires whole contract quantities, was {}",
196 quantity.as_f64()
197 );
198 }
199
200 #[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#[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#[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 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 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 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 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 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 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 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 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}