1use std::fmt::Display;
19
20use nautilus_core::UnixNanos;
21use serde::{Deserialize, Serialize};
22
23use crate::wire;
24
25const MARKER_HASH_DOMAIN: &[u8] = b"nautilus-event-store/marker/v1";
26const HIFI_HASH_DOMAIN: &[u8] = b"nautilus-event-store/hifi/v1";
27const DICT_HASH_DOMAIN: &[u8] = b"nautilus-event-store/dict/v1";
28const GAP_HASH_DOMAIN: &[u8] = b"nautilus-event-store/gap/v1";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum DataClass {
33 BookDeltas,
35 #[serde(alias = "BookDepth10")]
37 BookDepth,
38 Quote,
40 Trade,
42 Bar,
44}
45
46impl DataClass {
47 #[must_use]
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::BookDeltas => "BookDeltas",
52 Self::BookDepth => "BookDepth",
53 Self::Quote => "Quote",
54 Self::Trade => "Trade",
55 Self::Bar => "Bar",
56 }
57 }
58
59 const fn persisted_hash_token(self) -> &'static str {
64 match self {
65 Self::BookDeltas => "BookDeltas",
66 Self::BookDepth => "BookDepth10",
67 Self::Quote => "Quote",
68 Self::Trade => "Trade",
69 Self::Bar => "Bar",
70 }
71 }
72}
73
74impl Display for DataClass {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str(self.as_str())
77 }
78}
79
80impl std::str::FromStr for DataClass {
81 type Err = String;
82
83 fn from_str(s: &str) -> Result<Self, Self::Err> {
84 match s {
85 "BookDeltas" => Ok(Self::BookDeltas),
86 "BookDepth" | "BookDepth10" => Ok(Self::BookDepth),
88 "Quote" => Ok(Self::Quote),
89 "Trade" => Ok(Self::Trade),
90 "Bar" => Ok(Self::Bar),
91 other => Err(format!("unknown DataClass, was `{other}`")),
92 }
93 }
94}
95
96pub type StreamSlot = u32;
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct StreamCursor {
102 pub slot: StreamSlot,
104 #[serde(with = "wire::nanos_as_u64")]
106 pub ts_init_hi: UnixNanos,
107 pub count: u64,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct DataCursorSnapshot {
114 pub marker_seq: u64,
116 pub event_seq_before: u64,
118 #[serde(with = "wire::nanos_as_u64")]
120 pub ts_init: UnixNanos,
121 pub advanced: Vec<StreamCursor>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct HiFiMarker {
128 pub marker_seq: u64,
130 pub event_seq_before: u64,
132 pub slot: StreamSlot,
134 #[serde(with = "wire::nanos_as_u64")]
136 pub ts_event: UnixNanos,
137 #[serde(with = "wire::nanos_as_u64")]
139 pub ts_init: UnixNanos,
140 pub same_ts_ordinal: u32,
142 pub record_fingerprint: [u8; 32],
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub enum MarkerGapReason {
149 Overflow,
151 WriterClosed,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct MarkerGap {
158 pub from_marker_seq: u64,
160 pub to_marker_seq: u64,
162 pub reason: MarkerGapReason,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct StreamDictEntry {
169 pub slot: StreamSlot,
171 pub data_cls: DataClass,
173 pub identifier: String,
175}
176
177#[must_use]
184pub fn compute_marker_hash(snapshot: &DataCursorSnapshot) -> [u8; 32] {
185 let mut hasher = blake3::Hasher::new();
186 hasher.update(MARKER_HASH_DOMAIN);
187 hasher.update(&snapshot.marker_seq.to_be_bytes());
188 hasher.update(&snapshot.event_seq_before.to_be_bytes());
189 hasher.update(&snapshot.ts_init.as_u64().to_be_bytes());
190 hasher.update(&(snapshot.advanced.len() as u64).to_be_bytes());
191 for cursor in &snapshot.advanced {
192 hasher.update(&cursor.slot.to_be_bytes());
193 hasher.update(&cursor.ts_init_hi.as_u64().to_be_bytes());
194 hasher.update(&cursor.count.to_be_bytes());
195 }
196 *hasher.finalize().as_bytes()
197}
198
199#[must_use]
205pub fn compute_hifi_hash(marker: &HiFiMarker) -> [u8; 32] {
206 let mut hasher = blake3::Hasher::new();
207 hasher.update(HIFI_HASH_DOMAIN);
208 hasher.update(&marker.marker_seq.to_be_bytes());
209 hasher.update(&marker.event_seq_before.to_be_bytes());
210 hasher.update(&marker.slot.to_be_bytes());
211 hasher.update(&marker.ts_event.as_u64().to_be_bytes());
212 hasher.update(&marker.ts_init.as_u64().to_be_bytes());
213 hasher.update(&marker.same_ts_ordinal.to_be_bytes());
214 hasher.update(&marker.record_fingerprint);
215 *hasher.finalize().as_bytes()
216}
217
218#[must_use]
225pub fn compute_dict_hash(entry: &StreamDictEntry) -> [u8; 32] {
226 let mut hasher = blake3::Hasher::new();
227 hasher.update(DICT_HASH_DOMAIN);
228 hasher.update(&entry.slot.to_be_bytes());
229 let class = entry.data_cls.persisted_hash_token().as_bytes();
230 hasher.update(&(class.len() as u64).to_be_bytes());
231 hasher.update(class);
232 let identifier = entry.identifier.as_bytes();
233 hasher.update(&(identifier.len() as u64).to_be_bytes());
234 hasher.update(identifier);
235 *hasher.finalize().as_bytes()
236}
237
238#[must_use]
244pub fn compute_gap_hash(gap: &MarkerGap) -> [u8; 32] {
245 let mut hasher = blake3::Hasher::new();
246 hasher.update(GAP_HASH_DOMAIN);
247 hasher.update(&gap.from_marker_seq.to_be_bytes());
248 hasher.update(&gap.to_marker_seq.to_be_bytes());
249 let reason = match gap.reason {
250 MarkerGapReason::Overflow => 0u8,
251 MarkerGapReason::WriterClosed => 1u8,
252 };
253 hasher.update(&[reason]);
254 *hasher.finalize().as_bytes()
255}
256
257#[cfg(test)]
258mod tests {
259 use std::{fmt::Write, str::FromStr};
260
261 use rstest::rstest;
262 use serde::Deserialize;
263
264 use super::*;
265
266 #[rstest]
267 fn data_class_roundtrips_to_str() {
268 let variants = [
269 (DataClass::BookDeltas, "BookDeltas"),
270 (DataClass::BookDepth, "BookDepth"),
271 (DataClass::Quote, "Quote"),
272 (DataClass::Trade, "Trade"),
273 (DataClass::Bar, "Bar"),
274 ];
275
276 for (variant, expected) in variants {
277 assert_eq!(variant.as_str(), expected, "as_str for {variant:?}");
278 assert_eq!(variant.to_string(), expected, "Display for {variant:?}");
279 assert_eq!(
280 DataClass::from_str(expected).unwrap(),
281 variant,
282 "from_str for {expected}"
283 );
284 }
285 }
286
287 fn baseline_snapshot() -> DataCursorSnapshot {
288 DataCursorSnapshot {
289 marker_seq: 1,
290 event_seq_before: 42,
291 ts_init: UnixNanos::from(1_700_000_000_000_000_000),
292 advanced: vec![
293 StreamCursor {
294 slot: 0,
295 ts_init_hi: UnixNanos::from(1_700_000_000_000_000_001),
296 count: 7,
297 },
298 StreamCursor {
299 slot: 1,
300 ts_init_hi: UnixNanos::from(1_700_000_000_000_000_002),
301 count: 3,
302 },
303 ],
304 }
305 }
306
307 fn baseline_hifi() -> HiFiMarker {
308 HiFiMarker {
309 marker_seq: 1,
310 event_seq_before: 42,
311 slot: 0,
312 ts_event: UnixNanos::from(1_700_000_000_000_000_000),
313 ts_init: UnixNanos::from(1_700_000_000_000_000_001),
314 same_ts_ordinal: 0,
315 record_fingerprint: [0xABu8; 32],
316 }
317 }
318
319 fn baseline_dict() -> StreamDictEntry {
320 StreamDictEntry {
321 slot: 3,
322 data_cls: DataClass::Quote,
323 identifier: "ETHUSDT.BINANCE".to_string(),
324 }
325 }
326
327 fn baseline_gap() -> MarkerGap {
328 MarkerGap {
329 from_marker_seq: 5,
330 to_marker_seq: 9,
331 reason: MarkerGapReason::Overflow,
332 }
333 }
334
335 fn hex32(bytes: &[u8; 32]) -> String {
336 let mut out = String::with_capacity(64);
337 for byte in bytes {
338 write!(out, "{byte:02x}").expect("writing to a String is infallible");
339 }
340 out
341 }
342
343 #[rstest]
344 fn marker_hash_is_deterministic() {
345 let snap = baseline_snapshot();
346 let h1 = compute_marker_hash(&snap);
347 let h2 = compute_marker_hash(&snap);
348
349 assert_eq!(h1, h2);
350
351 let hex = hex32(&h1);
354 assert_eq!(
355 hex, "898bc3efdaf0edd9167a38a1c3060c9b4dc051658ea2f6132004bed78a481c47",
356 "marker hash wire format changed"
357 );
358 }
359
360 #[rstest]
361 fn hifi_hash_is_deterministic() {
362 let marker = baseline_hifi();
363 let h1 = compute_hifi_hash(&marker);
364 let h2 = compute_hifi_hash(&marker);
365
366 assert_eq!(h1, h2);
367
368 let hex = hex32(&h1);
369 assert_eq!(
370 hex, "06542408380d8815ef783b9dbde6b3e3ffdf05605bb17e83ad48474557457517",
371 "hifi hash wire format changed"
372 );
373 }
374
375 #[rstest]
376 fn dict_hash_is_deterministic() {
377 let entry = baseline_dict();
378 let h1 = compute_dict_hash(&entry);
379 let h2 = compute_dict_hash(&entry);
380
381 assert_eq!(h1, h2);
382
383 let hex = hex32(&h1);
385 assert_eq!(
386 hex, "24e702c5ae20b832ad6907676919fa18a89b79e97dde9df7e1de454191f42fda",
387 "dict hash wire format changed"
388 );
389 }
390
391 #[rstest]
392 fn gap_hash_is_deterministic() {
393 let gap = baseline_gap();
394 let h1 = compute_gap_hash(&gap);
395 let h2 = compute_gap_hash(&gap);
396
397 assert_eq!(h1, h2);
398
399 let hex = hex32(&h1);
401 assert_eq!(
402 hex, "ec1ae0ea813e9971155c6277e95c43de72da6f22ca1832f072aadd9b91f5a3ec",
403 "gap hash wire format changed"
404 );
405 }
406
407 #[rstest]
408 fn marker_record_codec_roundtrip() {
409 let snap = baseline_snapshot();
411 let bytes = crate::codec::encode_to_vec(&snap).expect("encode");
412 let decoded =
413 crate::codec::decode_from_slice::<DataCursorSnapshot>(&bytes).expect("decode");
414 assert_eq!(snap, decoded);
415
416 let hifi = baseline_hifi();
418 let bytes = crate::codec::encode_to_vec(&hifi).expect("encode");
419 let decoded = crate::codec::decode_from_slice::<HiFiMarker>(&bytes).expect("decode");
420 assert_eq!(hifi, decoded);
421
422 let gap = MarkerGap {
424 from_marker_seq: 5,
425 to_marker_seq: 10,
426 reason: MarkerGapReason::Overflow,
427 };
428 let bytes = crate::codec::encode_to_vec(&gap).expect("encode");
429 let decoded = crate::codec::decode_from_slice::<MarkerGap>(&bytes).expect("decode");
430 assert_eq!(gap, decoded);
431
432 let dict = StreamDictEntry {
434 slot: 2,
435 data_cls: DataClass::Bar,
436 identifier: "BTCUSDT-PERP.BINANCE".to_string(),
437 };
438 let bytes = crate::codec::encode_to_vec(&dict).expect("encode");
439 let decoded = crate::codec::decode_from_slice::<StreamDictEntry>(&bytes).expect("decode");
440 assert_eq!(dict, decoded);
441 }
442
443 #[rstest]
444 #[case::quote_lowercase("quote")]
445 #[case::empty("")]
446 #[case::trailing_s("Quotes")]
447 #[case::partial("BookDep")]
448 fn data_class_from_str_rejects_unknown(#[case] input: &str) {
449 let err = DataClass::from_str(input).unwrap_err();
450
451 assert!(
452 err.contains(input),
453 "error should name the rejected input, was `{err}`"
454 );
455 }
456
457 #[rstest]
458 fn dict_hash_for_depth_class_uses_pre_rename_token() {
459 let entry = StreamDictEntry {
460 slot: 7,
461 data_cls: DataClass::BookDepth,
462 identifier: "BTCUSDT-PERP.BINANCE".to_string(),
463 };
464
465 assert_eq!(
468 compute_dict_hash(&entry),
469 [
470 0x9c, 0x2e, 0x1e, 0xd8, 0x46, 0xe0, 0xa5, 0x24, 0xea, 0x3d, 0xb2, 0x49, 0x48, 0xbf,
471 0x66, 0xf7, 0x00, 0x8b, 0x91, 0x21, 0x4a, 0x60, 0x9d, 0x75, 0xca, 0x07, 0x0c, 0x1e,
472 0xbf, 0xe2, 0xa7, 0x79,
473 ]
474 );
475 }
476
477 #[rstest]
478 fn data_class_from_str_accepts_legacy_depth10_spelling() {
479 assert_eq!(
480 DataClass::from_str("BookDepth10").unwrap(),
481 DataClass::BookDepth
482 );
483 assert_eq!(
484 DataClass::from_str("BookDepth").unwrap(),
485 DataClass::BookDepth
486 );
487 }
488
489 #[rstest]
490 fn data_class_serde_accepts_legacy_depth10_spelling() {
491 let legacy =
492 serde::de::value::StrDeserializer::<serde::de::value::Error>::new("BookDepth10");
493 assert_eq!(
494 DataClass::deserialize(legacy).unwrap(),
495 DataClass::BookDepth
496 );
497 let canonical =
498 serde::de::value::StrDeserializer::<serde::de::value::Error>::new("BookDepth");
499 assert_eq!(
500 DataClass::deserialize(canonical).unwrap(),
501 DataClass::BookDepth
502 );
503 }
504
505 #[rstest]
506 #[case::marker_seq(|s: &mut DataCursorSnapshot| s.marker_seq = 99)]
507 #[case::event_seq_before(|s: &mut DataCursorSnapshot| s.event_seq_before = 99)]
508 #[case::ts_init(|s: &mut DataCursorSnapshot| s.ts_init = UnixNanos::from(1))]
509 #[case::cursor_slot(|s: &mut DataCursorSnapshot| s.advanced[0].slot = 256)]
510 #[case::cursor_ts_init_hi(|s: &mut DataCursorSnapshot| s.advanced[0].ts_init_hi = UnixNanos::from(1))]
511 #[case::cursor_count(|s: &mut DataCursorSnapshot| s.advanced[0].count = 999)]
512 #[case::extra_cursor(|s: &mut DataCursorSnapshot| s.advanced.push(StreamCursor { slot: 2, ts_init_hi: UnixNanos::from(1_700_000_000_000_000_003), count: 1 }))]
513 #[case::cursor_order(|s: &mut DataCursorSnapshot| s.advanced.reverse())]
514 fn every_marker_field_affects_hash(#[case] mutate: fn(&mut DataCursorSnapshot)) {
515 let base = baseline_snapshot();
516 let mut mutated = base.clone();
517 mutate(&mut mutated);
518
519 assert_ne!(compute_marker_hash(&base), compute_marker_hash(&mutated));
520 }
521
522 #[rstest]
523 #[case::marker_seq(|m: &mut HiFiMarker| m.marker_seq = 99)]
524 #[case::event_seq_before(|m: &mut HiFiMarker| m.event_seq_before = 99)]
525 #[case::slot(|m: &mut HiFiMarker| m.slot = 256)]
526 #[case::ts_event(|m: &mut HiFiMarker| m.ts_event = UnixNanos::from(1))]
527 #[case::ts_init(|m: &mut HiFiMarker| m.ts_init = UnixNanos::from(1))]
528 #[case::same_ts_ordinal(|m: &mut HiFiMarker| m.same_ts_ordinal = 256)]
529 #[case::fingerprint(|m: &mut HiFiMarker| m.record_fingerprint[0] ^= 0x01)]
530 fn every_hifi_field_affects_hash(#[case] mutate: fn(&mut HiFiMarker)) {
531 let base = baseline_hifi();
532 let mut mutated = base.clone();
533 mutate(&mut mutated);
534
535 assert_ne!(compute_hifi_hash(&base), compute_hifi_hash(&mutated));
536 }
537
538 #[rstest]
539 #[case::slot(|e: &mut StreamDictEntry| e.slot = 99)]
540 #[case::data_cls(|e: &mut StreamDictEntry| e.data_cls = DataClass::Trade)]
541 #[case::identifier(|e: &mut StreamDictEntry| e.identifier = "BTCUSDT.BINANCE".to_string())]
542 fn every_dict_field_affects_hash(#[case] mutate: fn(&mut StreamDictEntry)) {
543 let base = baseline_dict();
544 let mut mutated = base.clone();
545 mutate(&mut mutated);
546
547 assert_ne!(compute_dict_hash(&base), compute_dict_hash(&mutated));
548 }
549
550 #[rstest]
551 #[case::from(|g: &mut MarkerGap| g.from_marker_seq = 99)]
552 #[case::to(|g: &mut MarkerGap| g.to_marker_seq = 99)]
553 #[case::reason(|g: &mut MarkerGap| g.reason = MarkerGapReason::WriterClosed)]
554 fn every_gap_field_affects_hash(#[case] mutate: fn(&mut MarkerGap)) {
555 let base = baseline_gap();
556 let mut mutated = base.clone();
557 mutate(&mut mutated);
558
559 assert_ne!(compute_gap_hash(&base), compute_gap_hash(&mutated));
560 }
561
562 #[rstest]
563 fn marker_hash_handles_empty_advanced() {
564 let empty = DataCursorSnapshot {
565 marker_seq: 1,
566 event_seq_before: 42,
567 ts_init: UnixNanos::from(1_700_000_000_000_000_000),
568 advanced: vec![],
569 };
570
571 assert_eq!(compute_marker_hash(&empty), compute_marker_hash(&empty));
572 assert_ne!(
573 compute_marker_hash(&empty),
574 compute_marker_hash(&baseline_snapshot())
575 );
576 }
577}