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::{DurationNanos, 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_MODIFY_ORDER,
58 PAYLOAD_TYPE_OPTION_CHAIN_REFERENCE_PRICE_RESPONSE, PAYLOAD_TYPE_ORDER_STATUS_REPORT,
59 PAYLOAD_TYPE_ORDER_WITH_FILLS, PAYLOAD_TYPE_POSITION_STATUS_REPORT, PAYLOAD_TYPE_QUERY_ACCOUNT,
60 PAYLOAD_TYPE_QUERY_ORDER, PAYLOAD_TYPE_REQUEST_COMMAND, PAYLOAD_TYPE_SUBMIT_ORDER,
61 PAYLOAD_TYPE_SUBSCRIBE_COMMAND, 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_OPTION_CHAIN_REFERENCE_PRICE_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 <= position.quantity
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.non_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
1793 let magnitude = remaining_commission.abs().min(commission.abs());
1794
1795 let removed = if remaining_commission.is_negative() {
1796 -magnitude
1797 } else {
1798 magnitude
1799 };
1800
1801 allocations
1802 .entry(fragment.position_id)
1803 .and_modify(|allocation| {
1804 allocation.1 = Some(
1805 allocation
1806 .1
1807 .map_or(removed, |commission| commission + removed),
1808 );
1809 })
1810 .or_insert((
1811 Quantity::zero(fill_voided.voided_qty.precision),
1812 Some(removed),
1813 ));
1814 remaining_commission = remaining_commission - removed;
1815 }
1816
1817 if !remaining_commission.is_zero() {
1818 return Err(apply_error(
1819 entry,
1820 format!(
1821 "position fragments do not cover voided commission for fill {}",
1822 fill_voided.trade_id
1823 ),
1824 ));
1825 }
1826 }
1827 Ok(allocations)
1828}
1829
1830fn apply_position_opened(
1831 cache: &mut Cache,
1832 entry: &EventStoreEntry,
1833 opened: &PositionOpened,
1834) -> Result<bool, CacheReplayError> {
1835 let Some(mut position) = cache.position_owned(&opened.position_id) else {
1836 warn_position_skip(entry, opened.position_id);
1837 return Ok(false);
1838 };
1839
1840 position.trader_id = opened.trader_id;
1841 position.strategy_id = opened.strategy_id;
1842 position.instrument_id = opened.instrument_id;
1843 position.id = opened.position_id;
1844 position.account_id = opened.account_id;
1845 position.opening_order_id = opened.opening_order_id;
1846 position.closing_order_id = None;
1847 position.entry = opened.entry;
1848 position.side = opened.side;
1849 position.signed_qty = opened.signed_qty;
1850 position.quantity = opened.quantity;
1851 position.peak_qty = opened.quantity;
1852 position.quote_currency = opened.currency;
1853 position.ts_opened = opened.ts_event;
1854 position.ts_last = opened.ts_event;
1855 position.ts_closed = None;
1856 position.duration_ns = DurationNanos::default();
1857 position.avg_px_open = opened.avg_px_open;
1858 position.avg_px_close = None;
1859 position.realized_return = 0.0;
1860 position.realized_pnl = opened.realized_pnl;
1861
1862 apply_result(entry, cache.update_position(&position))?;
1863 Ok(true)
1864}
1865
1866fn apply_position_changed(
1867 cache: &mut Cache,
1868 entry: &EventStoreEntry,
1869 changed: &PositionChanged,
1870) -> Result<bool, CacheReplayError> {
1871 let Some(mut position) = cache.position_owned(&changed.position_id) else {
1872 warn_position_skip(entry, changed.position_id);
1873 return Ok(false);
1874 };
1875
1876 position.trader_id = changed.trader_id;
1877 position.strategy_id = changed.strategy_id;
1878 position.instrument_id = changed.instrument_id;
1879 position.id = changed.position_id;
1880 position.account_id = changed.account_id;
1881 position.opening_order_id = changed.opening_order_id;
1882 position.entry = changed.entry;
1883 position.side = changed.side;
1884 position.signed_qty = changed.signed_qty;
1885 position.quantity = changed.quantity;
1886 position.peak_qty = changed.peak_quantity;
1887 position.quote_currency = changed.currency;
1888 position.ts_opened = changed.ts_opened;
1889 position.ts_last = changed.ts_event;
1890 position.ts_closed = None;
1891 position.avg_px_open = changed.avg_px_open;
1892 position.avg_px_close = changed.avg_px_close;
1893 position.realized_return = changed.realized_return;
1894 position.realized_pnl = changed.realized_pnl;
1895
1896 apply_result(entry, cache.update_position(&position))?;
1897 Ok(true)
1898}
1899
1900fn apply_position_closed(
1901 cache: &mut Cache,
1902 entry: &EventStoreEntry,
1903 closed: &PositionClosed,
1904) -> Result<bool, CacheReplayError> {
1905 let Some(mut position) = cache.position_owned(&closed.position_id) else {
1906 warn_position_skip(entry, closed.position_id);
1907 return Ok(false);
1908 };
1909
1910 position.trader_id = closed.trader_id;
1911 position.strategy_id = closed.strategy_id;
1912 position.instrument_id = closed.instrument_id;
1913 position.id = closed.position_id;
1914 position.account_id = closed.account_id;
1915 position.opening_order_id = closed.opening_order_id;
1916 position.closing_order_id = closed.closing_order_id;
1917 position.entry = closed.entry;
1918 position.side = closed.side;
1919 position.signed_qty = closed.signed_qty;
1920 position.quantity = closed.quantity;
1921 position.peak_qty = closed.peak_quantity;
1922 position.quote_currency = closed.currency;
1923 position.ts_opened = closed.ts_opened;
1924 position.ts_last = closed.ts_event;
1925 position.ts_closed = closed.ts_closed;
1926 position.duration_ns = closed.duration;
1927 position.avg_px_open = closed.avg_px_open;
1928 position.avg_px_close = closed.avg_px_close;
1929 position.realized_return = closed.realized_return;
1930 position.realized_pnl = closed.realized_pnl;
1931
1932 apply_result(entry, cache.update_position(&position))?;
1933 Ok(true)
1934}
1935
1936fn apply_position_adjustment(
1937 cache: &mut Cache,
1938 entry: &EventStoreEntry,
1939 adjustment: PositionAdjusted,
1940) -> Result<bool, CacheReplayError> {
1941 let Some(mut position) = cache.position_owned(&adjustment.position_id) else {
1942 warn_position_skip(entry, adjustment.position_id);
1943 return Ok(false);
1944 };
1945
1946 position.apply_adjustment(adjustment);
1947 apply_result(entry, cache.update_position(&position))?;
1948 Ok(true)
1949}
1950
1951fn warn_position_skip(entry: &EventStoreEntry, position_id: PositionId) {
1954 log::warn!(
1955 "Replay seq {} skipped {}: position {position_id} not in cache",
1956 entry.seq,
1957 entry.payload_type,
1958 );
1959}
1960
1961fn decode_payload<T>(entry: &EventStoreEntry) -> Result<T, CacheReplayError>
1962where
1963 T: DeserializeOwned,
1964{
1965 rmp_serde::from_slice(&entry.payload).map_err(|e| CacheReplayError::Decode {
1966 seq: entry.seq,
1967 payload_type: entry.payload_type.to_string(),
1968 message: e.to_string(),
1969 })
1970}
1971
1972fn apply_result<T, E>(entry: &EventStoreEntry, result: Result<T, E>) -> Result<T, CacheReplayError>
1973where
1974 E: Display,
1975{
1976 result.map_err(|e| apply_error(entry, e))
1977}
1978
1979fn apply_error(entry: &EventStoreEntry, error: impl Display) -> CacheReplayError {
1980 CacheReplayError::Apply {
1981 seq: entry.seq,
1982 payload_type: entry.payload_type.to_string(),
1983 message: error.to_string(),
1984 }
1985}
1986
1987fn reject_quarantined_replay_source(
1988 run_id: &str,
1989 status: RunStatus,
1990) -> Result<(), CacheReplayError> {
1991 if matches!(status, RunStatus::Quarantined) {
1992 let error = EventStoreError::Backend(format!("replay source {run_id} is quarantined"));
1993 return Err(CacheReplayError::from(error));
1994 }
1995
1996 Ok(())
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001 use std::{any::Any, cell::Cell, rc::Rc};
2002
2003 use ahash::AHashSet;
2004 use bytes::Bytes;
2005 use indexmap::IndexMap;
2006 use nautilus_common::msgbus::{self, BusTap, Endpoint, MStr, Topic as BusTopic};
2007 use nautilus_core::{UUID4, UnixNanos};
2008 use nautilus_model::{
2009 accounts::AccountAny,
2010 data::{Bar, BarSpecification, BarType, FundingRateUpdate, QuoteTick, TradeTick},
2011 enums::{
2012 AggregationSource, AggressorSide, BarAggregation, OrderSide, OrderStatus,
2013 PositionAdjustmentType, PositionSide, PriceType,
2014 },
2015 events::{
2016 PositionEvent,
2017 account::stubs::{cash_account_state, cash_account_state_million_usd},
2018 order::spec::{
2019 OrderAcceptedSpec, OrderFillVoidedSpec, OrderFilledSpec, OrderInitializedSpec,
2020 OrderSubmittedSpec,
2021 },
2022 },
2023 identifiers::{
2024 AccountId, ClientId, ClientOrderId, InstrumentId, OrderListId, PositionId, TradeId,
2025 VenueOrderId,
2026 },
2027 instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
2028 orders::{Order, OrderList},
2029 types::{Currency, Money, Price, Quantity},
2030 };
2031 use rstest::rstest;
2032 use serde::Serialize;
2033 use tempfile::TempDir;
2034 use ustr::Ustr;
2035
2036 use super::*;
2037 use crate::{
2038 backend::{AppendEntry, MemoryBackend, RedbBackend},
2039 capture::{
2040 builtins::{
2041 DEFAULT_CAPTURE_PAYLOAD_TYPES, encode_order_event_any, encode_position_event,
2042 },
2043 encode_account_state,
2044 },
2045 entry::Topic as EntryTopic,
2046 hash::compute_entry_hash,
2047 headers::Headers,
2048 manifest::{RegisteredComponents, RunManifest, RunStatus},
2049 snapshot::SnapshotAnchor,
2050 };
2051
2052 fn manifest(run_id: &str) -> RunManifest {
2053 RunManifest {
2054 run_id: run_id.to_string(),
2055 parent_run_id: None,
2056 instance_id: "trader-001".to_string(),
2057 binary_hash: "deadbeef".to_string(),
2058 schema_version: 1,
2059 crate_versions: "feedface".to_string(),
2060 feature_flags: Vec::new(),
2061 adapter_versions: IndexMap::new(),
2062 config_hash: "cafebabe".to_string(),
2063 registered_components: RegisteredComponents::default(),
2064 seed: None,
2065 start_ts_init: UnixNanos::from(0),
2066 end_ts_init: None,
2067 high_watermark: 0,
2068 status: RunStatus::Running,
2069 }
2070 }
2071
2072 fn append_payload(seq: u64, payload_type: &str, payload: Bytes) -> AppendEntry {
2073 append_payload_with_ts(seq, seq, payload_type, payload)
2074 }
2075
2076 fn append_serde_payload<T: Serialize>(seq: u64, payload_type: &str, value: &T) -> AppendEntry {
2077 let payload = rmp_serde::to_vec_named(value).expect("encode replay payload");
2078 append_payload(seq, payload_type, Bytes::from(payload))
2079 }
2080
2081 fn append_payload_with_ts(
2082 seq: u64,
2083 ts_init: u64,
2084 payload_type: &str,
2085 payload: Bytes,
2086 ) -> AppendEntry {
2087 let topic = EntryTopic::from("events.account.SIM");
2088 let ts = UnixNanos::from(ts_init);
2089 let headers = Headers::empty();
2090 let hash = compute_entry_hash(
2091 seq,
2092 ts,
2093 ts,
2094 topic.as_ref(),
2095 payload_type,
2096 &payload,
2097 &headers,
2098 );
2099 let entry = EventStoreEntry::new(
2100 hash,
2101 seq,
2102 headers,
2103 topic,
2104 Ustr::from(payload_type),
2105 payload,
2106 ts,
2107 ts,
2108 );
2109 AppendEntry::without_indices(entry)
2110 }
2111
2112 fn append_account_state(seq: u64, state: &AccountState) -> AppendEntry {
2113 let encoded = encode_account_state(state).expect("encode account state");
2114 append_payload(seq, PAYLOAD_TYPE_ACCOUNT_STATE, encoded.payload)
2115 }
2116
2117 fn append_order_event(seq: u64, event: &OrderEventAny) -> AppendEntry {
2118 let encoded = encode_order_event_any(event).expect("encode order event");
2119 let payload_type = encoded.payload_type.expect("order payload type");
2120 append_payload(seq, payload_type.as_str(), encoded.payload)
2121 }
2122
2123 fn append_position_event(seq: u64, event: &PositionEvent) -> AppendEntry {
2124 let encoded = encode_position_event(event).expect("encode position event");
2125 let payload_type = encoded.payload_type.expect("position payload type");
2126 append_payload(seq, payload_type.as_str(), encoded.payload)
2127 }
2128
2129 fn reader_with_entries(
2130 run_id: &str,
2131 entries: &[AppendEntry],
2132 ) -> EventStoreReader<MemoryBackend> {
2133 let mut backend = MemoryBackend::new();
2134 backend.open_run(manifest(run_id)).expect("open");
2135 backend.append_batch(entries).expect("append");
2136 EventStoreReader::new(backend)
2137 }
2138
2139 fn reader_with_anchor(anchor_seq: u64) -> (EventStoreReader<MemoryBackend>, AccountState) {
2140 let anchored = cash_account_state();
2141 let replayed = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
2142 let mut backend = MemoryBackend::new();
2143 backend.open_run(manifest("run-replay")).expect("open");
2144 backend
2145 .append_batch(&[
2146 append_account_state(1, &anchored),
2147 append_account_state(2, &replayed),
2148 ])
2149 .expect("append");
2150 backend
2151 .record_snapshot_anchor(SnapshotAnchor::new(anchor_seq, "cache://account", "hash"))
2152 .expect("record anchor");
2153 (EventStoreReader::new(backend), replayed)
2154 }
2155
2156 fn catalog_quote_record(ts_init: u64) -> CatalogReplayRecord {
2157 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2158 CatalogReplayRecord::from_data(CatalogReplayData::Quote(QuoteTick::new(
2159 instrument_id,
2160 Price::from("1.0001"),
2161 Price::from("1.0002"),
2162 Quantity::from("100"),
2163 Quantity::from("100"),
2164 UnixNanos::from(ts_init),
2165 UnixNanos::from(ts_init),
2166 )))
2167 }
2168
2169 fn catalog_trade_record(ts_init: u64) -> CatalogReplayRecord {
2170 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2171 CatalogReplayRecord::from_data(CatalogReplayData::Trade(TradeTick::new(
2172 instrument_id,
2173 Price::from("1.0001"),
2174 Quantity::from("100"),
2175 AggressorSide::Buy,
2176 TradeId::from("T-1"),
2177 UnixNanos::from(ts_init),
2178 UnixNanos::from(ts_init),
2179 )))
2180 }
2181
2182 #[derive(Debug)]
2183 struct CountingTap {
2184 calls: Rc<Cell<usize>>,
2185 }
2186
2187 impl CountingTap {
2188 fn new(calls: Rc<Cell<usize>>) -> Self {
2189 Self { calls }
2190 }
2191
2192 fn increment(&self) {
2193 self.calls.set(self.calls.get() + 1);
2194 }
2195 }
2196
2197 impl BusTap for CountingTap {
2198 fn on_publish(&self, _topic: MStr<BusTopic>, _message: &dyn Any) {
2199 self.increment();
2200 }
2201
2202 fn on_send(&self, _endpoint: MStr<Endpoint>, _message: &dyn Any) {
2203 self.increment();
2204 }
2205 }
2206
2207 #[derive(Debug)]
2208 struct FakeReplayCatalog {
2209 coverage: CatalogSliceCoverage,
2210 records: Vec<CatalogReplayRecord>,
2211 plan_queries: Vec<CatalogSliceQuery>,
2212 load_plans: Vec<CatalogSlicePlan>,
2213 }
2214
2215 impl FakeReplayCatalog {
2216 fn new(coverage: CatalogSliceCoverage, records: Vec<CatalogReplayRecord>) -> Self {
2217 Self {
2218 coverage,
2219 records,
2220 plan_queries: Vec::new(),
2221 load_plans: Vec::new(),
2222 }
2223 }
2224 }
2225
2226 impl ReplayCatalog for FakeReplayCatalog {
2227 type Error = String;
2228
2229 fn plan_slice(
2230 &mut self,
2231 query: &CatalogSliceQuery,
2232 ) -> Result<CatalogSliceCoverage, Self::Error> {
2233 self.plan_queries.push(query.clone());
2234 Ok(self.coverage.clone())
2235 }
2236
2237 fn load_slice(
2238 &mut self,
2239 plan: &CatalogSlicePlan,
2240 ) -> Result<Vec<CatalogReplayRecord>, Self::Error> {
2241 self.load_plans.push(plan.clone());
2242 Ok(self.records.clone())
2243 }
2244 }
2245
2246 struct BusTapGuard;
2247
2248 impl Drop for BusTapGuard {
2249 fn drop(&mut self) {
2250 msgbus::clear_bus_tap();
2251 }
2252 }
2253
2254 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2255 enum CacheMutationRecoveryClass {
2256 SnapshotOwned,
2257 EventStoreCapturedAndReplayed,
2258 ForensicOnly,
2259 MissingLiveRecovery,
2260 }
2261
2262 #[derive(Clone, Copy, Debug)]
2263 struct CacheMutationCoverage {
2264 method: &'static str,
2265 class: CacheMutationRecoveryClass,
2266 payload_types: &'static [&'static str],
2267 }
2268
2269 const CACHE_MUTATION_COVERAGE: &[CacheMutationCoverage] = &[
2270 cache_mutation(
2271 "set_external_order_claims",
2274 CacheMutationRecoveryClass::MissingLiveRecovery,
2275 &[],
2276 ),
2277 cache_mutation(
2278 "register_external_order_claims",
2279 CacheMutationRecoveryClass::MissingLiveRecovery,
2280 &[],
2281 ),
2282 cache_mutation(
2283 "set_database",
2284 CacheMutationRecoveryClass::SnapshotOwned,
2285 &[],
2286 ),
2287 cache_mutation(
2288 "cache_general",
2289 CacheMutationRecoveryClass::SnapshotOwned,
2290 &[],
2291 ),
2292 cache_mutation("cache_all", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2293 cache_mutation(
2294 "cache_currencies",
2295 CacheMutationRecoveryClass::SnapshotOwned,
2296 &[],
2297 ),
2298 cache_mutation(
2299 "cache_instruments",
2300 CacheMutationRecoveryClass::SnapshotOwned,
2301 &[],
2302 ),
2303 cache_mutation(
2304 "cache_synthetics",
2305 CacheMutationRecoveryClass::SnapshotOwned,
2306 &[],
2307 ),
2308 cache_mutation(
2309 "cache_accounts",
2310 CacheMutationRecoveryClass::SnapshotOwned,
2311 &[],
2312 ),
2313 cache_mutation(
2314 "cache_orders",
2315 CacheMutationRecoveryClass::SnapshotOwned,
2316 &[],
2317 ),
2318 cache_mutation(
2319 "cache_positions",
2320 CacheMutationRecoveryClass::SnapshotOwned,
2321 &[],
2322 ),
2323 cache_mutation(
2324 "build_index",
2325 CacheMutationRecoveryClass::SnapshotOwned,
2326 &[],
2327 ),
2328 cache_mutation(
2329 "purge_closed_orders",
2330 CacheMutationRecoveryClass::SnapshotOwned,
2331 &[],
2332 ),
2333 cache_mutation(
2334 "purge_closed_positions",
2335 CacheMutationRecoveryClass::SnapshotOwned,
2336 &[],
2337 ),
2338 cache_mutation(
2339 "purge_order",
2340 CacheMutationRecoveryClass::SnapshotOwned,
2341 &[],
2342 ),
2343 cache_mutation(
2344 "purge_position",
2345 CacheMutationRecoveryClass::SnapshotOwned,
2346 &[],
2347 ),
2348 cache_mutation(
2349 "settle_position_snapshots",
2350 CacheMutationRecoveryClass::SnapshotOwned,
2351 &[],
2352 ),
2353 cache_mutation(
2354 "purge_instrument",
2355 CacheMutationRecoveryClass::SnapshotOwned,
2356 &[],
2357 ),
2358 cache_mutation(
2359 "purge_instrument_skip_order_guard",
2360 CacheMutationRecoveryClass::SnapshotOwned,
2361 &[],
2362 ),
2363 cache_mutation(
2364 "purge_account_events",
2365 CacheMutationRecoveryClass::SnapshotOwned,
2366 &[],
2367 ),
2368 cache_mutation(
2369 "clear_index",
2370 CacheMutationRecoveryClass::SnapshotOwned,
2371 &[],
2372 ),
2373 cache_mutation("reset", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2374 cache_mutation("dispose", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2375 cache_mutation("flush_db", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2376 cache_mutation("add", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2377 cache_mutation(
2378 "add_order_book",
2379 CacheMutationRecoveryClass::ForensicOnly,
2380 &[PAYLOAD_TYPE_BOOK_RESPONSE],
2381 ),
2382 cache_mutation(
2383 "add_own_order_book",
2384 CacheMutationRecoveryClass::SnapshotOwned,
2385 &[],
2386 ),
2387 cache_mutation(
2388 "add_mark_price",
2389 CacheMutationRecoveryClass::MissingLiveRecovery,
2390 &[],
2391 ),
2392 cache_mutation(
2393 "add_index_price",
2394 CacheMutationRecoveryClass::MissingLiveRecovery,
2395 &[],
2396 ),
2397 cache_mutation(
2398 "add_funding_rate",
2399 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2400 &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
2401 ),
2402 cache_mutation(
2403 "add_funding_rates",
2404 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2405 &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
2406 ),
2407 cache_mutation(
2408 "add_instrument_status",
2409 CacheMutationRecoveryClass::MissingLiveRecovery,
2410 &[],
2411 ),
2412 cache_mutation(
2413 "add_instrument_close",
2414 CacheMutationRecoveryClass::SnapshotOwned,
2415 &[],
2416 ),
2417 cache_mutation(
2418 "add_quote",
2419 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2420 &[PAYLOAD_TYPE_QUOTES_RESPONSE],
2421 ),
2422 cache_mutation(
2423 "add_quotes",
2424 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2425 &[PAYLOAD_TYPE_QUOTES_RESPONSE],
2426 ),
2427 cache_mutation(
2428 "add_trade",
2429 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2430 &[PAYLOAD_TYPE_TRADES_RESPONSE],
2431 ),
2432 cache_mutation(
2433 "add_trades",
2434 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2435 &[PAYLOAD_TYPE_TRADES_RESPONSE],
2436 ),
2437 cache_mutation(
2438 "add_bar",
2439 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2440 &[PAYLOAD_TYPE_BARS_RESPONSE],
2441 ),
2442 cache_mutation(
2443 "add_bars",
2444 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2445 &[PAYLOAD_TYPE_BARS_RESPONSE],
2446 ),
2447 cache_mutation(
2448 "add_greeks",
2449 CacheMutationRecoveryClass::MissingLiveRecovery,
2450 &[],
2451 ),
2452 cache_mutation(
2453 "add_option_greeks",
2454 CacheMutationRecoveryClass::MissingLiveRecovery,
2455 &[],
2456 ),
2457 cache_mutation(
2458 "add_yield_curve",
2459 CacheMutationRecoveryClass::MissingLiveRecovery,
2460 &[],
2461 ),
2462 cache_mutation(
2463 "add_currency",
2464 CacheMutationRecoveryClass::SnapshotOwned,
2465 &[],
2466 ),
2467 cache_mutation(
2468 "add_instrument",
2469 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2470 &[
2471 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
2472 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
2473 ],
2474 ),
2475 cache_mutation(
2476 "add_synthetic",
2477 CacheMutationRecoveryClass::SnapshotOwned,
2478 &[],
2479 ),
2480 cache_mutation(
2481 "add_account",
2482 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2483 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2484 ),
2485 cache_mutation(
2486 "add_venue_order_id",
2487 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2488 &[PAYLOAD_TYPE_ORDER_ACCEPTED, PAYLOAD_TYPE_ORDER_UPDATED],
2489 ),
2490 cache_mutation(
2491 "index_venue_order_id",
2494 CacheMutationRecoveryClass::MissingLiveRecovery,
2495 &[],
2496 ),
2497 cache_mutation(
2498 "add_order",
2499 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2500 &[PAYLOAD_TYPE_ORDER_INITIALIZED],
2501 ),
2502 cache_mutation(
2503 "claim_order_clients",
2506 CacheMutationRecoveryClass::MissingLiveRecovery,
2507 &[],
2508 ),
2509 cache_mutation(
2510 "add_order_list",
2511 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2512 &[PAYLOAD_TYPE_SUBMIT_ORDER_LIST],
2513 ),
2514 cache_mutation(
2515 "add_position_id",
2516 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2517 &[
2518 PAYLOAD_TYPE_ORDER_FILLED,
2519 PAYLOAD_TYPE_POSITION_OPENED,
2520 PAYLOAD_TYPE_POSITION_CHANGED,
2521 PAYLOAD_TYPE_POSITION_CLOSED,
2522 ],
2523 ),
2524 cache_mutation(
2525 "add_position",
2526 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2527 &[PAYLOAD_TYPE_ORDER_FILLED],
2528 ),
2529 cache_mutation(
2530 "add_position_without_order",
2531 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2532 &[PAYLOAD_TYPE_ORDER_FILLED],
2533 ),
2534 cache_mutation(
2535 "replace_position",
2536 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2537 &[PAYLOAD_TYPE_ORDER_FILLED],
2538 ),
2539 cache_mutation(
2540 "update_account",
2541 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2542 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2543 ),
2544 cache_mutation(
2545 "take_account",
2546 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2547 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2548 ),
2549 cache_mutation(
2550 "cache_account_owned",
2551 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2552 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2553 ),
2554 cache_mutation(
2555 "update_account_owned",
2556 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2557 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2558 ),
2559 cache_mutation(
2560 "update_account_state",
2561 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2562 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2563 ),
2564 cache_mutation(
2565 "replace_order",
2566 CacheMutationRecoveryClass::ForensicOnly,
2567 &[
2568 PAYLOAD_TYPE_ORDER_STATUS_REPORT,
2569 PAYLOAD_TYPE_ORDER_WITH_FILLS,
2570 PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
2571 ],
2572 ),
2573 cache_mutation(
2574 "update_order",
2575 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2576 &[
2577 PAYLOAD_TYPE_ORDER_DENIED,
2578 PAYLOAD_TYPE_ORDER_EMULATED,
2579 PAYLOAD_TYPE_ORDER_RELEASED,
2580 PAYLOAD_TYPE_ORDER_SUBMITTED,
2581 PAYLOAD_TYPE_ORDER_ACCEPTED,
2582 PAYLOAD_TYPE_ORDER_REJECTED,
2583 PAYLOAD_TYPE_ORDER_CANCELED,
2584 PAYLOAD_TYPE_ORDER_EXPIRED,
2585 PAYLOAD_TYPE_ORDER_TRIGGERED,
2586 PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
2587 PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
2588 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
2589 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED,
2590 PAYLOAD_TYPE_ORDER_UPDATED,
2591 PAYLOAD_TYPE_ORDER_FILLED,
2592 PAYLOAD_TYPE_ORDER_FILL_VOIDED,
2593 ],
2594 ),
2595 cache_mutation(
2596 "update_order_pending_cancel_local",
2597 CacheMutationRecoveryClass::MissingLiveRecovery,
2598 &[],
2599 ),
2600 cache_mutation(
2601 "update_position",
2602 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2603 &[
2604 PAYLOAD_TYPE_ORDER_FILLED,
2605 PAYLOAD_TYPE_ORDER_FILL_VOIDED,
2606 PAYLOAD_TYPE_POSITION_OPENED,
2607 PAYLOAD_TYPE_POSITION_CHANGED,
2608 PAYLOAD_TYPE_POSITION_CLOSED,
2609 PAYLOAD_TYPE_POSITION_ADJUSTED,
2610 ],
2611 ),
2612 cache_mutation(
2613 "update_position_from_fill",
2614 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2615 &[PAYLOAD_TYPE_ORDER_FILLED],
2616 ),
2617 cache_mutation(
2618 "snapshot_position",
2619 CacheMutationRecoveryClass::SnapshotOwned,
2620 &[],
2621 ),
2622 cache_mutation(
2623 "snapshot_position_encoded",
2624 CacheMutationRecoveryClass::SnapshotOwned,
2625 &[],
2626 ),
2627 cache_mutation(
2628 "snapshot_position_state",
2629 CacheMutationRecoveryClass::SnapshotOwned,
2630 &[],
2631 ),
2632 cache_mutation(
2633 "load_snapshot_blob",
2634 CacheMutationRecoveryClass::SnapshotOwned,
2635 &[],
2636 ),
2637 cache_mutation(
2638 "restore_snapshot_blob",
2639 CacheMutationRecoveryClass::SnapshotOwned,
2640 &[],
2641 ),
2642 cache_mutation(
2643 "order_mut",
2644 CacheMutationRecoveryClass::MissingLiveRecovery,
2645 &[],
2646 ),
2647 cache_mutation(
2648 "position_mut",
2649 CacheMutationRecoveryClass::MissingLiveRecovery,
2650 &[],
2651 ),
2652 cache_mutation(
2653 "order_book_mut",
2654 CacheMutationRecoveryClass::ForensicOnly,
2655 &[
2656 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE,
2657 PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
2658 ],
2659 ),
2660 cache_mutation(
2661 "own_order_book_mut",
2662 CacheMutationRecoveryClass::SnapshotOwned,
2663 &[],
2664 ),
2665 cache_mutation(
2666 "set_mark_xrate",
2667 CacheMutationRecoveryClass::MissingLiveRecovery,
2668 &[],
2669 ),
2670 cache_mutation(
2671 "clear_mark_xrate",
2672 CacheMutationRecoveryClass::MissingLiveRecovery,
2673 &[],
2674 ),
2675 cache_mutation(
2676 "clear_mark_xrates",
2677 CacheMutationRecoveryClass::MissingLiveRecovery,
2678 &[],
2679 ),
2680 cache_mutation(
2681 "account_mut",
2682 CacheMutationRecoveryClass::MissingLiveRecovery,
2683 &[],
2684 ),
2685 cache_mutation(
2686 "update_own_order_book",
2687 CacheMutationRecoveryClass::SnapshotOwned,
2688 &[],
2689 ),
2690 cache_mutation(
2691 "force_remove_from_own_order_book",
2692 CacheMutationRecoveryClass::SnapshotOwned,
2693 &[],
2694 ),
2695 cache_mutation(
2696 "audit_own_order_books",
2697 CacheMutationRecoveryClass::SnapshotOwned,
2698 &[],
2699 ),
2700 ];
2701
2702 const CACHE_MUTATION_EXCLUSIONS: &[&str] = &["check_integrity"];
2703
2704 const fn cache_mutation(
2705 method: &'static str,
2706 class: CacheMutationRecoveryClass,
2707 payload_types: &'static [&'static str],
2708 ) -> CacheMutationCoverage {
2709 CacheMutationCoverage {
2710 method,
2711 class,
2712 payload_types,
2713 }
2714 }
2715
2716 fn cache_public_methods() -> AHashSet<&'static str> {
2717 collect_cache_public_methods(false)
2718 }
2719
2720 fn cache_public_mutable_methods() -> AHashSet<&'static str> {
2721 collect_cache_public_methods(true)
2722 }
2723
2724 const CACHE_IMPL_SOURCES: &[&str] = &[
2728 include_str!("../../common/src/cache/mod.rs"),
2729 include_str!("../../common/src/cache/position.rs"),
2730 ];
2731
2732 fn collect_cache_public_methods(require_mut_self: bool) -> AHashSet<&'static str> {
2733 let mut methods = AHashSet::new();
2734 let mut pending_name: Option<&'static str> = None;
2735 let mut pending_signature = String::new();
2736
2737 for line in CACHE_IMPL_SOURCES.iter().flat_map(|source| source.lines()) {
2738 let trimmed = line.trim_start();
2739
2740 if pending_name.is_none() {
2741 let Some(rest) = trimmed
2742 .strip_prefix("pub fn ")
2743 .or_else(|| trimmed.strip_prefix("pub async fn "))
2744 else {
2745 continue;
2746 };
2747 pending_name = rest.split('(').next();
2748 pending_signature.clear();
2749 pending_signature.push_str(trimmed);
2750 } else {
2751 pending_signature.push(' ');
2752 pending_signature.push_str(trimmed);
2753 }
2754
2755 if trimmed.contains('{') {
2756 if let Some(name) = pending_name.take()
2757 && (!require_mut_self || pending_signature.contains("&mut self"))
2758 {
2759 methods.insert(name);
2760 }
2761 pending_signature.clear();
2762 }
2763 }
2764
2765 methods
2766 }
2767
2768 fn sorted_missing_methods<'a>(
2769 actual: &'a AHashSet<&'static str>,
2770 classified: &'a AHashSet<&'static str>,
2771 ) -> Vec<&'static str> {
2772 let mut missing: Vec<_> = actual
2773 .iter()
2774 .copied()
2775 .filter(|method| !classified.contains(method))
2776 .collect();
2777 missing.sort_unstable();
2778 missing
2779 }
2780
2781 fn sorted_stale_methods<'a>(
2782 classified: &'a AHashSet<&'static str>,
2783 actual: &'a AHashSet<&'static str>,
2784 ) -> Vec<&'static str> {
2785 let mut stale: Vec<_> = classified
2786 .iter()
2787 .copied()
2788 .filter(|method| !actual.contains(method))
2789 .collect();
2790 stale.sort_unstable();
2791 stale
2792 }
2793
2794 #[rstest]
2795 fn catalog_replay_inputs_join_event_entries_with_selected_catalog_slice() {
2796 let reader = reader_with_entries(
2797 "run-catalog",
2798 &[
2799 append_payload_with_ts(1, 120, "RunStarted", Bytes::from_static(b"started")),
2800 append_payload_with_ts(2, 100, "SubmitOrder", Bytes::from_static(b"submit")),
2801 ],
2802 );
2803 let record = catalog_quote_record(110);
2804 let mut catalog = FakeReplayCatalog::new(
2805 CatalogSliceCoverage::from_files(vec!["quotes/AUDUSD.SIM/100_120.parquet".into()]),
2806 vec![record.clone()],
2807 );
2808
2809 let plan = plan_catalog_replay_inputs(
2810 &reader,
2811 &mut catalog,
2812 ReplaySeqRange::new(1, 2),
2813 &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2814 )
2815 .expect("plan catalog replay");
2816
2817 assert_eq!(plan.event_range, Some(ReplaySeqRange::new(1, 2)));
2818 assert_eq!(plan.event_count, 2);
2819 assert_eq!(
2820 plan.event_time_range,
2821 Some(ReplayTimeRange::new(
2822 UnixNanos::from(100),
2823 UnixNanos::from(120),
2824 )),
2825 );
2826 assert!(!plan.catalog_slices[0].is_missing());
2827 assert_eq!(catalog.plan_queries.len(), 1);
2828 assert_eq!(catalog.plan_queries[0].data_cls, "quotes");
2829 assert_eq!(
2830 catalog.plan_queries[0].identifiers,
2831 vec!["AUD/USD.SIM".to_string()],
2832 );
2833 assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(100));
2834 assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(120));
2835
2836 let loaded =
2837 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
2838 let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
2839
2840 assert_eq!(seqs, vec![1, 2]);
2841 assert_eq!(loaded.catalog_slices.len(), 1);
2842 assert_eq!(loaded.catalog_slices[0].records, vec![record]);
2843 assert_eq!(catalog.load_plans.len(), 1);
2844 }
2845
2846 #[rstest]
2847 fn catalog_plan_marks_missing_catalog_slice() {
2848 let reader = reader_with_entries(
2849 "run-missing-catalog",
2850 &[append_payload_with_ts(
2851 1,
2852 1_000,
2853 "RunStarted",
2854 Bytes::from_static(b"started"),
2855 )],
2856 );
2857 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2858
2859 let plan = plan_catalog_replay_inputs(
2860 &reader,
2861 &mut catalog,
2862 ReplaySeqRange::new(1, 1),
2863 &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
2864 )
2865 .expect("plan catalog replay");
2866 let missing = plan.missing_catalog_slices();
2867
2868 assert_eq!(missing.len(), 1);
2869 assert_eq!(missing[0].query.data_cls, "trades");
2870 assert_eq!(
2871 missing[0].query.identifiers,
2872 vec!["AUD/USD.SIM".to_string()],
2873 );
2874 assert_eq!(missing[0].query.start, UnixNanos::from(1_000));
2875 assert_eq!(missing[0].query.end, UnixNanos::from(1_000));
2876 }
2877
2878 #[rstest]
2879 fn required_missing_catalog_slice_rejects_load() {
2880 let reader = reader_with_entries(
2881 "run-required-missing",
2882 &[append_payload_with_ts(
2883 1,
2884 1_000,
2885 "RunStarted",
2886 Bytes::from_static(b"started"),
2887 )],
2888 );
2889 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2890 let plan = plan_catalog_replay_inputs(
2891 &reader,
2892 &mut catalog,
2893 ReplaySeqRange::new(1, 1),
2894 &[CatalogSliceSelector::new("quotes")
2895 .with_identifier("AUD/USD.SIM")
2896 .require_coverage()],
2897 )
2898 .expect("plan missing slice");
2899
2900 let err = load_catalog_replay_inputs(&reader, &mut catalog, &plan)
2901 .expect_err("required missing slice must fail");
2902
2903 match err {
2904 ReplayInputError::MissingCatalogSlice {
2905 data_cls,
2906 identifiers,
2907 } => {
2908 assert_eq!(data_cls, "quotes");
2909 assert_eq!(identifiers, vec!["AUD/USD.SIM".to_string()]);
2910 }
2911 other => panic!("expected MissingCatalogSlice, was {other:?}"),
2912 }
2913 }
2914
2915 #[rstest]
2916 fn optional_missing_catalog_slice_loads_as_empty_without_catalog_load() {
2917 let reader = reader_with_entries(
2918 "run-optional-missing",
2919 &[append_payload_with_ts(
2920 1,
2921 1_000,
2922 "RunStarted",
2923 Bytes::from_static(b"started"),
2924 )],
2925 );
2926 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2927 let plan = plan_catalog_replay_inputs(
2928 &reader,
2929 &mut catalog,
2930 ReplaySeqRange::new(1, 1),
2931 &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2932 )
2933 .expect("plan optional missing slice");
2934
2935 let loaded =
2936 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load optional");
2937
2938 assert_eq!(loaded.catalog_slices.len(), 1);
2939 assert!(loaded.catalog_slices[0].plan.is_missing());
2940 assert!(loaded.catalog_slices[0].records.is_empty());
2941 assert!(catalog.load_plans.is_empty());
2942 }
2943
2944 #[rstest]
2945 fn catalog_joined_planner_rejects_empty_catalog_selection() {
2946 let reader = reader_with_entries(
2947 "run-empty-selection",
2948 &[append_payload_with_ts(
2949 1,
2950 1_000,
2951 "RunStarted",
2952 Bytes::from_static(b"started"),
2953 )],
2954 );
2955 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2956
2957 let err = plan_catalog_replay_inputs(&reader, &mut catalog, ReplaySeqRange::new(1, 1), &[])
2958 .expect_err("empty catalog selection must fail");
2959
2960 match err {
2961 ReplayInputError::EmptyCatalogSelection => {}
2962 other => panic!("expected EmptyCatalogSelection, was {other:?}"),
2963 }
2964 assert!(catalog.plan_queries.is_empty());
2965 }
2966
2967 #[rstest]
2968 fn catalog_selector_explicit_time_bounds_override_event_span() {
2969 let reader = reader_with_entries(
2970 "run-explicit-bounds",
2971 &[append_payload_with_ts(
2972 1,
2973 1_000,
2974 "RunStarted",
2975 Bytes::from_static(b"started"),
2976 )],
2977 );
2978 let mut catalog = FakeReplayCatalog::new(
2979 CatalogSliceCoverage::from_files(vec!["bars/AUDUSD.SIM/900_950.parquet".into()]),
2980 Vec::new(),
2981 );
2982
2983 let plan = plan_catalog_replay_inputs(
2984 &reader,
2985 &mut catalog,
2986 ReplaySeqRange::new(1, 1),
2987 &[CatalogSliceSelector::new("bars")
2988 .with_identifier("AUD/USD.SIM-1-MINUTE-BID-EXTERNAL")
2989 .with_time_bounds(UnixNanos::from(900), UnixNanos::from(950))],
2990 )
2991 .expect("plan explicit bounds");
2992
2993 assert_eq!(plan.catalog_slices[0].query.start, UnixNanos::from(900));
2994 assert_eq!(plan.catalog_slices[0].query.end, UnixNanos::from(950));
2995 assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(900));
2996 assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(950));
2997 }
2998
2999 #[rstest]
3000 fn catalog_replay_inputs_load_catalog_records() {
3001 let reader = reader_with_entries(
3002 "run-catalog-load",
3003 &[
3004 append_payload_with_ts(1, 100, "RunStarted", Bytes::from_static(b"started")),
3005 append_payload_with_ts(2, 110, "OrderFilled", Bytes::from_static(b"filled")),
3006 ],
3007 );
3008 let record = catalog_trade_record(105);
3009 let mut catalog = FakeReplayCatalog::new(
3010 CatalogSliceCoverage::from_files(vec!["trades/AUDUSD.SIM/100_110.parquet".into()]),
3011 vec![record.clone()],
3012 );
3013 let plan = plan_catalog_replay_inputs(
3014 &reader,
3015 &mut catalog,
3016 ReplaySeqRange::new(1, 2),
3017 &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
3018 )
3019 .expect("plan catalog replay");
3020
3021 assert_eq!(
3022 plan.catalog_slices[0].query.identifiers_option(),
3023 Some(vec!["AUD/USD.SIM".to_string()]),
3024 );
3025
3026 let loaded =
3027 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
3028 let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
3029
3030 assert_eq!(seqs, vec![1, 2]);
3031 assert_eq!(loaded.catalog_slices[0].records, vec![record]);
3032 assert_eq!(catalog.load_plans.len(), 1);
3033 }
3034
3035 #[rstest]
3036 fn unbounded_catalog_selector_rejects_empty_event_scan() {
3037 let reader = reader_with_entries("run-empty", &[]);
3038 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
3039
3040 let err = plan_catalog_replay_inputs(
3041 &reader,
3042 &mut catalog,
3043 ReplaySeqRange::new(1, 10),
3044 &[CatalogSliceSelector::new("quotes")],
3045 )
3046 .expect_err("empty replay scan must need explicit bounds");
3047
3048 match err {
3049 ReplayInputError::MissingCatalogTimeBounds { data_cls } => {
3050 assert_eq!(data_cls, "quotes");
3051 }
3052 other => panic!("expected MissingCatalogTimeBounds, was {other:?}"),
3053 }
3054 }
3055
3056 #[rstest]
3057 fn invalid_catalog_time_bounds_are_rejected_before_catalog_access() {
3058 let reader = reader_with_entries(
3059 "run-invalid-bounds",
3060 &[append_payload_with_ts(
3061 1,
3062 1_000,
3063 "RunStarted",
3064 Bytes::from_static(b"started"),
3065 )],
3066 );
3067 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
3068
3069 let err = plan_catalog_replay_inputs(
3070 &reader,
3071 &mut catalog,
3072 ReplaySeqRange::new(1, 1),
3073 &[CatalogSliceSelector::new("quotes")
3074 .with_time_bounds(UnixNanos::from(200), UnixNanos::from(100))],
3075 )
3076 .expect_err("invalid catalog bounds must fail");
3077
3078 match err {
3079 ReplayInputError::InvalidCatalogTimeRange {
3080 data_cls,
3081 start,
3082 end,
3083 } => {
3084 assert_eq!(data_cls, "quotes");
3085 assert_eq!(start, 200);
3086 assert_eq!(end, 100);
3087 }
3088 other => panic!("expected InvalidCatalogTimeRange, was {other:?}"),
3089 }
3090 assert!(catalog.plan_queries.is_empty());
3091 }
3092
3093 #[rstest]
3094 fn forensics_replay_inputs_do_not_require_catalog_source() {
3095 let reader = reader_with_entries(
3096 "run-forensics",
3097 &[append_payload_with_ts(
3098 1,
3099 500,
3100 "RunStarted",
3101 Bytes::from_static(b"started"),
3102 )],
3103 );
3104
3105 let plan = plan_forensics_replay_inputs(&reader, ReplaySeqRange::new(1, 1))
3106 .expect("plan forensics");
3107 let loaded = load_forensics_replay_inputs(&reader, &plan).expect("load forensics");
3108
3109 assert!(plan.catalog_slices.is_empty());
3110 assert_eq!(loaded.entries.len(), 1);
3111 assert!(loaded.catalog_slices.is_empty());
3112 }
3113
3114 #[rstest]
3115 #[case::zero_start(ReplaySeqRange::new(0, 1), "seq is 1-based")]
3116 #[case::from_after_to(ReplaySeqRange::new(2, 1), "from_seq exceeds to_seq")]
3117 fn invalid_replay_seq_range_rejected(
3118 #[case] range: ReplaySeqRange,
3119 #[case] expected_message: &str,
3120 ) {
3121 let reader = reader_with_entries("run-invalid-seq", &[]);
3122
3123 let err =
3124 plan_forensics_replay_inputs(&reader, range).expect_err("invalid seq range must fail");
3125
3126 match err {
3127 ReplayInputError::InvalidSeqRange {
3128 from_seq,
3129 to_seq,
3130 message,
3131 } => {
3132 assert_eq!(from_seq, range.from_seq);
3133 assert_eq!(to_seq, range.to_seq);
3134 assert_eq!(message, expected_message);
3135 }
3136 other => panic!("expected InvalidSeqRange, was {other:?}"),
3137 }
3138 }
3139
3140 #[rstest]
3141 fn replay_restores_snapshot_before_applying_tail() {
3142 let (reader, replayed) = reader_with_anchor(1);
3143 let mut cache = Cache::default();
3144 let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3145 let restored_id = restored.account_id;
3146
3147 let report =
3148 restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
3149 assert_eq!(anchor.expect("anchor").high_watermark, 1);
3150 let account = AccountAny::from_events(std::slice::from_ref(&restored))
3151 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
3152 cache
3153 .add_account(account)
3154 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
3155 })
3156 .expect("replay");
3157
3158 let account = cache.account_owned(&restored_id).expect("account restored");
3159 let events = account.events();
3160
3161 assert_eq!(report.plan.from_seq, 2);
3162 assert_eq!(report.applied_entries, 1);
3163 assert_eq!(report.ignored_entries, 0);
3164 assert_eq!(events, vec![restored, replayed]);
3165 }
3166
3167 #[rstest]
3168 fn replay_does_not_apply_entries_at_or_below_anchor_watermark() {
3169 let (reader, _) = reader_with_anchor(2);
3170 let mut cache = Cache::default();
3171 let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3172 let restored_id = restored.account_id;
3173
3174 let report =
3175 restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
3176 assert_eq!(anchor.expect("anchor").high_watermark, 2);
3177 let account = AccountAny::from_events(std::slice::from_ref(&restored))
3178 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
3179 cache
3180 .add_account(account)
3181 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
3182 })
3183 .expect("replay");
3184
3185 let account = cache.account_owned(&restored_id).expect("account restored");
3186
3187 assert!(report.plan.is_empty());
3188 assert_eq!(report.applied_entries, 0);
3189 assert_eq!(report.ignored_entries, 0);
3190 assert_eq!(account.events(), vec![restored]);
3191 }
3192
3193 #[rstest]
3194 fn replay_from_start_applies_account_state_without_bus_publish() {
3195 let state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3196 let account_id = AccountId::from("SIM-001");
3197 let bus_calls = Rc::new(Cell::new(0));
3198 msgbus::set_bus_tap(Rc::new(CountingTap::new(Rc::clone(&bus_calls))));
3199 let _guard = BusTapGuard;
3200 let mut backend = MemoryBackend::new();
3201 backend.open_run(manifest("run-replay")).expect("open");
3202 backend
3203 .append_batch(&[append_account_state(1, &state)])
3204 .expect("append");
3205 let reader = EventStoreReader::new(backend);
3206 let mut cache = Cache::default();
3207
3208 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3209 let account = cache.account_owned(&account_id).expect("account replayed");
3210
3211 assert_eq!(report.plan.anchor, None);
3212 assert_eq!(report.plan.from_seq, 1);
3213 assert_eq!(report.applied_entries, 1);
3214 assert_eq!(bus_calls.get(), 0);
3215 assert_eq!(account.last_event(), Some(state));
3216 assert_eq!(account.base_currency(), Some(Currency::USD()));
3217 }
3218
3219 #[rstest]
3220 fn unsupported_payload_is_ignored() {
3221 let mut backend = MemoryBackend::new();
3222 backend.open_run(manifest("run-replay")).expect("open");
3223 backend
3224 .append_batch(&[append_payload(
3225 1,
3226 "RunStarted",
3227 Bytes::copy_from_slice(UUID4::new().to_string().as_bytes()),
3228 )])
3229 .expect("append");
3230 let reader = EventStoreReader::new(backend);
3231 let mut cache = Cache::default();
3232
3233 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3234
3235 assert_eq!(report.applied_entries, 0);
3236 assert_eq!(report.ignored_entries, 1);
3237 }
3238
3239 #[rstest]
3240 fn default_capture_payload_types_are_classified_for_cache_replay() {
3241 let mut classified = AHashSet::new();
3242 let mut overlap = Vec::new();
3243
3244 for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
3245 classified.insert(*payload_type);
3246 }
3247
3248 for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
3249 if !classified.insert(*payload_type) {
3250 overlap.push(*payload_type);
3251 }
3252 }
3253
3254 let mut seen_defaults = AHashSet::new();
3255 let duplicate_defaults: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
3256 .iter()
3257 .copied()
3258 .filter(|payload_type| !seen_defaults.insert(*payload_type))
3259 .collect();
3260 let unclassified: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
3261 .iter()
3262 .copied()
3263 .filter(|payload_type| !classified.contains(payload_type))
3264 .collect();
3265 let extra: Vec<_> = classified
3266 .iter()
3267 .copied()
3268 .filter(|payload_type| !seen_defaults.contains(payload_type))
3269 .collect();
3270
3271 assert!(
3272 duplicate_defaults.is_empty(),
3273 "default capture payload types must be unique: {duplicate_defaults:?}",
3274 );
3275 assert!(
3276 overlap.is_empty(),
3277 "cache replay and forensic-only classes must not overlap: {overlap:?}",
3278 );
3279 assert!(
3280 unclassified.is_empty(),
3281 "default capture payload types must be cache replayed or forensic-only: {unclassified:?}",
3282 );
3283 assert!(
3284 extra.is_empty(),
3285 "cache replay classification must not list uncaptured payload types: {extra:?}",
3286 );
3287 }
3288
3289 #[rstest]
3290 fn cache_replay_capture_payload_types_have_replay_rules() {
3291 for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
3292 let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
3293 let mut cache = Cache::default();
3294
3295 let err = apply_cache_replay_entry(&mut cache, &entry)
3296 .expect_err("cache replay payload type must have a decode rule");
3297
3298 match err {
3299 CacheReplayError::Decode {
3300 payload_type: actual,
3301 ..
3302 } => {
3303 assert_eq!(actual, *payload_type);
3304 }
3305 other => panic!("expected Decode for {payload_type}, was {other:?}"),
3306 }
3307 }
3308 }
3309
3310 #[rstest]
3311 fn forensic_only_capture_payload_types_are_not_cache_replayed() {
3312 for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
3313 let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
3314 let mut cache = Cache::default();
3315
3316 let applied = apply_cache_replay_entry(&mut cache, &entry)
3317 .expect("forensic-only payload type must not be decoded by cache replay");
3318
3319 assert!(
3320 !applied,
3321 "forensic-only payload type must be ignored by cache replay: {payload_type}",
3322 );
3323 }
3324 }
3325
3326 #[rstest]
3327 fn legacy_forward_prices_response_is_ignored_by_cache_replay() {
3328 let entry = append_payload(1, "ForwardPricesResponse", Bytes::from_static(&[0xc1])).entry;
3329 let mut cache = Cache::default();
3330
3331 let applied = apply_cache_replay_entry(&mut cache, &entry)
3332 .expect("legacy forensic payload must not be decoded by cache replay");
3333
3334 assert!(!applied);
3335 }
3336
3337 #[rstest]
3338 fn cache_public_mutators_have_recovery_classification() {
3339 let mut classified = AHashSet::new();
3340 let mut duplicates = Vec::new();
3341
3342 for row in CACHE_MUTATION_COVERAGE {
3343 if !classified.insert(row.method) {
3344 duplicates.push(row.method);
3345 }
3346 }
3347
3348 for method in CACHE_MUTATION_EXCLUSIONS {
3349 if !classified.insert(*method) {
3350 duplicates.push(*method);
3351 }
3352 }
3353
3354 let public_methods = cache_public_methods();
3355 let mutable_methods = cache_public_mutable_methods();
3356 let missing = sorted_missing_methods(&mutable_methods, &classified);
3357 let stale = sorted_stale_methods(&classified, &public_methods);
3358
3359 assert!(
3360 duplicates.is_empty(),
3361 "cache mutation recovery classifications must be unique: {duplicates:?}",
3362 );
3363 assert!(
3364 missing.is_empty(),
3365 "public Cache mutators must be classified for recovery: {missing:?}",
3366 );
3367 assert!(
3368 stale.is_empty(),
3369 "cache mutation recovery classifications reference missing methods: {stale:?}",
3370 );
3371 }
3372
3373 #[rstest]
3374 fn cache_mutation_replay_classification_matches_payload_buckets() {
3375 for row in CACHE_MUTATION_COVERAGE {
3376 match row.class {
3377 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed => {
3378 assert!(
3379 !row.payload_types.is_empty(),
3380 "cache-replayed mutation must cite captured payloads: {}",
3381 row.method,
3382 );
3383
3384 for payload_type in row.payload_types {
3385 assert!(
3386 CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
3387 "cache mutation {} cites non-replayed payload {payload_type}",
3388 row.method,
3389 );
3390 }
3391 }
3392 CacheMutationRecoveryClass::ForensicOnly => {
3393 assert!(
3394 !row.payload_types.is_empty(),
3395 "forensic-only mutation must cite forensic payloads: {}",
3396 row.method,
3397 );
3398
3399 for payload_type in row.payload_types {
3400 assert!(
3401 FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
3402 "cache mutation {} cites non-forensic payload {payload_type}",
3403 row.method,
3404 );
3405 }
3406 }
3407 CacheMutationRecoveryClass::SnapshotOwned
3408 | CacheMutationRecoveryClass::MissingLiveRecovery => {
3409 assert!(
3410 row.payload_types.is_empty(),
3411 "non-event-store cache mutation {} should not cite payloads",
3412 row.method,
3413 );
3414 }
3415 }
3416 }
3417 }
3418
3419 #[rstest]
3420 fn submit_order_list_replay_restores_order_list() {
3421 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3422 let instrument_id = instrument.id();
3423 let first_init = OrderInitializedSpec::builder()
3424 .instrument_id(instrument_id)
3425 .client_order_id(ClientOrderId::from("O-LIST-001"))
3426 .build();
3427 let second_init = OrderInitializedSpec::builder()
3428 .instrument_id(instrument_id)
3429 .client_order_id(ClientOrderId::from("O-LIST-002"))
3430 .build();
3431 let order_list = OrderList::new(
3432 OrderListId::from("OL-001"),
3433 instrument_id,
3434 first_init.strategy_id,
3435 vec![first_init.client_order_id, second_init.client_order_id],
3436 UnixNanos::from(1),
3437 );
3438 let command = SubmitOrderList::new(
3439 first_init.trader_id,
3440 Some(ClientId::from("SIM")),
3441 first_init.strategy_id,
3442 order_list.clone(),
3443 vec![first_init, second_init],
3444 None,
3445 None,
3446 None,
3447 UUID4::new(),
3448 UnixNanos::from(2),
3449 None,
3450 );
3451 let entry = append_serde_payload(1, PAYLOAD_TYPE_SUBMIT_ORDER_LIST, &command).entry;
3452 let mut cache = Cache::default();
3453
3454 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply order list");
3455 let replayed = cache
3456 .order_list(&order_list.id)
3457 .expect("order list replayed");
3458
3459 assert!(applied);
3460 assert_eq!(replayed, &order_list);
3461 }
3462
3463 #[rstest]
3464 fn data_response_replay_restores_instruments_and_market_data() {
3465 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3466 let instrument_id = instrument.id();
3467 let client_id = ClientId::from("DATA");
3468 let quote = QuoteTick::new(
3469 instrument_id,
3470 Price::from("1.00000"),
3471 Price::from("1.00010"),
3472 Quantity::from("100000"),
3473 Quantity::from("100000"),
3474 UnixNanos::from(10),
3475 UnixNanos::from(11),
3476 );
3477 let trade = TradeTick::new(
3478 instrument_id,
3479 Price::from("1.00005"),
3480 Quantity::from("50000"),
3481 AggressorSide::Buy,
3482 TradeId::from("T-DATA-001"),
3483 UnixNanos::from(12),
3484 UnixNanos::from(13),
3485 );
3486 let funding_rate = FundingRateUpdate::new(
3487 instrument_id,
3488 "0.0001".parse().expect("funding rate"),
3489 Some(480),
3490 Some(UnixNanos::from(60)),
3491 UnixNanos::from(14),
3492 UnixNanos::from(15),
3493 );
3494 let bar_type = BarType::new(
3495 instrument_id,
3496 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3497 AggregationSource::External,
3498 );
3499 let bar = Bar::new(
3500 bar_type,
3501 Price::from("1.00000"),
3502 Price::from("1.00020"),
3503 Price::from("0.99990"),
3504 Price::from("1.00010"),
3505 Quantity::from("150000"),
3506 UnixNanos::from(16),
3507 UnixNanos::from(17),
3508 );
3509 let reader = reader_with_entries(
3510 "run-data-response-replay",
3511 &[
3512 append_serde_payload(
3513 1,
3514 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
3515 &InstrumentResponse::new(
3516 UUID4::new(),
3517 client_id,
3518 instrument_id,
3519 instrument.clone(),
3520 None,
3521 None,
3522 UnixNanos::from(1),
3523 None,
3524 ),
3525 ),
3526 append_serde_payload(
3527 2,
3528 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3529 &InstrumentsResponse::new(
3530 UUID4::new(),
3531 client_id,
3532 instrument_id.venue,
3533 vec![instrument],
3534 None,
3535 None,
3536 UnixNanos::from(2),
3537 None,
3538 ),
3539 ),
3540 append_serde_payload(
3541 3,
3542 PAYLOAD_TYPE_QUOTES_RESPONSE,
3543 &QuotesResponse::new(
3544 UUID4::new(),
3545 client_id,
3546 instrument_id,
3547 vec![quote],
3548 None,
3549 None,
3550 UnixNanos::from(3),
3551 None,
3552 ),
3553 ),
3554 append_serde_payload(
3555 4,
3556 PAYLOAD_TYPE_TRADES_RESPONSE,
3557 &TradesResponse::new(
3558 UUID4::new(),
3559 client_id,
3560 instrument_id,
3561 vec![trade],
3562 None,
3563 None,
3564 UnixNanos::from(4),
3565 None,
3566 ),
3567 ),
3568 append_serde_payload(
3569 5,
3570 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3571 &FundingRatesResponse::new(
3572 UUID4::new(),
3573 client_id,
3574 instrument_id,
3575 vec![funding_rate],
3576 None,
3577 None,
3578 UnixNanos::from(5),
3579 None,
3580 ),
3581 ),
3582 append_serde_payload(
3583 6,
3584 PAYLOAD_TYPE_BARS_RESPONSE,
3585 &BarsResponse::new(
3586 UUID4::new(),
3587 client_id,
3588 bar_type,
3589 vec![bar],
3590 None,
3591 None,
3592 UnixNanos::from(6),
3593 None,
3594 ),
3595 ),
3596 ],
3597 );
3598 let mut cache = Cache::default();
3599
3600 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3601
3602 assert_eq!(report.applied_entries, 6);
3603 assert_eq!(report.ignored_entries, 0);
3604 assert_eq!(
3605 cache.instrument(&instrument_id).map(Instrument::id),
3606 Some(instrument_id)
3607 );
3608 assert_eq!(cache.quotes(&instrument_id), Some(vec![quote]));
3609 assert_eq!(cache.trades(&instrument_id), Some(vec![trade]));
3610 assert_eq!(
3611 cache.funding_rates(&instrument_id),
3612 Some(vec![funding_rate])
3613 );
3614 assert_eq!(cache.bars(&bar_type), Some(vec![bar]));
3615 }
3616
3617 #[rstest]
3618 fn empty_data_response_replay_is_noop() {
3619 let instrument_id = InstrumentAny::CurrencyPair(audusd_sim()).id();
3620 let client_id = ClientId::from("DATA");
3621 let bar_type = BarType::new(
3622 instrument_id,
3623 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3624 AggregationSource::External,
3625 );
3626 let reader = reader_with_entries(
3627 "run-empty-data-response-replay",
3628 &[
3629 append_serde_payload(
3630 1,
3631 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3632 &InstrumentsResponse::new(
3633 UUID4::new(),
3634 client_id,
3635 instrument_id.venue,
3636 Vec::new(),
3637 None,
3638 None,
3639 UnixNanos::from(1),
3640 None,
3641 ),
3642 ),
3643 append_serde_payload(
3644 2,
3645 PAYLOAD_TYPE_QUOTES_RESPONSE,
3646 &QuotesResponse::new(
3647 UUID4::new(),
3648 client_id,
3649 instrument_id,
3650 Vec::new(),
3651 None,
3652 None,
3653 UnixNanos::from(2),
3654 None,
3655 ),
3656 ),
3657 append_serde_payload(
3658 3,
3659 PAYLOAD_TYPE_TRADES_RESPONSE,
3660 &TradesResponse::new(
3661 UUID4::new(),
3662 client_id,
3663 instrument_id,
3664 Vec::new(),
3665 None,
3666 None,
3667 UnixNanos::from(3),
3668 None,
3669 ),
3670 ),
3671 append_serde_payload(
3672 4,
3673 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3674 &FundingRatesResponse::new(
3675 UUID4::new(),
3676 client_id,
3677 instrument_id,
3678 Vec::new(),
3679 None,
3680 None,
3681 UnixNanos::from(4),
3682 None,
3683 ),
3684 ),
3685 append_serde_payload(
3686 5,
3687 PAYLOAD_TYPE_BARS_RESPONSE,
3688 &BarsResponse::new(
3689 UUID4::new(),
3690 client_id,
3691 bar_type,
3692 Vec::new(),
3693 None,
3694 None,
3695 UnixNanos::from(5),
3696 None,
3697 ),
3698 ),
3699 ],
3700 );
3701 let mut cache = Cache::default();
3702
3703 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3704
3705 assert_eq!(report.applied_entries, 5);
3706 assert_eq!(report.ignored_entries, 0);
3707 assert!(cache.instrument(&instrument_id).is_none());
3708 assert_eq!(cache.quotes(&instrument_id), None);
3709 assert_eq!(cache.trades(&instrument_id), None);
3710 assert_eq!(cache.funding_rates(&instrument_id), None);
3711 assert_eq!(cache.bars(&bar_type), None);
3712 }
3713
3714 #[rstest]
3715 fn order_fill_replay_updates_order_and_creates_position() {
3716 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3717 let position_id = PositionId::from("P-001");
3718 let initialized = OrderInitializedSpec::builder()
3719 .instrument_id(instrument.id())
3720 .build();
3721 let client_order_id = initialized.client_order_id;
3722 let submitted = OrderSubmittedSpec::builder()
3723 .instrument_id(instrument.id())
3724 .client_order_id(client_order_id)
3725 .build();
3726 let accepted = OrderAcceptedSpec::builder()
3727 .instrument_id(instrument.id())
3728 .client_order_id(client_order_id)
3729 .account_id(submitted.account_id)
3730 .build();
3731 let filled = OrderFilledSpec::builder()
3732 .instrument_id(instrument.id())
3733 .client_order_id(client_order_id)
3734 .venue_order_id(accepted.venue_order_id)
3735 .account_id(submitted.account_id)
3736 .position_id(position_id)
3737 .commission(Money::from("1 USD"))
3738 .build();
3739 let filled_event = OrderEventAny::Filled(filled.clone());
3740 let reader = reader_with_entries(
3741 "run-order-replay",
3742 &[
3743 append_order_event(1, &OrderEventAny::Initialized(initialized)),
3744 append_order_event(2, &OrderEventAny::Submitted(submitted)),
3745 append_order_event(3, &OrderEventAny::Accepted(accepted)),
3746 append_order_event(4, &filled_event),
3747 ],
3748 );
3749 let mut cache = Cache::default();
3750 cache.add_instrument(instrument).expect("add instrument");
3751
3752 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3753 let order = cache.order_owned(&client_order_id).expect("order replayed");
3754 let position = cache
3755 .position_owned(&position_id)
3756 .expect("position replayed");
3757
3758 assert_eq!(report.applied_entries, 4);
3759 assert_eq!(report.ignored_entries, 0);
3760 assert_eq!(order.status(), OrderStatus::Filled);
3761 assert_eq!(order.event_count(), 4);
3762 assert_eq!(order.last_event(), &filled_event);
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 }
3768
3769 #[rstest]
3770 fn orderless_leg_fill_replay_creates_position_without_order_mapping() {
3771 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3772 let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3773 let position_id = PositionId::from("P-ORDERLESS-LEG");
3774 let filled = OrderFilledSpec::builder()
3775 .instrument_id(instrument.id())
3776 .client_order_id(client_order_id)
3777 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-AUDUSD"))
3778 .position_id(position_id)
3779 .commission(Money::from("1 USD"))
3780 .build();
3781 let reader = reader_with_entries(
3782 "run-orderless-leg-fill-replay",
3783 &[append_order_event(
3784 1,
3785 &OrderEventAny::Filled(filled.clone()),
3786 )],
3787 );
3788 let mut cache = Cache::default();
3789 cache.add_instrument(instrument).expect("add instrument");
3790
3791 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3792 let position = cache
3793 .position_owned(&position_id)
3794 .expect("orderless leg position replayed");
3795
3796 assert_eq!(report.applied_entries, 1);
3797 assert_eq!(report.ignored_entries, 0);
3798 assert!(cache.order_owned(&client_order_id).is_none());
3799 assert_eq!(cache.position_id(&client_order_id), None);
3800 assert_eq!(position.event_count(), 1);
3801 assert_eq!(position.last_event(), Some(filled.clone()));
3802 assert_eq!(position.trade_ids(), vec![filled.trade_id]);
3803 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
3804 assert!(cache.check_integrity());
3805 }
3806
3807 #[rstest]
3808 fn orderless_netting_reopen_replay_does_not_treat_closed_position_as_flip() {
3809 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3810 let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3811 let position_id = PositionId::from("P-ORDERLESS-NETTING");
3812 let opening_fill = OrderFilledSpec::builder()
3813 .instrument_id(instrument.id())
3814 .client_order_id(client_order_id)
3815 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-1"))
3816 .trade_id(TradeId::from("T-SPREAD-LEG-1"))
3817 .order_side(OrderSide::Buy)
3818 .last_qty(Quantity::from(1))
3819 .position_id(position_id)
3820 .build();
3821 let closing_fill = OrderFilledSpec::builder()
3822 .instrument_id(instrument.id())
3823 .client_order_id(client_order_id)
3824 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-2"))
3825 .trade_id(TradeId::from("T-SPREAD-LEG-2"))
3826 .order_side(OrderSide::Sell)
3827 .last_qty(Quantity::from(1))
3828 .position_id(position_id)
3829 .build();
3830 let mut closed_position = Position::new(&instrument, opening_fill);
3831 closed_position.apply(&closing_fill);
3832 assert!(closed_position.is_closed());
3833
3834 let reopening_fill = OrderFilledSpec::builder()
3835 .instrument_id(instrument.id())
3836 .client_order_id(client_order_id)
3837 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-3"))
3838 .trade_id(TradeId::from("T-SPREAD-LEG-3"))
3839 .order_side(OrderSide::Sell)
3840 .last_qty(Quantity::from(1))
3841 .position_id(position_id)
3842 .build();
3843 let mut reopened_position = closed_position.clone();
3844 reopened_position.apply(&reopening_fill);
3845 let reopened = PositionOpened::create(
3846 &reopened_position,
3847 &reopening_fill,
3848 UUID4::new(),
3849 reopening_fill.ts_init,
3850 );
3851 let reader = reader_with_entries(
3852 "run-orderless-netting-reopen-replay",
3853 &[
3854 append_order_event(1, &OrderEventAny::Filled(reopening_fill.clone())),
3855 append_position_event(2, &PositionEvent::PositionOpened(reopened)),
3856 ],
3857 );
3858 let mut cache = Cache::default();
3859 cache.add_instrument(instrument).expect("add instrument");
3860 cache
3861 .add_position_without_order(&closed_position, OmsType::Netting)
3862 .expect("seed closed orderless position");
3863
3864 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3865 let position = cache
3866 .position_owned(&position_id)
3867 .expect("netting position reopened");
3868
3869 assert_eq!(report.applied_entries, 2);
3870 assert_eq!(report.ignored_entries, 0);
3871 assert!(position.is_open());
3872 assert_eq!(position.side, PositionSide::Short);
3873 assert_eq!(position.entry, OrderSide::Sell);
3874 assert_eq!(position.quantity, Quantity::from(1));
3875 assert_eq!(position.opening_order_id, client_order_id);
3876 assert_eq!(position.closing_order_id, None);
3877 assert_eq!(position.event_count(), 1);
3878 assert_eq!(position.trade_ids(), vec![reopening_fill.trade_id]);
3879 assert_eq!(position.last_event(), Some(reopening_fill));
3880 assert_eq!(cache.oms_type(&position_id), Some(OmsType::Netting));
3881 assert!(cache.orders_for_position(&position_id).is_empty());
3882 assert_eq!(cache.position_id(&client_order_id), None);
3883 assert!(cache.check_integrity());
3884 }
3885
3886 #[rstest]
3887 fn orderless_hedging_flip_replay_recreates_replacement_position() {
3888 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3889 let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3890 let first_position_id = PositionId::from("P-ORDERLESS-LEG-1");
3891 let replacement_position_id = PositionId::from("P-ORDERLESS-LEG-2");
3892 let opening_fill = OrderFilledSpec::builder()
3893 .instrument_id(instrument.id())
3894 .client_order_id(client_order_id)
3895 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-1"))
3896 .trade_id(TradeId::from("T-SPREAD-LEG-1"))
3897 .order_side(OrderSide::Buy)
3898 .last_qty(Quantity::from(1))
3899 .last_px(Price::from("1.00000"))
3900 .position_id(first_position_id)
3901 .commission(Money::from("1 USD"))
3902 .build();
3903 let first_position = Position::new(&instrument, opening_fill.clone());
3904 let first_opened = PositionOpened::create(
3905 &first_position,
3906 &opening_fill,
3907 UUID4::new(),
3908 opening_fill.ts_init,
3909 );
3910
3911 let flip_fill = OrderFilledSpec::builder()
3912 .instrument_id(instrument.id())
3913 .client_order_id(client_order_id)
3914 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-2"))
3915 .trade_id(TradeId::from("T-SPREAD-LEG-2"))
3916 .order_side(OrderSide::Sell)
3917 .last_qty(Quantity::from(2))
3918 .last_px(Price::from("1.10000"))
3919 .position_id(first_position_id)
3920 .commission(Money::from("2 USD"))
3921 .build();
3922 let mut closing_fragment = flip_fill.clone();
3923 closing_fragment.last_qty = Quantity::from(1);
3924 closing_fragment.commission = Some(Money::from("1 USD"));
3925 let mut closed_position = first_position;
3926 closed_position.apply(&closing_fragment);
3927 let first_closed = PositionClosed::create(
3928 &closed_position,
3929 &closing_fragment,
3930 UUID4::new(),
3931 flip_fill.ts_init,
3932 );
3933
3934 let mut opening_fragment = flip_fill.clone();
3935 opening_fragment.last_qty = Quantity::from(1);
3936 opening_fragment.position_id = Some(replacement_position_id);
3937 opening_fragment.commission = Some(Money::from("1 USD"));
3938 opening_fragment.event_id = UUID4::new();
3939 opening_fragment.causation_id = Some(flip_fill.event_id);
3940 let mut replacement_position = Position::new(&instrument, opening_fragment.clone());
3941 let replacement_opened = PositionOpened::create(
3942 &replacement_position,
3943 &opening_fragment,
3944 UUID4::new(),
3945 opening_fragment.ts_init,
3946 );
3947
3948 let subsequent_fill = OrderFilledSpec::builder()
3949 .instrument_id(instrument.id())
3950 .client_order_id(client_order_id)
3951 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-3"))
3952 .trade_id(TradeId::from("T-SPREAD-LEG-3"))
3953 .order_side(OrderSide::Sell)
3954 .last_qty(Quantity::from(1))
3955 .last_px(Price::from("1.20000"))
3956 .position_id(replacement_position_id)
3957 .commission(Money::from("1 USD"))
3958 .build();
3959 replacement_position.apply(&subsequent_fill);
3960 let replacement_changed = PositionChanged::create(
3961 &replacement_position,
3962 &subsequent_fill,
3963 UUID4::new(),
3964 subsequent_fill.ts_init,
3965 );
3966 let reader = reader_with_entries(
3967 "run-orderless-hedging-flip-replay",
3968 &[
3969 append_order_event(1, &OrderEventAny::Filled(opening_fill)),
3970 append_position_event(2, &PositionEvent::PositionOpened(first_opened)),
3971 append_order_event(3, &OrderEventAny::Filled(flip_fill.clone())),
3972 append_position_event(4, &PositionEvent::PositionClosed(first_closed)),
3973 append_position_event(5, &PositionEvent::PositionOpened(replacement_opened)),
3974 append_order_event(6, &OrderEventAny::Filled(subsequent_fill.clone())),
3975 append_position_event(7, &PositionEvent::PositionChanged(replacement_changed)),
3976 ],
3977 );
3978 let mut cache = Cache::default();
3979 cache.add_instrument(instrument).expect("add instrument");
3980
3981 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3982
3983 assert_eq!(report.applied_entries, 7);
3984 assert_eq!(report.ignored_entries, 0);
3985 let closed = cache
3986 .position_owned(&first_position_id)
3987 .expect("closed predecessor replayed");
3988 assert!(closed.is_closed());
3989 assert_eq!(closed.event_count(), 2);
3990 let closing_fragments = closed.fill_fragments(client_order_id, flip_fill.trade_id);
3991 assert_eq!(closing_fragments.len(), 1);
3992 assert_eq!(closing_fragments[0].last_qty, Quantity::from(1));
3993 assert_eq!(closing_fragments[0].commission, Some(Money::from("1 USD")));
3994 assert_eq!(closing_fragments[0].event_id, flip_fill.event_id);
3995
3996 let replacement = cache
3997 .position_owned(&replacement_position_id)
3998 .expect("open replacement replayed");
3999 assert!(replacement.is_open());
4000 assert_eq!(replacement.side, PositionSide::Short);
4001 assert_eq!(replacement.quantity, Quantity::from(2));
4002 assert_eq!(replacement.event_count(), 2);
4003 assert_eq!(
4004 cache.oms_type(&replacement_position_id),
4005 Some(OmsType::Hedging)
4006 );
4007 assert!(replacement.trade_ids().contains(&flip_fill.trade_id));
4008 assert!(replacement.trade_ids().contains(&subsequent_fill.trade_id));
4009 let opening_fragments = replacement.fill_fragments(client_order_id, flip_fill.trade_id);
4010 assert_eq!(opening_fragments.len(), 1);
4011 assert_eq!(opening_fragments[0].last_qty, Quantity::from(1));
4012 assert_eq!(opening_fragments[0].commission, Some(Money::from("1 USD")));
4013 assert_eq!(opening_fragments[0].causation_id, Some(flip_fill.event_id));
4014
4015 assert!(cache.orders_for_position(&first_position_id).is_empty());
4016 assert!(
4017 cache
4018 .orders_for_position(&replacement_position_id)
4019 .is_empty()
4020 );
4021 assert_eq!(cache.position_id(&client_order_id), None);
4022 assert!(cache.check_integrity());
4023 }
4024
4025 #[rstest]
4026 fn single_entry_orderless_flip_is_rejected_before_mutating_position() {
4027 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4028 let position_id = PositionId::from("P-ORDERLESS-SINGLE-ENTRY");
4029 let opening_fill = OrderFilledSpec::builder()
4030 .instrument_id(instrument.id())
4031 .client_order_id(ClientOrderId::from("SPREAD-LEG-SINGLE"))
4032 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-SINGLE-1"))
4033 .trade_id(TradeId::from("T-SPREAD-LEG-SINGLE-1"))
4034 .order_side(OrderSide::Buy)
4035 .last_qty(Quantity::from(1))
4036 .position_id(position_id)
4037 .build();
4038 let original = Position::new(&instrument, opening_fill.clone());
4039 let flip_fill = OrderFilledSpec::builder()
4040 .instrument_id(instrument.id())
4041 .client_order_id(opening_fill.client_order_id)
4042 .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-SINGLE-2"))
4043 .trade_id(TradeId::from("T-SPREAD-LEG-SINGLE-2"))
4044 .order_side(OrderSide::Sell)
4045 .last_qty(Quantity::from(2))
4046 .position_id(position_id)
4047 .build();
4048 let entry = append_order_event(1, &OrderEventAny::Filled(flip_fill)).entry;
4049 let mut cache = Cache::default();
4050 cache
4051 .add_instrument(instrument)
4052 .expect("add replay instrument");
4053 cache
4054 .add_position_without_order(&original, OmsType::Hedging)
4055 .expect("seed orderless position");
4056
4057 let error = apply_cache_replay_entry(&mut cache, &entry)
4058 .expect_err("single-entry API cannot defer the opening fragment");
4059 let after = cache
4060 .position_owned(&position_id)
4061 .expect("position retained");
4062
4063 assert!(error.to_string().contains("snapshot-tail replay context"));
4064 assert_eq!(after.side, original.side);
4065 assert_eq!(after.quantity, original.quantity);
4066 assert_eq!(after.event_count(), original.event_count());
4067 assert_eq!(after.trade_ids(), original.trade_ids());
4068 }
4069
4070 #[rstest]
4071 fn order_fill_replay_without_instrument_counts_fill_as_ignored() {
4072 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4075 let position_id = PositionId::from("P-NO-INSTR");
4076 let initialized = OrderInitializedSpec::builder()
4077 .instrument_id(instrument.id())
4078 .build();
4079 let client_order_id = initialized.client_order_id;
4080 let submitted = OrderSubmittedSpec::builder()
4081 .instrument_id(instrument.id())
4082 .client_order_id(client_order_id)
4083 .build();
4084 let accepted = OrderAcceptedSpec::builder()
4085 .instrument_id(instrument.id())
4086 .client_order_id(client_order_id)
4087 .account_id(submitted.account_id)
4088 .build();
4089 let filled = OrderFilledSpec::builder()
4090 .instrument_id(instrument.id())
4091 .client_order_id(client_order_id)
4092 .venue_order_id(accepted.venue_order_id)
4093 .account_id(submitted.account_id)
4094 .position_id(position_id)
4095 .build();
4096 let reader = reader_with_entries(
4097 "run-fill-no-instrument",
4098 &[
4099 append_order_event(1, &OrderEventAny::Initialized(initialized)),
4100 append_order_event(2, &OrderEventAny::Submitted(submitted)),
4101 append_order_event(3, &OrderEventAny::Accepted(accepted)),
4102 append_order_event(4, &OrderEventAny::Filled(filled)),
4103 ],
4104 );
4105 let mut cache = Cache::default();
4106
4107 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4108
4109 assert_eq!(report.applied_entries, 3);
4110 assert_eq!(report.ignored_entries, 1);
4111 assert!(cache.position_owned(&position_id).is_none());
4112 }
4113
4114 #[rstest]
4115 fn order_fill_void_replay_updates_order_and_position() {
4116 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4117 let position_id = PositionId::from("P-VOID-001");
4118 let initialized = OrderInitializedSpec::builder()
4119 .instrument_id(instrument.id())
4120 .build();
4121 let client_order_id = initialized.client_order_id;
4122 let submitted = OrderSubmittedSpec::builder()
4123 .instrument_id(instrument.id())
4124 .client_order_id(client_order_id)
4125 .build();
4126 let accepted = OrderAcceptedSpec::builder()
4127 .instrument_id(instrument.id())
4128 .client_order_id(client_order_id)
4129 .account_id(submitted.account_id)
4130 .build();
4131 let filled = OrderFilledSpec::builder()
4132 .instrument_id(instrument.id())
4133 .client_order_id(client_order_id)
4134 .venue_order_id(accepted.venue_order_id)
4135 .account_id(submitted.account_id)
4136 .position_id(position_id)
4137 .commission(Money::from("1 USD"))
4138 .build();
4139 let fill_voided = OrderFillVoidedSpec::builder()
4140 .trader_id(filled.trader_id)
4141 .strategy_id(filled.strategy_id)
4142 .instrument_id(filled.instrument_id)
4143 .client_order_id(filled.client_order_id)
4144 .venue_order_id(filled.venue_order_id)
4145 .account_id(filled.account_id)
4146 .trade_id(filled.trade_id)
4147 .voided_qty(Quantity::from(50_000))
4148 .commission_voided(Money::from("0.40 USD"))
4149 .order_side(filled.order_side)
4150 .order_type(filled.order_type)
4151 .last_px(filled.last_px)
4152 .currency(filled.currency)
4153 .liquidity_side(filled.liquidity_side)
4154 .position_id(position_id)
4155 .is_reopened(true)
4156 .build();
4157 let reader = reader_with_entries(
4158 "run-fill-void-replay",
4159 &[
4160 append_order_event(1, &OrderEventAny::Initialized(initialized)),
4161 append_order_event(2, &OrderEventAny::Submitted(submitted)),
4162 append_order_event(3, &OrderEventAny::Accepted(accepted)),
4163 append_order_event(4, &OrderEventAny::Filled(filled)),
4164 append_order_event(5, &OrderEventAny::FillVoided(fill_voided.clone())),
4165 ],
4166 );
4167 let mut cache = Cache::default();
4168 cache.add_instrument(instrument).expect("add instrument");
4169
4170 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4171 let order = cache.order_owned(&client_order_id).expect("order replayed");
4172 let position = cache
4173 .position_owned(&position_id)
4174 .expect("position replayed");
4175
4176 assert_eq!(report.applied_entries, 5);
4177 assert_eq!(report.ignored_entries, 0);
4178 assert_eq!(order.status(), OrderStatus::PartiallyFilled);
4179 assert_eq!(order.filled_qty(), Quantity::from(50_000));
4180 assert_eq!(order.voided_qty(), Quantity::from(50_000));
4181 assert_eq!(position.quantity, Quantity::from(50_000));
4182 assert_eq!(position.commissions(), vec![Money::from("0.60 USD")]);
4183 assert_eq!(position.fill_voids.len(), 1);
4184 assert_eq!(position.fill_voids[0].event, fill_voided);
4185 }
4186
4187 #[rstest]
4188 fn order_fill_void_replay_updates_order_without_position() {
4189 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4190 let initialized = OrderInitializedSpec::builder()
4191 .instrument_id(instrument.id())
4192 .build();
4193 let client_order_id = initialized.client_order_id;
4194 let submitted = OrderSubmittedSpec::builder()
4195 .instrument_id(instrument.id())
4196 .client_order_id(client_order_id)
4197 .build();
4198 let accepted = OrderAcceptedSpec::builder()
4199 .instrument_id(instrument.id())
4200 .client_order_id(client_order_id)
4201 .account_id(submitted.account_id)
4202 .build();
4203 let filled = OrderFilledSpec::builder()
4204 .instrument_id(instrument.id())
4205 .client_order_id(client_order_id)
4206 .venue_order_id(accepted.venue_order_id)
4207 .account_id(submitted.account_id)
4208 .build();
4209 let fill_voided = OrderFillVoidedSpec::builder()
4210 .trader_id(filled.trader_id)
4211 .strategy_id(filled.strategy_id)
4212 .instrument_id(filled.instrument_id)
4213 .client_order_id(filled.client_order_id)
4214 .venue_order_id(filled.venue_order_id)
4215 .account_id(filled.account_id)
4216 .trade_id(filled.trade_id)
4217 .voided_qty(Quantity::from(50_000))
4218 .order_side(filled.order_side)
4219 .order_type(filled.order_type)
4220 .last_px(filled.last_px)
4221 .currency(filled.currency)
4222 .liquidity_side(filled.liquidity_side)
4223 .is_reopened(true)
4224 .build();
4225 let reader = reader_with_entries(
4226 "run-order-only-fill-void-replay",
4227 &[
4228 append_order_event(1, &OrderEventAny::Initialized(initialized)),
4229 append_order_event(2, &OrderEventAny::Submitted(submitted)),
4230 append_order_event(3, &OrderEventAny::Accepted(accepted)),
4231 append_order_event(4, &OrderEventAny::Filled(filled)),
4232 append_order_event(5, &OrderEventAny::FillVoided(fill_voided)),
4233 ],
4234 );
4235 let mut cache = Cache::default();
4236 cache.add_instrument(instrument).expect("add instrument");
4237
4238 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4239 let order = cache.order_owned(&client_order_id).expect("order replayed");
4240
4241 assert_eq!(report.applied_entries, 5);
4242 assert_eq!(report.ignored_entries, 0);
4243 assert_eq!(order.status(), OrderStatus::PartiallyFilled);
4244 assert_eq!(order.filled_qty(), Quantity::from(50_000));
4245 assert_eq!(order.voided_qty(), Quantity::from(50_000));
4246 assert_eq!(cache.positions_total_count(None, None, None, None, None), 0);
4247 }
4248
4249 #[rstest]
4250 fn position_lifecycle_replay_updates_existing_position() {
4251 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4252 let position_id = PositionId::from("P-001");
4253 let opened_fill = OrderFilledSpec::builder()
4254 .instrument_id(instrument.id())
4255 .client_order_id(ClientOrderId::from("O-OPEN"))
4256 .venue_order_id(VenueOrderId::from("V-OPEN"))
4257 .trade_id(TradeId::from("T-OPEN"))
4258 .position_id(position_id)
4259 .last_qty(Quantity::from("1"))
4260 .last_px(Price::from("1.00000"))
4261 .build();
4262 let mut live_position = Position::new(&instrument, opened_fill.clone());
4263 let opened = PositionOpened::create(
4264 &live_position,
4265 &opened_fill,
4266 UUID4::new(),
4267 UnixNanos::from(10),
4268 );
4269
4270 let changed_fill = OrderFilledSpec::builder()
4271 .instrument_id(instrument.id())
4272 .client_order_id(ClientOrderId::from("O-CHANGE"))
4273 .venue_order_id(VenueOrderId::from("V-CHANGE"))
4274 .trade_id(TradeId::from("T-CHANGE"))
4275 .position_id(position_id)
4276 .last_qty(Quantity::from("2"))
4277 .last_px(Price::from("1.10000"))
4278 .build();
4279 live_position.apply(&changed_fill);
4280 let changed = PositionChanged::create(
4281 &live_position,
4282 &changed_fill,
4283 UUID4::new(),
4284 UnixNanos::from(20),
4285 );
4286
4287 let closed_fill = OrderFilledSpec::builder()
4288 .instrument_id(instrument.id())
4289 .client_order_id(ClientOrderId::from("O-CLOSE"))
4290 .venue_order_id(VenueOrderId::from("V-CLOSE"))
4291 .trade_id(TradeId::from("T-CLOSE"))
4292 .order_side(OrderSide::Sell)
4293 .position_id(position_id)
4294 .last_qty(Quantity::from("3"))
4295 .last_px(Price::from("1.20000"))
4296 .build();
4297 live_position.apply(&closed_fill);
4298 let closed = PositionClosed::create(
4299 &live_position,
4300 &closed_fill,
4301 UUID4::new(),
4302 UnixNanos::from(30),
4303 );
4304
4305 let mut stale_position = Position::new(&instrument, opened_fill);
4306 stale_position.signed_qty = 9.0;
4307 stale_position.quantity = Quantity::from("9");
4308 let mut cache = Cache::default();
4309 cache
4310 .add_position(&stale_position, OmsType::Unspecified)
4311 .expect("seed stale position");
4312
4313 let opened_entry =
4314 append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
4315 let changed_entry =
4316 append_position_event(2, &PositionEvent::PositionChanged(changed.clone())).entry;
4317 let closed_entry =
4318 append_position_event(3, &PositionEvent::PositionClosed(closed.clone())).entry;
4319
4320 assert!(apply_cache_replay_entry(&mut cache, &opened_entry).expect("apply opened"));
4321 let replayed = cache
4322 .position_owned(&position_id)
4323 .expect("position after opened");
4324 assert_eq!(replayed.signed_qty.to_bits(), opened.signed_qty.to_bits());
4325 assert_eq!(replayed.quantity, opened.quantity);
4326 assert_eq!(replayed.ts_last, opened.ts_event);
4327
4328 assert!(apply_cache_replay_entry(&mut cache, &changed_entry).expect("apply changed"));
4329 let replayed = cache
4330 .position_owned(&position_id)
4331 .expect("position after changed");
4332 assert_eq!(replayed.signed_qty.to_bits(), changed.signed_qty.to_bits());
4333 assert_eq!(replayed.quantity, changed.quantity);
4334 assert_eq!(replayed.peak_qty, changed.peak_quantity);
4335 assert_eq!(
4336 replayed.avg_px_open.to_bits(),
4337 changed.avg_px_open.to_bits()
4338 );
4339 assert!(replayed.is_open());
4340
4341 assert!(apply_cache_replay_entry(&mut cache, &closed_entry).expect("apply closed"));
4342 let replayed = cache
4343 .position_owned(&position_id)
4344 .expect("position after closed");
4345 assert_eq!(replayed.signed_qty.to_bits(), closed.signed_qty.to_bits());
4346 assert_eq!(replayed.quantity, closed.quantity);
4347 assert_eq!(replayed.closing_order_id, closed.closing_order_id);
4348 assert_eq!(replayed.duration_ns, closed.duration);
4349 assert!(replayed.is_closed());
4350 assert!(cache.is_position_closed(&position_id));
4351 }
4352
4353 #[rstest]
4354 fn position_opened_replay_replaces_realized_pnl() {
4355 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4356 let position_id = PositionId::from("P-001");
4357 let fill = OrderFilledSpec::builder()
4358 .instrument_id(instrument.id())
4359 .position_id(position_id)
4360 .commission(Money::from("1 USD"))
4361 .build();
4362 let position = Position::new(&instrument, fill.clone());
4363 let mut opened =
4364 PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
4365 assert_eq!(opened.realized_pnl, Some(Money::from("-1 USD")));
4366
4367 let mut stale_position = position;
4368 stale_position.realized_pnl = Some(Money::from("9 USD"));
4369 let mut cache = Cache::default();
4370 cache
4371 .add_position(&stale_position, OmsType::Unspecified)
4372 .expect("seed stale position");
4373 let entry = append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
4374
4375 assert!(apply_cache_replay_entry(&mut cache, &entry).expect("apply opened"));
4376 assert_eq!(
4377 cache
4378 .position_owned(&position_id)
4379 .expect("position after opened")
4380 .realized_pnl,
4381 Some(Money::from("-1 USD")),
4382 );
4383
4384 opened.realized_pnl = None;
4385 let entry = append_position_event(2, &PositionEvent::PositionOpened(opened)).entry;
4386
4387 assert!(apply_cache_replay_entry(&mut cache, &entry).expect("apply opened without PnL"));
4388 assert_eq!(
4389 cache
4390 .position_owned(&position_id)
4391 .expect("position after opened without PnL")
4392 .realized_pnl,
4393 None,
4394 );
4395 }
4396
4397 #[rstest]
4398 fn position_adjustment_replay_updates_existing_position() {
4399 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4400 let position_id = PositionId::from("P-001");
4401 let fill = OrderFilledSpec::builder()
4402 .instrument_id(instrument.id())
4403 .position_id(position_id)
4404 .build();
4405 let position = Position::new(&instrument, fill.clone());
4406 let adjustment = PositionAdjusted::new(
4407 fill.trader_id,
4408 fill.strategy_id,
4409 fill.instrument_id,
4410 position_id,
4411 fill.account_id,
4412 PositionAdjustmentType::Funding,
4413 None,
4414 Some(Money::from("2 USD")),
4415 Some(Ustr::from("funding")),
4416 UUID4::new(),
4417 UnixNanos::from(10),
4418 UnixNanos::from(11),
4419 );
4420 let entry = append_position_event(1, &PositionEvent::PositionAdjusted(adjustment)).entry;
4421 let mut cache = Cache::default();
4422 cache
4423 .add_position(&position, OmsType::Unspecified)
4424 .expect("seed position");
4425
4426 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
4427 let position = cache
4428 .position_owned(&position_id)
4429 .expect("position updated");
4430
4431 assert!(applied);
4432 assert_eq!(position.adjustments, vec![adjustment]);
4433 assert_eq!(position.realized_pnl, Some(Money::from("2 USD")));
4434 assert_eq!(position.ts_last, adjustment.ts_event);
4435 }
4436
4437 #[rstest]
4438 fn position_event_for_unknown_position_is_counted_as_ignored() {
4439 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4440 let position_id = PositionId::from("P-MISSING");
4441 let fill = OrderFilledSpec::builder()
4442 .instrument_id(instrument.id())
4443 .position_id(position_id)
4444 .build();
4445 let position = Position::new(&instrument, fill.clone());
4446 let opened = PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
4447 let entry = append_position_event(1, &PositionEvent::PositionOpened(opened)).entry;
4448 let mut cache = Cache::default();
4449
4450 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
4451
4452 assert!(
4453 !applied,
4454 "missing position must count as ignored, not applied"
4455 );
4456 }
4457
4458 #[rstest]
4459 fn order_filled_with_no_order_side_is_an_apply_error_not_a_panic() {
4460 let payload = IndexMap::from([("order_side", "NO_ORDER_SIDE")]);
4464 let entry = append_serde_payload(1, PAYLOAD_TYPE_ORDER_FILLED, &payload).entry;
4465 let mut cache = Cache::default();
4466
4467 let err = apply_cache_replay_entry(&mut cache, &entry).expect_err("must reject");
4468
4469 match err {
4470 CacheReplayError::Apply { seq, message, .. } => {
4471 assert_eq!(seq, 1);
4472 assert!(message.contains("NoOrderSide"), "message was: {message}");
4473 }
4474 other => panic!("expected Apply, was {other:?}"),
4475 }
4476 }
4477
4478 #[rstest]
4479 fn duplicate_position_fill_is_not_applied_twice() {
4480 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4481 let position_id = PositionId::from("P-001");
4482 let fill = OrderFilledSpec::builder()
4483 .instrument_id(instrument.id())
4484 .position_id(position_id)
4485 .commission(Money::from("1 USD"))
4486 .build();
4487 let position = Position::new(&instrument, fill.clone());
4488 let entry = append_order_event(1, &OrderEventAny::Filled(fill.clone())).entry;
4489 let mut cache = Cache::default();
4490 cache
4491 .add_position(&position, OmsType::Unspecified)
4492 .expect("seed position");
4493
4494 let applied = apply_fill_to_position(&mut cache, &entry, &fill, false).expect("apply fill");
4495 let position = cache
4496 .position_owned(&position_id)
4497 .expect("position updated");
4498
4499 assert!(
4500 applied,
4501 "duplicate trade within an open episode is the idempotent no-op and counts as applied"
4502 );
4503 assert_eq!(position.event_count(), 1);
4504 assert_eq!(position.trade_ids(), vec![fill.trade_id]);
4505 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
4506 }
4507
4508 #[rstest]
4509 fn flat_position_with_reused_trade_id_is_ignored_like_live() {
4510 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4514 let position_id = PositionId::from("P-REOPEN");
4515 let open_fill = OrderFilledSpec::builder()
4516 .instrument_id(instrument.id())
4517 .position_id(position_id)
4518 .order_side(OrderSide::Buy)
4519 .trade_id(TradeId::from("T-1"))
4520 .commission(Money::from("2 USD"))
4521 .build();
4522 let close_fill = OrderFilledSpec::builder()
4523 .instrument_id(instrument.id())
4524 .position_id(position_id)
4525 .order_side(OrderSide::Sell)
4526 .trade_id(TradeId::from("T-2"))
4527 .build();
4528 let mut position = Position::new(&instrument, open_fill);
4529 position.apply(&close_fill);
4530 assert_eq!(position.side, PositionSide::Flat);
4531
4532 let dup_fill = OrderFilledSpec::builder()
4533 .instrument_id(instrument.id())
4534 .position_id(position_id)
4535 .order_side(OrderSide::Buy)
4536 .trade_id(TradeId::from("T-1"))
4537 .commission(Money::from("1 USD"))
4538 .build();
4539 let entry = append_order_event(3, &OrderEventAny::Filled(dup_fill.clone())).entry;
4540 let mut cache = Cache::default();
4541 cache
4542 .add_position(&position, OmsType::Unspecified)
4543 .expect("seed position");
4544
4545 let applied = apply_fill_to_position(&mut cache, &entry, &dup_fill, false).expect("apply");
4546 let position = cache
4547 .position_owned(&position_id)
4548 .expect("position updated");
4549
4550 assert!(
4551 applied,
4552 "a historical duplicate is the idempotent no-op and counts as applied"
4553 );
4554 assert_eq!(position.side, PositionSide::Flat);
4555 assert_eq!(position.event_count(), 2);
4556 assert_eq!(position.trade_ids().len(), 2);
4557 assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
4558 }
4559
4560 #[rstest]
4561 fn fill_for_missing_instrument_is_counted_as_ignored() {
4562 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4563 let position_id = PositionId::from("P-NO-INSTR");
4564 let fill = OrderFilledSpec::builder()
4565 .instrument_id(instrument.id())
4566 .position_id(position_id)
4567 .build();
4568 let entry = append_order_event(1, &OrderEventAny::Filled(fill.clone())).entry;
4569 let mut cache = Cache::default();
4570
4571 let applied = apply_fill_to_position(&mut cache, &entry, &fill, false).expect("apply");
4572
4573 assert!(
4574 !applied,
4575 "a position that cannot open must count as ignored, was claimed applied"
4576 );
4577 assert!(cache.position_owned(&position_id).is_none());
4578 }
4579
4580 #[rstest]
4581 fn corrupt_supported_payload_returns_decode_error() {
4582 let reader = reader_with_entries(
4583 "run-decode-error",
4584 &[append_payload(
4585 1,
4586 PAYLOAD_TYPE_ACCOUNT_STATE,
4587 Bytes::copy_from_slice(&[0xc1]),
4588 )],
4589 );
4590 let mut cache = Cache::default();
4591
4592 let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("decode error");
4593
4594 match err {
4595 CacheReplayError::Decode {
4596 seq, payload_type, ..
4597 } => {
4598 assert_eq!(seq, 1);
4599 assert_eq!(payload_type, PAYLOAD_TYPE_ACCOUNT_STATE);
4600 }
4601 other => panic!("expected Decode, was {other:?}"),
4602 }
4603 }
4604
4605 #[rstest]
4606 fn missing_order_event_returns_apply_error() {
4607 let submitted = OrderSubmittedSpec::builder().build();
4608 let reader = reader_with_entries(
4609 "run-apply-error",
4610 &[append_order_event(1, &OrderEventAny::Submitted(submitted))],
4611 );
4612 let mut cache = Cache::default();
4613
4614 let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("apply error");
4615
4616 match err {
4617 CacheReplayError::Apply {
4618 seq,
4619 payload_type,
4620 message,
4621 } => {
4622 assert_eq!(seq, 1);
4623 assert_eq!(payload_type, PAYLOAD_TYPE_ORDER_SUBMITTED);
4624 assert!(
4625 message.contains("not found"),
4626 "message should include cache apply failure: {message}",
4627 );
4628 }
4629 other => panic!("expected Apply, was {other:?}"),
4630 }
4631 }
4632
4633 #[rstest]
4634 fn restore_cache_from_sealed_run_restores_snapshot_and_tail() {
4635 let tmp = TempDir::new().expect("tempdir");
4636 let run_id = "sealed-replay";
4637 let instance_id = "trader-001";
4638 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4639 let fill = OrderFilledSpec::builder()
4640 .instrument_id(instrument.id())
4641 .position_id(PositionId::from("P-SEALED-REPLAY-1"))
4642 .build();
4643 let position = Position::new(&instrument, fill);
4644 let mut snapshot_cache = Cache::default();
4645 let snapshot_ref = snapshot_cache
4646 .snapshot_position_encoded(&position)
4647 .expect("snapshot position");
4648 let anchored_state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
4649 let replayed_state = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
4650
4651 {
4652 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4653 backend.open_run(manifest(run_id)).expect("open run");
4654 backend
4655 .append_batch(&[append_account_state(1, &anchored_state)])
4656 .expect("append anchored state");
4657 backend
4658 .record_snapshot_anchor(SnapshotAnchor::new(
4659 1,
4660 snapshot_ref.blob_ref.clone(),
4661 compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
4662 ))
4663 .expect("record snapshot anchor");
4664 backend
4665 .append_batch(&[append_account_state(2, &replayed_state)])
4666 .expect("append replay tail");
4667 backend.seal(RunStatus::Ended).expect("seal run");
4668 }
4669
4670 let mut cache = Cache::default();
4671 cache
4672 .add(&snapshot_ref.blob_ref, snapshot_ref.blob.clone())
4673 .expect("seed snapshot blob");
4674
4675 let report = restore_cache_from_sealed_run(
4676 &mut cache,
4677 tmp.path().to_path_buf(),
4678 instance_id,
4679 run_id,
4680 )
4681 .expect("restore sealed run");
4682
4683 let frames = cache
4684 .position_snapshot_bytes(&position.id)
4685 .expect("restored position snapshot");
4686 let account = cache
4687 .account_owned(&replayed_state.account_id)
4688 .expect("replayed account");
4689
4690 assert_eq!(report.manifest.run_id, run_id);
4691 assert_eq!(report.manifest.status, RunStatus::Ended);
4692 assert_eq!(report.cache.plan.from_seq, 2);
4693 assert_eq!(report.cache.applied_entries, 1);
4694 assert_eq!(report.cache.ignored_entries, 0);
4695 assert_eq!(frames.len(), 1);
4696 assert_eq!(frames[0].as_slice(), snapshot_ref.blob.as_ref());
4697 assert_eq!(account.events(), vec![replayed_state]);
4698 }
4699
4700 #[rstest]
4701 fn restore_cache_from_sealed_run_rejects_snapshot_hash_mismatch() {
4702 let tmp = TempDir::new().expect("tempdir");
4703 let run_id = "sealed-replay-bad-snapshot";
4704 let instance_id = "trader-001";
4705 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4706 let fill = OrderFilledSpec::builder()
4707 .instrument_id(instrument.id())
4708 .position_id(PositionId::from("P-SEALED-REPLAY-BAD-SNAPSHOT-1"))
4709 .build();
4710 let position = Position::new(&instrument, fill);
4711 let mut snapshot_cache = Cache::default();
4712 let snapshot_ref = snapshot_cache
4713 .snapshot_position_encoded(&position)
4714 .expect("snapshot position");
4715
4716 {
4717 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4718 backend.open_run(manifest(run_id)).expect("open run");
4719 backend
4720 .record_snapshot_anchor(SnapshotAnchor::new(
4721 0,
4722 snapshot_ref.blob_ref.clone(),
4723 compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
4724 ))
4725 .expect("record snapshot anchor");
4726 backend.seal(RunStatus::Ended).expect("seal run");
4727 }
4728
4729 let mut cache = Cache::default();
4730 cache
4731 .add(
4732 &snapshot_ref.blob_ref,
4733 Bytes::from_static(b"tampered snapshot"),
4734 )
4735 .expect("seed tampered snapshot blob");
4736
4737 let err = restore_cache_from_sealed_run(
4738 &mut cache,
4739 tmp.path().to_path_buf(),
4740 instance_id,
4741 run_id,
4742 )
4743 .expect_err("hash mismatch");
4744
4745 match err {
4746 CacheReplayError::SnapshotRestore { blob_ref, message } => {
4747 assert_eq!(blob_ref, snapshot_ref.blob_ref);
4748 assert!(
4749 message.contains("content_hash mismatch"),
4750 "message should explain hash mismatch: {message}",
4751 );
4752 }
4753 other => panic!("expected SnapshotRestore, was {other:?}"),
4754 }
4755 }
4756
4757 #[rstest]
4758 fn open_event_store_replay_source_rejects_running_run() {
4759 let tmp = TempDir::new().expect("tempdir");
4760 let run_id = "running-replay";
4761 {
4762 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4763 backend.open_run(manifest(run_id)).expect("open run");
4764 }
4765
4766 let err = open_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
4767 .expect_err("running source must fail");
4768
4769 assert!(
4770 err.to_string().contains("not sealed"),
4771 "error should name sealed-run requirement: {err}",
4772 );
4773 }
4774
4775 #[rstest]
4776 fn validate_event_store_replay_source_rejects_quarantined_run() {
4777 let tmp = TempDir::new().expect("tempdir");
4778 let run_id = "quarantined-replay";
4779 {
4780 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4781 backend.open_run(manifest(run_id)).expect("open run");
4782 backend
4783 .append_batch(&[append_payload(1, "RunStarted", Bytes::new())])
4784 .expect("append");
4785 backend.seal(RunStatus::Quarantined).expect("seal run");
4786 }
4787
4788 let err =
4789 validate_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
4790 .expect_err("quarantined source must fail");
4791
4792 assert!(
4793 err.to_string().contains("quarantined"),
4794 "error should reject quarantined replay sources: {err}",
4795 );
4796 }
4797}