1use std::{fmt::Display, path::PathBuf};
24
25use indexmap::IndexMap;
26use nautilus_common::{
27 cache::Cache,
28 messages::{
29 data::{
30 BarsResponse, FundingRatesResponse, InstrumentResponse, InstrumentsResponse,
31 QuotesResponse, TradesResponse,
32 },
33 execution::SubmitOrderList,
34 },
35};
36use nautilus_core::{UUID4, UnixNanos};
37use nautilus_model::{
38 data::{Bar, QuoteTick, TradeTick},
39 enums::{OmsType, OrderSide, PositionSide},
40 events::{
41 AccountState, OrderEventAny, OrderFillVoided, OrderFilled, OrderInitialized,
42 PositionAdjusted, PositionChanged, PositionClosed, PositionOpened,
43 },
44 identifiers::PositionId,
45 orders::{Order, OrderAny},
46 position::{Position, PositionReplayEvent},
47 types::{Money, Quantity},
48};
49use serde::{Deserialize, de::DeserializeOwned};
50
51#[cfg(test)]
52use crate::capture::builtins::{
53 PAYLOAD_TYPE_BATCH_CANCEL_ORDERS, PAYLOAD_TYPE_BATCH_MODIFY_ORDERS,
54 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE, PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
55 PAYLOAD_TYPE_BOOK_RESPONSE, PAYLOAD_TYPE_CANCEL_ALL_ORDERS, PAYLOAD_TYPE_CANCEL_ORDER,
56 PAYLOAD_TYPE_CUSTOM_DATA_RESPONSE, PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
57 PAYLOAD_TYPE_FILL_REPORT, PAYLOAD_TYPE_FORWARD_PRICES_RESPONSE, PAYLOAD_TYPE_MODIFY_ORDER,
58 PAYLOAD_TYPE_ORDER_STATUS_REPORT, PAYLOAD_TYPE_ORDER_WITH_FILLS,
59 PAYLOAD_TYPE_POSITION_STATUS_REPORT, PAYLOAD_TYPE_QUERY_ACCOUNT, PAYLOAD_TYPE_QUERY_ORDER,
60 PAYLOAD_TYPE_REQUEST_COMMAND, PAYLOAD_TYPE_SUBMIT_ORDER, PAYLOAD_TYPE_SUBSCRIBE_COMMAND,
61 PAYLOAD_TYPE_TIME_EVENT, PAYLOAD_TYPE_UNSUBSCRIBE_COMMAND,
62};
63#[cfg(all(test, feature = "defi"))]
64use crate::capture::builtins::{
65 PAYLOAD_TYPE_DEFI_REQUEST_COMMAND, PAYLOAD_TYPE_DEFI_SUBSCRIBE_COMMAND,
66 PAYLOAD_TYPE_DEFI_UNSUBSCRIBE_COMMAND,
67};
68use crate::{
69 RedbBackend,
70 backend::{EventStore, ScanDirection},
71 capture::builtins::{
72 PAYLOAD_TYPE_ACCOUNT_STATE, PAYLOAD_TYPE_BARS_RESPONSE,
73 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE, PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
74 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE, PAYLOAD_TYPE_ORDER_ACCEPTED,
75 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED, PAYLOAD_TYPE_ORDER_CANCELED, PAYLOAD_TYPE_ORDER_DENIED,
76 PAYLOAD_TYPE_ORDER_EMULATED, PAYLOAD_TYPE_ORDER_EXPIRED, PAYLOAD_TYPE_ORDER_FILL_VOIDED,
77 PAYLOAD_TYPE_ORDER_FILLED, PAYLOAD_TYPE_ORDER_INITIALIZED,
78 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED, PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
79 PAYLOAD_TYPE_ORDER_PENDING_UPDATE, PAYLOAD_TYPE_ORDER_REJECTED,
80 PAYLOAD_TYPE_ORDER_RELEASED, PAYLOAD_TYPE_ORDER_SUBMITTED, PAYLOAD_TYPE_ORDER_TRIGGERED,
81 PAYLOAD_TYPE_ORDER_UPDATED, PAYLOAD_TYPE_POSITION_ADJUSTED, PAYLOAD_TYPE_POSITION_CHANGED,
82 PAYLOAD_TYPE_POSITION_CLOSED, PAYLOAD_TYPE_POSITION_OPENED, PAYLOAD_TYPE_QUOTES_RESPONSE,
83 PAYLOAD_TYPE_SUBMIT_ORDER_LIST, PAYLOAD_TYPE_TRADES_RESPONSE,
84 },
85 entry::EventStoreEntry,
86 error::EventStoreError,
87 manifest::{RunManifest, RunStatus},
88 reader::{EventStoreReader, SnapshotReplayPlan},
89 snapshot::{SnapshotAnchor, compute_snapshot_content_hash},
90};
91
92#[cfg(feature = "persistence")]
93mod catalog;
94
95#[cfg(feature = "persistence")]
96pub use catalog::ParquetReplayCatalog;
97
98#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct CacheReplayReport {
101 pub plan: SnapshotReplayPlan,
103 pub applied_entries: usize,
105 pub ignored_entries: usize,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct EventStoreReplayReport {
112 pub manifest: RunManifest,
114 pub cache: CacheReplayReport,
116}
117
118#[cfg(test)]
119pub(crate) const CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES: &[&str] = &[
120 PAYLOAD_TYPE_SUBMIT_ORDER_LIST,
121 PAYLOAD_TYPE_ACCOUNT_STATE,
122 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
123 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
124 PAYLOAD_TYPE_QUOTES_RESPONSE,
125 PAYLOAD_TYPE_TRADES_RESPONSE,
126 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
127 PAYLOAD_TYPE_BARS_RESPONSE,
128 PAYLOAD_TYPE_ORDER_INITIALIZED,
129 PAYLOAD_TYPE_ORDER_DENIED,
130 PAYLOAD_TYPE_ORDER_EMULATED,
131 PAYLOAD_TYPE_ORDER_RELEASED,
132 PAYLOAD_TYPE_ORDER_SUBMITTED,
133 PAYLOAD_TYPE_ORDER_ACCEPTED,
134 PAYLOAD_TYPE_ORDER_REJECTED,
135 PAYLOAD_TYPE_ORDER_CANCELED,
136 PAYLOAD_TYPE_ORDER_EXPIRED,
137 PAYLOAD_TYPE_ORDER_TRIGGERED,
138 PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
139 PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
140 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
141 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED,
142 PAYLOAD_TYPE_ORDER_UPDATED,
143 PAYLOAD_TYPE_ORDER_FILLED,
144 PAYLOAD_TYPE_ORDER_FILL_VOIDED,
145 PAYLOAD_TYPE_POSITION_OPENED,
146 PAYLOAD_TYPE_POSITION_CHANGED,
147 PAYLOAD_TYPE_POSITION_CLOSED,
148 PAYLOAD_TYPE_POSITION_ADJUSTED,
149];
150
151#[cfg(test)]
152pub(crate) const FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES: &[&str] = &[
153 PAYLOAD_TYPE_SUBMIT_ORDER,
154 PAYLOAD_TYPE_MODIFY_ORDER,
155 PAYLOAD_TYPE_BATCH_MODIFY_ORDERS,
156 PAYLOAD_TYPE_CANCEL_ORDER,
157 PAYLOAD_TYPE_CANCEL_ALL_ORDERS,
158 PAYLOAD_TYPE_BATCH_CANCEL_ORDERS,
159 PAYLOAD_TYPE_QUERY_ORDER,
160 PAYLOAD_TYPE_QUERY_ACCOUNT,
161 PAYLOAD_TYPE_ORDER_STATUS_REPORT,
162 PAYLOAD_TYPE_FILL_REPORT,
163 PAYLOAD_TYPE_ORDER_WITH_FILLS,
164 PAYLOAD_TYPE_POSITION_STATUS_REPORT,
165 PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
166 PAYLOAD_TYPE_TIME_EVENT,
167 PAYLOAD_TYPE_REQUEST_COMMAND,
168 PAYLOAD_TYPE_SUBSCRIBE_COMMAND,
169 PAYLOAD_TYPE_UNSUBSCRIBE_COMMAND,
170 #[cfg(feature = "defi")]
171 PAYLOAD_TYPE_DEFI_REQUEST_COMMAND,
172 #[cfg(feature = "defi")]
173 PAYLOAD_TYPE_DEFI_SUBSCRIBE_COMMAND,
174 #[cfg(feature = "defi")]
175 PAYLOAD_TYPE_DEFI_UNSUBSCRIBE_COMMAND,
176 PAYLOAD_TYPE_CUSTOM_DATA_RESPONSE,
177 PAYLOAD_TYPE_BOOK_RESPONSE,
178 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE,
179 PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
180 PAYLOAD_TYPE_FORWARD_PRICES_RESPONSE,
181];
182
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
185pub struct ReplaySeqRange {
186 pub from_seq: u64,
188 pub to_seq: u64,
190}
191
192impl ReplaySeqRange {
193 #[must_use]
195 pub const fn new(from_seq: u64, to_seq: u64) -> Self {
196 Self { from_seq, to_seq }
197 }
198}
199
200#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub struct ReplayTimeRange {
203 pub start: UnixNanos,
205 pub end: UnixNanos,
207}
208
209impl ReplayTimeRange {
210 #[must_use]
212 pub const fn new(start: UnixNanos, end: UnixNanos) -> Self {
213 Self { start, end }
214 }
215
216 fn from_entry(entry: &EventStoreEntry) -> Self {
217 Self {
218 start: entry.ts_init,
219 end: entry.ts_init,
220 }
221 }
222
223 fn include_entry(&mut self, entry: &EventStoreEntry) {
224 self.start = self.start.min(entry.ts_init);
225 self.end = self.end.max(entry.ts_init);
226 }
227}
228
229#[derive(Clone, Debug, PartialEq, Eq)]
231pub struct CatalogSliceSelector {
232 pub data_cls: String,
234 pub identifiers: Vec<String>,
236 pub start: Option<UnixNanos>,
238 pub end: Option<UnixNanos>,
240 pub required: bool,
242}
243
244impl CatalogSliceSelector {
245 pub fn new(data_cls: impl Into<String>) -> Self {
247 Self {
248 data_cls: data_cls.into(),
249 identifiers: Vec::new(),
250 start: None,
251 end: None,
252 required: false,
253 }
254 }
255
256 #[must_use]
258 pub fn with_identifier(mut self, identifier: impl Into<String>) -> Self {
259 self.identifiers.push(identifier.into());
260 self
261 }
262
263 #[must_use]
265 pub const fn with_time_bounds(mut self, start: UnixNanos, end: UnixNanos) -> Self {
266 self.start = Some(start);
267 self.end = Some(end);
268 self
269 }
270
271 #[must_use]
273 pub const fn require_coverage(mut self) -> Self {
274 self.required = true;
275 self
276 }
277}
278
279#[derive(Clone, Debug, PartialEq, Eq)]
281pub struct CatalogSliceQuery {
282 pub data_cls: String,
284 pub identifiers: Vec<String>,
286 pub start: UnixNanos,
288 pub end: UnixNanos,
290 pub required: bool,
292}
293
294impl CatalogSliceQuery {
295 #[must_use]
297 pub fn identifiers_option(&self) -> Option<Vec<String>> {
298 if self.identifiers.is_empty() {
299 None
300 } else {
301 Some(self.identifiers.clone())
302 }
303 }
304}
305
306#[derive(Clone, Debug, Default, PartialEq, Eq)]
308pub struct CatalogSliceCoverage {
309 pub files: Vec<String>,
311 pub intervals: Vec<ReplayTimeRange>,
313}
314
315impl CatalogSliceCoverage {
316 #[must_use]
318 pub fn from_files(files: Vec<String>) -> Self {
319 Self {
320 files,
321 intervals: Vec::new(),
322 }
323 }
324
325 #[must_use]
327 pub fn is_missing(&self) -> bool {
328 self.files.is_empty()
329 }
330}
331
332#[derive(Clone, Debug, PartialEq, Eq)]
334pub struct CatalogSlicePlan {
335 pub query: CatalogSliceQuery,
337 pub coverage: CatalogSliceCoverage,
339}
340
341impl CatalogSlicePlan {
342 #[must_use]
344 pub fn is_missing(&self) -> bool {
345 self.coverage.is_missing()
346 }
347}
348
349#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub enum CatalogReplayData {
352 Quote(QuoteTick),
354 Trade(TradeTick),
356 Bar(Bar),
358}
359
360impl CatalogReplayData {
361 #[must_use]
363 pub const fn data_cls(&self) -> &'static str {
364 match self {
365 Self::Quote(_) => "quotes",
366 Self::Trade(_) => "trades",
367 Self::Bar(_) => "bars",
368 }
369 }
370
371 #[must_use]
373 pub fn identifier(&self) -> String {
374 match self {
375 Self::Quote(quote) => quote.instrument_id.to_string(),
376 Self::Trade(trade) => trade.instrument_id.to_string(),
377 Self::Bar(bar) => bar.bar_type.to_string(),
378 }
379 }
380
381 #[must_use]
383 pub const fn ts_init(&self) -> UnixNanos {
384 match self {
385 Self::Quote(quote) => quote.ts_init,
386 Self::Trade(trade) => trade.ts_init,
387 Self::Bar(bar) => bar.ts_init,
388 }
389 }
390}
391
392impl From<QuoteTick> for CatalogReplayData {
393 fn from(value: QuoteTick) -> Self {
394 Self::Quote(value)
395 }
396}
397
398impl From<TradeTick> for CatalogReplayData {
399 fn from(value: TradeTick) -> Self {
400 Self::Trade(value)
401 }
402}
403
404impl From<Bar> for CatalogReplayData {
405 fn from(value: Bar) -> Self {
406 Self::Bar(value)
407 }
408}
409
410#[derive(Clone, Debug, PartialEq, Eq)]
412pub struct CatalogReplayRecord {
413 pub data_cls: String,
415 pub identifier: Option<String>,
417 pub ts_init: UnixNanos,
419 pub data: CatalogReplayData,
421}
422
423impl CatalogReplayRecord {
424 #[must_use]
426 pub fn from_data(data: CatalogReplayData) -> Self {
427 Self {
428 data_cls: data.data_cls().to_string(),
429 identifier: Some(data.identifier()),
430 ts_init: data.ts_init(),
431 data,
432 }
433 }
434}
435
436#[derive(Clone, Debug, PartialEq, Eq)]
438pub struct CatalogReplaySlice {
439 pub plan: CatalogSlicePlan,
441 pub records: Vec<CatalogReplayRecord>,
443}
444
445#[derive(Clone, Debug, PartialEq, Eq)]
447pub struct ReplayInputPlan {
448 pub requested_range: ReplaySeqRange,
450 pub event_range: Option<ReplaySeqRange>,
452 pub event_count: usize,
454 pub event_time_range: Option<ReplayTimeRange>,
456 pub catalog_slices: Vec<CatalogSlicePlan>,
458}
459
460impl ReplayInputPlan {
461 #[must_use]
463 pub fn missing_catalog_slices(&self) -> Vec<&CatalogSlicePlan> {
464 self.catalog_slices
465 .iter()
466 .filter(|slice| slice.is_missing())
467 .collect()
468 }
469}
470
471#[derive(Clone, Debug, PartialEq, Eq)]
473pub struct ReplayInputs {
474 pub entries: Vec<EventStoreEntry>,
476 pub catalog_slices: Vec<CatalogReplaySlice>,
478}
479
480pub trait ReplayCatalog {
482 type Error: Display;
484
485 fn plan_slice(
491 &mut self,
492 query: &CatalogSliceQuery,
493 ) -> Result<CatalogSliceCoverage, Self::Error>;
494
495 fn load_slice(
504 &mut self,
505 plan: &CatalogSlicePlan,
506 ) -> Result<Vec<CatalogReplayRecord>, Self::Error>;
507}
508
509#[derive(Debug, thiserror::Error)]
511pub enum ReplayInputError {
512 #[error(transparent)]
514 EventStore(#[from] EventStoreError),
515 #[error("invalid replay seq range {from_seq}..={to_seq}: {message}")]
517 InvalidSeqRange {
518 from_seq: u64,
520 to_seq: u64,
522 message: String,
524 },
525 #[error("catalog replay requires at least one selected catalog slice")]
527 EmptyCatalogSelection,
528 #[error(
530 "catalog slice {data_cls} requires explicit time bounds because the replay scan is empty"
531 )]
532 MissingCatalogTimeBounds {
533 data_cls: String,
535 },
536 #[error("invalid catalog time range for {data_cls}: {start}..={end}")]
538 InvalidCatalogTimeRange {
539 data_cls: String,
541 start: u64,
543 end: u64,
545 },
546 #[error("required catalog slice {data_cls} is missing for identifiers {identifiers:?}")]
548 MissingCatalogSlice {
549 data_cls: String,
551 identifiers: Vec<String>,
553 },
554 #[error("catalog slice {data_cls}: {message}")]
556 Catalog {
557 data_cls: String,
559 message: String,
561 },
562}
563
564#[derive(Debug, thiserror::Error)]
566pub enum CacheReplayError {
567 #[error(transparent)]
569 EventStore(#[from] EventStoreError),
570 #[error("restore cache snapshot {blob_ref}: {message}")]
572 SnapshotRestore {
573 blob_ref: String,
575 message: String,
577 },
578 #[error("entry seq {seq} is before replay start seq {from_seq}")]
580 UnexpectedSeq {
581 seq: u64,
583 from_seq: u64,
585 },
586 #[error("decode seq {seq} payload_type {payload_type}: {message}")]
588 Decode {
589 seq: u64,
591 payload_type: String,
593 message: String,
595 },
596 #[error("apply seq {seq} payload_type {payload_type}: {message}")]
598 Apply {
599 seq: u64,
601 payload_type: String,
603 message: String,
605 },
606}
607
608impl CacheReplayError {
609 #[must_use]
611 pub fn snapshot_restore(anchor: &SnapshotAnchor, error: impl Display) -> Self {
612 Self::SnapshotRestore {
613 blob_ref: anchor.blob_ref.clone(),
614 message: error.to_string(),
615 }
616 }
617}
618
619#[derive(Default)]
620struct CacheReplayContext {
621 allow_deferred_orderless_flips: bool,
622 pending_orderless_flips: Vec<PendingOrderlessFlip>,
623}
624
625struct PendingOrderlessFlip {
626 source_seq: u64,
627 source_position_id: PositionId,
628 oms_type: OmsType,
629 opening_fill: OrderFilled,
630}
631
632impl CacheReplayContext {
633 fn for_snapshot_tail() -> Self {
634 Self {
635 allow_deferred_orderless_flips: true,
636 pending_orderless_flips: Vec::new(),
637 }
638 }
639
640 fn contains_flip_source(&self, fill: &OrderFilled) -> bool {
641 self.pending_orderless_flips.iter().any(|pending| {
642 pending.opening_fill.client_order_id == fill.client_order_id
643 && pending.opening_fill.trade_id == fill.trade_id
644 && pending.opening_fill.causation_id == Some(fill.event_id)
645 })
646 }
647
648 fn push_orderless_flip(
649 &mut self,
650 source_seq: u64,
651 source_position_id: PositionId,
652 oms_type: OmsType,
653 opening_fill: OrderFilled,
654 ) {
655 self.pending_orderless_flips.push(PendingOrderlessFlip {
656 source_seq,
657 source_position_id,
658 oms_type,
659 opening_fill,
660 });
661 }
662
663 fn take_opening_fill(
664 &mut self,
665 entry: &EventStoreEntry,
666 opened: &PositionOpened,
667 ) -> Result<Option<(OrderFilled, OmsType)>, CacheReplayError> {
668 let matching: Vec<usize> = self
669 .pending_orderless_flips
670 .iter()
671 .enumerate()
672 .filter_map(|(index, pending)| {
673 let fill = &pending.opening_fill;
674 (fill.trader_id == opened.trader_id
675 && fill.strategy_id == opened.strategy_id
676 && fill.instrument_id == opened.instrument_id
677 && fill.account_id == opened.account_id
678 && fill.client_order_id == opened.opening_order_id
679 && fill.order_side == opened.entry
680 && fill.last_qty == opened.last_qty
681 && fill.last_px == opened.last_px
682 && fill.currency == opened.currency)
683 .then_some(index)
684 })
685 .collect();
686
687 if matching.len() > 1 {
688 return Err(apply_error(
689 entry,
690 format!(
691 "ambiguous orderless flip recovery for position {}: {} pending fills match",
692 opened.position_id,
693 matching.len(),
694 ),
695 ));
696 }
697
698 let Some(index) = matching.first().copied() else {
699 return Ok(None);
700 };
701 let pending = self.pending_orderless_flips.remove(index);
702 let mut fill = pending.opening_fill;
703 let oms_type = if pending.source_position_id == opened.position_id {
704 pending.oms_type
705 } else {
706 OmsType::Hedging
710 };
711 fill.position_id = Some(opened.position_id);
712 fill.event_id = opened.event_id;
716 Ok(Some((fill, oms_type)))
717 }
718
719 fn ensure_complete(&self) -> Result<(), CacheReplayError> {
720 let Some(pending) = self.pending_orderless_flips.first() else {
721 return Ok(());
722 };
723
724 Err(CacheReplayError::Apply {
725 seq: pending.source_seq,
726 payload_type: PAYLOAD_TYPE_ORDER_FILLED.to_string(),
727 message: format!(
728 "orderless flip fill {} has no matching PositionOpened event",
729 pending.opening_fill.trade_id,
730 ),
731 })
732 }
733}
734
735pub fn restore_cache_snapshot_and_replay_tail<B, F>(
753 cache: &mut Cache,
754 reader: &EventStoreReader<B>,
755 restore_snapshot: F,
756) -> Result<CacheReplayReport, CacheReplayError>
757where
758 B: EventStore,
759 F: FnOnce(&mut Cache, Option<&SnapshotAnchor>) -> Result<(), CacheReplayError>,
760{
761 let (plan, scan) = reader.scan_snapshot_replay_tail()?;
762 restore_snapshot(cache, plan.anchor.as_ref())?;
763
764 let mut applied_entries = 0;
765 let mut ignored_entries = 0;
766 let mut context = CacheReplayContext::for_snapshot_tail();
767
768 for entry in scan {
769 let entry = entry?;
770
771 if entry.seq < plan.from_seq {
772 return Err(CacheReplayError::UnexpectedSeq {
773 seq: entry.seq,
774 from_seq: plan.from_seq,
775 });
776 }
777
778 if apply_cache_replay_entry_with_context(cache, &entry, &mut context)? {
779 applied_entries += 1;
780 } else {
781 ignored_entries += 1;
782 }
783 }
784
785 context.ensure_complete()?;
786
787 Ok(CacheReplayReport {
788 plan,
789 applied_entries,
790 ignored_entries,
791 })
792}
793
794pub fn replay_cache_snapshot_tail<B>(
803 cache: &mut Cache,
804 reader: &EventStoreReader<B>,
805) -> Result<CacheReplayReport, CacheReplayError>
806where
807 B: EventStore,
808{
809 restore_cache_snapshot_and_replay_tail(cache, reader, |_, _| Ok(()))
810}
811
812pub fn plan_forensics_replay_inputs<B>(
822 reader: &EventStoreReader<B>,
823 range: ReplaySeqRange,
824) -> Result<ReplayInputPlan, ReplayInputError>
825where
826 B: EventStore,
827{
828 let span = collect_replay_entry_span(reader, range)?;
829 Ok(ReplayInputPlan {
830 requested_range: range,
831 event_range: span.event_range,
832 event_count: span.event_count,
833 event_time_range: span.time_range,
834 catalog_slices: Vec::new(),
835 })
836}
837
838pub fn load_forensics_replay_inputs<B>(
847 reader: &EventStoreReader<B>,
848 plan: &ReplayInputPlan,
849) -> Result<ReplayInputs, ReplayInputError>
850where
851 B: EventStore,
852{
853 let entries = load_replay_entries(reader, plan.requested_range)?;
854 Ok(ReplayInputs {
855 entries,
856 catalog_slices: Vec::new(),
857 })
858}
859
860pub fn plan_catalog_replay_inputs<B, C>(
874 reader: &EventStoreReader<B>,
875 catalog: &mut C,
876 range: ReplaySeqRange,
877 catalog_slices: &[CatalogSliceSelector],
878) -> Result<ReplayInputPlan, ReplayInputError>
879where
880 B: EventStore,
881 C: ReplayCatalog,
882{
883 plan_catalog_joined_replay_inputs(reader, catalog, range, catalog_slices)
884}
885
886pub fn load_catalog_replay_inputs<B, C>(
898 reader: &EventStoreReader<B>,
899 catalog: &mut C,
900 plan: &ReplayInputPlan,
901) -> Result<ReplayInputs, ReplayInputError>
902where
903 B: EventStore,
904 C: ReplayCatalog,
905{
906 load_catalog_joined_replay_inputs(reader, catalog, plan)
907}
908
909pub fn restore_cache_from_sealed_run(
922 cache: &mut Cache,
923 base_dir: impl Into<PathBuf>,
924 instance_id: &str,
925 run_id: &str,
926) -> Result<EventStoreReplayReport, CacheReplayError> {
927 let (manifest, reader) = open_event_store_replay_source(base_dir, instance_id, run_id)?;
928 let cache_report =
929 restore_cache_snapshot_and_replay_tail(cache, &reader, restore_cache_snapshot_blob)?;
930
931 Ok(EventStoreReplayReport {
932 manifest,
933 cache: cache_report,
934 })
935}
936
937pub fn open_event_store_replay_source(
944 base_dir: impl Into<PathBuf>,
945 instance_id: &str,
946 run_id: &str,
947) -> Result<(RunManifest, EventStoreReader<RedbBackend>), CacheReplayError> {
948 let backend = RedbBackend::open_sealed(base_dir, instance_id, run_id)?;
949 let manifest = backend.manifest()?;
950 reject_quarantined_replay_source(run_id, manifest.status)?;
951 Ok((manifest, EventStoreReader::new(backend)))
952}
953
954pub fn validate_event_store_replay_source(
961 base_dir: impl Into<PathBuf>,
962 instance_id: &str,
963 run_id: &str,
964) -> Result<RunManifest, CacheReplayError> {
965 let backend = RedbBackend::open_sealed(base_dir, instance_id, run_id)?;
966 let manifest = backend.manifest()?;
967 reject_quarantined_replay_source(run_id, manifest.status)?;
968 Ok(manifest)
969}
970
971#[derive(Clone, Copy, Debug, PartialEq, Eq)]
972struct ReplayEntrySpan {
973 event_range: Option<ReplaySeqRange>,
974 event_count: usize,
975 time_range: Option<ReplayTimeRange>,
976}
977
978fn plan_catalog_joined_replay_inputs<B, C>(
979 reader: &EventStoreReader<B>,
980 catalog: &mut C,
981 range: ReplaySeqRange,
982 catalog_slices: &[CatalogSliceSelector],
983) -> Result<ReplayInputPlan, ReplayInputError>
984where
985 B: EventStore,
986 C: ReplayCatalog,
987{
988 if catalog_slices.is_empty() {
989 return Err(ReplayInputError::EmptyCatalogSelection);
990 }
991
992 let span = collect_replay_entry_span(reader, range)?;
993 let catalog_slices = plan_catalog_slices(catalog, catalog_slices, span.time_range)?;
994
995 Ok(ReplayInputPlan {
996 requested_range: range,
997 event_range: span.event_range,
998 event_count: span.event_count,
999 event_time_range: span.time_range,
1000 catalog_slices,
1001 })
1002}
1003
1004fn load_catalog_joined_replay_inputs<B, C>(
1005 reader: &EventStoreReader<B>,
1006 catalog: &mut C,
1007 plan: &ReplayInputPlan,
1008) -> Result<ReplayInputs, ReplayInputError>
1009where
1010 B: EventStore,
1011 C: ReplayCatalog,
1012{
1013 let entries = load_replay_entries(reader, plan.requested_range)?;
1014 let catalog_slices = load_catalog_slices(catalog, &plan.catalog_slices)?;
1015
1016 Ok(ReplayInputs {
1017 entries,
1018 catalog_slices,
1019 })
1020}
1021
1022fn collect_replay_entry_span<B>(
1023 reader: &EventStoreReader<B>,
1024 range: ReplaySeqRange,
1025) -> Result<ReplayEntrySpan, ReplayInputError>
1026where
1027 B: EventStore,
1028{
1029 validate_seq_range(range)?;
1030
1031 let mut first_seq = None;
1032 let mut last_seq = None;
1033 let mut event_count = 0;
1034 let mut time_range: Option<ReplayTimeRange> = None;
1035
1036 for entry in reader.scan_range(range.from_seq, range.to_seq, ScanDirection::Forward) {
1037 let entry = entry?;
1038 first_seq.get_or_insert(entry.seq);
1039 last_seq = Some(entry.seq);
1040 event_count += 1;
1041
1042 match time_range.as_mut() {
1043 Some(bounds) => bounds.include_entry(&entry),
1044 None => time_range = Some(ReplayTimeRange::from_entry(&entry)),
1045 }
1046 }
1047
1048 let event_range = match (first_seq, last_seq) {
1049 (Some(from_seq), Some(to_seq)) => Some(ReplaySeqRange::new(from_seq, to_seq)),
1050 _ => None,
1051 };
1052
1053 Ok(ReplayEntrySpan {
1054 event_range,
1055 event_count,
1056 time_range,
1057 })
1058}
1059
1060fn load_replay_entries<B>(
1061 reader: &EventStoreReader<B>,
1062 range: ReplaySeqRange,
1063) -> Result<Vec<EventStoreEntry>, ReplayInputError>
1064where
1065 B: EventStore,
1066{
1067 validate_seq_range(range)?;
1068
1069 reader
1070 .scan_range(range.from_seq, range.to_seq, ScanDirection::Forward)
1071 .collect::<Result<Vec<_>, _>>()
1072 .map_err(ReplayInputError::from)
1073}
1074
1075fn plan_catalog_slices<C>(
1076 catalog: &mut C,
1077 selectors: &[CatalogSliceSelector],
1078 event_time_range: Option<ReplayTimeRange>,
1079) -> Result<Vec<CatalogSlicePlan>, ReplayInputError>
1080where
1081 C: ReplayCatalog,
1082{
1083 let mut plans = Vec::with_capacity(selectors.len());
1084
1085 for selector in selectors {
1086 let query = resolve_catalog_slice_query(selector, event_time_range)?;
1087 let coverage = catalog
1088 .plan_slice(&query)
1089 .map_err(|e| ReplayInputError::Catalog {
1090 data_cls: query.data_cls.clone(),
1091 message: e.to_string(),
1092 })?;
1093 plans.push(CatalogSlicePlan { query, coverage });
1094 }
1095
1096 Ok(plans)
1097}
1098
1099fn load_catalog_slices<C>(
1100 catalog: &mut C,
1101 plans: &[CatalogSlicePlan],
1102) -> Result<Vec<CatalogReplaySlice>, ReplayInputError>
1103where
1104 C: ReplayCatalog,
1105{
1106 let mut slices = Vec::with_capacity(plans.len());
1107
1108 for plan in plans {
1109 if plan.is_missing() {
1110 if plan.query.required {
1111 return Err(ReplayInputError::MissingCatalogSlice {
1112 data_cls: plan.query.data_cls.clone(),
1113 identifiers: plan.query.identifiers.clone(),
1114 });
1115 }
1116
1117 slices.push(CatalogReplaySlice {
1118 plan: plan.clone(),
1119 records: Vec::new(),
1120 });
1121 continue;
1122 }
1123
1124 let records = catalog
1125 .load_slice(plan)
1126 .map_err(|e| ReplayInputError::Catalog {
1127 data_cls: plan.query.data_cls.clone(),
1128 message: e.to_string(),
1129 })?;
1130 slices.push(CatalogReplaySlice {
1131 plan: plan.clone(),
1132 records,
1133 });
1134 }
1135
1136 Ok(slices)
1137}
1138
1139fn resolve_catalog_slice_query(
1140 selector: &CatalogSliceSelector,
1141 event_time_range: Option<ReplayTimeRange>,
1142) -> Result<CatalogSliceQuery, ReplayInputError> {
1143 let Some(start) = selector
1144 .start
1145 .or(event_time_range.map(|bounds| bounds.start))
1146 else {
1147 return Err(ReplayInputError::MissingCatalogTimeBounds {
1148 data_cls: selector.data_cls.clone(),
1149 });
1150 };
1151 let Some(end) = selector.end.or(event_time_range.map(|bounds| bounds.end)) else {
1152 return Err(ReplayInputError::MissingCatalogTimeBounds {
1153 data_cls: selector.data_cls.clone(),
1154 });
1155 };
1156
1157 if start > end {
1158 return Err(ReplayInputError::InvalidCatalogTimeRange {
1159 data_cls: selector.data_cls.clone(),
1160 start: start.as_u64(),
1161 end: end.as_u64(),
1162 });
1163 }
1164
1165 Ok(CatalogSliceQuery {
1166 data_cls: selector.data_cls.clone(),
1167 identifiers: selector.identifiers.clone(),
1168 start,
1169 end,
1170 required: selector.required,
1171 })
1172}
1173
1174fn validate_seq_range(range: ReplaySeqRange) -> Result<(), ReplayInputError> {
1175 if range.from_seq == 0 {
1176 return Err(ReplayInputError::InvalidSeqRange {
1177 from_seq: range.from_seq,
1178 to_seq: range.to_seq,
1179 message: "seq is 1-based".to_string(),
1180 });
1181 }
1182
1183 if range.from_seq > range.to_seq {
1184 return Err(ReplayInputError::InvalidSeqRange {
1185 from_seq: range.from_seq,
1186 to_seq: range.to_seq,
1187 message: "from_seq exceeds to_seq".to_string(),
1188 });
1189 }
1190
1191 Ok(())
1192}
1193
1194pub fn restore_cache_snapshot_blob(
1201 cache: &mut Cache,
1202 anchor: Option<&SnapshotAnchor>,
1203) -> Result<(), CacheReplayError> {
1204 let Some(anchor) = anchor else {
1205 return Ok(());
1206 };
1207
1208 let blob = cache
1209 .load_snapshot_blob(&anchor.blob_ref)
1210 .map_err(|e| CacheReplayError::snapshot_restore(anchor, e))?
1211 .ok_or_else(|| CacheReplayError::snapshot_restore(anchor, "snapshot blob not found"))?;
1212 let actual_hash = compute_snapshot_content_hash(blob.as_ref());
1213
1214 if actual_hash != anchor.content_hash {
1215 return Err(CacheReplayError::snapshot_restore(
1216 anchor,
1217 format!(
1218 "content_hash mismatch: expected {}, actual {actual_hash}",
1219 anchor.content_hash
1220 ),
1221 ));
1222 }
1223
1224 cache
1225 .restore_snapshot_blob(&anchor.blob_ref, blob)
1226 .map_err(|e| CacheReplayError::snapshot_restore(anchor, e))
1227}
1228
1229pub fn apply_cache_replay_entry(
1243 cache: &mut Cache,
1244 entry: &EventStoreEntry,
1245) -> Result<bool, CacheReplayError> {
1246 let mut context = CacheReplayContext::default();
1247 let applied = apply_cache_replay_entry_with_context(cache, entry, &mut context)?;
1248 context.ensure_complete()?;
1249 Ok(applied)
1250}
1251
1252fn apply_cache_replay_entry_with_context(
1253 cache: &mut Cache,
1254 entry: &EventStoreEntry,
1255 context: &mut CacheReplayContext,
1256) -> Result<bool, CacheReplayError> {
1257 if apply_complete_cache_payload_entry(cache, entry)? {
1258 return Ok(true);
1259 }
1260
1261 match entry.payload_type.as_str() {
1262 PAYLOAD_TYPE_ACCOUNT_STATE => {
1263 let state = decode_payload::<AccountState>(entry)?;
1264 apply_result(entry, cache.update_account_state(&state))?;
1265 }
1266 PAYLOAD_TYPE_ORDER_INITIALIZED => {
1267 let event = decode_order_event::<OrderInitialized>(entry, OrderEventAny::Initialized)?;
1268 let order = OrderAny::from_events(vec![event]).map_err(|e| apply_error(entry, e))?;
1269 apply_result(entry, cache.add_order(order, None, None, false))?;
1270 }
1271 PAYLOAD_TYPE_ORDER_DENIED => {
1272 apply_order_event(cache, entry, OrderEventAny::Denied)?;
1273 }
1274 PAYLOAD_TYPE_ORDER_EMULATED => {
1275 apply_order_event(cache, entry, OrderEventAny::Emulated)?;
1276 }
1277 PAYLOAD_TYPE_ORDER_RELEASED => {
1278 apply_order_event(cache, entry, OrderEventAny::Released)?;
1279 }
1280 PAYLOAD_TYPE_ORDER_SUBMITTED => {
1281 apply_order_event(cache, entry, OrderEventAny::Submitted)?;
1282 }
1283 PAYLOAD_TYPE_ORDER_ACCEPTED => {
1284 apply_order_event(cache, entry, OrderEventAny::Accepted)?;
1285 }
1286 PAYLOAD_TYPE_ORDER_REJECTED => {
1287 apply_order_event(cache, entry, OrderEventAny::Rejected)?;
1288 }
1289 PAYLOAD_TYPE_ORDER_CANCELED => {
1290 apply_order_event(cache, entry, OrderEventAny::Canceled)?;
1291 }
1292 PAYLOAD_TYPE_ORDER_EXPIRED => {
1293 apply_order_event(cache, entry, OrderEventAny::Expired)?;
1294 }
1295 PAYLOAD_TYPE_ORDER_TRIGGERED => {
1296 apply_order_event(cache, entry, OrderEventAny::Triggered)?;
1297 }
1298 PAYLOAD_TYPE_ORDER_PENDING_UPDATE => {
1299 apply_order_event(cache, entry, OrderEventAny::PendingUpdate)?;
1300 }
1301 PAYLOAD_TYPE_ORDER_PENDING_CANCEL => {
1302 apply_order_event(cache, entry, OrderEventAny::PendingCancel)?;
1303 }
1304 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED => {
1305 apply_order_event(cache, entry, OrderEventAny::ModifyRejected)?;
1306 }
1307 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED => {
1308 apply_order_event(cache, entry, OrderEventAny::CancelRejected)?;
1309 }
1310 PAYLOAD_TYPE_ORDER_UPDATED => {
1311 apply_order_event(cache, entry, OrderEventAny::Updated)?;
1312 }
1313 PAYLOAD_TYPE_ORDER_FILLED => return apply_order_filled(cache, entry, context),
1314 PAYLOAD_TYPE_ORDER_FILL_VOIDED => {
1315 let fill_voided = decode_payload::<OrderFillVoided>(entry)?;
1316 apply_fill_void_to_order_and_positions(cache, entry, &fill_voided)?;
1317 }
1318 PAYLOAD_TYPE_POSITION_OPENED => {
1319 let opened = decode_payload::<PositionOpened>(entry)?;
1320 if let Some(applied) =
1321 apply_pending_orderless_flip_opened(cache, entry, &opened, context)?
1322 {
1323 return Ok(applied);
1324 }
1325 return apply_position_opened(cache, entry, &opened);
1326 }
1327 PAYLOAD_TYPE_POSITION_CHANGED => {
1328 let changed = decode_payload::<PositionChanged>(entry)?;
1329 return apply_position_changed(cache, entry, &changed);
1330 }
1331 PAYLOAD_TYPE_POSITION_CLOSED => {
1332 let closed = decode_payload::<PositionClosed>(entry)?;
1333 return apply_position_closed(cache, entry, &closed);
1334 }
1335 PAYLOAD_TYPE_POSITION_ADJUSTED => {
1336 let adjustment = decode_payload::<PositionAdjusted>(entry)?;
1337 return apply_position_adjustment(cache, entry, adjustment);
1338 }
1339 _ => return Ok(false),
1340 }
1341
1342 Ok(true)
1343}
1344
1345fn apply_complete_cache_payload_entry(
1346 cache: &mut Cache,
1347 entry: &EventStoreEntry,
1348) -> Result<bool, CacheReplayError> {
1349 match entry.payload_type.as_str() {
1350 PAYLOAD_TYPE_SUBMIT_ORDER_LIST => {
1351 let command = decode_payload::<SubmitOrderList>(entry)?;
1352 apply_result(entry, cache.add_order_list(command.order_list))?;
1353 }
1354 PAYLOAD_TYPE_INSTRUMENT_RESPONSE => {
1355 let response = decode_payload::<InstrumentResponse>(entry)?;
1356 apply_result(entry, cache.add_instrument(response.data))?;
1357 }
1358 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE => {
1359 let response = decode_payload::<InstrumentsResponse>(entry)?;
1360 for instrument in response.data {
1361 apply_result(entry, cache.add_instrument(instrument))?;
1362 }
1363 }
1364 PAYLOAD_TYPE_QUOTES_RESPONSE => {
1365 let response = decode_payload::<QuotesResponse>(entry)?;
1366 if !response.data.is_empty() {
1367 apply_result(entry, cache.add_quotes(&response.data))?;
1368 }
1369 }
1370 PAYLOAD_TYPE_TRADES_RESPONSE => {
1371 let response = decode_payload::<TradesResponse>(entry)?;
1372 if !response.data.is_empty() {
1373 apply_result(entry, cache.add_trades(&response.data))?;
1374 }
1375 }
1376 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE => {
1377 let response = decode_payload::<FundingRatesResponse>(entry)?;
1378 if !response.data.is_empty() {
1379 apply_result(entry, cache.add_funding_rates(&response.data))?;
1380 }
1381 }
1382 PAYLOAD_TYPE_BARS_RESPONSE => {
1383 let response = decode_payload::<BarsResponse>(entry)?;
1384 if !response.data.is_empty() {
1385 apply_result(entry, cache.add_bars(&response.data))?;
1386 }
1387 }
1388 _ => return Ok(false),
1389 }
1390
1391 Ok(true)
1392}
1393
1394fn apply_order_event<T>(
1395 cache: &mut Cache,
1396 entry: &EventStoreEntry,
1397 wrap: impl FnOnce(T) -> OrderEventAny,
1398) -> Result<(), CacheReplayError>
1399where
1400 T: DeserializeOwned,
1401{
1402 let event = decode_order_event(entry, wrap)?;
1403 apply_result(entry, cache.update_order(&event))?;
1404 Ok(())
1405}
1406
1407fn decode_order_event<T>(
1408 entry: &EventStoreEntry,
1409 wrap: impl FnOnce(T) -> OrderEventAny,
1410) -> Result<OrderEventAny, CacheReplayError>
1411where
1412 T: DeserializeOwned,
1413{
1414 Ok(wrap(decode_payload(entry)?))
1415}
1416
1417fn apply_order_filled(
1418 cache: &mut Cache,
1419 entry: &EventStoreEntry,
1420 context: &mut CacheReplayContext,
1421) -> Result<bool, CacheReplayError> {
1422 let replay_side = decode_payload::<OrderFilledReplaySide>(entry)?;
1423 if replay_side.order_side.is_none() {
1424 return Err(apply_error(
1425 entry,
1426 "OrderFilled.order_side must be Buy or Sell, was NoOrderSide",
1427 ));
1428 }
1429 let fill = decode_payload::<OrderFilled>(entry)?;
1430 let event = OrderEventAny::Filled(fill.clone());
1431 let orderless_leg_fill = is_orderless_leg_fill(cache, &fill);
1432 if !orderless_leg_fill {
1433 apply_result(entry, cache.update_order(&event))?;
1434 }
1435
1436 let flip_applied =
1437 orderless_leg_fill && apply_orderless_flip_fill(cache, entry, &fill, context)?;
1438
1439 if flip_applied {
1440 return Ok(true);
1441 }
1442
1443 apply_fill_to_position(cache, entry, &fill, orderless_leg_fill)
1444}
1445
1446#[derive(Deserialize)]
1447struct OrderFilledReplaySide {
1448 #[serde(with = "nautilus_model::enums::serde_option_order_side")]
1449 order_side: Option<OrderSide>,
1450}
1451
1452fn apply_orderless_flip_fill(
1453 cache: &mut Cache,
1454 entry: &EventStoreEntry,
1455 fill: &OrderFilled,
1456 context: &mut CacheReplayContext,
1457) -> Result<bool, CacheReplayError> {
1458 if context.contains_flip_source(fill) {
1459 return Ok(true);
1460 }
1461
1462 let Some(position_id) = fill.position_id else {
1463 return Ok(false);
1464 };
1465 let Some(mut position) = cache.position_owned(&position_id) else {
1466 return Ok(false);
1467 };
1468
1469 if position.is_closed()
1470 || !position.is_opposite_side(fill.order_side)
1471 || fill.last_qty.raw <= position.quantity.raw
1472 {
1473 return Ok(false);
1474 }
1475
1476 if position.side != PositionSide::Flat && position.trade_ids().contains(&fill.trade_id) {
1477 return Ok(true);
1478 }
1479
1480 if !context.allow_deferred_orderless_flips {
1481 return Err(apply_error(
1482 entry,
1483 "orderless position flip requires snapshot-tail replay context to match the following PositionOpened event",
1484 ));
1485 }
1486
1487 let oms_type = cache.oms_type(&position_id).unwrap_or(OmsType::Unspecified);
1488 let (closing_fill, opening_fill) = fill
1489 .split_for_position_flip(position.quantity, None, fill.event_id)
1490 .map_err(|e| apply_error(entry, e))?;
1491 position.apply(&closing_fill);
1492 apply_result(entry, cache.update_position(&position))?;
1493 context.push_orderless_flip(entry.seq, position_id, oms_type, opening_fill);
1494 Ok(true)
1495}
1496
1497fn apply_pending_orderless_flip_opened(
1498 cache: &mut Cache,
1499 entry: &EventStoreEntry,
1500 opened: &PositionOpened,
1501 context: &mut CacheReplayContext,
1502) -> Result<Option<bool>, CacheReplayError> {
1503 let Some((opening_fill, oms_type)) = context.take_opening_fill(entry, opened)? else {
1504 return Ok(None);
1505 };
1506 let instrument = cache
1507 .instrument(&opening_fill.instrument_id)
1508 .cloned()
1509 .ok_or_else(|| {
1510 apply_error(
1511 entry,
1512 format!(
1513 "instrument {} not found for orderless flip position {}",
1514 opening_fill.instrument_id, opened.position_id,
1515 ),
1516 )
1517 })?;
1518 let prior = cache.position_owned(&opened.position_id);
1519 let mut position = Position::new(&instrument, opening_fill);
1520 if let Some(prior) = prior {
1521 let current_replay = position.replay_events.clone();
1522 position.replay_events = prior.replay_events;
1523 position.replay_events.extend(current_replay);
1524 position.fill_voids = prior.fill_voids;
1525 }
1526 apply_result(entry, cache.add_position_without_order(&position, oms_type))?;
1527
1528 apply_position_opened(cache, entry, opened).map(Some)
1529}
1530
1531fn apply_fill_to_position(
1535 cache: &mut Cache,
1536 entry: &EventStoreEntry,
1537 fill: &OrderFilled,
1538 orderless_leg_fill: bool,
1539) -> Result<bool, CacheReplayError> {
1540 let Some(position_id) = fill.position_id else {
1541 return Ok(true);
1542 };
1543
1544 if let Some(mut position) = cache.position_owned(&position_id) {
1545 if position.side != PositionSide::Flat && position.trade_ids().contains(&fill.trade_id) {
1549 return Ok(true);
1550 }
1551
1552 position.apply(fill);
1553 apply_result(entry, cache.update_position(&position))?;
1554 return Ok(true);
1555 }
1556
1557 let Some(instrument) = cache.instrument(&fill.instrument_id).cloned() else {
1558 log::warn!(
1559 "Replay seq {} skipped opening position {position_id}: instrument {} not in cache",
1560 entry.seq,
1561 fill.instrument_id,
1562 );
1563 return Ok(false);
1564 };
1565
1566 let position = Position::new(&instrument, fill.clone());
1567
1568 if orderless_leg_fill {
1569 apply_result(
1570 entry,
1571 cache.add_position_without_order(&position, OmsType::Unspecified),
1572 )?;
1573 } else {
1574 apply_result(entry, cache.add_position(&position, OmsType::Unspecified))?;
1575 }
1576 Ok(true)
1577}
1578
1579fn is_orderless_leg_fill(cache: &Cache, fill: &OrderFilled) -> bool {
1580 if !fill.client_order_id.as_str().contains("-LEG-")
1581 && !fill.venue_order_id.as_str().contains("-LEG-")
1582 {
1583 return false;
1584 }
1585
1586 let is_non_spread_instrument = cache
1587 .instrument(&fill.instrument_id)
1588 .is_none_or(|instrument| !instrument.is_spread());
1589
1590 is_non_spread_instrument
1591 && !cache.order_exists(&fill.client_order_id)
1592 && cache.client_order_id(&fill.venue_order_id).is_none()
1593}
1594
1595fn apply_fill_void_to_order_and_positions(
1596 cache: &mut Cache,
1597 entry: &EventStoreEntry,
1598 fill_voided: &OrderFillVoided,
1599) -> Result<(), CacheReplayError> {
1600 let event = OrderEventAny::FillVoided(fill_voided.clone());
1601 let order = cache
1602 .order_owned(&fill_voided.client_order_id)
1603 .ok_or_else(|| {
1604 apply_error(
1605 entry,
1606 format!("order {} not found", fill_voided.client_order_id),
1607 )
1608 })?;
1609 let original_fill = order
1610 .events()
1611 .into_iter()
1612 .find_map(|candidate| match candidate {
1613 OrderEventAny::Filled(fill) if fill.trade_id == fill_voided.trade_id => Some(fill),
1614 _ => None,
1615 });
1616
1617 let mut validated_order = order.clone();
1618 apply_result(entry, validated_order.apply(event.clone()))?;
1619
1620 let corrected_positions =
1621 if let Some(original_fill) = original_fill.filter(|fill| fill.position_id.is_some()) {
1622 prepare_fill_void_positions(cache, entry, fill_voided, original_fill.event_id)?
1623 } else {
1624 Vec::new()
1625 };
1626
1627 apply_result(entry, cache.update_order(&event))?;
1628 for position in corrected_positions {
1629 apply_result(entry, cache.update_position(&position))?;
1630 }
1631 Ok(())
1632}
1633
1634fn prepare_fill_void_positions(
1635 cache: &Cache,
1636 entry: &EventStoreEntry,
1637 fill_voided: &OrderFillVoided,
1638 source_event_id: UUID4,
1639) -> Result<Vec<Position>, CacheReplayError> {
1640 let fragments = collect_fill_void_fragments(cache, entry, fill_voided, source_event_id)?;
1641 let allocations = allocate_fill_void_fragments(entry, fill_voided, &fragments)?;
1642 let mut corrected_positions = Vec::new();
1643
1644 for (position_id, (voided_qty, commission_voided)) in allocations {
1645 if voided_qty.is_zero() {
1646 return Err(apply_error(
1647 entry,
1648 format!(
1649 "commission-only position correction requires authoritative reconciliation for fill {}",
1650 fill_voided.trade_id
1651 ),
1652 ));
1653 }
1654 let mut position = cache
1655 .position_owned(&position_id)
1656 .ok_or_else(|| apply_error(entry, format!("position {position_id} not found")))?;
1657 let previous = position
1658 .fill_voids
1659 .iter()
1660 .rev()
1661 .find(|record| {
1662 record.event.client_order_id == fill_voided.client_order_id
1663 && record.event.trade_id == fill_voided.trade_id
1664 })
1665 .map(|record| (record.voided_qty, record.commission_voided));
1666 if previous == Some((voided_qty, commission_voided)) {
1667 continue;
1668 }
1669 apply_result(
1670 entry,
1671 position.apply_fill_void(fill_voided.clone(), voided_qty, commission_voided),
1672 )?;
1673 corrected_positions.push(position);
1674 }
1675 Ok(corrected_positions)
1676}
1677
1678#[derive(Clone, Copy, Debug)]
1679struct FillVoidFragment {
1680 position_id: PositionId,
1681 split_rank: u8,
1682 quantity: Quantity,
1683 commission: Option<Money>,
1684}
1685
1686fn collect_fill_void_fragments(
1687 cache: &Cache,
1688 entry: &EventStoreEntry,
1689 fill_voided: &OrderFillVoided,
1690 source_event_id: UUID4,
1691) -> Result<Vec<FillVoidFragment>, CacheReplayError> {
1692 let positions: Vec<Position> = cache
1693 .positions(
1694 None,
1695 Some(&fill_voided.instrument_id),
1696 Some(&fill_voided.strategy_id),
1697 Some(&fill_voided.account_id),
1698 None,
1699 )
1700 .into_iter()
1701 .map(|position| position.cloned())
1702 .collect();
1703 let mut fragments = Vec::new();
1704
1705 for position in &positions {
1706 for replay_event in &position.replay_events {
1707 let PositionReplayEvent::Filled(fill) = replay_event else {
1708 continue;
1709 };
1710
1711 if fill.client_order_id != fill_voided.client_order_id
1712 || fill.trade_id != fill_voided.trade_id
1713 {
1714 continue;
1715 }
1716 let split_rank = if fill.event_id == source_event_id {
1717 0
1718 } else if fill.causation_id == Some(source_event_id) {
1719 1
1720 } else {
1721 continue;
1722 };
1723 fragments.push(FillVoidFragment {
1724 position_id: position.id,
1725 split_rank,
1726 quantity: fill.last_qty,
1727 commission: fill.commission,
1728 });
1729 }
1730 }
1731
1732 if fragments.is_empty() {
1733 return Err(apply_error(
1734 entry,
1735 format!(
1736 "no position fragments found for fill {}",
1737 fill_voided.trade_id
1738 ),
1739 ));
1740 }
1741 fragments.sort_by_key(|fragment| fragment.split_rank);
1742 Ok(fragments)
1743}
1744
1745fn allocate_fill_void_fragments(
1746 entry: &EventStoreEntry,
1747 fill_voided: &OrderFillVoided,
1748 fragments: &[FillVoidFragment],
1749) -> Result<IndexMap<PositionId, (Quantity, Option<Money>)>, CacheReplayError> {
1750 let mut allocations = IndexMap::<PositionId, (Quantity, Option<Money>)>::new();
1751 let mut remaining_qty = fill_voided.voided_qty;
1752 for fragment in fragments.iter().rev() {
1753 if remaining_qty.is_zero() {
1754 break;
1755 }
1756 let removed = remaining_qty.min(fragment.quantity);
1757 allocations
1758 .entry(fragment.position_id)
1759 .and_modify(|allocation| allocation.0 = allocation.0 + removed)
1760 .or_insert((removed, None));
1761 remaining_qty = remaining_qty - removed;
1762 }
1763
1764 if !remaining_qty.is_zero() {
1765 return Err(apply_error(
1766 entry,
1767 format!(
1768 "position fragments do not cover voided quantity for fill {}",
1769 fill_voided.trade_id
1770 ),
1771 ));
1772 }
1773
1774 if let Some(mut remaining_commission) = fill_voided.commission_voided {
1775 for fragment in fragments.iter().rev() {
1776 if remaining_commission.is_zero() {
1777 break;
1778 }
1779 let Some(commission) = fragment.commission else {
1780 continue;
1781 };
1782
1783 if commission.currency != remaining_commission.currency {
1784 return Err(apply_error(
1785 entry,
1786 format!(
1787 "position commission currency differs for fill {}",
1788 fill_voided.trade_id
1789 ),
1790 ));
1791 }
1792 let removed_raw = remaining_commission.raw.abs().min(commission.raw.abs());
1793 let removed = Money::from_raw(
1794 removed_raw * remaining_commission.raw.signum(),
1795 remaining_commission.currency,
1796 );
1797 allocations
1798 .entry(fragment.position_id)
1799 .and_modify(|allocation| {
1800 allocation.1 = Some(
1801 allocation
1802 .1
1803 .map_or(removed, |commission| commission + removed),
1804 );
1805 })
1806 .or_insert((
1807 Quantity::zero(fill_voided.voided_qty.precision),
1808 Some(removed),
1809 ));
1810 remaining_commission = remaining_commission - removed;
1811 }
1812
1813 if !remaining_commission.is_zero() {
1814 return Err(apply_error(
1815 entry,
1816 format!(
1817 "position fragments do not cover voided commission for fill {}",
1818 fill_voided.trade_id
1819 ),
1820 ));
1821 }
1822 }
1823 Ok(allocations)
1824}
1825
1826fn apply_position_opened(
1827 cache: &mut Cache,
1828 entry: &EventStoreEntry,
1829 opened: &PositionOpened,
1830) -> Result<bool, CacheReplayError> {
1831 let Some(mut position) = cache.position_owned(&opened.position_id) else {
1832 warn_position_skip(entry, opened.position_id);
1833 return Ok(false);
1834 };
1835
1836 position.trader_id = opened.trader_id;
1837 position.strategy_id = opened.strategy_id;
1838 position.instrument_id = opened.instrument_id;
1839 position.id = opened.position_id;
1840 position.account_id = opened.account_id;
1841 position.opening_order_id = opened.opening_order_id;
1842 position.closing_order_id = None;
1843 position.entry = opened.entry;
1844 position.side = opened.side;
1845 position.signed_qty = opened.signed_qty;
1846 position.quantity = opened.quantity;
1847 position.peak_qty = opened.quantity;
1848 position.quote_currency = opened.currency;
1849 position.ts_opened = opened.ts_event;
1850 position.ts_last = opened.ts_event;
1851 position.ts_closed = None;
1852 position.duration_ns = 0;
1853 position.avg_px_open = opened.avg_px_open;
1854 position.avg_px_close = None;
1855 position.realized_return = 0.0;
1856 position.realized_pnl = opened.realized_pnl;
1857
1858 apply_result(entry, cache.update_position(&position))?;
1859 Ok(true)
1860}
1861
1862fn apply_position_changed(
1863 cache: &mut Cache,
1864 entry: &EventStoreEntry,
1865 changed: &PositionChanged,
1866) -> Result<bool, CacheReplayError> {
1867 let Some(mut position) = cache.position_owned(&changed.position_id) else {
1868 warn_position_skip(entry, changed.position_id);
1869 return Ok(false);
1870 };
1871
1872 position.trader_id = changed.trader_id;
1873 position.strategy_id = changed.strategy_id;
1874 position.instrument_id = changed.instrument_id;
1875 position.id = changed.position_id;
1876 position.account_id = changed.account_id;
1877 position.opening_order_id = changed.opening_order_id;
1878 position.entry = changed.entry;
1879 position.side = changed.side;
1880 position.signed_qty = changed.signed_qty;
1881 position.quantity = changed.quantity;
1882 position.peak_qty = changed.peak_quantity;
1883 position.quote_currency = changed.currency;
1884 position.ts_opened = changed.ts_opened;
1885 position.ts_last = changed.ts_event;
1886 position.ts_closed = None;
1887 position.avg_px_open = changed.avg_px_open;
1888 position.avg_px_close = changed.avg_px_close;
1889 position.realized_return = changed.realized_return;
1890 position.realized_pnl = changed.realized_pnl;
1891
1892 apply_result(entry, cache.update_position(&position))?;
1893 Ok(true)
1894}
1895
1896fn apply_position_closed(
1897 cache: &mut Cache,
1898 entry: &EventStoreEntry,
1899 closed: &PositionClosed,
1900) -> Result<bool, CacheReplayError> {
1901 let Some(mut position) = cache.position_owned(&closed.position_id) else {
1902 warn_position_skip(entry, closed.position_id);
1903 return Ok(false);
1904 };
1905
1906 position.trader_id = closed.trader_id;
1907 position.strategy_id = closed.strategy_id;
1908 position.instrument_id = closed.instrument_id;
1909 position.id = closed.position_id;
1910 position.account_id = closed.account_id;
1911 position.opening_order_id = closed.opening_order_id;
1912 position.closing_order_id = closed.closing_order_id;
1913 position.entry = closed.entry;
1914 position.side = closed.side;
1915 position.signed_qty = closed.signed_qty;
1916 position.quantity = closed.quantity;
1917 position.peak_qty = closed.peak_quantity;
1918 position.quote_currency = closed.currency;
1919 position.ts_opened = closed.ts_opened;
1920 position.ts_last = closed.ts_event;
1921 position.ts_closed = closed.ts_closed;
1922 position.duration_ns = closed.duration;
1923 position.avg_px_open = closed.avg_px_open;
1924 position.avg_px_close = closed.avg_px_close;
1925 position.realized_return = closed.realized_return;
1926 position.realized_pnl = closed.realized_pnl;
1927
1928 apply_result(entry, cache.update_position(&position))?;
1929 Ok(true)
1930}
1931
1932fn apply_position_adjustment(
1933 cache: &mut Cache,
1934 entry: &EventStoreEntry,
1935 adjustment: PositionAdjusted,
1936) -> Result<bool, CacheReplayError> {
1937 let Some(mut position) = cache.position_owned(&adjustment.position_id) else {
1938 warn_position_skip(entry, adjustment.position_id);
1939 return Ok(false);
1940 };
1941
1942 position.apply_adjustment(adjustment);
1943 apply_result(entry, cache.update_position(&position))?;
1944 Ok(true)
1945}
1946
1947fn warn_position_skip(entry: &EventStoreEntry, position_id: PositionId) {
1950 log::warn!(
1951 "Replay seq {} skipped {}: position {position_id} not in cache",
1952 entry.seq,
1953 entry.payload_type,
1954 );
1955}
1956
1957fn decode_payload<T>(entry: &EventStoreEntry) -> Result<T, CacheReplayError>
1958where
1959 T: DeserializeOwned,
1960{
1961 rmp_serde::from_slice(&entry.payload).map_err(|e| CacheReplayError::Decode {
1962 seq: entry.seq,
1963 payload_type: entry.payload_type.to_string(),
1964 message: e.to_string(),
1965 })
1966}
1967
1968fn apply_result<T, E>(entry: &EventStoreEntry, result: Result<T, E>) -> Result<T, CacheReplayError>
1969where
1970 E: Display,
1971{
1972 result.map_err(|e| apply_error(entry, e))
1973}
1974
1975fn apply_error(entry: &EventStoreEntry, error: impl Display) -> CacheReplayError {
1976 CacheReplayError::Apply {
1977 seq: entry.seq,
1978 payload_type: entry.payload_type.to_string(),
1979 message: error.to_string(),
1980 }
1981}
1982
1983fn reject_quarantined_replay_source(
1984 run_id: &str,
1985 status: RunStatus,
1986) -> Result<(), CacheReplayError> {
1987 if matches!(status, RunStatus::Quarantined) {
1988 let error = EventStoreError::Backend(format!("replay source {run_id} is quarantined"));
1989 return Err(CacheReplayError::from(error));
1990 }
1991
1992 Ok(())
1993}
1994
1995#[cfg(test)]
1996mod tests {
1997 use std::{any::Any, cell::Cell, rc::Rc};
1998
1999 use ahash::AHashSet;
2000 use bytes::Bytes;
2001 use indexmap::IndexMap;
2002 use nautilus_common::msgbus::{self, BusTap, Endpoint, MStr, Topic as BusTopic};
2003 use nautilus_core::{UUID4, UnixNanos};
2004 use nautilus_model::{
2005 accounts::AccountAny,
2006 data::{Bar, BarSpecification, BarType, FundingRateUpdate, QuoteTick, TradeTick},
2007 enums::{
2008 AggregationSource, AggressorSide, BarAggregation, OrderSide, OrderStatus,
2009 PositionAdjustmentType, PositionSide, PriceType,
2010 },
2011 events::{
2012 PositionEvent,
2013 account::stubs::{cash_account_state, cash_account_state_million_usd},
2014 order::spec::{
2015 OrderAcceptedSpec, OrderFillVoidedSpec, OrderFilledSpec, OrderInitializedSpec,
2016 OrderSubmittedSpec,
2017 },
2018 },
2019 identifiers::{
2020 AccountId, ClientId, ClientOrderId, InstrumentId, OrderListId, PositionId, TradeId,
2021 VenueOrderId,
2022 },
2023 instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
2024 orders::{Order, OrderList},
2025 types::{Currency, Money, Price, Quantity},
2026 };
2027 use rstest::rstest;
2028 use serde::Serialize;
2029 use tempfile::TempDir;
2030 use ustr::Ustr;
2031
2032 use super::*;
2033 use crate::{
2034 backend::{AppendEntry, MemoryBackend, RedbBackend},
2035 capture::{
2036 builtins::{
2037 DEFAULT_CAPTURE_PAYLOAD_TYPES, encode_order_event_any, encode_position_event,
2038 },
2039 encode_account_state,
2040 },
2041 entry::Topic as EntryTopic,
2042 hash::compute_entry_hash,
2043 headers::Headers,
2044 manifest::{RegisteredComponents, RunManifest, RunStatus},
2045 snapshot::SnapshotAnchor,
2046 };
2047
2048 fn manifest(run_id: &str) -> RunManifest {
2049 RunManifest {
2050 run_id: run_id.to_string(),
2051 parent_run_id: None,
2052 instance_id: "trader-001".to_string(),
2053 binary_hash: "deadbeef".to_string(),
2054 schema_version: 1,
2055 crate_versions: "feedface".to_string(),
2056 feature_flags: Vec::new(),
2057 adapter_versions: IndexMap::new(),
2058 config_hash: "cafebabe".to_string(),
2059 registered_components: RegisteredComponents::default(),
2060 seed: None,
2061 start_ts_init: UnixNanos::from(0),
2062 end_ts_init: None,
2063 high_watermark: 0,
2064 status: RunStatus::Running,
2065 }
2066 }
2067
2068 fn append_payload(seq: u64, payload_type: &str, payload: Bytes) -> AppendEntry {
2069 append_payload_with_ts(seq, seq, payload_type, payload)
2070 }
2071
2072 fn append_serde_payload<T: Serialize>(seq: u64, payload_type: &str, value: &T) -> AppendEntry {
2073 let payload = rmp_serde::to_vec_named(value).expect("encode replay payload");
2074 append_payload(seq, payload_type, Bytes::from(payload))
2075 }
2076
2077 fn append_payload_with_ts(
2078 seq: u64,
2079 ts_init: u64,
2080 payload_type: &str,
2081 payload: Bytes,
2082 ) -> AppendEntry {
2083 let topic = EntryTopic::from("events.account.SIM");
2084 let ts = UnixNanos::from(ts_init);
2085 let headers = Headers::empty();
2086 let hash = compute_entry_hash(
2087 seq,
2088 ts,
2089 ts,
2090 topic.as_ref(),
2091 payload_type,
2092 &payload,
2093 &headers,
2094 );
2095 let entry = EventStoreEntry::new(
2096 hash,
2097 seq,
2098 headers,
2099 topic,
2100 Ustr::from(payload_type),
2101 payload,
2102 ts,
2103 ts,
2104 );
2105 AppendEntry::without_indices(entry)
2106 }
2107
2108 fn append_account_state(seq: u64, state: &AccountState) -> AppendEntry {
2109 let encoded = encode_account_state(state).expect("encode account state");
2110 append_payload(seq, PAYLOAD_TYPE_ACCOUNT_STATE, encoded.payload)
2111 }
2112
2113 fn append_order_event(seq: u64, event: &OrderEventAny) -> AppendEntry {
2114 let encoded = encode_order_event_any(event).expect("encode order event");
2115 let payload_type = encoded.payload_type.expect("order payload type");
2116 append_payload(seq, payload_type.as_str(), encoded.payload)
2117 }
2118
2119 fn append_position_event(seq: u64, event: &PositionEvent) -> AppendEntry {
2120 let encoded = encode_position_event(event).expect("encode position event");
2121 let payload_type = encoded.payload_type.expect("position payload type");
2122 append_payload(seq, payload_type.as_str(), encoded.payload)
2123 }
2124
2125 fn reader_with_entries(
2126 run_id: &str,
2127 entries: &[AppendEntry],
2128 ) -> EventStoreReader<MemoryBackend> {
2129 let mut backend = MemoryBackend::new();
2130 backend.open_run(manifest(run_id)).expect("open");
2131 backend.append_batch(entries).expect("append");
2132 EventStoreReader::new(backend)
2133 }
2134
2135 fn reader_with_anchor(anchor_seq: u64) -> (EventStoreReader<MemoryBackend>, AccountState) {
2136 let anchored = cash_account_state();
2137 let replayed = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
2138 let mut backend = MemoryBackend::new();
2139 backend.open_run(manifest("run-replay")).expect("open");
2140 backend
2141 .append_batch(&[
2142 append_account_state(1, &anchored),
2143 append_account_state(2, &replayed),
2144 ])
2145 .expect("append");
2146 backend
2147 .record_snapshot_anchor(SnapshotAnchor::new(anchor_seq, "cache://account", "hash"))
2148 .expect("record anchor");
2149 (EventStoreReader::new(backend), replayed)
2150 }
2151
2152 fn catalog_quote_record(ts_init: u64) -> CatalogReplayRecord {
2153 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2154 CatalogReplayRecord::from_data(CatalogReplayData::Quote(QuoteTick::new(
2155 instrument_id,
2156 Price::from("1.0001"),
2157 Price::from("1.0002"),
2158 Quantity::from("100"),
2159 Quantity::from("100"),
2160 UnixNanos::from(ts_init),
2161 UnixNanos::from(ts_init),
2162 )))
2163 }
2164
2165 fn catalog_trade_record(ts_init: u64) -> CatalogReplayRecord {
2166 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2167 CatalogReplayRecord::from_data(CatalogReplayData::Trade(TradeTick::new(
2168 instrument_id,
2169 Price::from("1.0001"),
2170 Quantity::from("100"),
2171 AggressorSide::Buy,
2172 TradeId::from("T-1"),
2173 UnixNanos::from(ts_init),
2174 UnixNanos::from(ts_init),
2175 )))
2176 }
2177
2178 #[derive(Debug)]
2179 struct CountingTap {
2180 calls: Rc<Cell<usize>>,
2181 }
2182
2183 impl CountingTap {
2184 fn new(calls: Rc<Cell<usize>>) -> Self {
2185 Self { calls }
2186 }
2187
2188 fn increment(&self) {
2189 self.calls.set(self.calls.get() + 1);
2190 }
2191 }
2192
2193 impl BusTap for CountingTap {
2194 fn on_publish(&self, _topic: MStr<BusTopic>, _message: &dyn Any) {
2195 self.increment();
2196 }
2197
2198 fn on_send(&self, _endpoint: MStr<Endpoint>, _message: &dyn Any) {
2199 self.increment();
2200 }
2201 }
2202
2203 #[derive(Debug)]
2204 struct FakeReplayCatalog {
2205 coverage: CatalogSliceCoverage,
2206 records: Vec<CatalogReplayRecord>,
2207 plan_queries: Vec<CatalogSliceQuery>,
2208 load_plans: Vec<CatalogSlicePlan>,
2209 }
2210
2211 impl FakeReplayCatalog {
2212 fn new(coverage: CatalogSliceCoverage, records: Vec<CatalogReplayRecord>) -> Self {
2213 Self {
2214 coverage,
2215 records,
2216 plan_queries: Vec::new(),
2217 load_plans: Vec::new(),
2218 }
2219 }
2220 }
2221
2222 impl ReplayCatalog for FakeReplayCatalog {
2223 type Error = String;
2224
2225 fn plan_slice(
2226 &mut self,
2227 query: &CatalogSliceQuery,
2228 ) -> Result<CatalogSliceCoverage, Self::Error> {
2229 self.plan_queries.push(query.clone());
2230 Ok(self.coverage.clone())
2231 }
2232
2233 fn load_slice(
2234 &mut self,
2235 plan: &CatalogSlicePlan,
2236 ) -> Result<Vec<CatalogReplayRecord>, Self::Error> {
2237 self.load_plans.push(plan.clone());
2238 Ok(self.records.clone())
2239 }
2240 }
2241
2242 struct BusTapGuard;
2243
2244 impl Drop for BusTapGuard {
2245 fn drop(&mut self) {
2246 msgbus::clear_bus_tap();
2247 }
2248 }
2249
2250 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2251 enum CacheMutationRecoveryClass {
2252 SnapshotOwned,
2253 EventStoreCapturedAndReplayed,
2254 ForensicOnly,
2255 MissingLiveRecovery,
2256 }
2257
2258 #[derive(Clone, Copy, Debug)]
2259 struct CacheMutationCoverage {
2260 method: &'static str,
2261 class: CacheMutationRecoveryClass,
2262 payload_types: &'static [&'static str],
2263 }
2264
2265 const CACHE_MUTATION_COVERAGE: &[CacheMutationCoverage] = &[
2266 cache_mutation(
2267 "set_database",
2268 CacheMutationRecoveryClass::SnapshotOwned,
2269 &[],
2270 ),
2271 cache_mutation(
2272 "cache_general",
2273 CacheMutationRecoveryClass::SnapshotOwned,
2274 &[],
2275 ),
2276 cache_mutation("cache_all", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2277 cache_mutation(
2278 "cache_currencies",
2279 CacheMutationRecoveryClass::SnapshotOwned,
2280 &[],
2281 ),
2282 cache_mutation(
2283 "cache_instruments",
2284 CacheMutationRecoveryClass::SnapshotOwned,
2285 &[],
2286 ),
2287 cache_mutation(
2288 "cache_synthetics",
2289 CacheMutationRecoveryClass::SnapshotOwned,
2290 &[],
2291 ),
2292 cache_mutation(
2293 "cache_accounts",
2294 CacheMutationRecoveryClass::SnapshotOwned,
2295 &[],
2296 ),
2297 cache_mutation(
2298 "cache_orders",
2299 CacheMutationRecoveryClass::SnapshotOwned,
2300 &[],
2301 ),
2302 cache_mutation(
2303 "cache_positions",
2304 CacheMutationRecoveryClass::SnapshotOwned,
2305 &[],
2306 ),
2307 cache_mutation(
2308 "build_index",
2309 CacheMutationRecoveryClass::SnapshotOwned,
2310 &[],
2311 ),
2312 cache_mutation(
2313 "purge_closed_orders",
2314 CacheMutationRecoveryClass::SnapshotOwned,
2315 &[],
2316 ),
2317 cache_mutation(
2318 "purge_closed_positions",
2319 CacheMutationRecoveryClass::SnapshotOwned,
2320 &[],
2321 ),
2322 cache_mutation(
2323 "purge_order",
2324 CacheMutationRecoveryClass::SnapshotOwned,
2325 &[],
2326 ),
2327 cache_mutation(
2328 "purge_position",
2329 CacheMutationRecoveryClass::SnapshotOwned,
2330 &[],
2331 ),
2332 cache_mutation(
2333 "settle_position_snapshots",
2334 CacheMutationRecoveryClass::SnapshotOwned,
2335 &[],
2336 ),
2337 cache_mutation(
2338 "purge_instrument",
2339 CacheMutationRecoveryClass::SnapshotOwned,
2340 &[],
2341 ),
2342 cache_mutation(
2343 "purge_instrument_skip_order_guard",
2344 CacheMutationRecoveryClass::SnapshotOwned,
2345 &[],
2346 ),
2347 cache_mutation(
2348 "purge_account_events",
2349 CacheMutationRecoveryClass::SnapshotOwned,
2350 &[],
2351 ),
2352 cache_mutation(
2353 "clear_index",
2354 CacheMutationRecoveryClass::SnapshotOwned,
2355 &[],
2356 ),
2357 cache_mutation("reset", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2358 cache_mutation("dispose", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2359 cache_mutation("flush_db", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2360 cache_mutation("add", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2361 cache_mutation(
2362 "add_order_book",
2363 CacheMutationRecoveryClass::ForensicOnly,
2364 &[PAYLOAD_TYPE_BOOK_RESPONSE],
2365 ),
2366 cache_mutation(
2367 "add_own_order_book",
2368 CacheMutationRecoveryClass::SnapshotOwned,
2369 &[],
2370 ),
2371 cache_mutation(
2372 "add_mark_price",
2373 CacheMutationRecoveryClass::MissingLiveRecovery,
2374 &[],
2375 ),
2376 cache_mutation(
2377 "add_index_price",
2378 CacheMutationRecoveryClass::MissingLiveRecovery,
2379 &[],
2380 ),
2381 cache_mutation(
2382 "add_funding_rate",
2383 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2384 &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
2385 ),
2386 cache_mutation(
2387 "add_funding_rates",
2388 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2389 &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
2390 ),
2391 cache_mutation(
2392 "add_instrument_status",
2393 CacheMutationRecoveryClass::MissingLiveRecovery,
2394 &[],
2395 ),
2396 cache_mutation(
2397 "add_quote",
2398 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2399 &[PAYLOAD_TYPE_QUOTES_RESPONSE],
2400 ),
2401 cache_mutation(
2402 "add_quotes",
2403 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2404 &[PAYLOAD_TYPE_QUOTES_RESPONSE],
2405 ),
2406 cache_mutation(
2407 "add_trade",
2408 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2409 &[PAYLOAD_TYPE_TRADES_RESPONSE],
2410 ),
2411 cache_mutation(
2412 "add_trades",
2413 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2414 &[PAYLOAD_TYPE_TRADES_RESPONSE],
2415 ),
2416 cache_mutation(
2417 "add_bar",
2418 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2419 &[PAYLOAD_TYPE_BARS_RESPONSE],
2420 ),
2421 cache_mutation(
2422 "add_bars",
2423 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2424 &[PAYLOAD_TYPE_BARS_RESPONSE],
2425 ),
2426 cache_mutation(
2427 "add_greeks",
2428 CacheMutationRecoveryClass::MissingLiveRecovery,
2429 &[],
2430 ),
2431 cache_mutation(
2432 "add_option_greeks",
2433 CacheMutationRecoveryClass::MissingLiveRecovery,
2434 &[],
2435 ),
2436 cache_mutation(
2437 "add_yield_curve",
2438 CacheMutationRecoveryClass::MissingLiveRecovery,
2439 &[],
2440 ),
2441 cache_mutation(
2442 "add_currency",
2443 CacheMutationRecoveryClass::SnapshotOwned,
2444 &[],
2445 ),
2446 cache_mutation(
2447 "add_instrument",
2448 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2449 &[
2450 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
2451 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
2452 ],
2453 ),
2454 cache_mutation(
2455 "add_synthetic",
2456 CacheMutationRecoveryClass::SnapshotOwned,
2457 &[],
2458 ),
2459 cache_mutation(
2460 "add_account",
2461 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2462 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2463 ),
2464 cache_mutation(
2465 "add_venue_order_id",
2466 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2467 &[PAYLOAD_TYPE_ORDER_ACCEPTED, PAYLOAD_TYPE_ORDER_UPDATED],
2468 ),
2469 cache_mutation(
2470 "index_venue_order_id",
2473 CacheMutationRecoveryClass::MissingLiveRecovery,
2474 &[],
2475 ),
2476 cache_mutation(
2477 "add_order",
2478 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2479 &[PAYLOAD_TYPE_ORDER_INITIALIZED],
2480 ),
2481 cache_mutation(
2482 "claim_order_clients",
2485 CacheMutationRecoveryClass::MissingLiveRecovery,
2486 &[],
2487 ),
2488 cache_mutation(
2489 "add_order_list",
2490 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2491 &[PAYLOAD_TYPE_SUBMIT_ORDER_LIST],
2492 ),
2493 cache_mutation(
2494 "add_position_id",
2495 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2496 &[
2497 PAYLOAD_TYPE_ORDER_FILLED,
2498 PAYLOAD_TYPE_POSITION_OPENED,
2499 PAYLOAD_TYPE_POSITION_CHANGED,
2500 PAYLOAD_TYPE_POSITION_CLOSED,
2501 ],
2502 ),
2503 cache_mutation(
2504 "add_position",
2505 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2506 &[PAYLOAD_TYPE_ORDER_FILLED],
2507 ),
2508 cache_mutation(
2509 "add_position_without_order",
2510 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2511 &[PAYLOAD_TYPE_ORDER_FILLED],
2512 ),
2513 cache_mutation(
2514 "update_account",
2515 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2516 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2517 ),
2518 cache_mutation(
2519 "take_account",
2520 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2521 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2522 ),
2523 cache_mutation(
2524 "cache_account_owned",
2525 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2526 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2527 ),
2528 cache_mutation(
2529 "update_account_owned",
2530 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2531 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2532 ),
2533 cache_mutation(
2534 "update_account_state",
2535 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2536 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2537 ),
2538 cache_mutation(
2539 "replace_order",
2540 CacheMutationRecoveryClass::ForensicOnly,
2541 &[
2542 PAYLOAD_TYPE_ORDER_STATUS_REPORT,
2543 PAYLOAD_TYPE_ORDER_WITH_FILLS,
2544 PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
2545 ],
2546 ),
2547 cache_mutation(
2548 "update_order",
2549 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2550 &[
2551 PAYLOAD_TYPE_ORDER_DENIED,
2552 PAYLOAD_TYPE_ORDER_EMULATED,
2553 PAYLOAD_TYPE_ORDER_RELEASED,
2554 PAYLOAD_TYPE_ORDER_SUBMITTED,
2555 PAYLOAD_TYPE_ORDER_ACCEPTED,
2556 PAYLOAD_TYPE_ORDER_REJECTED,
2557 PAYLOAD_TYPE_ORDER_CANCELED,
2558 PAYLOAD_TYPE_ORDER_EXPIRED,
2559 PAYLOAD_TYPE_ORDER_TRIGGERED,
2560 PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
2561 PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
2562 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
2563 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED,
2564 PAYLOAD_TYPE_ORDER_UPDATED,
2565 PAYLOAD_TYPE_ORDER_FILLED,
2566 PAYLOAD_TYPE_ORDER_FILL_VOIDED,
2567 ],
2568 ),
2569 cache_mutation(
2570 "update_order_pending_cancel_local",
2571 CacheMutationRecoveryClass::MissingLiveRecovery,
2572 &[],
2573 ),
2574 cache_mutation(
2575 "update_position",
2576 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2577 &[
2578 PAYLOAD_TYPE_ORDER_FILLED,
2579 PAYLOAD_TYPE_ORDER_FILL_VOIDED,
2580 PAYLOAD_TYPE_POSITION_OPENED,
2581 PAYLOAD_TYPE_POSITION_CHANGED,
2582 PAYLOAD_TYPE_POSITION_CLOSED,
2583 PAYLOAD_TYPE_POSITION_ADJUSTED,
2584 ],
2585 ),
2586 cache_mutation(
2587 "update_position_from_fill",
2588 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2589 &[PAYLOAD_TYPE_ORDER_FILLED],
2590 ),
2591 cache_mutation(
2592 "snapshot_position",
2593 CacheMutationRecoveryClass::SnapshotOwned,
2594 &[],
2595 ),
2596 cache_mutation(
2597 "snapshot_position_encoded",
2598 CacheMutationRecoveryClass::SnapshotOwned,
2599 &[],
2600 ),
2601 cache_mutation(
2602 "snapshot_position_state",
2603 CacheMutationRecoveryClass::SnapshotOwned,
2604 &[],
2605 ),
2606 cache_mutation(
2607 "load_snapshot_blob",
2608 CacheMutationRecoveryClass::SnapshotOwned,
2609 &[],
2610 ),
2611 cache_mutation(
2612 "restore_snapshot_blob",
2613 CacheMutationRecoveryClass::SnapshotOwned,
2614 &[],
2615 ),
2616 cache_mutation(
2617 "order_mut",
2618 CacheMutationRecoveryClass::MissingLiveRecovery,
2619 &[],
2620 ),
2621 cache_mutation(
2622 "position_mut",
2623 CacheMutationRecoveryClass::MissingLiveRecovery,
2624 &[],
2625 ),
2626 cache_mutation(
2627 "order_book_mut",
2628 CacheMutationRecoveryClass::ForensicOnly,
2629 &[
2630 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE,
2631 PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
2632 ],
2633 ),
2634 cache_mutation(
2635 "own_order_book_mut",
2636 CacheMutationRecoveryClass::SnapshotOwned,
2637 &[],
2638 ),
2639 cache_mutation(
2640 "set_mark_xrate",
2641 CacheMutationRecoveryClass::MissingLiveRecovery,
2642 &[],
2643 ),
2644 cache_mutation(
2645 "clear_mark_xrate",
2646 CacheMutationRecoveryClass::MissingLiveRecovery,
2647 &[],
2648 ),
2649 cache_mutation(
2650 "clear_mark_xrates",
2651 CacheMutationRecoveryClass::MissingLiveRecovery,
2652 &[],
2653 ),
2654 cache_mutation(
2655 "account_mut",
2656 CacheMutationRecoveryClass::MissingLiveRecovery,
2657 &[],
2658 ),
2659 cache_mutation(
2660 "update_own_order_book",
2661 CacheMutationRecoveryClass::SnapshotOwned,
2662 &[],
2663 ),
2664 cache_mutation(
2665 "force_remove_from_own_order_book",
2666 CacheMutationRecoveryClass::SnapshotOwned,
2667 &[],
2668 ),
2669 cache_mutation(
2670 "audit_own_order_books",
2671 CacheMutationRecoveryClass::SnapshotOwned,
2672 &[],
2673 ),
2674 ];
2675
2676 const CACHE_MUTATION_EXCLUSIONS: &[&str] = &["check_integrity"];
2677
2678 const fn cache_mutation(
2679 method: &'static str,
2680 class: CacheMutationRecoveryClass,
2681 payload_types: &'static [&'static str],
2682 ) -> CacheMutationCoverage {
2683 CacheMutationCoverage {
2684 method,
2685 class,
2686 payload_types,
2687 }
2688 }
2689
2690 fn cache_public_methods() -> AHashSet<&'static str> {
2691 collect_cache_public_methods(false)
2692 }
2693
2694 fn cache_public_mutable_methods() -> AHashSet<&'static str> {
2695 collect_cache_public_methods(true)
2696 }
2697
2698 const CACHE_IMPL_SOURCES: &[&str] = &[
2702 include_str!("../../common/src/cache/mod.rs"),
2703 include_str!("../../common/src/cache/position.rs"),
2704 ];
2705
2706 fn collect_cache_public_methods(require_mut_self: bool) -> AHashSet<&'static str> {
2707 let mut methods = AHashSet::new();
2708 let mut pending_name: Option<&'static str> = None;
2709 let mut pending_signature = String::new();
2710
2711 for line in CACHE_IMPL_SOURCES.iter().flat_map(|source| source.lines()) {
2712 let trimmed = line.trim_start();
2713
2714 if pending_name.is_none() {
2715 let Some(rest) = trimmed
2716 .strip_prefix("pub fn ")
2717 .or_else(|| trimmed.strip_prefix("pub async fn "))
2718 else {
2719 continue;
2720 };
2721 pending_name = rest.split('(').next();
2722 pending_signature.clear();
2723 pending_signature.push_str(trimmed);
2724 } else {
2725 pending_signature.push(' ');
2726 pending_signature.push_str(trimmed);
2727 }
2728
2729 if trimmed.contains('{') {
2730 if let Some(name) = pending_name.take()
2731 && (!require_mut_self || pending_signature.contains("&mut self"))
2732 {
2733 methods.insert(name);
2734 }
2735 pending_signature.clear();
2736 }
2737 }
2738
2739 methods
2740 }
2741
2742 fn sorted_missing_methods<'a>(
2743 actual: &'a AHashSet<&'static str>,
2744 classified: &'a AHashSet<&'static str>,
2745 ) -> Vec<&'static str> {
2746 let mut missing: Vec<_> = actual
2747 .iter()
2748 .copied()
2749 .filter(|method| !classified.contains(method))
2750 .collect();
2751 missing.sort_unstable();
2752 missing
2753 }
2754
2755 fn sorted_stale_methods<'a>(
2756 classified: &'a AHashSet<&'static str>,
2757 actual: &'a AHashSet<&'static str>,
2758 ) -> Vec<&'static str> {
2759 let mut stale: Vec<_> = classified
2760 .iter()
2761 .copied()
2762 .filter(|method| !actual.contains(method))
2763 .collect();
2764 stale.sort_unstable();
2765 stale
2766 }
2767
2768 #[rstest]
2769 fn catalog_replay_inputs_join_event_entries_with_selected_catalog_slice() {
2770 let reader = reader_with_entries(
2771 "run-catalog",
2772 &[
2773 append_payload_with_ts(1, 120, "RunStarted", Bytes::from_static(b"started")),
2774 append_payload_with_ts(2, 100, "SubmitOrder", Bytes::from_static(b"submit")),
2775 ],
2776 );
2777 let record = catalog_quote_record(110);
2778 let mut catalog = FakeReplayCatalog::new(
2779 CatalogSliceCoverage::from_files(vec!["quotes/AUDUSD.SIM/100_120.parquet".into()]),
2780 vec![record.clone()],
2781 );
2782
2783 let plan = plan_catalog_replay_inputs(
2784 &reader,
2785 &mut catalog,
2786 ReplaySeqRange::new(1, 2),
2787 &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2788 )
2789 .expect("plan catalog replay");
2790
2791 assert_eq!(plan.event_range, Some(ReplaySeqRange::new(1, 2)));
2792 assert_eq!(plan.event_count, 2);
2793 assert_eq!(
2794 plan.event_time_range,
2795 Some(ReplayTimeRange::new(
2796 UnixNanos::from(100),
2797 UnixNanos::from(120),
2798 )),
2799 );
2800 assert!(!plan.catalog_slices[0].is_missing());
2801 assert_eq!(catalog.plan_queries.len(), 1);
2802 assert_eq!(catalog.plan_queries[0].data_cls, "quotes");
2803 assert_eq!(
2804 catalog.plan_queries[0].identifiers,
2805 vec!["AUD/USD.SIM".to_string()],
2806 );
2807 assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(100));
2808 assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(120));
2809
2810 let loaded =
2811 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
2812 let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
2813
2814 assert_eq!(seqs, vec![1, 2]);
2815 assert_eq!(loaded.catalog_slices.len(), 1);
2816 assert_eq!(loaded.catalog_slices[0].records, vec![record]);
2817 assert_eq!(catalog.load_plans.len(), 1);
2818 }
2819
2820 #[rstest]
2821 fn catalog_plan_marks_missing_catalog_slice() {
2822 let reader = reader_with_entries(
2823 "run-missing-catalog",
2824 &[append_payload_with_ts(
2825 1,
2826 1_000,
2827 "RunStarted",
2828 Bytes::from_static(b"started"),
2829 )],
2830 );
2831 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2832
2833 let plan = plan_catalog_replay_inputs(
2834 &reader,
2835 &mut catalog,
2836 ReplaySeqRange::new(1, 1),
2837 &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
2838 )
2839 .expect("plan catalog replay");
2840 let missing = plan.missing_catalog_slices();
2841
2842 assert_eq!(missing.len(), 1);
2843 assert_eq!(missing[0].query.data_cls, "trades");
2844 assert_eq!(
2845 missing[0].query.identifiers,
2846 vec!["AUD/USD.SIM".to_string()],
2847 );
2848 assert_eq!(missing[0].query.start, UnixNanos::from(1_000));
2849 assert_eq!(missing[0].query.end, UnixNanos::from(1_000));
2850 }
2851
2852 #[rstest]
2853 fn required_missing_catalog_slice_rejects_load() {
2854 let reader = reader_with_entries(
2855 "run-required-missing",
2856 &[append_payload_with_ts(
2857 1,
2858 1_000,
2859 "RunStarted",
2860 Bytes::from_static(b"started"),
2861 )],
2862 );
2863 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2864 let plan = plan_catalog_replay_inputs(
2865 &reader,
2866 &mut catalog,
2867 ReplaySeqRange::new(1, 1),
2868 &[CatalogSliceSelector::new("quotes")
2869 .with_identifier("AUD/USD.SIM")
2870 .require_coverage()],
2871 )
2872 .expect("plan missing slice");
2873
2874 let err = load_catalog_replay_inputs(&reader, &mut catalog, &plan)
2875 .expect_err("required missing slice must fail");
2876
2877 match err {
2878 ReplayInputError::MissingCatalogSlice {
2879 data_cls,
2880 identifiers,
2881 } => {
2882 assert_eq!(data_cls, "quotes");
2883 assert_eq!(identifiers, vec!["AUD/USD.SIM".to_string()]);
2884 }
2885 other => panic!("expected MissingCatalogSlice, was {other:?}"),
2886 }
2887 }
2888
2889 #[rstest]
2890 fn optional_missing_catalog_slice_loads_as_empty_without_catalog_load() {
2891 let reader = reader_with_entries(
2892 "run-optional-missing",
2893 &[append_payload_with_ts(
2894 1,
2895 1_000,
2896 "RunStarted",
2897 Bytes::from_static(b"started"),
2898 )],
2899 );
2900 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2901 let plan = plan_catalog_replay_inputs(
2902 &reader,
2903 &mut catalog,
2904 ReplaySeqRange::new(1, 1),
2905 &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2906 )
2907 .expect("plan optional missing slice");
2908
2909 let loaded =
2910 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load optional");
2911
2912 assert_eq!(loaded.catalog_slices.len(), 1);
2913 assert!(loaded.catalog_slices[0].plan.is_missing());
2914 assert!(loaded.catalog_slices[0].records.is_empty());
2915 assert!(catalog.load_plans.is_empty());
2916 }
2917
2918 #[rstest]
2919 fn catalog_joined_planner_rejects_empty_catalog_selection() {
2920 let reader = reader_with_entries(
2921 "run-empty-selection",
2922 &[append_payload_with_ts(
2923 1,
2924 1_000,
2925 "RunStarted",
2926 Bytes::from_static(b"started"),
2927 )],
2928 );
2929 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2930
2931 let err = plan_catalog_replay_inputs(&reader, &mut catalog, ReplaySeqRange::new(1, 1), &[])
2932 .expect_err("empty catalog selection must fail");
2933
2934 match err {
2935 ReplayInputError::EmptyCatalogSelection => {}
2936 other => panic!("expected EmptyCatalogSelection, was {other:?}"),
2937 }
2938 assert!(catalog.plan_queries.is_empty());
2939 }
2940
2941 #[rstest]
2942 fn catalog_selector_explicit_time_bounds_override_event_span() {
2943 let reader = reader_with_entries(
2944 "run-explicit-bounds",
2945 &[append_payload_with_ts(
2946 1,
2947 1_000,
2948 "RunStarted",
2949 Bytes::from_static(b"started"),
2950 )],
2951 );
2952 let mut catalog = FakeReplayCatalog::new(
2953 CatalogSliceCoverage::from_files(vec!["bars/AUDUSD.SIM/900_950.parquet".into()]),
2954 Vec::new(),
2955 );
2956
2957 let plan = plan_catalog_replay_inputs(
2958 &reader,
2959 &mut catalog,
2960 ReplaySeqRange::new(1, 1),
2961 &[CatalogSliceSelector::new("bars")
2962 .with_identifier("AUD/USD.SIM-1-MINUTE-BID-EXTERNAL")
2963 .with_time_bounds(UnixNanos::from(900), UnixNanos::from(950))],
2964 )
2965 .expect("plan explicit bounds");
2966
2967 assert_eq!(plan.catalog_slices[0].query.start, UnixNanos::from(900));
2968 assert_eq!(plan.catalog_slices[0].query.end, UnixNanos::from(950));
2969 assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(900));
2970 assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(950));
2971 }
2972
2973 #[rstest]
2974 fn catalog_replay_inputs_load_catalog_records() {
2975 let reader = reader_with_entries(
2976 "run-catalog-load",
2977 &[
2978 append_payload_with_ts(1, 100, "RunStarted", Bytes::from_static(b"started")),
2979 append_payload_with_ts(2, 110, "OrderFilled", Bytes::from_static(b"filled")),
2980 ],
2981 );
2982 let record = catalog_trade_record(105);
2983 let mut catalog = FakeReplayCatalog::new(
2984 CatalogSliceCoverage::from_files(vec!["trades/AUDUSD.SIM/100_110.parquet".into()]),
2985 vec![record.clone()],
2986 );
2987 let plan = plan_catalog_replay_inputs(
2988 &reader,
2989 &mut catalog,
2990 ReplaySeqRange::new(1, 2),
2991 &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
2992 )
2993 .expect("plan catalog replay");
2994
2995 assert_eq!(
2996 plan.catalog_slices[0].query.identifiers_option(),
2997 Some(vec!["AUD/USD.SIM".to_string()]),
2998 );
2999
3000 let loaded =
3001 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
3002 let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
3003
3004 assert_eq!(seqs, vec![1, 2]);
3005 assert_eq!(loaded.catalog_slices[0].records, vec![record]);
3006 assert_eq!(catalog.load_plans.len(), 1);
3007 }
3008
3009 #[rstest]
3010 fn unbounded_catalog_selector_rejects_empty_event_scan() {
3011 let reader = reader_with_entries("run-empty", &[]);
3012 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
3013
3014 let err = plan_catalog_replay_inputs(
3015 &reader,
3016 &mut catalog,
3017 ReplaySeqRange::new(1, 10),
3018 &[CatalogSliceSelector::new("quotes")],
3019 )
3020 .expect_err("empty replay scan must need explicit bounds");
3021
3022 match err {
3023 ReplayInputError::MissingCatalogTimeBounds { data_cls } => {
3024 assert_eq!(data_cls, "quotes");
3025 }
3026 other => panic!("expected MissingCatalogTimeBounds, was {other:?}"),
3027 }
3028 }
3029
3030 #[rstest]
3031 fn invalid_catalog_time_bounds_are_rejected_before_catalog_access() {
3032 let reader = reader_with_entries(
3033 "run-invalid-bounds",
3034 &[append_payload_with_ts(
3035 1,
3036 1_000,
3037 "RunStarted",
3038 Bytes::from_static(b"started"),
3039 )],
3040 );
3041 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
3042
3043 let err = plan_catalog_replay_inputs(
3044 &reader,
3045 &mut catalog,
3046 ReplaySeqRange::new(1, 1),
3047 &[CatalogSliceSelector::new("quotes")
3048 .with_time_bounds(UnixNanos::from(200), UnixNanos::from(100))],
3049 )
3050 .expect_err("invalid catalog bounds must fail");
3051
3052 match err {
3053 ReplayInputError::InvalidCatalogTimeRange {
3054 data_cls,
3055 start,
3056 end,
3057 } => {
3058 assert_eq!(data_cls, "quotes");
3059 assert_eq!(start, 200);
3060 assert_eq!(end, 100);
3061 }
3062 other => panic!("expected InvalidCatalogTimeRange, was {other:?}"),
3063 }
3064 assert!(catalog.plan_queries.is_empty());
3065 }
3066
3067 #[rstest]
3068 fn forensics_replay_inputs_do_not_require_catalog_source() {
3069 let reader = reader_with_entries(
3070 "run-forensics",
3071 &[append_payload_with_ts(
3072 1,
3073 500,
3074 "RunStarted",
3075 Bytes::from_static(b"started"),
3076 )],
3077 );
3078
3079 let plan = plan_forensics_replay_inputs(&reader, ReplaySeqRange::new(1, 1))
3080 .expect("plan forensics");
3081 let loaded = load_forensics_replay_inputs(&reader, &plan).expect("load forensics");
3082
3083 assert!(plan.catalog_slices.is_empty());
3084 assert_eq!(loaded.entries.len(), 1);
3085 assert!(loaded.catalog_slices.is_empty());
3086 }
3087
3088 #[rstest]
3089 #[case::zero_start(ReplaySeqRange::new(0, 1), "seq is 1-based")]
3090 #[case::from_after_to(ReplaySeqRange::new(2, 1), "from_seq exceeds to_seq")]
3091 fn invalid_replay_seq_range_rejected(
3092 #[case] range: ReplaySeqRange,
3093 #[case] expected_message: &str,
3094 ) {
3095 let reader = reader_with_entries("run-invalid-seq", &[]);
3096
3097 let err =
3098 plan_forensics_replay_inputs(&reader, range).expect_err("invalid seq range must fail");
3099
3100 match err {
3101 ReplayInputError::InvalidSeqRange {
3102 from_seq,
3103 to_seq,
3104 message,
3105 } => {
3106 assert_eq!(from_seq, range.from_seq);
3107 assert_eq!(to_seq, range.to_seq);
3108 assert_eq!(message, expected_message);
3109 }
3110 other => panic!("expected InvalidSeqRange, was {other:?}"),
3111 }
3112 }
3113
3114 #[rstest]
3115 fn replay_restores_snapshot_before_applying_tail() {
3116 let (reader, replayed) = reader_with_anchor(1);
3117 let mut cache = Cache::default();
3118 let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3119 let restored_id = restored.account_id;
3120
3121 let report =
3122 restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
3123 assert_eq!(anchor.expect("anchor").high_watermark, 1);
3124 let account = AccountAny::from_events(std::slice::from_ref(&restored))
3125 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
3126 cache
3127 .add_account(account)
3128 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
3129 })
3130 .expect("replay");
3131
3132 let account = cache.account_owned(&restored_id).expect("account restored");
3133 let events = account.events();
3134
3135 assert_eq!(report.plan.from_seq, 2);
3136 assert_eq!(report.applied_entries, 1);
3137 assert_eq!(report.ignored_entries, 0);
3138 assert_eq!(events, vec![restored, replayed]);
3139 }
3140
3141 #[rstest]
3142 fn replay_does_not_apply_entries_at_or_below_anchor_watermark() {
3143 let (reader, _) = reader_with_anchor(2);
3144 let mut cache = Cache::default();
3145 let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3146 let restored_id = restored.account_id;
3147
3148 let report =
3149 restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
3150 assert_eq!(anchor.expect("anchor").high_watermark, 2);
3151 let account = AccountAny::from_events(std::slice::from_ref(&restored))
3152 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
3153 cache
3154 .add_account(account)
3155 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
3156 })
3157 .expect("replay");
3158
3159 let account = cache.account_owned(&restored_id).expect("account restored");
3160
3161 assert!(report.plan.is_empty());
3162 assert_eq!(report.applied_entries, 0);
3163 assert_eq!(report.ignored_entries, 0);
3164 assert_eq!(account.events(), vec![restored]);
3165 }
3166
3167 #[rstest]
3168 fn replay_from_start_applies_account_state_without_bus_publish() {
3169 let state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3170 let account_id = AccountId::from("SIM-001");
3171 let bus_calls = Rc::new(Cell::new(0));
3172 msgbus::set_bus_tap(Rc::new(CountingTap::new(Rc::clone(&bus_calls))));
3173 let _guard = BusTapGuard;
3174 let mut backend = MemoryBackend::new();
3175 backend.open_run(manifest("run-replay")).expect("open");
3176 backend
3177 .append_batch(&[append_account_state(1, &state)])
3178 .expect("append");
3179 let reader = EventStoreReader::new(backend);
3180 let mut cache = Cache::default();
3181
3182 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3183 let account = cache.account_owned(&account_id).expect("account replayed");
3184
3185 assert_eq!(report.plan.anchor, None);
3186 assert_eq!(report.plan.from_seq, 1);
3187 assert_eq!(report.applied_entries, 1);
3188 assert_eq!(bus_calls.get(), 0);
3189 assert_eq!(account.last_event(), Some(state));
3190 assert_eq!(account.base_currency(), Some(Currency::USD()));
3191 }
3192
3193 #[rstest]
3194 fn unsupported_payload_is_ignored() {
3195 let mut backend = MemoryBackend::new();
3196 backend.open_run(manifest("run-replay")).expect("open");
3197 backend
3198 .append_batch(&[append_payload(
3199 1,
3200 "RunStarted",
3201 Bytes::copy_from_slice(UUID4::new().to_string().as_bytes()),
3202 )])
3203 .expect("append");
3204 let reader = EventStoreReader::new(backend);
3205 let mut cache = Cache::default();
3206
3207 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3208
3209 assert_eq!(report.applied_entries, 0);
3210 assert_eq!(report.ignored_entries, 1);
3211 }
3212
3213 #[rstest]
3214 fn default_capture_payload_types_are_classified_for_cache_replay() {
3215 let mut classified = AHashSet::new();
3216 let mut overlap = Vec::new();
3217
3218 for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
3219 classified.insert(*payload_type);
3220 }
3221
3222 for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
3223 if !classified.insert(*payload_type) {
3224 overlap.push(*payload_type);
3225 }
3226 }
3227
3228 let mut seen_defaults = AHashSet::new();
3229 let duplicate_defaults: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
3230 .iter()
3231 .copied()
3232 .filter(|payload_type| !seen_defaults.insert(*payload_type))
3233 .collect();
3234 let unclassified: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
3235 .iter()
3236 .copied()
3237 .filter(|payload_type| !classified.contains(payload_type))
3238 .collect();
3239 let extra: Vec<_> = classified
3240 .iter()
3241 .copied()
3242 .filter(|payload_type| !seen_defaults.contains(payload_type))
3243 .collect();
3244
3245 assert!(
3246 duplicate_defaults.is_empty(),
3247 "default capture payload types must be unique: {duplicate_defaults:?}",
3248 );
3249 assert!(
3250 overlap.is_empty(),
3251 "cache replay and forensic-only classes must not overlap: {overlap:?}",
3252 );
3253 assert!(
3254 unclassified.is_empty(),
3255 "default capture payload types must be cache replayed or forensic-only: {unclassified:?}",
3256 );
3257 assert!(
3258 extra.is_empty(),
3259 "cache replay classification must not list uncaptured payload types: {extra:?}",
3260 );
3261 }
3262
3263 #[rstest]
3264 fn cache_replay_capture_payload_types_have_replay_rules() {
3265 for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
3266 let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
3267 let mut cache = Cache::default();
3268
3269 let err = apply_cache_replay_entry(&mut cache, &entry)
3270 .expect_err("cache replay payload type must have a decode rule");
3271
3272 match err {
3273 CacheReplayError::Decode {
3274 payload_type: actual,
3275 ..
3276 } => {
3277 assert_eq!(actual, *payload_type);
3278 }
3279 other => panic!("expected Decode for {payload_type}, was {other:?}"),
3280 }
3281 }
3282 }
3283
3284 #[rstest]
3285 fn forensic_only_capture_payload_types_are_not_cache_replayed() {
3286 for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
3287 let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
3288 let mut cache = Cache::default();
3289
3290 let applied = apply_cache_replay_entry(&mut cache, &entry)
3291 .expect("forensic-only payload type must not be decoded by cache replay");
3292
3293 assert!(
3294 !applied,
3295 "forensic-only payload type must be ignored by cache replay: {payload_type}",
3296 );
3297 }
3298 }
3299
3300 #[rstest]
3301 fn cache_public_mutators_have_recovery_classification() {
3302 let mut classified = AHashSet::new();
3303 let mut duplicates = Vec::new();
3304
3305 for row in CACHE_MUTATION_COVERAGE {
3306 if !classified.insert(row.method) {
3307 duplicates.push(row.method);
3308 }
3309 }
3310
3311 for method in CACHE_MUTATION_EXCLUSIONS {
3312 if !classified.insert(*method) {
3313 duplicates.push(*method);
3314 }
3315 }
3316
3317 let public_methods = cache_public_methods();
3318 let mutable_methods = cache_public_mutable_methods();
3319 let missing = sorted_missing_methods(&mutable_methods, &classified);
3320 let stale = sorted_stale_methods(&classified, &public_methods);
3321
3322 assert!(
3323 duplicates.is_empty(),
3324 "cache mutation recovery classifications must be unique: {duplicates:?}",
3325 );
3326 assert!(
3327 missing.is_empty(),
3328 "public Cache mutators must be classified for recovery: {missing:?}",
3329 );
3330 assert!(
3331 stale.is_empty(),
3332 "cache mutation recovery classifications reference missing methods: {stale:?}",
3333 );
3334 }
3335
3336 #[rstest]
3337 fn cache_mutation_replay_classification_matches_payload_buckets() {
3338 for row in CACHE_MUTATION_COVERAGE {
3339 match row.class {
3340 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed => {
3341 assert!(
3342 !row.payload_types.is_empty(),
3343 "cache-replayed mutation must cite captured payloads: {}",
3344 row.method,
3345 );
3346
3347 for payload_type in row.payload_types {
3348 assert!(
3349 CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
3350 "cache mutation {} cites non-replayed payload {payload_type}",
3351 row.method,
3352 );
3353 }
3354 }
3355 CacheMutationRecoveryClass::ForensicOnly => {
3356 assert!(
3357 !row.payload_types.is_empty(),
3358 "forensic-only mutation must cite forensic payloads: {}",
3359 row.method,
3360 );
3361
3362 for payload_type in row.payload_types {
3363 assert!(
3364 FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
3365 "cache mutation {} cites non-forensic payload {payload_type}",
3366 row.method,
3367 );
3368 }
3369 }
3370 CacheMutationRecoveryClass::SnapshotOwned
3371 | CacheMutationRecoveryClass::MissingLiveRecovery => {
3372 assert!(
3373 row.payload_types.is_empty(),
3374 "non-event-store cache mutation {} should not cite payloads",
3375 row.method,
3376 );
3377 }
3378 }
3379 }
3380 }
3381
3382 #[rstest]
3383 fn submit_order_list_replay_restores_order_list() {
3384 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3385 let instrument_id = instrument.id();
3386 let first_init = OrderInitializedSpec::builder()
3387 .instrument_id(instrument_id)
3388 .client_order_id(ClientOrderId::from("O-LIST-001"))
3389 .build();
3390 let second_init = OrderInitializedSpec::builder()
3391 .instrument_id(instrument_id)
3392 .client_order_id(ClientOrderId::from("O-LIST-002"))
3393 .build();
3394 let order_list = OrderList::new(
3395 OrderListId::from("OL-001"),
3396 instrument_id,
3397 first_init.strategy_id,
3398 vec![first_init.client_order_id, second_init.client_order_id],
3399 UnixNanos::from(1),
3400 );
3401 let command = SubmitOrderList::new(
3402 first_init.trader_id,
3403 Some(ClientId::from("SIM")),
3404 first_init.strategy_id,
3405 order_list.clone(),
3406 vec![first_init, second_init],
3407 None,
3408 None,
3409 None,
3410 UUID4::new(),
3411 UnixNanos::from(2),
3412 None,
3413 );
3414 let entry = append_serde_payload(1, PAYLOAD_TYPE_SUBMIT_ORDER_LIST, &command).entry;
3415 let mut cache = Cache::default();
3416
3417 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply order list");
3418 let replayed = cache
3419 .order_list(&order_list.id)
3420 .expect("order list replayed");
3421
3422 assert!(applied);
3423 assert_eq!(replayed, &order_list);
3424 }
3425
3426 #[rstest]
3427 fn data_response_replay_restores_instruments_and_market_data() {
3428 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3429 let instrument_id = instrument.id();
3430 let client_id = ClientId::from("DATA");
3431 let quote = QuoteTick::new(
3432 instrument_id,
3433 Price::from("1.00000"),
3434 Price::from("1.00010"),
3435 Quantity::from("100000"),
3436 Quantity::from("100000"),
3437 UnixNanos::from(10),
3438 UnixNanos::from(11),
3439 );
3440 let trade = TradeTick::new(
3441 instrument_id,
3442 Price::from("1.00005"),
3443 Quantity::from("50000"),
3444 AggressorSide::Buy,
3445 TradeId::from("T-DATA-001"),
3446 UnixNanos::from(12),
3447 UnixNanos::from(13),
3448 );
3449 let funding_rate = FundingRateUpdate::new(
3450 instrument_id,
3451 "0.0001".parse().expect("funding rate"),
3452 Some(480),
3453 Some(UnixNanos::from(60)),
3454 UnixNanos::from(14),
3455 UnixNanos::from(15),
3456 );
3457 let bar_type = BarType::new(
3458 instrument_id,
3459 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3460 AggregationSource::External,
3461 );
3462 let bar = Bar::new(
3463 bar_type,
3464 Price::from("1.00000"),
3465 Price::from("1.00020"),
3466 Price::from("0.99990"),
3467 Price::from("1.00010"),
3468 Quantity::from("150000"),
3469 UnixNanos::from(16),
3470 UnixNanos::from(17),
3471 );
3472 let reader = reader_with_entries(
3473 "run-data-response-replay",
3474 &[
3475 append_serde_payload(
3476 1,
3477 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
3478 &InstrumentResponse::new(
3479 UUID4::new(),
3480 client_id,
3481 instrument_id,
3482 instrument.clone(),
3483 None,
3484 None,
3485 UnixNanos::from(1),
3486 None,
3487 ),
3488 ),
3489 append_serde_payload(
3490 2,
3491 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3492 &InstrumentsResponse::new(
3493 UUID4::new(),
3494 client_id,
3495 instrument_id.venue,
3496 vec![instrument],
3497 None,
3498 None,
3499 UnixNanos::from(2),
3500 None,
3501 ),
3502 ),
3503 append_serde_payload(
3504 3,
3505 PAYLOAD_TYPE_QUOTES_RESPONSE,
3506 &QuotesResponse::new(
3507 UUID4::new(),
3508 client_id,
3509 instrument_id,
3510 vec![quote],
3511 None,
3512 None,
3513 UnixNanos::from(3),
3514 None,
3515 ),
3516 ),
3517 append_serde_payload(
3518 4,
3519 PAYLOAD_TYPE_TRADES_RESPONSE,
3520 &TradesResponse::new(
3521 UUID4::new(),
3522 client_id,
3523 instrument_id,
3524 vec![trade],
3525 None,
3526 None,
3527 UnixNanos::from(4),
3528 None,
3529 ),
3530 ),
3531 append_serde_payload(
3532 5,
3533 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3534 &FundingRatesResponse::new(
3535 UUID4::new(),
3536 client_id,
3537 instrument_id,
3538 vec![funding_rate],
3539 None,
3540 None,
3541 UnixNanos::from(5),
3542 None,
3543 ),
3544 ),
3545 append_serde_payload(
3546 6,
3547 PAYLOAD_TYPE_BARS_RESPONSE,
3548 &BarsResponse::new(
3549 UUID4::new(),
3550 client_id,
3551 bar_type,
3552 vec![bar],
3553 None,
3554 None,
3555 UnixNanos::from(6),
3556 None,
3557 ),
3558 ),
3559 ],
3560 );
3561 let mut cache = Cache::default();
3562
3563 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3564
3565 assert_eq!(report.applied_entries, 6);
3566 assert_eq!(report.ignored_entries, 0);
3567 assert_eq!(
3568 cache.instrument(&instrument_id).map(Instrument::id),
3569 Some(instrument_id)
3570 );
3571 assert_eq!(cache.quotes(&instrument_id), Some(vec![quote]));
3572 assert_eq!(cache.trades(&instrument_id), Some(vec![trade]));
3573 assert_eq!(
3574 cache.funding_rates(&instrument_id),
3575 Some(vec![funding_rate])
3576 );
3577 assert_eq!(cache.bars(&bar_type), Some(vec![bar]));
3578 }
3579
3580 #[rstest]
3581 fn empty_data_response_replay_is_noop() {
3582 let instrument_id = InstrumentAny::CurrencyPair(audusd_sim()).id();
3583 let client_id = ClientId::from("DATA");
3584 let bar_type = BarType::new(
3585 instrument_id,
3586 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3587 AggregationSource::External,
3588 );
3589 let reader = reader_with_entries(
3590 "run-empty-data-response-replay",
3591 &[
3592 append_serde_payload(
3593 1,
3594 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3595 &InstrumentsResponse::new(
3596 UUID4::new(),
3597 client_id,
3598 instrument_id.venue,
3599 Vec::new(),
3600 None,
3601 None,
3602 UnixNanos::from(1),
3603 None,
3604 ),
3605 ),
3606 append_serde_payload(
3607 2,
3608 PAYLOAD_TYPE_QUOTES_RESPONSE,
3609 &QuotesResponse::new(
3610 UUID4::new(),
3611 client_id,
3612 instrument_id,
3613 Vec::new(),
3614 None,
3615 None,
3616 UnixNanos::from(2),
3617 None,
3618 ),
3619 ),
3620 append_serde_payload(
3621 3,
3622 PAYLOAD_TYPE_TRADES_RESPONSE,
3623 &TradesResponse::new(
3624 UUID4::new(),
3625 client_id,
3626 instrument_id,
3627 Vec::new(),
3628 None,
3629 None,
3630 UnixNanos::from(3),
3631 None,
3632 ),
3633 ),
3634 append_serde_payload(
3635 4,
3636 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3637 &FundingRatesResponse::new(
3638 UUID4::new(),
3639 client_id,
3640 instrument_id,
3641 Vec::new(),
3642 None,
3643 None,
3644 UnixNanos::from(4),
3645 None,
3646 ),
3647 ),
3648 append_serde_payload(
3649 5,
3650 PAYLOAD_TYPE_BARS_RESPONSE,
3651 &BarsResponse::new(
3652 UUID4::new(),
3653 client_id,
3654 bar_type,
3655 Vec::new(),
3656 None,
3657 None,
3658 UnixNanos::from(5),
3659 None,
3660 ),
3661 ),
3662 ],
3663 );
3664 let mut cache = Cache::default();
3665
3666 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3667
3668 assert_eq!(report.applied_entries, 5);
3669 assert_eq!(report.ignored_entries, 0);
3670 assert!(cache.instrument(&instrument_id).is_none());
3671 assert_eq!(cache.quotes(&instrument_id), None);
3672 assert_eq!(cache.trades(&instrument_id), None);
3673 assert_eq!(cache.funding_rates(&instrument_id), None);
3674 assert_eq!(cache.bars(&bar_type), None);
3675 }
3676
3677 #[rstest]
3678 fn order_fill_replay_updates_order_and_creates_position() {
3679 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3680 let position_id = PositionId::from("P-001");
3681 let initialized = OrderInitializedSpec::builder()
3682 .instrument_id(instrument.id())
3683 .build();
3684 let client_order_id = initialized.client_order_id;
3685 let submitted = OrderSubmittedSpec::builder()
3686 .instrument_id(instrument.id())
3687 .client_order_id(client_order_id)
3688 .build();
3689 let accepted = OrderAcceptedSpec::builder()
3690 .instrument_id(instrument.id())
3691 .client_order_id(client_order_id)
3692 .account_id(submitted.account_id)
3693 .build();
3694 let filled = OrderFilledSpec::builder()
3695 .instrument_id(instrument.id())
3696 .client_order_id(client_order_id)
3697 .venue_order_id(accepted.venue_order_id)
3698 .account_id(submitted.account_id)
3699 .position_id(position_id)
3700 .commission(Money::from("1 USD"))
3701 .build();
3702 let filled_event = OrderEventAny::Filled(filled.clone());
3703 let reader = reader_with_entries(
3704 "run-order-replay",
3705 &[
3706 append_order_event(1, &OrderEventAny::Initialized(initialized)),
3707 append_order_event(2, &OrderEventAny::Submitted(submitted)),
3708 append_order_event(3, &OrderEventAny::Accepted(accepted)),
3709 append_order_event(4, &filled_event),
3710 ],
3711 );
3712 let mut cache = Cache::default();
3713 cache.add_instrument(instrument).expect("add instrument");
3714
3715 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3716 let order = cache.order_owned(&client_order_id).expect("order replayed");
3717 let position = cache
3718 .position_owned(&position_id)
3719 .expect("position replayed");
3720
3721 assert_eq!(report.applied_entries, 4);
3722 assert_eq!(report.ignored_entries, 0);
3723 assert_eq!(order.status(), OrderStatus::Filled);
3724 assert_eq!(order.event_count(), 4);
3725 assert_eq!(order.last_event(), &filled_event);
3726 assert_eq!(position.event_count(), 1);
3727 assert_eq!(position.last_event(), Some(filled.clone()));
3728 assert_eq!(position.trade_ids(), vec![filled.trade_id]);
3729 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
3730 }
3731
3732 #[rstest]
3733 fn orderless_leg_fill_replay_creates_position_without_order_mapping() {
3734 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3735 let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3736 let position_id = PositionId::from("P-ORDERLESS-LEG");
3737 let filled = OrderFilledSpec::builder()
3738 .instrument_id(instrument.id())
3739 .client_order_id(client_order_id)
3740 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-AUDUSD"))
3741 .position_id(position_id)
3742 .commission(Money::from("1 USD"))
3743 .build();
3744 let reader = reader_with_entries(
3745 "run-orderless-leg-fill-replay",
3746 &[append_order_event(
3747 1,
3748 &OrderEventAny::Filled(filled.clone()),
3749 )],
3750 );
3751 let mut cache = Cache::default();
3752 cache.add_instrument(instrument).expect("add instrument");
3753
3754 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3755 let position = cache
3756 .position_owned(&position_id)
3757 .expect("orderless leg position replayed");
3758
3759 assert_eq!(report.applied_entries, 1);
3760 assert_eq!(report.ignored_entries, 0);
3761 assert!(cache.order_owned(&client_order_id).is_none());
3762 assert_eq!(cache.position_id(&client_order_id), None);
3763 assert_eq!(position.event_count(), 1);
3764 assert_eq!(position.last_event(), Some(filled.clone()));
3765 assert_eq!(position.trade_ids(), vec![filled.trade_id]);
3766 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
3767 assert!(cache.check_integrity());
3768 }
3769
3770 #[rstest]
3771 fn orderless_netting_reopen_replay_does_not_treat_closed_position_as_flip() {
3772 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3773 let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3774 let position_id = PositionId::from("P-ORDERLESS-NETTING");
3775 let opening_fill = OrderFilledSpec::builder()
3776 .instrument_id(instrument.id())
3777 .client_order_id(client_order_id)
3778 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-1"))
3779 .trade_id(TradeId::from("T-SPREAD-LEG-1"))
3780 .order_side(OrderSide::Buy)
3781 .last_qty(Quantity::from(1))
3782 .position_id(position_id)
3783 .build();
3784 let closing_fill = OrderFilledSpec::builder()
3785 .instrument_id(instrument.id())
3786 .client_order_id(client_order_id)
3787 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-2"))
3788 .trade_id(TradeId::from("T-SPREAD-LEG-2"))
3789 .order_side(OrderSide::Sell)
3790 .last_qty(Quantity::from(1))
3791 .position_id(position_id)
3792 .build();
3793 let mut closed_position = Position::new(&instrument, opening_fill);
3794 closed_position.apply(&closing_fill);
3795 assert!(closed_position.is_closed());
3796
3797 let reopening_fill = OrderFilledSpec::builder()
3798 .instrument_id(instrument.id())
3799 .client_order_id(client_order_id)
3800 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-3"))
3801 .trade_id(TradeId::from("T-SPREAD-LEG-3"))
3802 .order_side(OrderSide::Sell)
3803 .last_qty(Quantity::from(1))
3804 .position_id(position_id)
3805 .build();
3806 let mut reopened_position = closed_position.clone();
3807 reopened_position.apply(&reopening_fill);
3808 let reopened = PositionOpened::create(
3809 &reopened_position,
3810 &reopening_fill,
3811 UUID4::new(),
3812 reopening_fill.ts_init,
3813 );
3814 let reader = reader_with_entries(
3815 "run-orderless-netting-reopen-replay",
3816 &[
3817 append_order_event(1, &OrderEventAny::Filled(reopening_fill.clone())),
3818 append_position_event(2, &PositionEvent::PositionOpened(reopened)),
3819 ],
3820 );
3821 let mut cache = Cache::default();
3822 cache.add_instrument(instrument).expect("add instrument");
3823 cache
3824 .add_position_without_order(&closed_position, OmsType::Netting)
3825 .expect("seed closed orderless position");
3826
3827 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3828 let position = cache
3829 .position_owned(&position_id)
3830 .expect("netting position reopened");
3831
3832 assert_eq!(report.applied_entries, 2);
3833 assert_eq!(report.ignored_entries, 0);
3834 assert!(position.is_open());
3835 assert_eq!(position.side, PositionSide::Short);
3836 assert_eq!(position.entry, OrderSide::Sell);
3837 assert_eq!(position.quantity, Quantity::from(1));
3838 assert_eq!(position.opening_order_id, client_order_id);
3839 assert_eq!(position.closing_order_id, None);
3840 assert_eq!(position.event_count(), 1);
3841 assert_eq!(position.trade_ids(), vec![reopening_fill.trade_id]);
3842 assert_eq!(position.last_event(), Some(reopening_fill));
3843 assert_eq!(cache.oms_type(&position_id), Some(OmsType::Netting));
3844 assert!(cache.orders_for_position(&position_id).is_empty());
3845 assert_eq!(cache.position_id(&client_order_id), None);
3846 assert!(cache.check_integrity());
3847 }
3848
3849 #[rstest]
3850 fn orderless_hedging_flip_replay_recreates_replacement_position() {
3851 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3852 let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3853 let first_position_id = PositionId::from("P-ORDERLESS-LEG-1");
3854 let replacement_position_id = PositionId::from("P-ORDERLESS-LEG-2");
3855 let opening_fill = OrderFilledSpec::builder()
3856 .instrument_id(instrument.id())
3857 .client_order_id(client_order_id)
3858 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-1"))
3859 .trade_id(TradeId::from("T-SPREAD-LEG-1"))
3860 .order_side(OrderSide::Buy)
3861 .last_qty(Quantity::from(1))
3862 .last_px(Price::from("1.00000"))
3863 .position_id(first_position_id)
3864 .commission(Money::from("1 USD"))
3865 .build();
3866 let first_position = Position::new(&instrument, opening_fill.clone());
3867 let first_opened = PositionOpened::create(
3868 &first_position,
3869 &opening_fill,
3870 UUID4::new(),
3871 opening_fill.ts_init,
3872 );
3873
3874 let flip_fill = OrderFilledSpec::builder()
3875 .instrument_id(instrument.id())
3876 .client_order_id(client_order_id)
3877 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-2"))
3878 .trade_id(TradeId::from("T-SPREAD-LEG-2"))
3879 .order_side(OrderSide::Sell)
3880 .last_qty(Quantity::from(2))
3881 .last_px(Price::from("1.10000"))
3882 .position_id(first_position_id)
3883 .commission(Money::from("2 USD"))
3884 .build();
3885 let mut closing_fragment = flip_fill.clone();
3886 closing_fragment.last_qty = Quantity::from(1);
3887 closing_fragment.commission = Some(Money::from("1 USD"));
3888 let mut closed_position = first_position;
3889 closed_position.apply(&closing_fragment);
3890 let first_closed = PositionClosed::create(
3891 &closed_position,
3892 &closing_fragment,
3893 UUID4::new(),
3894 flip_fill.ts_init,
3895 );
3896
3897 let mut opening_fragment = flip_fill.clone();
3898 opening_fragment.last_qty = Quantity::from(1);
3899 opening_fragment.position_id = Some(replacement_position_id);
3900 opening_fragment.commission = Some(Money::from("1 USD"));
3901 opening_fragment.event_id = UUID4::new();
3902 opening_fragment.causation_id = Some(flip_fill.event_id);
3903 let mut replacement_position = Position::new(&instrument, opening_fragment.clone());
3904 let replacement_opened = PositionOpened::create(
3905 &replacement_position,
3906 &opening_fragment,
3907 UUID4::new(),
3908 opening_fragment.ts_init,
3909 );
3910
3911 let subsequent_fill = OrderFilledSpec::builder()
3912 .instrument_id(instrument.id())
3913 .client_order_id(client_order_id)
3914 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-3"))
3915 .trade_id(TradeId::from("T-SPREAD-LEG-3"))
3916 .order_side(OrderSide::Sell)
3917 .last_qty(Quantity::from(1))
3918 .last_px(Price::from("1.20000"))
3919 .position_id(replacement_position_id)
3920 .commission(Money::from("1 USD"))
3921 .build();
3922 replacement_position.apply(&subsequent_fill);
3923 let replacement_changed = PositionChanged::create(
3924 &replacement_position,
3925 &subsequent_fill,
3926 UUID4::new(),
3927 subsequent_fill.ts_init,
3928 );
3929 let reader = reader_with_entries(
3930 "run-orderless-hedging-flip-replay",
3931 &[
3932 append_order_event(1, &OrderEventAny::Filled(opening_fill)),
3933 append_position_event(2, &PositionEvent::PositionOpened(first_opened)),
3934 append_order_event(3, &OrderEventAny::Filled(flip_fill.clone())),
3935 append_position_event(4, &PositionEvent::PositionClosed(first_closed)),
3936 append_position_event(5, &PositionEvent::PositionOpened(replacement_opened)),
3937 append_order_event(6, &OrderEventAny::Filled(subsequent_fill.clone())),
3938 append_position_event(7, &PositionEvent::PositionChanged(replacement_changed)),
3939 ],
3940 );
3941 let mut cache = Cache::default();
3942 cache.add_instrument(instrument).expect("add instrument");
3943
3944 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3945
3946 assert_eq!(report.applied_entries, 7);
3947 assert_eq!(report.ignored_entries, 0);
3948 let closed = cache
3949 .position_owned(&first_position_id)
3950 .expect("closed predecessor replayed");
3951 assert!(closed.is_closed());
3952 assert_eq!(closed.event_count(), 2);
3953 let closing_fragments = closed.fill_fragments(client_order_id, flip_fill.trade_id);
3954 assert_eq!(closing_fragments.len(), 1);
3955 assert_eq!(closing_fragments[0].last_qty, Quantity::from(1));
3956 assert_eq!(closing_fragments[0].commission, Some(Money::from("1 USD")));
3957 assert_eq!(closing_fragments[0].event_id, flip_fill.event_id);
3958
3959 let replacement = cache
3960 .position_owned(&replacement_position_id)
3961 .expect("open replacement replayed");
3962 assert!(replacement.is_open());
3963 assert_eq!(replacement.side, PositionSide::Short);
3964 assert_eq!(replacement.quantity, Quantity::from(2));
3965 assert_eq!(replacement.event_count(), 2);
3966 assert_eq!(
3967 cache.oms_type(&replacement_position_id),
3968 Some(OmsType::Hedging)
3969 );
3970 assert!(replacement.trade_ids().contains(&flip_fill.trade_id));
3971 assert!(replacement.trade_ids().contains(&subsequent_fill.trade_id));
3972 let opening_fragments = replacement.fill_fragments(client_order_id, flip_fill.trade_id);
3973 assert_eq!(opening_fragments.len(), 1);
3974 assert_eq!(opening_fragments[0].last_qty, Quantity::from(1));
3975 assert_eq!(opening_fragments[0].commission, Some(Money::from("1 USD")));
3976 assert_eq!(opening_fragments[0].causation_id, Some(flip_fill.event_id));
3977
3978 assert!(cache.orders_for_position(&first_position_id).is_empty());
3979 assert!(
3980 cache
3981 .orders_for_position(&replacement_position_id)
3982 .is_empty()
3983 );
3984 assert_eq!(cache.position_id(&client_order_id), None);
3985 assert!(cache.check_integrity());
3986 }
3987
3988 #[rstest]
3989 fn single_entry_orderless_flip_is_rejected_before_mutating_position() {
3990 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3991 let position_id = PositionId::from("P-ORDERLESS-SINGLE-ENTRY");
3992 let opening_fill = OrderFilledSpec::builder()
3993 .instrument_id(instrument.id())
3994 .client_order_id(ClientOrderId::from("SPREAD-LEG-SINGLE"))
3995 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-SINGLE-1"))
3996 .trade_id(TradeId::from("T-SPREAD-LEG-SINGLE-1"))
3997 .order_side(OrderSide::Buy)
3998 .last_qty(Quantity::from(1))
3999 .position_id(position_id)
4000 .build();
4001 let original = Position::new(&instrument, opening_fill.clone());
4002 let flip_fill = OrderFilledSpec::builder()
4003 .instrument_id(instrument.id())
4004 .client_order_id(opening_fill.client_order_id)
4005 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-SINGLE-2"))
4006 .trade_id(TradeId::from("T-SPREAD-LEG-SINGLE-2"))
4007 .order_side(OrderSide::Sell)
4008 .last_qty(Quantity::from(2))
4009 .position_id(position_id)
4010 .build();
4011 let entry = append_order_event(1, &OrderEventAny::Filled(flip_fill)).entry;
4012 let mut cache = Cache::default();
4013 cache
4014 .add_instrument(instrument)
4015 .expect("add replay instrument");
4016 cache
4017 .add_position_without_order(&original, OmsType::Hedging)
4018 .expect("seed orderless position");
4019
4020 let error = apply_cache_replay_entry(&mut cache, &entry)
4021 .expect_err("single-entry API cannot defer the opening fragment");
4022 let after = cache
4023 .position_owned(&position_id)
4024 .expect("position retained");
4025
4026 assert!(error.to_string().contains("snapshot-tail replay context"));
4027 assert_eq!(after.side, original.side);
4028 assert_eq!(after.quantity, original.quantity);
4029 assert_eq!(after.event_count(), original.event_count());
4030 assert_eq!(after.trade_ids(), original.trade_ids());
4031 }
4032
4033 #[rstest]
4034 fn order_fill_replay_without_instrument_counts_fill_as_ignored() {
4035 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4038 let position_id = PositionId::from("P-NO-INSTR");
4039 let initialized = OrderInitializedSpec::builder()
4040 .instrument_id(instrument.id())
4041 .build();
4042 let client_order_id = initialized.client_order_id;
4043 let submitted = OrderSubmittedSpec::builder()
4044 .instrument_id(instrument.id())
4045 .client_order_id(client_order_id)
4046 .build();
4047 let accepted = OrderAcceptedSpec::builder()
4048 .instrument_id(instrument.id())
4049 .client_order_id(client_order_id)
4050 .account_id(submitted.account_id)
4051 .build();
4052 let filled = OrderFilledSpec::builder()
4053 .instrument_id(instrument.id())
4054 .client_order_id(client_order_id)
4055 .venue_order_id(accepted.venue_order_id)
4056 .account_id(submitted.account_id)
4057 .position_id(position_id)
4058 .build();
4059 let reader = reader_with_entries(
4060 "run-fill-no-instrument",
4061 &[
4062 append_order_event(1, &OrderEventAny::Initialized(initialized)),
4063 append_order_event(2, &OrderEventAny::Submitted(submitted)),
4064 append_order_event(3, &OrderEventAny::Accepted(accepted)),
4065 append_order_event(4, &OrderEventAny::Filled(filled)),
4066 ],
4067 );
4068 let mut cache = Cache::default();
4069
4070 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4071
4072 assert_eq!(report.applied_entries, 3);
4073 assert_eq!(report.ignored_entries, 1);
4074 assert!(cache.position_owned(&position_id).is_none());
4075 }
4076
4077 #[rstest]
4078 fn order_fill_void_replay_updates_order_and_position() {
4079 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4080 let position_id = PositionId::from("P-VOID-001");
4081 let initialized = OrderInitializedSpec::builder()
4082 .instrument_id(instrument.id())
4083 .build();
4084 let client_order_id = initialized.client_order_id;
4085 let submitted = OrderSubmittedSpec::builder()
4086 .instrument_id(instrument.id())
4087 .client_order_id(client_order_id)
4088 .build();
4089 let accepted = OrderAcceptedSpec::builder()
4090 .instrument_id(instrument.id())
4091 .client_order_id(client_order_id)
4092 .account_id(submitted.account_id)
4093 .build();
4094 let filled = OrderFilledSpec::builder()
4095 .instrument_id(instrument.id())
4096 .client_order_id(client_order_id)
4097 .venue_order_id(accepted.venue_order_id)
4098 .account_id(submitted.account_id)
4099 .position_id(position_id)
4100 .commission(Money::from("1 USD"))
4101 .build();
4102 let fill_voided = OrderFillVoidedSpec::builder()
4103 .trader_id(filled.trader_id)
4104 .strategy_id(filled.strategy_id)
4105 .instrument_id(filled.instrument_id)
4106 .client_order_id(filled.client_order_id)
4107 .venue_order_id(filled.venue_order_id)
4108 .account_id(filled.account_id)
4109 .trade_id(filled.trade_id)
4110 .voided_qty(Quantity::from(50_000))
4111 .commission_voided(Money::from("0.40 USD"))
4112 .order_side(filled.order_side)
4113 .order_type(filled.order_type)
4114 .last_px(filled.last_px)
4115 .currency(filled.currency)
4116 .liquidity_side(filled.liquidity_side)
4117 .position_id(position_id)
4118 .is_reopened(true)
4119 .build();
4120 let reader = reader_with_entries(
4121 "run-fill-void-replay",
4122 &[
4123 append_order_event(1, &OrderEventAny::Initialized(initialized)),
4124 append_order_event(2, &OrderEventAny::Submitted(submitted)),
4125 append_order_event(3, &OrderEventAny::Accepted(accepted)),
4126 append_order_event(4, &OrderEventAny::Filled(filled)),
4127 append_order_event(5, &OrderEventAny::FillVoided(fill_voided.clone())),
4128 ],
4129 );
4130 let mut cache = Cache::default();
4131 cache.add_instrument(instrument).expect("add instrument");
4132
4133 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4134 let order = cache.order_owned(&client_order_id).expect("order replayed");
4135 let position = cache
4136 .position_owned(&position_id)
4137 .expect("position replayed");
4138
4139 assert_eq!(report.applied_entries, 5);
4140 assert_eq!(report.ignored_entries, 0);
4141 assert_eq!(order.status(), OrderStatus::PartiallyFilled);
4142 assert_eq!(order.filled_qty(), Quantity::from(50_000));
4143 assert_eq!(order.voided_qty(), Quantity::from(50_000));
4144 assert_eq!(position.quantity, Quantity::from(50_000));
4145 assert_eq!(position.commissions(), vec![Money::from("0.60 USD")]);
4146 assert_eq!(position.fill_voids.len(), 1);
4147 assert_eq!(position.fill_voids[0].event, fill_voided);
4148 }
4149
4150 #[rstest]
4151 fn order_fill_void_replay_updates_order_without_position() {
4152 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4153 let initialized = OrderInitializedSpec::builder()
4154 .instrument_id(instrument.id())
4155 .build();
4156 let client_order_id = initialized.client_order_id;
4157 let submitted = OrderSubmittedSpec::builder()
4158 .instrument_id(instrument.id())
4159 .client_order_id(client_order_id)
4160 .build();
4161 let accepted = OrderAcceptedSpec::builder()
4162 .instrument_id(instrument.id())
4163 .client_order_id(client_order_id)
4164 .account_id(submitted.account_id)
4165 .build();
4166 let filled = OrderFilledSpec::builder()
4167 .instrument_id(instrument.id())
4168 .client_order_id(client_order_id)
4169 .venue_order_id(accepted.venue_order_id)
4170 .account_id(submitted.account_id)
4171 .build();
4172 let fill_voided = OrderFillVoidedSpec::builder()
4173 .trader_id(filled.trader_id)
4174 .strategy_id(filled.strategy_id)
4175 .instrument_id(filled.instrument_id)
4176 .client_order_id(filled.client_order_id)
4177 .venue_order_id(filled.venue_order_id)
4178 .account_id(filled.account_id)
4179 .trade_id(filled.trade_id)
4180 .voided_qty(Quantity::from(50_000))
4181 .order_side(filled.order_side)
4182 .order_type(filled.order_type)
4183 .last_px(filled.last_px)
4184 .currency(filled.currency)
4185 .liquidity_side(filled.liquidity_side)
4186 .is_reopened(true)
4187 .build();
4188 let reader = reader_with_entries(
4189 "run-order-only-fill-void-replay",
4190 &[
4191 append_order_event(1, &OrderEventAny::Initialized(initialized)),
4192 append_order_event(2, &OrderEventAny::Submitted(submitted)),
4193 append_order_event(3, &OrderEventAny::Accepted(accepted)),
4194 append_order_event(4, &OrderEventAny::Filled(filled)),
4195 append_order_event(5, &OrderEventAny::FillVoided(fill_voided)),
4196 ],
4197 );
4198 let mut cache = Cache::default();
4199 cache.add_instrument(instrument).expect("add instrument");
4200
4201 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4202 let order = cache.order_owned(&client_order_id).expect("order replayed");
4203
4204 assert_eq!(report.applied_entries, 5);
4205 assert_eq!(report.ignored_entries, 0);
4206 assert_eq!(order.status(), OrderStatus::PartiallyFilled);
4207 assert_eq!(order.filled_qty(), Quantity::from(50_000));
4208 assert_eq!(order.voided_qty(), Quantity::from(50_000));
4209 assert_eq!(cache.positions_total_count(None, None, None, None, None), 0);
4210 }
4211
4212 #[rstest]
4213 fn position_lifecycle_replay_updates_existing_position() {
4214 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4215 let position_id = PositionId::from("P-001");
4216 let opened_fill = OrderFilledSpec::builder()
4217 .instrument_id(instrument.id())
4218 .client_order_id(ClientOrderId::from("O-OPEN"))
4219 .venue_order_id(VenueOrderId::from("V-OPEN"))
4220 .trade_id(TradeId::from("T-OPEN"))
4221 .position_id(position_id)
4222 .last_qty(Quantity::from("1"))
4223 .last_px(Price::from("1.00000"))
4224 .build();
4225 let mut live_position = Position::new(&instrument, opened_fill.clone());
4226 let opened = PositionOpened::create(
4227 &live_position,
4228 &opened_fill,
4229 UUID4::new(),
4230 UnixNanos::from(10),
4231 );
4232
4233 let changed_fill = OrderFilledSpec::builder()
4234 .instrument_id(instrument.id())
4235 .client_order_id(ClientOrderId::from("O-CHANGE"))
4236 .venue_order_id(VenueOrderId::from("V-CHANGE"))
4237 .trade_id(TradeId::from("T-CHANGE"))
4238 .position_id(position_id)
4239 .last_qty(Quantity::from("2"))
4240 .last_px(Price::from("1.10000"))
4241 .build();
4242 live_position.apply(&changed_fill);
4243 let changed = PositionChanged::create(
4244 &live_position,
4245 &changed_fill,
4246 UUID4::new(),
4247 UnixNanos::from(20),
4248 );
4249
4250 let closed_fill = OrderFilledSpec::builder()
4251 .instrument_id(instrument.id())
4252 .client_order_id(ClientOrderId::from("O-CLOSE"))
4253 .venue_order_id(VenueOrderId::from("V-CLOSE"))
4254 .trade_id(TradeId::from("T-CLOSE"))
4255 .order_side(OrderSide::Sell)
4256 .position_id(position_id)
4257 .last_qty(Quantity::from("3"))
4258 .last_px(Price::from("1.20000"))
4259 .build();
4260 live_position.apply(&closed_fill);
4261 let closed = PositionClosed::create(
4262 &live_position,
4263 &closed_fill,
4264 UUID4::new(),
4265 UnixNanos::from(30),
4266 );
4267
4268 let mut stale_position = Position::new(&instrument, opened_fill);
4269 stale_position.signed_qty = 9.0;
4270 stale_position.quantity = Quantity::from("9");
4271 let mut cache = Cache::default();
4272 cache
4273 .add_position(&stale_position, OmsType::Unspecified)
4274 .expect("seed stale position");
4275
4276 let opened_entry =
4277 append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
4278 let changed_entry =
4279 append_position_event(2, &PositionEvent::PositionChanged(changed.clone())).entry;
4280 let closed_entry =
4281 append_position_event(3, &PositionEvent::PositionClosed(closed.clone())).entry;
4282
4283 assert!(apply_cache_replay_entry(&mut cache, &opened_entry).expect("apply opened"));
4284 let replayed = cache
4285 .position_owned(&position_id)
4286 .expect("position after opened");
4287 assert_eq!(replayed.signed_qty.to_bits(), opened.signed_qty.to_bits());
4288 assert_eq!(replayed.quantity, opened.quantity);
4289 assert_eq!(replayed.ts_last, opened.ts_event);
4290
4291 assert!(apply_cache_replay_entry(&mut cache, &changed_entry).expect("apply changed"));
4292 let replayed = cache
4293 .position_owned(&position_id)
4294 .expect("position after changed");
4295 assert_eq!(replayed.signed_qty.to_bits(), changed.signed_qty.to_bits());
4296 assert_eq!(replayed.quantity, changed.quantity);
4297 assert_eq!(replayed.peak_qty, changed.peak_quantity);
4298 assert_eq!(
4299 replayed.avg_px_open.to_bits(),
4300 changed.avg_px_open.to_bits()
4301 );
4302 assert!(replayed.is_open());
4303
4304 assert!(apply_cache_replay_entry(&mut cache, &closed_entry).expect("apply closed"));
4305 let replayed = cache
4306 .position_owned(&position_id)
4307 .expect("position after closed");
4308 assert_eq!(replayed.signed_qty.to_bits(), closed.signed_qty.to_bits());
4309 assert_eq!(replayed.quantity, closed.quantity);
4310 assert_eq!(replayed.closing_order_id, closed.closing_order_id);
4311 assert_eq!(replayed.duration_ns, closed.duration);
4312 assert!(replayed.is_closed());
4313 assert!(cache.is_position_closed(&position_id));
4314 }
4315
4316 #[rstest]
4317 fn position_opened_replay_replaces_realized_pnl() {
4318 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4319 let position_id = PositionId::from("P-001");
4320 let fill = OrderFilledSpec::builder()
4321 .instrument_id(instrument.id())
4322 .position_id(position_id)
4323 .commission(Money::from("1 USD"))
4324 .build();
4325 let position = Position::new(&instrument, fill.clone());
4326 let mut opened =
4327 PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
4328 assert_eq!(opened.realized_pnl, Some(Money::from("-1 USD")));
4329
4330 let mut stale_position = position;
4331 stale_position.realized_pnl = Some(Money::from("9 USD"));
4332 let mut cache = Cache::default();
4333 cache
4334 .add_position(&stale_position, OmsType::Unspecified)
4335 .expect("seed stale position");
4336 let entry = append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
4337
4338 assert!(apply_cache_replay_entry(&mut cache, &entry).expect("apply opened"));
4339 assert_eq!(
4340 cache
4341 .position_owned(&position_id)
4342 .expect("position after opened")
4343 .realized_pnl,
4344 Some(Money::from("-1 USD")),
4345 );
4346
4347 opened.realized_pnl = None;
4348 let entry = append_position_event(2, &PositionEvent::PositionOpened(opened)).entry;
4349
4350 assert!(apply_cache_replay_entry(&mut cache, &entry).expect("apply opened without PnL"));
4351 assert_eq!(
4352 cache
4353 .position_owned(&position_id)
4354 .expect("position after opened without PnL")
4355 .realized_pnl,
4356 None,
4357 );
4358 }
4359
4360 #[rstest]
4361 fn position_adjustment_replay_updates_existing_position() {
4362 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4363 let position_id = PositionId::from("P-001");
4364 let fill = OrderFilledSpec::builder()
4365 .instrument_id(instrument.id())
4366 .position_id(position_id)
4367 .build();
4368 let position = Position::new(&instrument, fill.clone());
4369 let adjustment = PositionAdjusted::new(
4370 fill.trader_id,
4371 fill.strategy_id,
4372 fill.instrument_id,
4373 position_id,
4374 fill.account_id,
4375 PositionAdjustmentType::Funding,
4376 None,
4377 Some(Money::from("2 USD")),
4378 Some(Ustr::from("funding")),
4379 UUID4::new(),
4380 UnixNanos::from(10),
4381 UnixNanos::from(11),
4382 );
4383 let entry = append_position_event(1, &PositionEvent::PositionAdjusted(adjustment)).entry;
4384 let mut cache = Cache::default();
4385 cache
4386 .add_position(&position, OmsType::Unspecified)
4387 .expect("seed position");
4388
4389 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
4390 let position = cache
4391 .position_owned(&position_id)
4392 .expect("position updated");
4393
4394 assert!(applied);
4395 assert_eq!(position.adjustments, vec![adjustment]);
4396 assert_eq!(position.realized_pnl, Some(Money::from("2 USD")));
4397 assert_eq!(position.ts_last, adjustment.ts_event);
4398 }
4399
4400 #[rstest]
4401 fn position_event_for_unknown_position_is_counted_as_ignored() {
4402 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4403 let position_id = PositionId::from("P-MISSING");
4404 let fill = OrderFilledSpec::builder()
4405 .instrument_id(instrument.id())
4406 .position_id(position_id)
4407 .build();
4408 let position = Position::new(&instrument, fill.clone());
4409 let opened = PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
4410 let entry = append_position_event(1, &PositionEvent::PositionOpened(opened)).entry;
4411 let mut cache = Cache::default();
4412
4413 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
4414
4415 assert!(
4416 !applied,
4417 "missing position must count as ignored, not applied"
4418 );
4419 }
4420
4421 #[rstest]
4422 fn order_filled_with_no_order_side_is_an_apply_error_not_a_panic() {
4423 let payload = IndexMap::from([("order_side", "NO_ORDER_SIDE")]);
4427 let entry = append_serde_payload(1, PAYLOAD_TYPE_ORDER_FILLED, &payload).entry;
4428 let mut cache = Cache::default();
4429
4430 let err = apply_cache_replay_entry(&mut cache, &entry).expect_err("must reject");
4431
4432 match err {
4433 CacheReplayError::Apply { seq, message, .. } => {
4434 assert_eq!(seq, 1);
4435 assert!(message.contains("NoOrderSide"), "message was: {message}");
4436 }
4437 other => panic!("expected Apply, was {other:?}"),
4438 }
4439 }
4440
4441 #[rstest]
4442 fn duplicate_position_fill_is_not_applied_twice() {
4443 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4444 let position_id = PositionId::from("P-001");
4445 let fill = OrderFilledSpec::builder()
4446 .instrument_id(instrument.id())
4447 .position_id(position_id)
4448 .commission(Money::from("1 USD"))
4449 .build();
4450 let position = Position::new(&instrument, fill.clone());
4451 let entry = append_order_event(1, &OrderEventAny::Filled(fill.clone())).entry;
4452 let mut cache = Cache::default();
4453 cache
4454 .add_position(&position, OmsType::Unspecified)
4455 .expect("seed position");
4456
4457 let applied = apply_fill_to_position(&mut cache, &entry, &fill, false).expect("apply fill");
4458 let position = cache
4459 .position_owned(&position_id)
4460 .expect("position updated");
4461
4462 assert!(
4463 applied,
4464 "duplicate trade within an open episode is the idempotent no-op and counts as applied"
4465 );
4466 assert_eq!(position.event_count(), 1);
4467 assert_eq!(position.trade_ids(), vec![fill.trade_id]);
4468 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
4469 }
4470
4471 #[rstest]
4472 fn flat_position_with_reused_trade_id_is_ignored_like_live() {
4473 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4477 let position_id = PositionId::from("P-REOPEN");
4478 let open_fill = OrderFilledSpec::builder()
4479 .instrument_id(instrument.id())
4480 .position_id(position_id)
4481 .order_side(OrderSide::Buy)
4482 .trade_id(TradeId::from("T-1"))
4483 .commission(Money::from("2 USD"))
4484 .build();
4485 let close_fill = OrderFilledSpec::builder()
4486 .instrument_id(instrument.id())
4487 .position_id(position_id)
4488 .order_side(OrderSide::Sell)
4489 .trade_id(TradeId::from("T-2"))
4490 .build();
4491 let mut position = Position::new(&instrument, open_fill);
4492 position.apply(&close_fill);
4493 assert_eq!(position.side, PositionSide::Flat);
4494
4495 let dup_fill = OrderFilledSpec::builder()
4496 .instrument_id(instrument.id())
4497 .position_id(position_id)
4498 .order_side(OrderSide::Buy)
4499 .trade_id(TradeId::from("T-1"))
4500 .commission(Money::from("1 USD"))
4501 .build();
4502 let entry = append_order_event(3, &OrderEventAny::Filled(dup_fill.clone())).entry;
4503 let mut cache = Cache::default();
4504 cache
4505 .add_position(&position, OmsType::Unspecified)
4506 .expect("seed position");
4507
4508 let applied = apply_fill_to_position(&mut cache, &entry, &dup_fill, false).expect("apply");
4509 let position = cache
4510 .position_owned(&position_id)
4511 .expect("position updated");
4512
4513 assert!(
4514 applied,
4515 "a historical duplicate is the idempotent no-op and counts as applied"
4516 );
4517 assert_eq!(position.side, PositionSide::Flat);
4518 assert_eq!(position.event_count(), 2);
4519 assert_eq!(position.trade_ids().len(), 2);
4520 assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
4521 }
4522
4523 #[rstest]
4524 fn fill_for_missing_instrument_is_counted_as_ignored() {
4525 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4526 let position_id = PositionId::from("P-NO-INSTR");
4527 let fill = OrderFilledSpec::builder()
4528 .instrument_id(instrument.id())
4529 .position_id(position_id)
4530 .build();
4531 let entry = append_order_event(1, &OrderEventAny::Filled(fill.clone())).entry;
4532 let mut cache = Cache::default();
4533
4534 let applied = apply_fill_to_position(&mut cache, &entry, &fill, false).expect("apply");
4535
4536 assert!(
4537 !applied,
4538 "a position that cannot open must count as ignored, was claimed applied"
4539 );
4540 assert!(cache.position_owned(&position_id).is_none());
4541 }
4542
4543 #[rstest]
4544 fn corrupt_supported_payload_returns_decode_error() {
4545 let reader = reader_with_entries(
4546 "run-decode-error",
4547 &[append_payload(
4548 1,
4549 PAYLOAD_TYPE_ACCOUNT_STATE,
4550 Bytes::copy_from_slice(&[0xc1]),
4551 )],
4552 );
4553 let mut cache = Cache::default();
4554
4555 let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("decode error");
4556
4557 match err {
4558 CacheReplayError::Decode {
4559 seq, payload_type, ..
4560 } => {
4561 assert_eq!(seq, 1);
4562 assert_eq!(payload_type, PAYLOAD_TYPE_ACCOUNT_STATE);
4563 }
4564 other => panic!("expected Decode, was {other:?}"),
4565 }
4566 }
4567
4568 #[rstest]
4569 fn missing_order_event_returns_apply_error() {
4570 let submitted = OrderSubmittedSpec::builder().build();
4571 let reader = reader_with_entries(
4572 "run-apply-error",
4573 &[append_order_event(1, &OrderEventAny::Submitted(submitted))],
4574 );
4575 let mut cache = Cache::default();
4576
4577 let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("apply error");
4578
4579 match err {
4580 CacheReplayError::Apply {
4581 seq,
4582 payload_type,
4583 message,
4584 } => {
4585 assert_eq!(seq, 1);
4586 assert_eq!(payload_type, PAYLOAD_TYPE_ORDER_SUBMITTED);
4587 assert!(
4588 message.contains("not found"),
4589 "message should include cache apply failure: {message}",
4590 );
4591 }
4592 other => panic!("expected Apply, was {other:?}"),
4593 }
4594 }
4595
4596 #[rstest]
4597 fn restore_cache_from_sealed_run_restores_snapshot_and_tail() {
4598 let tmp = TempDir::new().expect("tempdir");
4599 let run_id = "sealed-replay";
4600 let instance_id = "trader-001";
4601 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4602 let fill = OrderFilledSpec::builder()
4603 .instrument_id(instrument.id())
4604 .position_id(PositionId::from("P-SEALED-REPLAY-1"))
4605 .build();
4606 let position = Position::new(&instrument, fill);
4607 let mut snapshot_cache = Cache::default();
4608 let snapshot_ref = snapshot_cache
4609 .snapshot_position_encoded(&position)
4610 .expect("snapshot position");
4611 let anchored_state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
4612 let replayed_state = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
4613
4614 {
4615 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4616 backend.open_run(manifest(run_id)).expect("open run");
4617 backend
4618 .append_batch(&[append_account_state(1, &anchored_state)])
4619 .expect("append anchored state");
4620 backend
4621 .record_snapshot_anchor(SnapshotAnchor::new(
4622 1,
4623 snapshot_ref.blob_ref.clone(),
4624 compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
4625 ))
4626 .expect("record snapshot anchor");
4627 backend
4628 .append_batch(&[append_account_state(2, &replayed_state)])
4629 .expect("append replay tail");
4630 backend.seal(RunStatus::Ended).expect("seal run");
4631 }
4632
4633 let mut cache = Cache::default();
4634 cache
4635 .add(&snapshot_ref.blob_ref, snapshot_ref.blob.clone())
4636 .expect("seed snapshot blob");
4637
4638 let report = restore_cache_from_sealed_run(
4639 &mut cache,
4640 tmp.path().to_path_buf(),
4641 instance_id,
4642 run_id,
4643 )
4644 .expect("restore sealed run");
4645
4646 let frames = cache
4647 .position_snapshot_bytes(&position.id)
4648 .expect("restored position snapshot");
4649 let account = cache
4650 .account_owned(&replayed_state.account_id)
4651 .expect("replayed account");
4652
4653 assert_eq!(report.manifest.run_id, run_id);
4654 assert_eq!(report.manifest.status, RunStatus::Ended);
4655 assert_eq!(report.cache.plan.from_seq, 2);
4656 assert_eq!(report.cache.applied_entries, 1);
4657 assert_eq!(report.cache.ignored_entries, 0);
4658 assert_eq!(frames.len(), 1);
4659 assert_eq!(frames[0].as_slice(), snapshot_ref.blob.as_ref());
4660 assert_eq!(account.events(), vec![replayed_state]);
4661 }
4662
4663 #[rstest]
4664 fn restore_cache_from_sealed_run_rejects_snapshot_hash_mismatch() {
4665 let tmp = TempDir::new().expect("tempdir");
4666 let run_id = "sealed-replay-bad-snapshot";
4667 let instance_id = "trader-001";
4668 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4669 let fill = OrderFilledSpec::builder()
4670 .instrument_id(instrument.id())
4671 .position_id(PositionId::from("P-SEALED-REPLAY-BAD-SNAPSHOT-1"))
4672 .build();
4673 let position = Position::new(&instrument, fill);
4674 let mut snapshot_cache = Cache::default();
4675 let snapshot_ref = snapshot_cache
4676 .snapshot_position_encoded(&position)
4677 .expect("snapshot position");
4678
4679 {
4680 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4681 backend.open_run(manifest(run_id)).expect("open run");
4682 backend
4683 .record_snapshot_anchor(SnapshotAnchor::new(
4684 0,
4685 snapshot_ref.blob_ref.clone(),
4686 compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
4687 ))
4688 .expect("record snapshot anchor");
4689 backend.seal(RunStatus::Ended).expect("seal run");
4690 }
4691
4692 let mut cache = Cache::default();
4693 cache
4694 .add(
4695 &snapshot_ref.blob_ref,
4696 Bytes::from_static(b"tampered snapshot"),
4697 )
4698 .expect("seed tampered snapshot blob");
4699
4700 let err = restore_cache_from_sealed_run(
4701 &mut cache,
4702 tmp.path().to_path_buf(),
4703 instance_id,
4704 run_id,
4705 )
4706 .expect_err("hash mismatch");
4707
4708 match err {
4709 CacheReplayError::SnapshotRestore { blob_ref, message } => {
4710 assert_eq!(blob_ref, snapshot_ref.blob_ref);
4711 assert!(
4712 message.contains("content_hash mismatch"),
4713 "message should explain hash mismatch: {message}",
4714 );
4715 }
4716 other => panic!("expected SnapshotRestore, was {other:?}"),
4717 }
4718 }
4719
4720 #[rstest]
4721 fn open_event_store_replay_source_rejects_running_run() {
4722 let tmp = TempDir::new().expect("tempdir");
4723 let run_id = "running-replay";
4724 {
4725 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4726 backend.open_run(manifest(run_id)).expect("open run");
4727 }
4728
4729 let err = open_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
4730 .expect_err("running source must fail");
4731
4732 assert!(
4733 err.to_string().contains("not sealed"),
4734 "error should name sealed-run requirement: {err}",
4735 );
4736 }
4737
4738 #[rstest]
4739 fn validate_event_store_replay_source_rejects_quarantined_run() {
4740 let tmp = TempDir::new().expect("tempdir");
4741 let run_id = "quarantined-replay";
4742 {
4743 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4744 backend.open_run(manifest(run_id)).expect("open run");
4745 backend
4746 .append_batch(&[append_payload(1, "RunStarted", Bytes::new())])
4747 .expect("append");
4748 backend.seal(RunStatus::Quarantined).expect("seal run");
4749 }
4750
4751 let err =
4752 validate_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
4753 .expect_err("quarantined source must fail");
4754
4755 assert!(
4756 err.to_string().contains("quarantined"),
4757 "error should reject quarantined replay sources: {err}",
4758 );
4759 }
4760}