nautilus_architect_ax/common/
parse.rs1use 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
37pub 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
50pub 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
66pub 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
79static CID_HASHER: LazyLock<RandomState> = LazyLock::new(|| {
81 RandomState::with_seeds(
82 0x517cc1b727220a95,
83 0x9b5c18c90c3c314d,
84 0x5851f42d4c957f2d,
85 0x14057b7ef767814f,
86 )
87});
88
89pub fn map_bar_spec_to_candle_width(spec: &BarSpecification) -> anyhow::Result<AxCandleWidth> {
95 AxCandleWidth::try_from(spec)
96}
97
98pub 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 if !raw.is_multiple_of(scale) {
114 anyhow::bail!(
115 "AX requires whole contract quantities, was {}",
116 quantity.as_f64()
117 );
118 }
119
120 #[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#[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#[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 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 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 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 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}