1use std::{collections::BTreeMap, fmt::Display};
33
34use ahash::AHashMap;
35use nautilus_model::{
36 data::{OrderBookDelta, OrderBookDeltas},
37 enums::{BookAction, BookType, RecordFlag},
38 identifiers::InstrumentId,
39 orderbook::{OrderBook, analysis::book_check_integrity},
40};
41use rust_decimal::Decimal;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum BookContractViolation {
46 MissingLast,
48 SnapshotFlagMismatch,
50 SnapshotAction(BookAction),
52 ClearInIncremental,
54 IncrementalBeforeSnapshot,
56 SequenceNotIncreasing { previous: u64, sequence: u64 },
58 OutputWhileClosed,
60 Integrity(String),
62 TruthMismatch { sequence: u64 },
64 Unsynced,
66}
67
68impl Display for BookContractViolation {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 Self::MissingLast => write!(f, "batch does not end with F_LAST"),
72 Self::SnapshotFlagMismatch => {
73 write!(f, "F_SNAPSHOT disagrees with the event group kind")
74 }
75 Self::SnapshotAction(action) => {
76 write!(f, "snapshot contains {action:?} after its Clear")
77 }
78 Self::ClearInIncremental => write!(f, "Clear inside an incremental event group"),
79 Self::IncrementalBeforeSnapshot => {
80 write!(f, "incremental event group before a snapshot")
81 }
82 Self::SequenceNotIncreasing { previous, sequence } => {
83 write!(f, "sequence {sequence} does not follow {previous}")
84 }
85 Self::OutputWhileClosed => write!(f, "output while the book is closed"),
86 Self::Integrity(e) => write!(f, "book integrity: {e}"),
87 Self::TruthMismatch { sequence } => {
88 write!(f, "book differs from venue truth at sequence {sequence}")
89 }
90 Self::Unsynced => write!(f, "no snapshot to verify"),
91 }
92 }
93}
94
95impl std::error::Error for BookContractViolation {}
96
97#[derive(Debug)]
99pub struct BookStreamChecker {
100 book_type: BookType,
101 sequenced: bool,
102 books: AHashMap<InstrumentId, CheckedBook>,
103}
104
105impl BookStreamChecker {
106 #[must_use]
113 pub fn new(book_type: BookType, sequenced: bool) -> Self {
114 Self {
115 book_type,
116 sequenced,
117 books: AHashMap::new(),
118 }
119 }
120
121 pub fn open(&mut self, instrument_id: InstrumentId) {
123 let book_type = self.book_type;
124
125 let checked = self
126 .books
127 .entry(instrument_id)
128 .or_insert_with(|| CheckedBook {
129 book: OrderBook::new(instrument_id, book_type),
130 open: false,
131 sequence: None,
132 episodes: 0,
133 verified: 0,
134 episode_verified: false,
135 });
136
137 checked.open = true;
138 checked.sequence = None;
139 }
140
141 pub fn close(&mut self, instrument_id: InstrumentId) {
143 if let Some(checked) = self.books.get_mut(&instrument_id) {
144 checked.open = false;
145 }
146 }
147
148 pub fn apply(&mut self, deltas: &OrderBookDeltas) -> Result<(), BookContractViolation> {
155 let checked = self
156 .books
157 .get_mut(&deltas.instrument_id)
158 .filter(|checked| checked.open)
159 .ok_or(BookContractViolation::OutputWhileClosed)?;
160
161 if !deltas
162 .deltas
163 .last()
164 .is_some_and(|last| RecordFlag::F_LAST.matches(last.flags))
165 {
166 return Err(BookContractViolation::MissingLast);
167 }
168
169 for group in deltas
171 .deltas
172 .split_inclusive(|delta| RecordFlag::F_LAST.matches(delta.flags))
173 {
174 checked.check_group(group, self.sequenced)?;
175
176 let group = OrderBookDeltas::new(deltas.instrument_id, group.to_vec());
177 checked
178 .book
179 .apply_deltas(&group)
180 .map_err(|e| BookContractViolation::Integrity(e.to_string()))?;
181 book_check_integrity(&checked.book)
182 .map_err(|e| BookContractViolation::Integrity(e.to_string()))?;
183 }
184
185 Ok(())
186 }
187
188 pub fn verify(
194 &mut self,
195 instrument_id: InstrumentId,
196 depth: usize,
197 bids: &BTreeMap<Decimal, Decimal>,
198 asks: &BTreeMap<Decimal, Decimal>,
199 ) -> Result<(), BookContractViolation> {
200 let checked = self
201 .books
202 .get_mut(&instrument_id)
203 .ok_or(BookContractViolation::Unsynced)?;
204 let sequence = checked.sequence.ok_or(BookContractViolation::Unsynced)?;
205
206 let book_bids: BTreeMap<_, _> = checked.book.bids_as_map(Some(depth)).into_iter().collect();
207 let book_asks: BTreeMap<_, _> = checked.book.asks_as_map(Some(depth)).into_iter().collect();
208
209 if book_bids != *bids || book_asks != *asks {
210 return Err(BookContractViolation::TruthMismatch { sequence });
211 }
212
213 if !checked.episode_verified {
214 checked.episode_verified = true;
215 checked.verified += 1;
216 }
217
218 Ok(())
219 }
220
221 #[must_use]
223 pub fn book(&self, instrument_id: InstrumentId) -> Option<&OrderBook> {
224 self.books.get(&instrument_id).map(|checked| &checked.book)
225 }
226
227 #[must_use]
229 pub fn coverage(&self) -> (u64, u64) {
230 self.books.values().fold((0, 0), |(episodes, verified), c| {
231 (episodes + c.episodes, verified + c.verified)
232 })
233 }
234}
235
236#[derive(Debug)]
237struct CheckedBook {
238 book: OrderBook,
239 open: bool,
240 sequence: Option<u64>,
241 episodes: u64,
242 verified: u64,
243 episode_verified: bool,
244}
245
246impl CheckedBook {
247 fn check_group(
248 &mut self,
249 group: &[OrderBookDelta],
250 sequenced: bool,
251 ) -> Result<(), BookContractViolation> {
252 let snapshot = group[0].action == BookAction::Clear;
253 let sequence = group[group.len() - 1].sequence;
254
255 if group
256 .iter()
257 .any(|delta| RecordFlag::F_SNAPSHOT.matches(delta.flags) != snapshot)
258 {
259 return Err(BookContractViolation::SnapshotFlagMismatch);
260 }
261
262 if snapshot {
263 if let Some(delta) = group[1..]
264 .iter()
265 .find(|delta| delta.action != BookAction::Add)
266 {
267 return Err(BookContractViolation::SnapshotAction(delta.action));
268 }
269
270 self.episodes += 1;
271 self.episode_verified = false;
272 } else {
273 if group.iter().any(|delta| delta.action == BookAction::Clear) {
274 return Err(BookContractViolation::ClearInIncremental);
275 }
276
277 let Some(previous) = self.sequence else {
278 return Err(BookContractViolation::IncrementalBeforeSnapshot);
279 };
280
281 if sequenced && sequence <= previous {
282 return Err(BookContractViolation::SequenceNotIncreasing { previous, sequence });
283 }
284 }
285
286 self.sequence = Some(sequence);
287 Ok(())
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use nautilus_core::UnixNanos;
294 use nautilus_model::{
295 data::{BookOrder, OrderBookDelta},
296 enums::OrderSide,
297 types::{Price, Quantity},
298 };
299 use rstest::rstest;
300 use rust_decimal_macros::dec;
301
302 use super::*;
303
304 const LAST: u8 = RecordFlag::F_LAST as u8;
305 const SNAPSHOT: u8 = RecordFlag::F_SNAPSHOT as u8;
306
307 fn instrument_id() -> InstrumentId {
308 InstrumentId::from("BTCUSDT.VENUE")
309 }
310
311 fn delta(
312 action: BookAction,
313 side: OrderSide,
314 price: &str,
315 size: &str,
316 flags: u8,
317 sequence: u64,
318 ) -> OrderBookDelta {
319 let order = BookOrder::new(side, Price::from(price), Quantity::from(size), 0);
320
321 OrderBookDelta::new(
322 instrument_id(),
323 action,
324 order,
325 flags,
326 sequence,
327 UnixNanos::from(sequence),
328 UnixNanos::from(sequence),
329 )
330 }
331
332 fn batch(deltas: Vec<OrderBookDelta>) -> OrderBookDeltas {
333 OrderBookDeltas::new(instrument_id(), deltas)
334 }
335
336 fn clear(flags: u8, sequence: u64) -> OrderBookDelta {
337 let mut delta = OrderBookDelta::clear(
338 instrument_id(),
339 sequence,
340 UnixNanos::from(sequence),
341 UnixNanos::from(sequence),
342 );
343 delta.flags = flags;
344 delta
345 }
346
347 fn snapshot(sequence: u64) -> OrderBookDeltas {
348 batch(vec![
349 clear(SNAPSHOT, sequence),
350 delta(
351 BookAction::Add,
352 OrderSide::Buy,
353 "100.0",
354 "1.0",
355 SNAPSHOT,
356 sequence,
357 ),
358 delta(
359 BookAction::Add,
360 OrderSide::Sell,
361 "101.0",
362 "2.0",
363 SNAPSHOT | LAST,
364 sequence,
365 ),
366 ])
367 }
368
369 fn update(price: &str, size: &str, sequence: u64) -> OrderBookDeltas {
370 batch(vec![delta(
371 BookAction::Update,
372 OrderSide::Buy,
373 price,
374 size,
375 LAST,
376 sequence,
377 )])
378 }
379
380 fn checker() -> BookStreamChecker {
381 let mut checker = BookStreamChecker::new(BookType::L2_MBP, true);
382 checker.open(instrument_id());
383 checker
384 }
385
386 #[rstest]
387 fn conforming_stream_passes_and_counts_verified_episode() {
388 let mut checker = checker();
389
390 checker.apply(&snapshot(10)).unwrap();
391 checker.apply(&update("100.0", "3.0", 11)).unwrap();
392 let verified = checker.verify(
393 instrument_id(),
394 10,
395 &BTreeMap::from([(dec!(100.0), dec!(3.0))]),
396 &BTreeMap::from([(dec!(101.0), dec!(2.0))]),
397 );
398
399 assert_eq!(verified, Ok(()));
400 assert_eq!(checker.coverage(), (1, 1));
401 }
402
403 #[rstest]
404 fn empty_snapshot_is_a_lone_clear() {
405 let mut checker = checker();
406
407 let result = checker.apply(&batch(vec![clear(SNAPSHOT | LAST, 5)]));
408
409 assert_eq!(result, Ok(()));
410 assert_eq!(checker.book(instrument_id()).unwrap().bids(None).count(), 0);
411 }
412
413 #[rstest]
415 #[case::missing_last(
416 vec![delta(BookAction::Update, OrderSide::Buy, "100.0", "1.0", 0, 11)],
417 BookContractViolation::MissingLast,
418 )]
419 #[case::snapshot_ended_early(
420 vec![
421 clear(SNAPSHOT, 11),
422 delta(BookAction::Add, OrderSide::Buy, "100.0", "1.0", SNAPSHOT | LAST, 11),
423 delta(BookAction::Add, OrderSide::Buy, "99.0", "1.0", SNAPSHOT | LAST, 11),
424 ],
425 BookContractViolation::SnapshotFlagMismatch,
426 )]
427 #[case::incremental_flagged_snapshot(
428 vec![delta(BookAction::Update, OrderSide::Buy, "100.0", "1.0", SNAPSHOT | LAST, 11)],
429 BookContractViolation::SnapshotFlagMismatch,
430 )]
431 #[case::snapshot_missing_flag(
432 vec![
433 clear(SNAPSHOT, 11),
434 delta(BookAction::Add, OrderSide::Buy, "100.0", "1.0", LAST, 11),
435 ],
436 BookContractViolation::SnapshotFlagMismatch,
437 )]
438 #[case::snapshot_update_action(
439 vec![
440 clear(SNAPSHOT, 11),
441 delta(BookAction::Update, OrderSide::Buy, "100.0", "1.0", SNAPSHOT | LAST, 11),
442 ],
443 BookContractViolation::SnapshotAction(BookAction::Update),
444 )]
445 #[case::clear_in_incremental(
446 vec![
447 delta(BookAction::Update, OrderSide::Buy, "100.0", "1.0", 0, 11),
448 clear(LAST, 11),
449 ],
450 BookContractViolation::ClearInIncremental,
451 )]
452 #[case::duplicate_sequence(
453 vec![delta(BookAction::Update, OrderSide::Buy, "100.0", "1.0", LAST, 10)],
454 BookContractViolation::SequenceNotIncreasing { previous: 10, sequence: 10 },
455 )]
456 #[case::crossed_book(
457 vec![delta(BookAction::Add, OrderSide::Buy, "102.0", "1.0", LAST, 11)],
458 BookContractViolation::Integrity(String::new()),
459 )]
460 fn planted_fault_fails_checker(
461 #[case] deltas: Vec<OrderBookDelta>,
462 #[case] expected: BookContractViolation,
463 ) {
464 let mut checker = checker();
465 checker.apply(&snapshot(10)).unwrap();
466
467 let result = checker.apply(&batch(deltas));
468
469 match (result, expected) {
470 (Err(BookContractViolation::Integrity(_)), BookContractViolation::Integrity(_)) => {}
471 (result, expected) => assert_eq!(result, Err(expected)),
472 }
473 }
474
475 #[rstest]
476 fn batch_of_event_groups_checks_each_group() {
477 let mut checker = checker();
478 let deltas = batch(vec![
479 clear(SNAPSHOT, 10),
480 delta(
481 BookAction::Add,
482 OrderSide::Buy,
483 "100.0",
484 "1.0",
485 SNAPSHOT,
486 10,
487 ),
488 delta(
489 BookAction::Add,
490 OrderSide::Sell,
491 "101.0",
492 "2.0",
493 SNAPSHOT | LAST,
494 10,
495 ),
496 delta(BookAction::Update, OrderSide::Buy, "100.0", "3.0", LAST, 11),
497 delta(BookAction::Update, OrderSide::Buy, "100.0", "4.0", LAST, 12),
498 ]);
499
500 let result = checker.apply(&deltas);
501
502 assert_eq!(result, Ok(()));
503 assert_eq!(checker.coverage(), (1, 0));
504 assert_eq!(
505 checker
506 .book(instrument_id())
507 .unwrap()
508 .bids_as_map(None)
509 .get(&dec!(100.0)),
510 Some(&dec!(4.0))
511 );
512 }
513
514 #[rstest]
515 fn crossed_book_at_group_boundary_fails_even_if_later_repaired() {
516 let mut checker = checker();
517 checker.apply(&snapshot(10)).unwrap();
518
519 let result = checker.apply(&batch(vec![
520 delta(BookAction::Add, OrderSide::Buy, "102.0", "1.0", LAST, 11),
521 delta(BookAction::Delete, OrderSide::Buy, "102.0", "1.0", LAST, 12),
522 ]));
523
524 assert!(matches!(result, Err(BookContractViolation::Integrity(_))));
525 }
526
527 #[rstest]
528 fn incremental_before_snapshot_fails() {
529 let mut checker = checker();
530
531 let result = checker.apply(&update("100.0", "1.0", 11));
532
533 assert_eq!(
534 result,
535 Err(BookContractViolation::IncrementalBeforeSnapshot)
536 );
537 }
538
539 #[rstest]
540 fn output_after_close_fails_until_reopened() {
541 let mut checker = checker();
542 checker.apply(&snapshot(10)).unwrap();
543 checker.close(instrument_id());
544
545 let closed = checker.apply(&update("100.0", "1.0", 11));
546 checker.open(instrument_id());
547 let reopened_incremental = checker.apply(&update("100.0", "1.0", 12));
548 let reopened_snapshot = checker.apply(&snapshot(13));
549
550 assert_eq!(closed, Err(BookContractViolation::OutputWhileClosed));
551 assert_eq!(
552 reopened_incremental,
553 Err(BookContractViolation::IncrementalBeforeSnapshot)
554 );
555 assert_eq!(reopened_snapshot, Ok(()));
556 }
557
558 #[rstest]
559 fn dropped_update_fails_truth_comparison() {
560 let mut checker = checker();
561 checker.apply(&snapshot(10)).unwrap();
562
563 checker.apply(&update("99.0", "1.0", 12)).unwrap();
565 let result = checker.verify(
566 instrument_id(),
567 10,
568 &BTreeMap::from([(dec!(100.0), dec!(3.0)), (dec!(99.0), dec!(1.0))]),
569 &BTreeMap::from([(dec!(101.0), dec!(2.0))]),
570 );
571
572 assert_eq!(
573 result,
574 Err(BookContractViolation::TruthMismatch { sequence: 12 })
575 );
576 assert_eq!(checker.coverage(), (1, 0));
577 }
578
579 #[rstest]
580 fn ask_mismatch_fails_truth_comparison() {
581 let mut checker = checker();
582 checker.apply(&snapshot(10)).unwrap();
583
584 let result = checker.verify(
585 instrument_id(),
586 10,
587 &BTreeMap::from([(dec!(100.0), dec!(1.0))]),
588 &BTreeMap::from([(dec!(101.0), dec!(5.0))]),
589 );
590
591 assert_eq!(
592 result,
593 Err(BookContractViolation::TruthMismatch { sequence: 10 })
594 );
595 assert_eq!(checker.coverage(), (1, 0));
596 }
597
598 #[rstest]
599 #[case::never_synced(false)]
600 #[case::reopened(true)]
601 fn verify_requires_snapshot_since_open(#[case] reopened: bool) {
602 let mut checker = checker();
603
604 if reopened {
605 checker.apply(&snapshot(10)).unwrap();
606 checker.open(instrument_id());
607 }
608
609 let result = checker.verify(instrument_id(), 10, &BTreeMap::new(), &BTreeMap::new());
610
611 assert_eq!(result, Err(BookContractViolation::Unsynced));
612 }
613
614 #[rstest]
615 fn unsequenced_checker_accepts_repeated_sequence() {
616 let mut checker = BookStreamChecker::new(BookType::L2_MBP, false);
617 checker.open(instrument_id());
618 checker.apply(&snapshot(0)).unwrap();
619
620 let result = checker.apply(&update("100.0", "1.0", 0));
621
622 assert_eq!(result, Ok(()));
623 }
624}