Skip to main content

nautilus_live/book/
conformance.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Output checks for the order book stream contract.
17//!
18//! [`BookStreamChecker`] consumes the [`OrderBookDeltas`] an adapter emits and rejects the first
19//! batch that breaks the contract:
20//!
21//! - Every batch ends with `F_LAST`; an earlier `F_LAST` ends an event group inside the batch, and
22//!   each group is checked on its own, as the `DataEngine` publishes them.
23//! - A snapshot group starts with `Clear`, continues with `Add` deltas, and flags every delta
24//!   `F_SNAPSHOT`; an empty snapshot is a lone `Clear`.
25//! - An incremental group carries no `F_SNAPSHOT` and no `Clear`, and follows a snapshot.
26//! - Sequences increase within a snapshot episode when the checker requires it.
27//! - The maintained book stays consistent, and a closed book emits nothing.
28//!
29//! Harnesses compare the maintained book with venue truth through [`BookStreamChecker::verify`],
30//! which also counts verified episodes so a run can require every recovery to be checked.
31
32use 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/// A batch or book state that breaks the order book stream contract.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum BookContractViolation {
46    /// The batch does not end with an `F_LAST` delta.
47    MissingLast,
48    /// `F_SNAPSHOT` disagrees with the event group kind on at least one delta.
49    SnapshotFlagMismatch,
50    /// A snapshot delta after the leading `Clear` is not an `Add`.
51    SnapshotAction(BookAction),
52    /// An incremental event group contains a `Clear`.
53    ClearInIncremental,
54    /// An incremental event group arrived before the episode's snapshot.
55    IncrementalBeforeSnapshot,
56    /// An incremental sequence does not exceed the previous one.
57    SequenceNotIncreasing { previous: u64, sequence: u64 },
58    /// Output arrived for a book that is not open.
59    OutputWhileClosed,
60    /// The maintained book failed an integrity check.
61    Integrity(String),
62    /// The maintained book differs from venue truth.
63    TruthMismatch { sequence: u64 },
64    /// Verification was requested before any snapshot.
65    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/// Checks adapter order book output against the book stream contract.
98#[derive(Debug)]
99pub struct BookStreamChecker {
100    book_type: BookType,
101    sequenced: bool,
102    books: AHashMap<InstrumentId, CheckedBook>,
103}
104
105impl BookStreamChecker {
106    /// Creates a checker; `sequenced` requires each incremental event group's sequence to exceed
107    /// the previous one.
108    ///
109    /// Emitted deltas do not carry a venue's linkage fields, so the adapter's tracker validates
110    /// linkage. Pass `false` for a venue whose sequence can reset within an episode, such as OKX,
111    /// and rely on [`Self::verify`] against venue truth.
112    #[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    /// Accepts output for a subscribed book; its next batch must be a snapshot.
122    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    /// Rejects later output for a book after a settled unsubscribe or client stop.
142    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    /// Applies one emitted batch.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the batch violates the book contract. The error reports the first
153    /// violation.
154    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        // Consumers see the book at every event boundary, so each group must leave it valid
170        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    /// Compares the top `depth` levels with venue truth, counting the current episode verified.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if either side differs or no snapshot has arrived since the book opened.
193    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    /// Returns the maintained book.
222    #[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    /// Returns snapshot episodes and verified episodes across all books.
228    #[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    // Each planted fault must fail the checker, proving the oracle can see it
414    #[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        // The venue moved the bid to 3.0 at sequence 11, but only sequence 12 was emitted
564        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}