1use std::{fmt::Display, path::PathBuf};
24
25use nautilus_common::{
26 cache::Cache,
27 messages::{
28 data::{
29 BarsResponse, FundingRatesResponse, InstrumentResponse, InstrumentsResponse,
30 QuotesResponse, TradesResponse,
31 },
32 execution::SubmitOrderList,
33 },
34};
35use nautilus_core::UnixNanos;
36use nautilus_model::{
37 data::{Bar, QuoteTick, TradeTick},
38 enums::{OmsType, OrderSide},
39 events::{
40 AccountState, OrderEventAny, OrderFilled, OrderInitialized, PositionAdjusted,
41 PositionChanged, PositionClosed, PositionOpened,
42 },
43 identifiers::PositionId,
44 orders::OrderAny,
45 position::Position,
46};
47use serde::de::DeserializeOwned;
48
49#[cfg(test)]
50use crate::capture::builtins::{
51 PAYLOAD_TYPE_BATCH_CANCEL_ORDERS, PAYLOAD_TYPE_BATCH_MODIFY_ORDERS,
52 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE, PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
53 PAYLOAD_TYPE_BOOK_RESPONSE, PAYLOAD_TYPE_CANCEL_ALL_ORDERS, PAYLOAD_TYPE_CANCEL_ORDER,
54 PAYLOAD_TYPE_CUSTOM_DATA_RESPONSE, PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
55 PAYLOAD_TYPE_FILL_REPORT, PAYLOAD_TYPE_FORWARD_PRICES_RESPONSE, PAYLOAD_TYPE_MODIFY_ORDER,
56 PAYLOAD_TYPE_ORDER_STATUS_REPORT, PAYLOAD_TYPE_ORDER_WITH_FILLS,
57 PAYLOAD_TYPE_POSITION_STATUS_REPORT, PAYLOAD_TYPE_QUERY_ACCOUNT, PAYLOAD_TYPE_QUERY_ORDER,
58 PAYLOAD_TYPE_REQUEST_COMMAND, PAYLOAD_TYPE_SUBMIT_ORDER, PAYLOAD_TYPE_SUBSCRIBE_COMMAND,
59 PAYLOAD_TYPE_TIME_EVENT, PAYLOAD_TYPE_UNSUBSCRIBE_COMMAND,
60};
61#[cfg(all(test, feature = "defi"))]
62use crate::capture::builtins::{
63 PAYLOAD_TYPE_DEFI_REQUEST_COMMAND, PAYLOAD_TYPE_DEFI_SUBSCRIBE_COMMAND,
64 PAYLOAD_TYPE_DEFI_UNSUBSCRIBE_COMMAND,
65};
66use crate::{
67 RedbBackend,
68 backend::{EventStore, ScanDirection},
69 capture::builtins::{
70 PAYLOAD_TYPE_ACCOUNT_STATE, PAYLOAD_TYPE_BARS_RESPONSE,
71 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE, PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
72 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE, PAYLOAD_TYPE_ORDER_ACCEPTED,
73 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED, PAYLOAD_TYPE_ORDER_CANCELED, PAYLOAD_TYPE_ORDER_DENIED,
74 PAYLOAD_TYPE_ORDER_EMULATED, PAYLOAD_TYPE_ORDER_EXPIRED, PAYLOAD_TYPE_ORDER_FILLED,
75 PAYLOAD_TYPE_ORDER_INITIALIZED, PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
76 PAYLOAD_TYPE_ORDER_PENDING_CANCEL, PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
77 PAYLOAD_TYPE_ORDER_REJECTED, PAYLOAD_TYPE_ORDER_RELEASED, PAYLOAD_TYPE_ORDER_SUBMITTED,
78 PAYLOAD_TYPE_ORDER_TRIGGERED, PAYLOAD_TYPE_ORDER_UPDATED, PAYLOAD_TYPE_POSITION_ADJUSTED,
79 PAYLOAD_TYPE_POSITION_CHANGED, PAYLOAD_TYPE_POSITION_CLOSED, PAYLOAD_TYPE_POSITION_OPENED,
80 PAYLOAD_TYPE_QUOTES_RESPONSE, PAYLOAD_TYPE_SUBMIT_ORDER_LIST, PAYLOAD_TYPE_TRADES_RESPONSE,
81 },
82 entry::EventStoreEntry,
83 error::EventStoreError,
84 manifest::{RunManifest, RunStatus},
85 reader::{EventStoreReader, SnapshotReplayPlan},
86 snapshot::{SnapshotAnchor, compute_snapshot_content_hash},
87};
88
89#[cfg(feature = "persistence")]
90mod catalog;
91
92#[cfg(feature = "persistence")]
93pub use catalog::ParquetReplayCatalog;
94
95#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct CacheReplayReport {
98 pub plan: SnapshotReplayPlan,
100 pub applied_entries: usize,
102 pub ignored_entries: usize,
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct EventStoreReplayReport {
109 pub manifest: RunManifest,
111 pub cache: CacheReplayReport,
113}
114
115#[cfg(test)]
116pub(crate) const CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES: &[&str] = &[
117 PAYLOAD_TYPE_SUBMIT_ORDER_LIST,
118 PAYLOAD_TYPE_ACCOUNT_STATE,
119 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
120 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
121 PAYLOAD_TYPE_QUOTES_RESPONSE,
122 PAYLOAD_TYPE_TRADES_RESPONSE,
123 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
124 PAYLOAD_TYPE_BARS_RESPONSE,
125 PAYLOAD_TYPE_ORDER_INITIALIZED,
126 PAYLOAD_TYPE_ORDER_DENIED,
127 PAYLOAD_TYPE_ORDER_EMULATED,
128 PAYLOAD_TYPE_ORDER_RELEASED,
129 PAYLOAD_TYPE_ORDER_SUBMITTED,
130 PAYLOAD_TYPE_ORDER_ACCEPTED,
131 PAYLOAD_TYPE_ORDER_REJECTED,
132 PAYLOAD_TYPE_ORDER_CANCELED,
133 PAYLOAD_TYPE_ORDER_EXPIRED,
134 PAYLOAD_TYPE_ORDER_TRIGGERED,
135 PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
136 PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
137 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
138 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED,
139 PAYLOAD_TYPE_ORDER_UPDATED,
140 PAYLOAD_TYPE_ORDER_FILLED,
141 PAYLOAD_TYPE_POSITION_OPENED,
142 PAYLOAD_TYPE_POSITION_CHANGED,
143 PAYLOAD_TYPE_POSITION_CLOSED,
144 PAYLOAD_TYPE_POSITION_ADJUSTED,
145];
146
147#[cfg(test)]
148pub(crate) const FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES: &[&str] = &[
149 PAYLOAD_TYPE_SUBMIT_ORDER,
150 PAYLOAD_TYPE_MODIFY_ORDER,
151 PAYLOAD_TYPE_BATCH_MODIFY_ORDERS,
152 PAYLOAD_TYPE_CANCEL_ORDER,
153 PAYLOAD_TYPE_CANCEL_ALL_ORDERS,
154 PAYLOAD_TYPE_BATCH_CANCEL_ORDERS,
155 PAYLOAD_TYPE_QUERY_ORDER,
156 PAYLOAD_TYPE_QUERY_ACCOUNT,
157 PAYLOAD_TYPE_ORDER_STATUS_REPORT,
158 PAYLOAD_TYPE_FILL_REPORT,
159 PAYLOAD_TYPE_ORDER_WITH_FILLS,
160 PAYLOAD_TYPE_POSITION_STATUS_REPORT,
161 PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
162 PAYLOAD_TYPE_TIME_EVENT,
163 PAYLOAD_TYPE_REQUEST_COMMAND,
164 PAYLOAD_TYPE_SUBSCRIBE_COMMAND,
165 PAYLOAD_TYPE_UNSUBSCRIBE_COMMAND,
166 #[cfg(feature = "defi")]
167 PAYLOAD_TYPE_DEFI_REQUEST_COMMAND,
168 #[cfg(feature = "defi")]
169 PAYLOAD_TYPE_DEFI_SUBSCRIBE_COMMAND,
170 #[cfg(feature = "defi")]
171 PAYLOAD_TYPE_DEFI_UNSUBSCRIBE_COMMAND,
172 PAYLOAD_TYPE_CUSTOM_DATA_RESPONSE,
173 PAYLOAD_TYPE_BOOK_RESPONSE,
174 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE,
175 PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
176 PAYLOAD_TYPE_FORWARD_PRICES_RESPONSE,
177];
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub struct ReplaySeqRange {
182 pub from_seq: u64,
184 pub to_seq: u64,
186}
187
188impl ReplaySeqRange {
189 #[must_use]
191 pub const fn new(from_seq: u64, to_seq: u64) -> Self {
192 Self { from_seq, to_seq }
193 }
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub struct ReplayTimeRange {
199 pub start: UnixNanos,
201 pub end: UnixNanos,
203}
204
205impl ReplayTimeRange {
206 #[must_use]
208 pub const fn new(start: UnixNanos, end: UnixNanos) -> Self {
209 Self { start, end }
210 }
211
212 fn from_entry(entry: &EventStoreEntry) -> Self {
213 Self {
214 start: entry.ts_init,
215 end: entry.ts_init,
216 }
217 }
218
219 fn include_entry(&mut self, entry: &EventStoreEntry) {
220 self.start = self.start.min(entry.ts_init);
221 self.end = self.end.max(entry.ts_init);
222 }
223}
224
225#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct CatalogSliceSelector {
228 pub data_cls: String,
230 pub identifiers: Vec<String>,
232 pub start: Option<UnixNanos>,
234 pub end: Option<UnixNanos>,
236 pub required: bool,
238}
239
240impl CatalogSliceSelector {
241 pub fn new(data_cls: impl Into<String>) -> Self {
243 Self {
244 data_cls: data_cls.into(),
245 identifiers: Vec::new(),
246 start: None,
247 end: None,
248 required: false,
249 }
250 }
251
252 #[must_use]
254 pub fn with_identifier(mut self, identifier: impl Into<String>) -> Self {
255 self.identifiers.push(identifier.into());
256 self
257 }
258
259 #[must_use]
261 pub const fn with_time_bounds(mut self, start: UnixNanos, end: UnixNanos) -> Self {
262 self.start = Some(start);
263 self.end = Some(end);
264 self
265 }
266
267 #[must_use]
269 pub const fn require_coverage(mut self) -> Self {
270 self.required = true;
271 self
272 }
273}
274
275#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct CatalogSliceQuery {
278 pub data_cls: String,
280 pub identifiers: Vec<String>,
282 pub start: UnixNanos,
284 pub end: UnixNanos,
286 pub required: bool,
288}
289
290impl CatalogSliceQuery {
291 #[must_use]
293 pub fn identifiers_option(&self) -> Option<Vec<String>> {
294 if self.identifiers.is_empty() {
295 None
296 } else {
297 Some(self.identifiers.clone())
298 }
299 }
300}
301
302#[derive(Clone, Debug, Default, PartialEq, Eq)]
304pub struct CatalogSliceCoverage {
305 pub files: Vec<String>,
307 pub intervals: Vec<ReplayTimeRange>,
309}
310
311impl CatalogSliceCoverage {
312 #[must_use]
314 pub fn from_files(files: Vec<String>) -> Self {
315 Self {
316 files,
317 intervals: Vec::new(),
318 }
319 }
320
321 #[must_use]
323 pub fn is_missing(&self) -> bool {
324 self.files.is_empty()
325 }
326}
327
328#[derive(Clone, Debug, PartialEq, Eq)]
330pub struct CatalogSlicePlan {
331 pub query: CatalogSliceQuery,
333 pub coverage: CatalogSliceCoverage,
335}
336
337impl CatalogSlicePlan {
338 #[must_use]
340 pub fn is_missing(&self) -> bool {
341 self.coverage.is_missing()
342 }
343}
344
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
347pub enum CatalogReplayData {
348 Quote(QuoteTick),
350 Trade(TradeTick),
352 Bar(Bar),
354}
355
356impl CatalogReplayData {
357 #[must_use]
359 pub const fn data_cls(&self) -> &'static str {
360 match self {
361 Self::Quote(_) => "quotes",
362 Self::Trade(_) => "trades",
363 Self::Bar(_) => "bars",
364 }
365 }
366
367 #[must_use]
369 pub fn identifier(&self) -> String {
370 match self {
371 Self::Quote(quote) => quote.instrument_id.to_string(),
372 Self::Trade(trade) => trade.instrument_id.to_string(),
373 Self::Bar(bar) => bar.bar_type.to_string(),
374 }
375 }
376
377 #[must_use]
379 pub const fn ts_init(&self) -> UnixNanos {
380 match self {
381 Self::Quote(quote) => quote.ts_init,
382 Self::Trade(trade) => trade.ts_init,
383 Self::Bar(bar) => bar.ts_init,
384 }
385 }
386}
387
388impl From<QuoteTick> for CatalogReplayData {
389 fn from(value: QuoteTick) -> Self {
390 Self::Quote(value)
391 }
392}
393
394impl From<TradeTick> for CatalogReplayData {
395 fn from(value: TradeTick) -> Self {
396 Self::Trade(value)
397 }
398}
399
400impl From<Bar> for CatalogReplayData {
401 fn from(value: Bar) -> Self {
402 Self::Bar(value)
403 }
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
408pub struct CatalogReplayRecord {
409 pub data_cls: String,
411 pub identifier: Option<String>,
413 pub ts_init: UnixNanos,
415 pub data: CatalogReplayData,
417}
418
419impl CatalogReplayRecord {
420 #[must_use]
422 pub fn from_data(data: CatalogReplayData) -> Self {
423 Self {
424 data_cls: data.data_cls().to_string(),
425 identifier: Some(data.identifier()),
426 ts_init: data.ts_init(),
427 data,
428 }
429 }
430}
431
432#[derive(Clone, Debug, PartialEq, Eq)]
434pub struct CatalogReplaySlice {
435 pub plan: CatalogSlicePlan,
437 pub records: Vec<CatalogReplayRecord>,
439}
440
441#[derive(Clone, Debug, PartialEq, Eq)]
443pub struct ReplayInputPlan {
444 pub requested_range: ReplaySeqRange,
446 pub event_range: Option<ReplaySeqRange>,
448 pub event_count: usize,
450 pub event_time_range: Option<ReplayTimeRange>,
452 pub catalog_slices: Vec<CatalogSlicePlan>,
454}
455
456impl ReplayInputPlan {
457 #[must_use]
459 pub fn missing_catalog_slices(&self) -> Vec<&CatalogSlicePlan> {
460 self.catalog_slices
461 .iter()
462 .filter(|slice| slice.is_missing())
463 .collect()
464 }
465}
466
467#[derive(Clone, Debug, PartialEq, Eq)]
469pub struct ReplayInputs {
470 pub entries: Vec<EventStoreEntry>,
472 pub catalog_slices: Vec<CatalogReplaySlice>,
474}
475
476pub trait ReplayCatalog {
478 type Error: Display;
480
481 fn plan_slice(
487 &mut self,
488 query: &CatalogSliceQuery,
489 ) -> Result<CatalogSliceCoverage, Self::Error>;
490
491 fn load_slice(
500 &mut self,
501 plan: &CatalogSlicePlan,
502 ) -> Result<Vec<CatalogReplayRecord>, Self::Error>;
503}
504
505#[derive(Debug, thiserror::Error)]
507pub enum ReplayInputError {
508 #[error(transparent)]
510 EventStore(#[from] EventStoreError),
511 #[error("invalid replay seq range {from_seq}..={to_seq}: {message}")]
513 InvalidSeqRange {
514 from_seq: u64,
516 to_seq: u64,
518 message: String,
520 },
521 #[error("catalog replay requires at least one selected catalog slice")]
523 EmptyCatalogSelection,
524 #[error(
526 "catalog slice {data_cls} requires explicit time bounds because the replay scan is empty"
527 )]
528 MissingCatalogTimeBounds {
529 data_cls: String,
531 },
532 #[error("invalid catalog time range for {data_cls}: {start}..={end}")]
534 InvalidCatalogTimeRange {
535 data_cls: String,
537 start: u64,
539 end: u64,
541 },
542 #[error("required catalog slice {data_cls} is missing for identifiers {identifiers:?}")]
544 MissingCatalogSlice {
545 data_cls: String,
547 identifiers: Vec<String>,
549 },
550 #[error("catalog slice {data_cls}: {message}")]
552 Catalog {
553 data_cls: String,
555 message: String,
557 },
558}
559
560#[derive(Debug, thiserror::Error)]
562pub enum CacheReplayError {
563 #[error(transparent)]
565 EventStore(#[from] EventStoreError),
566 #[error("restore cache snapshot {blob_ref}: {message}")]
568 SnapshotRestore {
569 blob_ref: String,
571 message: String,
573 },
574 #[error("entry seq {seq} is before replay start seq {from_seq}")]
576 UnexpectedSeq {
577 seq: u64,
579 from_seq: u64,
581 },
582 #[error("decode seq {seq} payload_type {payload_type}: {message}")]
584 Decode {
585 seq: u64,
587 payload_type: String,
589 message: String,
591 },
592 #[error("apply seq {seq} payload_type {payload_type}: {message}")]
594 Apply {
595 seq: u64,
597 payload_type: String,
599 message: String,
601 },
602}
603
604impl CacheReplayError {
605 #[must_use]
607 pub fn snapshot_restore(anchor: &SnapshotAnchor, error: impl Display) -> Self {
608 Self::SnapshotRestore {
609 blob_ref: anchor.blob_ref.clone(),
610 message: error.to_string(),
611 }
612 }
613}
614
615pub fn restore_cache_snapshot_and_replay_tail<B, F>(
633 cache: &mut Cache,
634 reader: &EventStoreReader<B>,
635 restore_snapshot: F,
636) -> Result<CacheReplayReport, CacheReplayError>
637where
638 B: EventStore,
639 F: FnOnce(&mut Cache, Option<&SnapshotAnchor>) -> Result<(), CacheReplayError>,
640{
641 let (plan, scan) = reader.scan_snapshot_replay_tail()?;
642 restore_snapshot(cache, plan.anchor.as_ref())?;
643
644 let mut applied_entries = 0;
645 let mut ignored_entries = 0;
646
647 for entry in scan {
648 let entry = entry?;
649
650 if entry.seq < plan.from_seq {
651 return Err(CacheReplayError::UnexpectedSeq {
652 seq: entry.seq,
653 from_seq: plan.from_seq,
654 });
655 }
656
657 if apply_cache_replay_entry(cache, &entry)? {
658 applied_entries += 1;
659 } else {
660 ignored_entries += 1;
661 }
662 }
663
664 Ok(CacheReplayReport {
665 plan,
666 applied_entries,
667 ignored_entries,
668 })
669}
670
671pub fn replay_cache_snapshot_tail<B>(
680 cache: &mut Cache,
681 reader: &EventStoreReader<B>,
682) -> Result<CacheReplayReport, CacheReplayError>
683where
684 B: EventStore,
685{
686 restore_cache_snapshot_and_replay_tail(cache, reader, |_, _| Ok(()))
687}
688
689pub fn plan_forensics_replay_inputs<B>(
699 reader: &EventStoreReader<B>,
700 range: ReplaySeqRange,
701) -> Result<ReplayInputPlan, ReplayInputError>
702where
703 B: EventStore,
704{
705 let span = collect_replay_entry_span(reader, range)?;
706 Ok(ReplayInputPlan {
707 requested_range: range,
708 event_range: span.event_range,
709 event_count: span.event_count,
710 event_time_range: span.time_range,
711 catalog_slices: Vec::new(),
712 })
713}
714
715pub fn load_forensics_replay_inputs<B>(
724 reader: &EventStoreReader<B>,
725 plan: &ReplayInputPlan,
726) -> Result<ReplayInputs, ReplayInputError>
727where
728 B: EventStore,
729{
730 let entries = load_replay_entries(reader, plan.requested_range)?;
731 Ok(ReplayInputs {
732 entries,
733 catalog_slices: Vec::new(),
734 })
735}
736
737pub fn plan_catalog_replay_inputs<B, C>(
751 reader: &EventStoreReader<B>,
752 catalog: &mut C,
753 range: ReplaySeqRange,
754 catalog_slices: &[CatalogSliceSelector],
755) -> Result<ReplayInputPlan, ReplayInputError>
756where
757 B: EventStore,
758 C: ReplayCatalog,
759{
760 plan_catalog_joined_replay_inputs(reader, catalog, range, catalog_slices)
761}
762
763pub fn load_catalog_replay_inputs<B, C>(
775 reader: &EventStoreReader<B>,
776 catalog: &mut C,
777 plan: &ReplayInputPlan,
778) -> Result<ReplayInputs, ReplayInputError>
779where
780 B: EventStore,
781 C: ReplayCatalog,
782{
783 load_catalog_joined_replay_inputs(reader, catalog, plan)
784}
785
786pub fn restore_cache_from_sealed_run(
799 cache: &mut Cache,
800 base_dir: impl Into<PathBuf>,
801 instance_id: &str,
802 run_id: &str,
803) -> Result<EventStoreReplayReport, CacheReplayError> {
804 let (manifest, reader) = open_event_store_replay_source(base_dir, instance_id, run_id)?;
805 let cache_report =
806 restore_cache_snapshot_and_replay_tail(cache, &reader, restore_cache_snapshot_blob)?;
807
808 Ok(EventStoreReplayReport {
809 manifest,
810 cache: cache_report,
811 })
812}
813
814pub fn open_event_store_replay_source(
821 base_dir: impl Into<PathBuf>,
822 instance_id: &str,
823 run_id: &str,
824) -> Result<(RunManifest, EventStoreReader<RedbBackend>), CacheReplayError> {
825 let backend = RedbBackend::open_sealed(base_dir, instance_id, run_id)?;
826 let manifest = backend.manifest()?;
827 reject_quarantined_replay_source(run_id, manifest.status)?;
828 Ok((manifest, EventStoreReader::new(backend)))
829}
830
831pub fn validate_event_store_replay_source(
838 base_dir: impl Into<PathBuf>,
839 instance_id: &str,
840 run_id: &str,
841) -> Result<RunManifest, CacheReplayError> {
842 let backend = RedbBackend::open_sealed(base_dir, instance_id, run_id)?;
843 let manifest = backend.manifest()?;
844 reject_quarantined_replay_source(run_id, manifest.status)?;
845 Ok(manifest)
846}
847
848#[derive(Clone, Copy, Debug, PartialEq, Eq)]
849struct ReplayEntrySpan {
850 event_range: Option<ReplaySeqRange>,
851 event_count: usize,
852 time_range: Option<ReplayTimeRange>,
853}
854
855fn plan_catalog_joined_replay_inputs<B, C>(
856 reader: &EventStoreReader<B>,
857 catalog: &mut C,
858 range: ReplaySeqRange,
859 catalog_slices: &[CatalogSliceSelector],
860) -> Result<ReplayInputPlan, ReplayInputError>
861where
862 B: EventStore,
863 C: ReplayCatalog,
864{
865 if catalog_slices.is_empty() {
866 return Err(ReplayInputError::EmptyCatalogSelection);
867 }
868
869 let span = collect_replay_entry_span(reader, range)?;
870 let catalog_slices = plan_catalog_slices(catalog, catalog_slices, span.time_range)?;
871
872 Ok(ReplayInputPlan {
873 requested_range: range,
874 event_range: span.event_range,
875 event_count: span.event_count,
876 event_time_range: span.time_range,
877 catalog_slices,
878 })
879}
880
881fn load_catalog_joined_replay_inputs<B, C>(
882 reader: &EventStoreReader<B>,
883 catalog: &mut C,
884 plan: &ReplayInputPlan,
885) -> Result<ReplayInputs, ReplayInputError>
886where
887 B: EventStore,
888 C: ReplayCatalog,
889{
890 let entries = load_replay_entries(reader, plan.requested_range)?;
891 let catalog_slices = load_catalog_slices(catalog, &plan.catalog_slices)?;
892
893 Ok(ReplayInputs {
894 entries,
895 catalog_slices,
896 })
897}
898
899fn collect_replay_entry_span<B>(
900 reader: &EventStoreReader<B>,
901 range: ReplaySeqRange,
902) -> Result<ReplayEntrySpan, ReplayInputError>
903where
904 B: EventStore,
905{
906 validate_seq_range(range)?;
907
908 let mut first_seq = None;
909 let mut last_seq = None;
910 let mut event_count = 0;
911 let mut time_range: Option<ReplayTimeRange> = None;
912
913 for entry in reader.scan_range(range.from_seq, range.to_seq, ScanDirection::Forward) {
914 let entry = entry?;
915 first_seq.get_or_insert(entry.seq);
916 last_seq = Some(entry.seq);
917 event_count += 1;
918
919 match time_range.as_mut() {
920 Some(bounds) => bounds.include_entry(&entry),
921 None => time_range = Some(ReplayTimeRange::from_entry(&entry)),
922 }
923 }
924
925 let event_range = match (first_seq, last_seq) {
926 (Some(from_seq), Some(to_seq)) => Some(ReplaySeqRange::new(from_seq, to_seq)),
927 _ => None,
928 };
929
930 Ok(ReplayEntrySpan {
931 event_range,
932 event_count,
933 time_range,
934 })
935}
936
937fn load_replay_entries<B>(
938 reader: &EventStoreReader<B>,
939 range: ReplaySeqRange,
940) -> Result<Vec<EventStoreEntry>, ReplayInputError>
941where
942 B: EventStore,
943{
944 validate_seq_range(range)?;
945
946 reader
947 .scan_range(range.from_seq, range.to_seq, ScanDirection::Forward)
948 .collect::<Result<Vec<_>, _>>()
949 .map_err(ReplayInputError::from)
950}
951
952fn plan_catalog_slices<C>(
953 catalog: &mut C,
954 selectors: &[CatalogSliceSelector],
955 event_time_range: Option<ReplayTimeRange>,
956) -> Result<Vec<CatalogSlicePlan>, ReplayInputError>
957where
958 C: ReplayCatalog,
959{
960 let mut plans = Vec::with_capacity(selectors.len());
961
962 for selector in selectors {
963 let query = resolve_catalog_slice_query(selector, event_time_range)?;
964 let coverage = catalog
965 .plan_slice(&query)
966 .map_err(|e| ReplayInputError::Catalog {
967 data_cls: query.data_cls.clone(),
968 message: e.to_string(),
969 })?;
970 plans.push(CatalogSlicePlan { query, coverage });
971 }
972
973 Ok(plans)
974}
975
976fn load_catalog_slices<C>(
977 catalog: &mut C,
978 plans: &[CatalogSlicePlan],
979) -> Result<Vec<CatalogReplaySlice>, ReplayInputError>
980where
981 C: ReplayCatalog,
982{
983 let mut slices = Vec::with_capacity(plans.len());
984
985 for plan in plans {
986 if plan.is_missing() {
987 if plan.query.required {
988 return Err(ReplayInputError::MissingCatalogSlice {
989 data_cls: plan.query.data_cls.clone(),
990 identifiers: plan.query.identifiers.clone(),
991 });
992 }
993
994 slices.push(CatalogReplaySlice {
995 plan: plan.clone(),
996 records: Vec::new(),
997 });
998 continue;
999 }
1000
1001 let records = catalog
1002 .load_slice(plan)
1003 .map_err(|e| ReplayInputError::Catalog {
1004 data_cls: plan.query.data_cls.clone(),
1005 message: e.to_string(),
1006 })?;
1007 slices.push(CatalogReplaySlice {
1008 plan: plan.clone(),
1009 records,
1010 });
1011 }
1012
1013 Ok(slices)
1014}
1015
1016fn resolve_catalog_slice_query(
1017 selector: &CatalogSliceSelector,
1018 event_time_range: Option<ReplayTimeRange>,
1019) -> Result<CatalogSliceQuery, ReplayInputError> {
1020 let Some(start) = selector
1021 .start
1022 .or(event_time_range.map(|bounds| bounds.start))
1023 else {
1024 return Err(ReplayInputError::MissingCatalogTimeBounds {
1025 data_cls: selector.data_cls.clone(),
1026 });
1027 };
1028 let Some(end) = selector.end.or(event_time_range.map(|bounds| bounds.end)) else {
1029 return Err(ReplayInputError::MissingCatalogTimeBounds {
1030 data_cls: selector.data_cls.clone(),
1031 });
1032 };
1033
1034 if start > end {
1035 return Err(ReplayInputError::InvalidCatalogTimeRange {
1036 data_cls: selector.data_cls.clone(),
1037 start: start.as_u64(),
1038 end: end.as_u64(),
1039 });
1040 }
1041
1042 Ok(CatalogSliceQuery {
1043 data_cls: selector.data_cls.clone(),
1044 identifiers: selector.identifiers.clone(),
1045 start,
1046 end,
1047 required: selector.required,
1048 })
1049}
1050
1051fn validate_seq_range(range: ReplaySeqRange) -> Result<(), ReplayInputError> {
1052 if range.from_seq == 0 {
1053 return Err(ReplayInputError::InvalidSeqRange {
1054 from_seq: range.from_seq,
1055 to_seq: range.to_seq,
1056 message: "seq is 1-based".to_string(),
1057 });
1058 }
1059
1060 if range.from_seq > range.to_seq {
1061 return Err(ReplayInputError::InvalidSeqRange {
1062 from_seq: range.from_seq,
1063 to_seq: range.to_seq,
1064 message: "from_seq exceeds to_seq".to_string(),
1065 });
1066 }
1067
1068 Ok(())
1069}
1070
1071pub fn restore_cache_snapshot_blob(
1078 cache: &mut Cache,
1079 anchor: Option<&SnapshotAnchor>,
1080) -> Result<(), CacheReplayError> {
1081 let Some(anchor) = anchor else {
1082 return Ok(());
1083 };
1084
1085 let blob = cache
1086 .load_snapshot_blob(&anchor.blob_ref)
1087 .map_err(|e| CacheReplayError::snapshot_restore(anchor, e))?
1088 .ok_or_else(|| CacheReplayError::snapshot_restore(anchor, "snapshot blob not found"))?;
1089 let actual_hash = compute_snapshot_content_hash(blob.as_ref());
1090
1091 if actual_hash != anchor.content_hash {
1092 return Err(CacheReplayError::snapshot_restore(
1093 anchor,
1094 format!(
1095 "content_hash mismatch: expected {}, actual {actual_hash}",
1096 anchor.content_hash
1097 ),
1098 ));
1099 }
1100
1101 cache
1102 .restore_snapshot_blob(&anchor.blob_ref, blob)
1103 .map_err(|e| CacheReplayError::snapshot_restore(anchor, e))
1104}
1105
1106pub fn apply_cache_replay_entry(
1118 cache: &mut Cache,
1119 entry: &EventStoreEntry,
1120) -> Result<bool, CacheReplayError> {
1121 if apply_complete_cache_payload_entry(cache, entry)? {
1122 return Ok(true);
1123 }
1124
1125 match entry.payload_type.as_str() {
1126 PAYLOAD_TYPE_ACCOUNT_STATE => {
1127 let state = decode_payload::<AccountState>(entry)?;
1128 apply_result(entry, cache.update_account_state(&state))?;
1129 }
1130 PAYLOAD_TYPE_ORDER_INITIALIZED => {
1131 let event = decode_order_event::<OrderInitialized>(entry, OrderEventAny::Initialized)?;
1132 let order = OrderAny::from_events(vec![event]).map_err(|e| apply_error(entry, e))?;
1133 apply_result(entry, cache.add_order(order, None, None, false))?;
1134 }
1135 PAYLOAD_TYPE_ORDER_DENIED => {
1136 apply_order_event(cache, entry, OrderEventAny::Denied)?;
1137 }
1138 PAYLOAD_TYPE_ORDER_EMULATED => {
1139 apply_order_event(cache, entry, OrderEventAny::Emulated)?;
1140 }
1141 PAYLOAD_TYPE_ORDER_RELEASED => {
1142 apply_order_event(cache, entry, OrderEventAny::Released)?;
1143 }
1144 PAYLOAD_TYPE_ORDER_SUBMITTED => {
1145 apply_order_event(cache, entry, OrderEventAny::Submitted)?;
1146 }
1147 PAYLOAD_TYPE_ORDER_ACCEPTED => {
1148 apply_order_event(cache, entry, OrderEventAny::Accepted)?;
1149 }
1150 PAYLOAD_TYPE_ORDER_REJECTED => {
1151 apply_order_event(cache, entry, OrderEventAny::Rejected)?;
1152 }
1153 PAYLOAD_TYPE_ORDER_CANCELED => {
1154 apply_order_event(cache, entry, OrderEventAny::Canceled)?;
1155 }
1156 PAYLOAD_TYPE_ORDER_EXPIRED => {
1157 apply_order_event(cache, entry, OrderEventAny::Expired)?;
1158 }
1159 PAYLOAD_TYPE_ORDER_TRIGGERED => {
1160 apply_order_event(cache, entry, OrderEventAny::Triggered)?;
1161 }
1162 PAYLOAD_TYPE_ORDER_PENDING_UPDATE => {
1163 apply_order_event(cache, entry, OrderEventAny::PendingUpdate)?;
1164 }
1165 PAYLOAD_TYPE_ORDER_PENDING_CANCEL => {
1166 apply_order_event(cache, entry, OrderEventAny::PendingCancel)?;
1167 }
1168 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED => {
1169 apply_order_event(cache, entry, OrderEventAny::ModifyRejected)?;
1170 }
1171 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED => {
1172 apply_order_event(cache, entry, OrderEventAny::CancelRejected)?;
1173 }
1174 PAYLOAD_TYPE_ORDER_UPDATED => {
1175 apply_order_event(cache, entry, OrderEventAny::Updated)?;
1176 }
1177 PAYLOAD_TYPE_ORDER_FILLED => {
1178 let fill = decode_payload::<OrderFilled>(entry)?;
1179 if matches!(fill.order_side, OrderSide::NoOrderSide) {
1183 return Err(apply_error(
1184 entry,
1185 "OrderFilled.order_side must be Buy or Sell, was NoOrderSide",
1186 ));
1187 }
1188 let event = OrderEventAny::Filled(fill);
1189 apply_result(entry, cache.update_order(&event))?;
1190 apply_fill_to_position(cache, entry, &fill)?;
1191 }
1192 PAYLOAD_TYPE_POSITION_OPENED => {
1193 let opened = decode_payload::<PositionOpened>(entry)?;
1194 return apply_position_opened(cache, entry, &opened);
1195 }
1196 PAYLOAD_TYPE_POSITION_CHANGED => {
1197 let changed = decode_payload::<PositionChanged>(entry)?;
1198 return apply_position_changed(cache, entry, &changed);
1199 }
1200 PAYLOAD_TYPE_POSITION_CLOSED => {
1201 let closed = decode_payload::<PositionClosed>(entry)?;
1202 return apply_position_closed(cache, entry, &closed);
1203 }
1204 PAYLOAD_TYPE_POSITION_ADJUSTED => {
1205 let adjustment = decode_payload::<PositionAdjusted>(entry)?;
1206 return apply_position_adjustment(cache, entry, adjustment);
1207 }
1208 _ => return Ok(false),
1209 }
1210
1211 Ok(true)
1212}
1213
1214fn apply_complete_cache_payload_entry(
1215 cache: &mut Cache,
1216 entry: &EventStoreEntry,
1217) -> Result<bool, CacheReplayError> {
1218 match entry.payload_type.as_str() {
1219 PAYLOAD_TYPE_SUBMIT_ORDER_LIST => {
1220 let command = decode_payload::<SubmitOrderList>(entry)?;
1221 apply_result(entry, cache.add_order_list(command.order_list))?;
1222 }
1223 PAYLOAD_TYPE_INSTRUMENT_RESPONSE => {
1224 let response = decode_payload::<InstrumentResponse>(entry)?;
1225 apply_result(entry, cache.add_instrument(response.data))?;
1226 }
1227 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE => {
1228 let response = decode_payload::<InstrumentsResponse>(entry)?;
1229 for instrument in response.data {
1230 apply_result(entry, cache.add_instrument(instrument))?;
1231 }
1232 }
1233 PAYLOAD_TYPE_QUOTES_RESPONSE => {
1234 let response = decode_payload::<QuotesResponse>(entry)?;
1235 if !response.data.is_empty() {
1236 apply_result(entry, cache.add_quotes(&response.data))?;
1237 }
1238 }
1239 PAYLOAD_TYPE_TRADES_RESPONSE => {
1240 let response = decode_payload::<TradesResponse>(entry)?;
1241 if !response.data.is_empty() {
1242 apply_result(entry, cache.add_trades(&response.data))?;
1243 }
1244 }
1245 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE => {
1246 let response = decode_payload::<FundingRatesResponse>(entry)?;
1247 if !response.data.is_empty() {
1248 apply_result(entry, cache.add_funding_rates(&response.data))?;
1249 }
1250 }
1251 PAYLOAD_TYPE_BARS_RESPONSE => {
1252 let response = decode_payload::<BarsResponse>(entry)?;
1253 if !response.data.is_empty() {
1254 apply_result(entry, cache.add_bars(&response.data))?;
1255 }
1256 }
1257 _ => return Ok(false),
1258 }
1259
1260 Ok(true)
1261}
1262
1263fn apply_order_event<T>(
1264 cache: &mut Cache,
1265 entry: &EventStoreEntry,
1266 wrap: impl FnOnce(T) -> OrderEventAny,
1267) -> Result<(), CacheReplayError>
1268where
1269 T: DeserializeOwned,
1270{
1271 let event = decode_order_event(entry, wrap)?;
1272 apply_result(entry, cache.update_order(&event))?;
1273 Ok(())
1274}
1275
1276fn decode_order_event<T>(
1277 entry: &EventStoreEntry,
1278 wrap: impl FnOnce(T) -> OrderEventAny,
1279) -> Result<OrderEventAny, CacheReplayError>
1280where
1281 T: DeserializeOwned,
1282{
1283 Ok(wrap(decode_payload(entry)?))
1284}
1285
1286fn apply_fill_to_position(
1287 cache: &mut Cache,
1288 entry: &EventStoreEntry,
1289 fill: &OrderFilled,
1290) -> Result<(), CacheReplayError> {
1291 let Some(position_id) = fill.position_id else {
1292 return Ok(());
1293 };
1294
1295 if let Some(mut position) = cache.position_owned(&position_id) {
1296 if position.trade_ids().contains(&fill.trade_id) {
1297 return Ok(());
1298 }
1299
1300 position.apply(fill);
1301 apply_result(entry, cache.update_position(&position))?;
1302 return Ok(());
1303 }
1304
1305 let Some(instrument) = cache.instrument(&fill.instrument_id).cloned() else {
1306 log::warn!(
1307 "Replay seq {} skipped opening position {position_id}: instrument {} not in cache",
1308 entry.seq,
1309 fill.instrument_id,
1310 );
1311 return Ok(());
1312 };
1313
1314 let position = Position::new(&instrument, *fill);
1315 apply_result(entry, cache.add_position(&position, OmsType::Unspecified))?;
1316 Ok(())
1317}
1318
1319fn apply_position_opened(
1320 cache: &mut Cache,
1321 entry: &EventStoreEntry,
1322 opened: &PositionOpened,
1323) -> Result<bool, CacheReplayError> {
1324 let Some(mut position) = cache.position_owned(&opened.position_id) else {
1325 warn_position_skip(entry, opened.position_id);
1326 return Ok(false);
1327 };
1328
1329 position.trader_id = opened.trader_id;
1330 position.strategy_id = opened.strategy_id;
1331 position.instrument_id = opened.instrument_id;
1332 position.id = opened.position_id;
1333 position.account_id = opened.account_id;
1334 position.opening_order_id = opened.opening_order_id;
1335 position.closing_order_id = None;
1336 position.entry = opened.entry;
1337 position.side = opened.side;
1338 position.signed_qty = opened.signed_qty;
1339 position.quantity = opened.quantity;
1340 position.peak_qty = opened.quantity;
1341 position.quote_currency = opened.currency;
1342 position.ts_opened = opened.ts_event;
1343 position.ts_last = opened.ts_event;
1344 position.ts_closed = None;
1345 position.duration_ns = 0;
1346 position.avg_px_open = opened.avg_px_open;
1347 position.avg_px_close = None;
1348 position.realized_return = 0.0;
1349
1350 apply_result(entry, cache.update_position(&position))?;
1351 Ok(true)
1352}
1353
1354fn apply_position_changed(
1355 cache: &mut Cache,
1356 entry: &EventStoreEntry,
1357 changed: &PositionChanged,
1358) -> Result<bool, CacheReplayError> {
1359 let Some(mut position) = cache.position_owned(&changed.position_id) else {
1360 warn_position_skip(entry, changed.position_id);
1361 return Ok(false);
1362 };
1363
1364 position.trader_id = changed.trader_id;
1365 position.strategy_id = changed.strategy_id;
1366 position.instrument_id = changed.instrument_id;
1367 position.id = changed.position_id;
1368 position.account_id = changed.account_id;
1369 position.opening_order_id = changed.opening_order_id;
1370 position.entry = changed.entry;
1371 position.side = changed.side;
1372 position.signed_qty = changed.signed_qty;
1373 position.quantity = changed.quantity;
1374 position.peak_qty = changed.peak_quantity;
1375 position.quote_currency = changed.currency;
1376 position.ts_opened = changed.ts_opened;
1377 position.ts_last = changed.ts_event;
1378 position.ts_closed = None;
1379 position.avg_px_open = changed.avg_px_open;
1380 position.avg_px_close = changed.avg_px_close;
1381 position.realized_return = changed.realized_return;
1382 position.realized_pnl = changed.realized_pnl;
1383
1384 apply_result(entry, cache.update_position(&position))?;
1385 Ok(true)
1386}
1387
1388fn apply_position_closed(
1389 cache: &mut Cache,
1390 entry: &EventStoreEntry,
1391 closed: &PositionClosed,
1392) -> Result<bool, CacheReplayError> {
1393 let Some(mut position) = cache.position_owned(&closed.position_id) else {
1394 warn_position_skip(entry, closed.position_id);
1395 return Ok(false);
1396 };
1397
1398 position.trader_id = closed.trader_id;
1399 position.strategy_id = closed.strategy_id;
1400 position.instrument_id = closed.instrument_id;
1401 position.id = closed.position_id;
1402 position.account_id = closed.account_id;
1403 position.opening_order_id = closed.opening_order_id;
1404 position.closing_order_id = closed.closing_order_id;
1405 position.entry = closed.entry;
1406 position.side = closed.side;
1407 position.signed_qty = closed.signed_qty;
1408 position.quantity = closed.quantity;
1409 position.peak_qty = closed.peak_quantity;
1410 position.quote_currency = closed.currency;
1411 position.ts_opened = closed.ts_opened;
1412 position.ts_last = closed.ts_event;
1413 position.ts_closed = closed.ts_closed;
1414 position.duration_ns = closed.duration;
1415 position.avg_px_open = closed.avg_px_open;
1416 position.avg_px_close = closed.avg_px_close;
1417 position.realized_return = closed.realized_return;
1418 position.realized_pnl = closed.realized_pnl;
1419
1420 apply_result(entry, cache.update_position(&position))?;
1421 Ok(true)
1422}
1423
1424fn apply_position_adjustment(
1425 cache: &mut Cache,
1426 entry: &EventStoreEntry,
1427 adjustment: PositionAdjusted,
1428) -> Result<bool, CacheReplayError> {
1429 let Some(mut position) = cache.position_owned(&adjustment.position_id) else {
1430 warn_position_skip(entry, adjustment.position_id);
1431 return Ok(false);
1432 };
1433
1434 position.apply_adjustment(adjustment);
1435 apply_result(entry, cache.update_position(&position))?;
1436 Ok(true)
1437}
1438
1439fn warn_position_skip(entry: &EventStoreEntry, position_id: PositionId) {
1442 log::warn!(
1443 "Replay seq {} skipped {}: position {position_id} not in cache",
1444 entry.seq,
1445 entry.payload_type,
1446 );
1447}
1448
1449fn decode_payload<T>(entry: &EventStoreEntry) -> Result<T, CacheReplayError>
1450where
1451 T: DeserializeOwned,
1452{
1453 rmp_serde::from_slice(&entry.payload).map_err(|e| CacheReplayError::Decode {
1454 seq: entry.seq,
1455 payload_type: entry.payload_type.to_string(),
1456 message: e.to_string(),
1457 })
1458}
1459
1460fn apply_result<T, E>(entry: &EventStoreEntry, result: Result<T, E>) -> Result<T, CacheReplayError>
1461where
1462 E: Display,
1463{
1464 result.map_err(|e| apply_error(entry, e))
1465}
1466
1467fn apply_error(entry: &EventStoreEntry, error: impl Display) -> CacheReplayError {
1468 CacheReplayError::Apply {
1469 seq: entry.seq,
1470 payload_type: entry.payload_type.to_string(),
1471 message: error.to_string(),
1472 }
1473}
1474
1475fn reject_quarantined_replay_source(
1476 run_id: &str,
1477 status: RunStatus,
1478) -> Result<(), CacheReplayError> {
1479 if matches!(status, RunStatus::Quarantined) {
1480 let error = EventStoreError::Backend(format!("replay source {run_id} is quarantined"));
1481 return Err(CacheReplayError::from(error));
1482 }
1483
1484 Ok(())
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489 use std::{any::Any, cell::Cell, rc::Rc};
1490
1491 use ahash::AHashSet;
1492 use bytes::Bytes;
1493 use indexmap::IndexMap;
1494 use nautilus_common::msgbus::{self, BusTap, Endpoint, MStr, Topic as BusTopic};
1495 use nautilus_core::{UUID4, UnixNanos};
1496 use nautilus_model::{
1497 accounts::AccountAny,
1498 data::{Bar, BarSpecification, BarType, FundingRateUpdate, QuoteTick, TradeTick},
1499 enums::{
1500 AggregationSource, AggressorSide, BarAggregation, OrderSide, OrderStatus,
1501 PositionAdjustmentType, PriceType,
1502 },
1503 events::{
1504 PositionEvent,
1505 account::stubs::{cash_account_state, cash_account_state_million_usd},
1506 order::spec::{
1507 OrderAcceptedSpec, OrderFilledSpec, OrderInitializedSpec, OrderSubmittedSpec,
1508 },
1509 },
1510 identifiers::{
1511 AccountId, ClientId, ClientOrderId, InstrumentId, OrderListId, PositionId, TradeId,
1512 VenueOrderId,
1513 },
1514 instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
1515 orders::{Order, OrderList},
1516 types::{Currency, Money, Price, Quantity},
1517 };
1518 use rstest::rstest;
1519 use serde::Serialize;
1520 use tempfile::TempDir;
1521 use ustr::Ustr;
1522
1523 use super::*;
1524 use crate::{
1525 backend::{AppendEntry, MemoryBackend, RedbBackend},
1526 capture::{
1527 builtins::{
1528 DEFAULT_CAPTURE_PAYLOAD_TYPES, encode_order_event_any, encode_position_event,
1529 },
1530 encode_account_state,
1531 },
1532 entry::Topic as EntryTopic,
1533 hash::compute_entry_hash,
1534 headers::Headers,
1535 manifest::{RegisteredComponents, RunManifest, RunStatus},
1536 snapshot::SnapshotAnchor,
1537 };
1538
1539 fn manifest(run_id: &str) -> RunManifest {
1540 RunManifest {
1541 run_id: run_id.to_string(),
1542 parent_run_id: None,
1543 instance_id: "trader-001".to_string(),
1544 binary_hash: "deadbeef".to_string(),
1545 schema_version: 1,
1546 crate_versions: "feedface".to_string(),
1547 feature_flags: Vec::new(),
1548 adapter_versions: IndexMap::new(),
1549 config_hash: "cafebabe".to_string(),
1550 registered_components: RegisteredComponents::default(),
1551 seed: None,
1552 start_ts_init: UnixNanos::from(0),
1553 end_ts_init: None,
1554 high_watermark: 0,
1555 status: RunStatus::Running,
1556 }
1557 }
1558
1559 fn append_payload(seq: u64, payload_type: &str, payload: Bytes) -> AppendEntry {
1560 append_payload_with_ts(seq, seq, payload_type, payload)
1561 }
1562
1563 fn append_serde_payload<T: Serialize>(seq: u64, payload_type: &str, value: &T) -> AppendEntry {
1564 let payload = rmp_serde::to_vec_named(value).expect("encode replay payload");
1565 append_payload(seq, payload_type, Bytes::from(payload))
1566 }
1567
1568 fn append_payload_with_ts(
1569 seq: u64,
1570 ts_init: u64,
1571 payload_type: &str,
1572 payload: Bytes,
1573 ) -> AppendEntry {
1574 let topic = EntryTopic::from("events.account.SIM");
1575 let ts = UnixNanos::from(ts_init);
1576 let headers = Headers::empty();
1577 let hash = compute_entry_hash(
1578 seq,
1579 ts,
1580 ts,
1581 topic.as_ref(),
1582 payload_type,
1583 &payload,
1584 &headers,
1585 );
1586 let entry = EventStoreEntry::new(
1587 hash,
1588 seq,
1589 headers,
1590 topic,
1591 Ustr::from(payload_type),
1592 payload,
1593 ts,
1594 ts,
1595 );
1596 AppendEntry::without_indices(entry)
1597 }
1598
1599 fn append_account_state(seq: u64, state: &AccountState) -> AppendEntry {
1600 let encoded = encode_account_state(state).expect("encode account state");
1601 append_payload(seq, PAYLOAD_TYPE_ACCOUNT_STATE, encoded.payload)
1602 }
1603
1604 fn append_order_event(seq: u64, event: &OrderEventAny) -> AppendEntry {
1605 let encoded = encode_order_event_any(event).expect("encode order event");
1606 let payload_type = encoded.payload_type.expect("order payload type");
1607 append_payload(seq, payload_type.as_str(), encoded.payload)
1608 }
1609
1610 fn append_position_event(seq: u64, event: &PositionEvent) -> AppendEntry {
1611 let encoded = encode_position_event(event).expect("encode position event");
1612 let payload_type = encoded.payload_type.expect("position payload type");
1613 append_payload(seq, payload_type.as_str(), encoded.payload)
1614 }
1615
1616 fn reader_with_entries(
1617 run_id: &str,
1618 entries: &[AppendEntry],
1619 ) -> EventStoreReader<MemoryBackend> {
1620 let mut backend = MemoryBackend::new();
1621 backend.open_run(manifest(run_id)).expect("open");
1622 backend.append_batch(entries).expect("append");
1623 EventStoreReader::new(backend)
1624 }
1625
1626 fn reader_with_anchor(anchor_seq: u64) -> (EventStoreReader<MemoryBackend>, AccountState) {
1627 let anchored = cash_account_state();
1628 let replayed = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
1629 let mut backend = MemoryBackend::new();
1630 backend.open_run(manifest("run-replay")).expect("open");
1631 backend
1632 .append_batch(&[
1633 append_account_state(1, &anchored),
1634 append_account_state(2, &replayed),
1635 ])
1636 .expect("append");
1637 backend
1638 .record_snapshot_anchor(SnapshotAnchor::new(anchor_seq, "cache://account", "hash"))
1639 .expect("record anchor");
1640 (EventStoreReader::new(backend), replayed)
1641 }
1642
1643 fn catalog_quote_record(ts_init: u64) -> CatalogReplayRecord {
1644 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1645 CatalogReplayRecord::from_data(CatalogReplayData::Quote(QuoteTick::new(
1646 instrument_id,
1647 Price::from("1.0001"),
1648 Price::from("1.0002"),
1649 Quantity::from("100"),
1650 Quantity::from("100"),
1651 UnixNanos::from(ts_init),
1652 UnixNanos::from(ts_init),
1653 )))
1654 }
1655
1656 fn catalog_trade_record(ts_init: u64) -> CatalogReplayRecord {
1657 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1658 CatalogReplayRecord::from_data(CatalogReplayData::Trade(TradeTick::new(
1659 instrument_id,
1660 Price::from("1.0001"),
1661 Quantity::from("100"),
1662 AggressorSide::Buyer,
1663 TradeId::from("T-1"),
1664 UnixNanos::from(ts_init),
1665 UnixNanos::from(ts_init),
1666 )))
1667 }
1668
1669 #[derive(Debug)]
1670 struct CountingTap {
1671 calls: Rc<Cell<usize>>,
1672 }
1673
1674 impl CountingTap {
1675 fn new(calls: Rc<Cell<usize>>) -> Self {
1676 Self { calls }
1677 }
1678
1679 fn increment(&self) {
1680 self.calls.set(self.calls.get() + 1);
1681 }
1682 }
1683
1684 impl BusTap for CountingTap {
1685 fn on_publish(&self, _topic: MStr<BusTopic>, _message: &dyn Any) {
1686 self.increment();
1687 }
1688
1689 fn on_send(&self, _endpoint: MStr<Endpoint>, _message: &dyn Any) {
1690 self.increment();
1691 }
1692 }
1693
1694 #[derive(Debug)]
1695 struct FakeReplayCatalog {
1696 coverage: CatalogSliceCoverage,
1697 records: Vec<CatalogReplayRecord>,
1698 plan_queries: Vec<CatalogSliceQuery>,
1699 load_plans: Vec<CatalogSlicePlan>,
1700 }
1701
1702 impl FakeReplayCatalog {
1703 fn new(coverage: CatalogSliceCoverage, records: Vec<CatalogReplayRecord>) -> Self {
1704 Self {
1705 coverage,
1706 records,
1707 plan_queries: Vec::new(),
1708 load_plans: Vec::new(),
1709 }
1710 }
1711 }
1712
1713 impl ReplayCatalog for FakeReplayCatalog {
1714 type Error = String;
1715
1716 fn plan_slice(
1717 &mut self,
1718 query: &CatalogSliceQuery,
1719 ) -> Result<CatalogSliceCoverage, Self::Error> {
1720 self.plan_queries.push(query.clone());
1721 Ok(self.coverage.clone())
1722 }
1723
1724 fn load_slice(
1725 &mut self,
1726 plan: &CatalogSlicePlan,
1727 ) -> Result<Vec<CatalogReplayRecord>, Self::Error> {
1728 self.load_plans.push(plan.clone());
1729 Ok(self.records.clone())
1730 }
1731 }
1732
1733 struct BusTapGuard;
1734
1735 impl Drop for BusTapGuard {
1736 fn drop(&mut self) {
1737 msgbus::clear_bus_tap();
1738 }
1739 }
1740
1741 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1742 enum CacheMutationRecoveryClass {
1743 SnapshotOwned,
1744 EventStoreCapturedAndReplayed,
1745 ForensicOnly,
1746 MissingLiveRecovery,
1747 }
1748
1749 #[derive(Clone, Copy, Debug)]
1750 struct CacheMutationCoverage {
1751 method: &'static str,
1752 class: CacheMutationRecoveryClass,
1753 payload_types: &'static [&'static str],
1754 }
1755
1756 const CACHE_MUTATION_COVERAGE: &[CacheMutationCoverage] = &[
1757 cache_mutation(
1758 "set_database",
1759 CacheMutationRecoveryClass::SnapshotOwned,
1760 &[],
1761 ),
1762 cache_mutation(
1763 "cache_general",
1764 CacheMutationRecoveryClass::SnapshotOwned,
1765 &[],
1766 ),
1767 cache_mutation("cache_all", CacheMutationRecoveryClass::SnapshotOwned, &[]),
1768 cache_mutation(
1769 "cache_currencies",
1770 CacheMutationRecoveryClass::SnapshotOwned,
1771 &[],
1772 ),
1773 cache_mutation(
1774 "cache_instruments",
1775 CacheMutationRecoveryClass::SnapshotOwned,
1776 &[],
1777 ),
1778 cache_mutation(
1779 "cache_synthetics",
1780 CacheMutationRecoveryClass::SnapshotOwned,
1781 &[],
1782 ),
1783 cache_mutation(
1784 "cache_accounts",
1785 CacheMutationRecoveryClass::SnapshotOwned,
1786 &[],
1787 ),
1788 cache_mutation(
1789 "cache_orders",
1790 CacheMutationRecoveryClass::SnapshotOwned,
1791 &[],
1792 ),
1793 cache_mutation(
1794 "cache_positions",
1795 CacheMutationRecoveryClass::SnapshotOwned,
1796 &[],
1797 ),
1798 cache_mutation(
1799 "build_index",
1800 CacheMutationRecoveryClass::SnapshotOwned,
1801 &[],
1802 ),
1803 cache_mutation(
1804 "purge_closed_orders",
1805 CacheMutationRecoveryClass::SnapshotOwned,
1806 &[],
1807 ),
1808 cache_mutation(
1809 "purge_closed_positions",
1810 CacheMutationRecoveryClass::SnapshotOwned,
1811 &[],
1812 ),
1813 cache_mutation(
1814 "purge_order",
1815 CacheMutationRecoveryClass::SnapshotOwned,
1816 &[],
1817 ),
1818 cache_mutation(
1819 "purge_position",
1820 CacheMutationRecoveryClass::SnapshotOwned,
1821 &[],
1822 ),
1823 cache_mutation(
1824 "purge_instrument",
1825 CacheMutationRecoveryClass::SnapshotOwned,
1826 &[],
1827 ),
1828 cache_mutation(
1829 "purge_instrument_skip_order_guard",
1830 CacheMutationRecoveryClass::SnapshotOwned,
1831 &[],
1832 ),
1833 cache_mutation(
1834 "purge_account_events",
1835 CacheMutationRecoveryClass::SnapshotOwned,
1836 &[],
1837 ),
1838 cache_mutation(
1839 "clear_index",
1840 CacheMutationRecoveryClass::SnapshotOwned,
1841 &[],
1842 ),
1843 cache_mutation("reset", CacheMutationRecoveryClass::SnapshotOwned, &[]),
1844 cache_mutation("dispose", CacheMutationRecoveryClass::SnapshotOwned, &[]),
1845 cache_mutation("flush_db", CacheMutationRecoveryClass::SnapshotOwned, &[]),
1846 cache_mutation("add", CacheMutationRecoveryClass::SnapshotOwned, &[]),
1847 cache_mutation(
1848 "add_order_book",
1849 CacheMutationRecoveryClass::ForensicOnly,
1850 &[PAYLOAD_TYPE_BOOK_RESPONSE],
1851 ),
1852 cache_mutation(
1853 "add_own_order_book",
1854 CacheMutationRecoveryClass::SnapshotOwned,
1855 &[],
1856 ),
1857 cache_mutation(
1858 "add_mark_price",
1859 CacheMutationRecoveryClass::MissingLiveRecovery,
1860 &[],
1861 ),
1862 cache_mutation(
1863 "add_index_price",
1864 CacheMutationRecoveryClass::MissingLiveRecovery,
1865 &[],
1866 ),
1867 cache_mutation(
1868 "add_funding_rate",
1869 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1870 &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
1871 ),
1872 cache_mutation(
1873 "add_funding_rates",
1874 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1875 &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
1876 ),
1877 cache_mutation(
1878 "add_instrument_status",
1879 CacheMutationRecoveryClass::MissingLiveRecovery,
1880 &[],
1881 ),
1882 cache_mutation(
1883 "add_quote",
1884 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1885 &[PAYLOAD_TYPE_QUOTES_RESPONSE],
1886 ),
1887 cache_mutation(
1888 "add_quotes",
1889 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1890 &[PAYLOAD_TYPE_QUOTES_RESPONSE],
1891 ),
1892 cache_mutation(
1893 "add_trade",
1894 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1895 &[PAYLOAD_TYPE_TRADES_RESPONSE],
1896 ),
1897 cache_mutation(
1898 "add_trades",
1899 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1900 &[PAYLOAD_TYPE_TRADES_RESPONSE],
1901 ),
1902 cache_mutation(
1903 "add_bar",
1904 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1905 &[PAYLOAD_TYPE_BARS_RESPONSE],
1906 ),
1907 cache_mutation(
1908 "add_bars",
1909 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1910 &[PAYLOAD_TYPE_BARS_RESPONSE],
1911 ),
1912 cache_mutation(
1913 "add_greeks",
1914 CacheMutationRecoveryClass::MissingLiveRecovery,
1915 &[],
1916 ),
1917 cache_mutation(
1918 "add_option_greeks",
1919 CacheMutationRecoveryClass::MissingLiveRecovery,
1920 &[],
1921 ),
1922 cache_mutation(
1923 "add_yield_curve",
1924 CacheMutationRecoveryClass::MissingLiveRecovery,
1925 &[],
1926 ),
1927 cache_mutation(
1928 "add_currency",
1929 CacheMutationRecoveryClass::SnapshotOwned,
1930 &[],
1931 ),
1932 cache_mutation(
1933 "add_instrument",
1934 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1935 &[
1936 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
1937 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
1938 ],
1939 ),
1940 cache_mutation(
1941 "add_synthetic",
1942 CacheMutationRecoveryClass::SnapshotOwned,
1943 &[],
1944 ),
1945 cache_mutation(
1946 "add_account",
1947 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1948 &[PAYLOAD_TYPE_ACCOUNT_STATE],
1949 ),
1950 cache_mutation(
1951 "add_venue_order_id",
1952 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1953 &[PAYLOAD_TYPE_ORDER_ACCEPTED, PAYLOAD_TYPE_ORDER_UPDATED],
1954 ),
1955 cache_mutation(
1956 "add_order",
1957 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1958 &[PAYLOAD_TYPE_ORDER_INITIALIZED],
1959 ),
1960 cache_mutation(
1961 "add_order_list",
1962 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1963 &[PAYLOAD_TYPE_SUBMIT_ORDER_LIST],
1964 ),
1965 cache_mutation(
1966 "add_position_id",
1967 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1968 &[
1969 PAYLOAD_TYPE_ORDER_FILLED,
1970 PAYLOAD_TYPE_POSITION_OPENED,
1971 PAYLOAD_TYPE_POSITION_CHANGED,
1972 PAYLOAD_TYPE_POSITION_CLOSED,
1973 ],
1974 ),
1975 cache_mutation(
1976 "add_position",
1977 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1978 &[PAYLOAD_TYPE_ORDER_FILLED],
1979 ),
1980 cache_mutation(
1981 "update_account",
1982 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1983 &[PAYLOAD_TYPE_ACCOUNT_STATE],
1984 ),
1985 cache_mutation(
1986 "take_account",
1987 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1988 &[PAYLOAD_TYPE_ACCOUNT_STATE],
1989 ),
1990 cache_mutation(
1991 "cache_account_owned",
1992 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1993 &[PAYLOAD_TYPE_ACCOUNT_STATE],
1994 ),
1995 cache_mutation(
1996 "update_account_owned",
1997 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
1998 &[PAYLOAD_TYPE_ACCOUNT_STATE],
1999 ),
2000 cache_mutation(
2001 "update_account_state",
2002 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2003 &[PAYLOAD_TYPE_ACCOUNT_STATE],
2004 ),
2005 cache_mutation(
2006 "replace_order",
2007 CacheMutationRecoveryClass::ForensicOnly,
2008 &[
2009 PAYLOAD_TYPE_ORDER_STATUS_REPORT,
2010 PAYLOAD_TYPE_ORDER_WITH_FILLS,
2011 PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
2012 ],
2013 ),
2014 cache_mutation(
2015 "update_order",
2016 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2017 &[
2018 PAYLOAD_TYPE_ORDER_DENIED,
2019 PAYLOAD_TYPE_ORDER_EMULATED,
2020 PAYLOAD_TYPE_ORDER_RELEASED,
2021 PAYLOAD_TYPE_ORDER_SUBMITTED,
2022 PAYLOAD_TYPE_ORDER_ACCEPTED,
2023 PAYLOAD_TYPE_ORDER_REJECTED,
2024 PAYLOAD_TYPE_ORDER_CANCELED,
2025 PAYLOAD_TYPE_ORDER_EXPIRED,
2026 PAYLOAD_TYPE_ORDER_TRIGGERED,
2027 PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
2028 PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
2029 PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
2030 PAYLOAD_TYPE_ORDER_CANCEL_REJECTED,
2031 PAYLOAD_TYPE_ORDER_UPDATED,
2032 PAYLOAD_TYPE_ORDER_FILLED,
2033 ],
2034 ),
2035 cache_mutation(
2036 "update_order_pending_cancel_local",
2037 CacheMutationRecoveryClass::MissingLiveRecovery,
2038 &[],
2039 ),
2040 cache_mutation(
2041 "update_position",
2042 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2043 &[
2044 PAYLOAD_TYPE_ORDER_FILLED,
2045 PAYLOAD_TYPE_POSITION_OPENED,
2046 PAYLOAD_TYPE_POSITION_CHANGED,
2047 PAYLOAD_TYPE_POSITION_CLOSED,
2048 PAYLOAD_TYPE_POSITION_ADJUSTED,
2049 ],
2050 ),
2051 cache_mutation(
2052 "snapshot_position",
2053 CacheMutationRecoveryClass::SnapshotOwned,
2054 &[],
2055 ),
2056 cache_mutation(
2057 "snapshot_position_state",
2058 CacheMutationRecoveryClass::SnapshotOwned,
2059 &[],
2060 ),
2061 cache_mutation(
2062 "load_snapshot_blob",
2063 CacheMutationRecoveryClass::SnapshotOwned,
2064 &[],
2065 ),
2066 cache_mutation(
2067 "restore_snapshot_blob",
2068 CacheMutationRecoveryClass::SnapshotOwned,
2069 &[],
2070 ),
2071 cache_mutation(
2072 "order_mut",
2073 CacheMutationRecoveryClass::MissingLiveRecovery,
2074 &[],
2075 ),
2076 cache_mutation(
2077 "position_mut",
2078 CacheMutationRecoveryClass::MissingLiveRecovery,
2079 &[],
2080 ),
2081 cache_mutation(
2082 "order_book_mut",
2083 CacheMutationRecoveryClass::ForensicOnly,
2084 &[
2085 PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE,
2086 PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
2087 ],
2088 ),
2089 cache_mutation(
2090 "own_order_book_mut",
2091 CacheMutationRecoveryClass::SnapshotOwned,
2092 &[],
2093 ),
2094 cache_mutation(
2095 "set_mark_xrate",
2096 CacheMutationRecoveryClass::MissingLiveRecovery,
2097 &[],
2098 ),
2099 cache_mutation(
2100 "clear_mark_xrate",
2101 CacheMutationRecoveryClass::MissingLiveRecovery,
2102 &[],
2103 ),
2104 cache_mutation(
2105 "clear_mark_xrates",
2106 CacheMutationRecoveryClass::MissingLiveRecovery,
2107 &[],
2108 ),
2109 cache_mutation(
2110 "account_mut",
2111 CacheMutationRecoveryClass::MissingLiveRecovery,
2112 &[],
2113 ),
2114 cache_mutation(
2115 "update_own_order_book",
2116 CacheMutationRecoveryClass::SnapshotOwned,
2117 &[],
2118 ),
2119 cache_mutation(
2120 "force_remove_from_own_order_book",
2121 CacheMutationRecoveryClass::SnapshotOwned,
2122 &[],
2123 ),
2124 cache_mutation(
2125 "audit_own_order_books",
2126 CacheMutationRecoveryClass::SnapshotOwned,
2127 &[],
2128 ),
2129 ];
2130
2131 const CACHE_MUTATION_EXCLUSIONS: &[&str] = &["check_integrity"];
2132
2133 const fn cache_mutation(
2134 method: &'static str,
2135 class: CacheMutationRecoveryClass,
2136 payload_types: &'static [&'static str],
2137 ) -> CacheMutationCoverage {
2138 CacheMutationCoverage {
2139 method,
2140 class,
2141 payload_types,
2142 }
2143 }
2144
2145 fn cache_public_methods() -> AHashSet<&'static str> {
2146 collect_cache_public_methods(false)
2147 }
2148
2149 fn cache_public_mutable_methods() -> AHashSet<&'static str> {
2150 collect_cache_public_methods(true)
2151 }
2152
2153 fn collect_cache_public_methods(require_mut_self: bool) -> AHashSet<&'static str> {
2154 let source = include_str!("../../common/src/cache/mod.rs");
2155 let mut methods = AHashSet::new();
2156 let mut pending_name: Option<&'static str> = None;
2157 let mut pending_signature = String::new();
2158
2159 for line in source.lines() {
2160 let trimmed = line.trim_start();
2161
2162 if pending_name.is_none() {
2163 let Some(rest) = trimmed
2164 .strip_prefix("pub fn ")
2165 .or_else(|| trimmed.strip_prefix("pub async fn "))
2166 else {
2167 continue;
2168 };
2169 pending_name = rest.split('(').next();
2170 pending_signature.clear();
2171 pending_signature.push_str(trimmed);
2172 } else {
2173 pending_signature.push(' ');
2174 pending_signature.push_str(trimmed);
2175 }
2176
2177 if trimmed.contains('{') {
2178 if let Some(name) = pending_name.take()
2179 && (!require_mut_self || pending_signature.contains("&mut self"))
2180 {
2181 methods.insert(name);
2182 }
2183 pending_signature.clear();
2184 }
2185 }
2186
2187 methods
2188 }
2189
2190 fn sorted_missing_methods<'a>(
2191 actual: &'a AHashSet<&'static str>,
2192 classified: &'a AHashSet<&'static str>,
2193 ) -> Vec<&'static str> {
2194 let mut missing: Vec<_> = actual
2195 .iter()
2196 .copied()
2197 .filter(|method| !classified.contains(method))
2198 .collect();
2199 missing.sort_unstable();
2200 missing
2201 }
2202
2203 fn sorted_stale_methods<'a>(
2204 classified: &'a AHashSet<&'static str>,
2205 actual: &'a AHashSet<&'static str>,
2206 ) -> Vec<&'static str> {
2207 let mut stale: Vec<_> = classified
2208 .iter()
2209 .copied()
2210 .filter(|method| !actual.contains(method))
2211 .collect();
2212 stale.sort_unstable();
2213 stale
2214 }
2215
2216 #[rstest]
2217 fn catalog_replay_inputs_join_event_entries_with_selected_catalog_slice() {
2218 let reader = reader_with_entries(
2219 "run-catalog",
2220 &[
2221 append_payload_with_ts(1, 120, "RunStarted", Bytes::from_static(b"started")),
2222 append_payload_with_ts(2, 100, "SubmitOrder", Bytes::from_static(b"submit")),
2223 ],
2224 );
2225 let record = catalog_quote_record(110);
2226 let mut catalog = FakeReplayCatalog::new(
2227 CatalogSliceCoverage::from_files(vec!["quotes/AUDUSD.SIM/100_120.parquet".into()]),
2228 vec![record.clone()],
2229 );
2230
2231 let plan = plan_catalog_replay_inputs(
2232 &reader,
2233 &mut catalog,
2234 ReplaySeqRange::new(1, 2),
2235 &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2236 )
2237 .expect("plan catalog replay");
2238
2239 assert_eq!(plan.event_range, Some(ReplaySeqRange::new(1, 2)));
2240 assert_eq!(plan.event_count, 2);
2241 assert_eq!(
2242 plan.event_time_range,
2243 Some(ReplayTimeRange::new(
2244 UnixNanos::from(100),
2245 UnixNanos::from(120),
2246 )),
2247 );
2248 assert!(!plan.catalog_slices[0].is_missing());
2249 assert_eq!(catalog.plan_queries.len(), 1);
2250 assert_eq!(catalog.plan_queries[0].data_cls, "quotes");
2251 assert_eq!(
2252 catalog.plan_queries[0].identifiers,
2253 vec!["AUD/USD.SIM".to_string()],
2254 );
2255 assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(100));
2256 assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(120));
2257
2258 let loaded =
2259 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
2260 let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
2261
2262 assert_eq!(seqs, vec![1, 2]);
2263 assert_eq!(loaded.catalog_slices.len(), 1);
2264 assert_eq!(loaded.catalog_slices[0].records, vec![record]);
2265 assert_eq!(catalog.load_plans.len(), 1);
2266 }
2267
2268 #[rstest]
2269 fn catalog_plan_marks_missing_catalog_slice() {
2270 let reader = reader_with_entries(
2271 "run-missing-catalog",
2272 &[append_payload_with_ts(
2273 1,
2274 1_000,
2275 "RunStarted",
2276 Bytes::from_static(b"started"),
2277 )],
2278 );
2279 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2280
2281 let plan = plan_catalog_replay_inputs(
2282 &reader,
2283 &mut catalog,
2284 ReplaySeqRange::new(1, 1),
2285 &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
2286 )
2287 .expect("plan catalog replay");
2288 let missing = plan.missing_catalog_slices();
2289
2290 assert_eq!(missing.len(), 1);
2291 assert_eq!(missing[0].query.data_cls, "trades");
2292 assert_eq!(
2293 missing[0].query.identifiers,
2294 vec!["AUD/USD.SIM".to_string()],
2295 );
2296 assert_eq!(missing[0].query.start, UnixNanos::from(1_000));
2297 assert_eq!(missing[0].query.end, UnixNanos::from(1_000));
2298 }
2299
2300 #[rstest]
2301 fn required_missing_catalog_slice_rejects_load() {
2302 let reader = reader_with_entries(
2303 "run-required-missing",
2304 &[append_payload_with_ts(
2305 1,
2306 1_000,
2307 "RunStarted",
2308 Bytes::from_static(b"started"),
2309 )],
2310 );
2311 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2312 let plan = plan_catalog_replay_inputs(
2313 &reader,
2314 &mut catalog,
2315 ReplaySeqRange::new(1, 1),
2316 &[CatalogSliceSelector::new("quotes")
2317 .with_identifier("AUD/USD.SIM")
2318 .require_coverage()],
2319 )
2320 .expect("plan missing slice");
2321
2322 let err = load_catalog_replay_inputs(&reader, &mut catalog, &plan)
2323 .expect_err("required missing slice must fail");
2324
2325 match err {
2326 ReplayInputError::MissingCatalogSlice {
2327 data_cls,
2328 identifiers,
2329 } => {
2330 assert_eq!(data_cls, "quotes");
2331 assert_eq!(identifiers, vec!["AUD/USD.SIM".to_string()]);
2332 }
2333 other => panic!("expected MissingCatalogSlice, was {other:?}"),
2334 }
2335 }
2336
2337 #[rstest]
2338 fn optional_missing_catalog_slice_loads_as_empty_without_catalog_load() {
2339 let reader = reader_with_entries(
2340 "run-optional-missing",
2341 &[append_payload_with_ts(
2342 1,
2343 1_000,
2344 "RunStarted",
2345 Bytes::from_static(b"started"),
2346 )],
2347 );
2348 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2349 let plan = plan_catalog_replay_inputs(
2350 &reader,
2351 &mut catalog,
2352 ReplaySeqRange::new(1, 1),
2353 &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2354 )
2355 .expect("plan optional missing slice");
2356
2357 let loaded =
2358 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load optional");
2359
2360 assert_eq!(loaded.catalog_slices.len(), 1);
2361 assert!(loaded.catalog_slices[0].plan.is_missing());
2362 assert!(loaded.catalog_slices[0].records.is_empty());
2363 assert!(catalog.load_plans.is_empty());
2364 }
2365
2366 #[rstest]
2367 fn catalog_joined_planner_rejects_empty_catalog_selection() {
2368 let reader = reader_with_entries(
2369 "run-empty-selection",
2370 &[append_payload_with_ts(
2371 1,
2372 1_000,
2373 "RunStarted",
2374 Bytes::from_static(b"started"),
2375 )],
2376 );
2377 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2378
2379 let err = plan_catalog_replay_inputs(&reader, &mut catalog, ReplaySeqRange::new(1, 1), &[])
2380 .expect_err("empty catalog selection must fail");
2381
2382 match err {
2383 ReplayInputError::EmptyCatalogSelection => {}
2384 other => panic!("expected EmptyCatalogSelection, was {other:?}"),
2385 }
2386 assert!(catalog.plan_queries.is_empty());
2387 }
2388
2389 #[rstest]
2390 fn catalog_selector_explicit_time_bounds_override_event_span() {
2391 let reader = reader_with_entries(
2392 "run-explicit-bounds",
2393 &[append_payload_with_ts(
2394 1,
2395 1_000,
2396 "RunStarted",
2397 Bytes::from_static(b"started"),
2398 )],
2399 );
2400 let mut catalog = FakeReplayCatalog::new(
2401 CatalogSliceCoverage::from_files(vec!["bars/AUDUSD.SIM/900_950.parquet".into()]),
2402 Vec::new(),
2403 );
2404
2405 let plan = plan_catalog_replay_inputs(
2406 &reader,
2407 &mut catalog,
2408 ReplaySeqRange::new(1, 1),
2409 &[CatalogSliceSelector::new("bars")
2410 .with_identifier("AUD/USD.SIM-1-MINUTE-BID-EXTERNAL")
2411 .with_time_bounds(UnixNanos::from(900), UnixNanos::from(950))],
2412 )
2413 .expect("plan explicit bounds");
2414
2415 assert_eq!(plan.catalog_slices[0].query.start, UnixNanos::from(900));
2416 assert_eq!(plan.catalog_slices[0].query.end, UnixNanos::from(950));
2417 assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(900));
2418 assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(950));
2419 }
2420
2421 #[rstest]
2422 fn catalog_replay_inputs_load_catalog_records() {
2423 let reader = reader_with_entries(
2424 "run-catalog-load",
2425 &[
2426 append_payload_with_ts(1, 100, "RunStarted", Bytes::from_static(b"started")),
2427 append_payload_with_ts(2, 110, "OrderFilled", Bytes::from_static(b"filled")),
2428 ],
2429 );
2430 let record = catalog_trade_record(105);
2431 let mut catalog = FakeReplayCatalog::new(
2432 CatalogSliceCoverage::from_files(vec!["trades/AUDUSD.SIM/100_110.parquet".into()]),
2433 vec![record.clone()],
2434 );
2435 let plan = plan_catalog_replay_inputs(
2436 &reader,
2437 &mut catalog,
2438 ReplaySeqRange::new(1, 2),
2439 &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
2440 )
2441 .expect("plan catalog replay");
2442
2443 assert_eq!(
2444 plan.catalog_slices[0].query.identifiers_option(),
2445 Some(vec!["AUD/USD.SIM".to_string()]),
2446 );
2447
2448 let loaded =
2449 load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
2450 let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
2451
2452 assert_eq!(seqs, vec![1, 2]);
2453 assert_eq!(loaded.catalog_slices[0].records, vec![record]);
2454 assert_eq!(catalog.load_plans.len(), 1);
2455 }
2456
2457 #[rstest]
2458 fn unbounded_catalog_selector_rejects_empty_event_scan() {
2459 let reader = reader_with_entries("run-empty", &[]);
2460 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2461
2462 let err = plan_catalog_replay_inputs(
2463 &reader,
2464 &mut catalog,
2465 ReplaySeqRange::new(1, 10),
2466 &[CatalogSliceSelector::new("quotes")],
2467 )
2468 .expect_err("empty replay scan must need explicit bounds");
2469
2470 match err {
2471 ReplayInputError::MissingCatalogTimeBounds { data_cls } => {
2472 assert_eq!(data_cls, "quotes");
2473 }
2474 other => panic!("expected MissingCatalogTimeBounds, was {other:?}"),
2475 }
2476 }
2477
2478 #[rstest]
2479 fn invalid_catalog_time_bounds_are_rejected_before_catalog_access() {
2480 let reader = reader_with_entries(
2481 "run-invalid-bounds",
2482 &[append_payload_with_ts(
2483 1,
2484 1_000,
2485 "RunStarted",
2486 Bytes::from_static(b"started"),
2487 )],
2488 );
2489 let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2490
2491 let err = plan_catalog_replay_inputs(
2492 &reader,
2493 &mut catalog,
2494 ReplaySeqRange::new(1, 1),
2495 &[CatalogSliceSelector::new("quotes")
2496 .with_time_bounds(UnixNanos::from(200), UnixNanos::from(100))],
2497 )
2498 .expect_err("invalid catalog bounds must fail");
2499
2500 match err {
2501 ReplayInputError::InvalidCatalogTimeRange {
2502 data_cls,
2503 start,
2504 end,
2505 } => {
2506 assert_eq!(data_cls, "quotes");
2507 assert_eq!(start, 200);
2508 assert_eq!(end, 100);
2509 }
2510 other => panic!("expected InvalidCatalogTimeRange, was {other:?}"),
2511 }
2512 assert!(catalog.plan_queries.is_empty());
2513 }
2514
2515 #[rstest]
2516 fn forensics_replay_inputs_do_not_require_catalog_source() {
2517 let reader = reader_with_entries(
2518 "run-forensics",
2519 &[append_payload_with_ts(
2520 1,
2521 500,
2522 "RunStarted",
2523 Bytes::from_static(b"started"),
2524 )],
2525 );
2526
2527 let plan = plan_forensics_replay_inputs(&reader, ReplaySeqRange::new(1, 1))
2528 .expect("plan forensics");
2529 let loaded = load_forensics_replay_inputs(&reader, &plan).expect("load forensics");
2530
2531 assert!(plan.catalog_slices.is_empty());
2532 assert_eq!(loaded.entries.len(), 1);
2533 assert!(loaded.catalog_slices.is_empty());
2534 }
2535
2536 #[rstest]
2537 #[case::zero_start(ReplaySeqRange::new(0, 1), "seq is 1-based")]
2538 #[case::from_after_to(ReplaySeqRange::new(2, 1), "from_seq exceeds to_seq")]
2539 fn invalid_replay_seq_range_rejected(
2540 #[case] range: ReplaySeqRange,
2541 #[case] expected_message: &str,
2542 ) {
2543 let reader = reader_with_entries("run-invalid-seq", &[]);
2544
2545 let err =
2546 plan_forensics_replay_inputs(&reader, range).expect_err("invalid seq range must fail");
2547
2548 match err {
2549 ReplayInputError::InvalidSeqRange {
2550 from_seq,
2551 to_seq,
2552 message,
2553 } => {
2554 assert_eq!(from_seq, range.from_seq);
2555 assert_eq!(to_seq, range.to_seq);
2556 assert_eq!(message, expected_message);
2557 }
2558 other => panic!("expected InvalidSeqRange, was {other:?}"),
2559 }
2560 }
2561
2562 #[rstest]
2563 fn replay_restores_snapshot_before_applying_tail() {
2564 let (reader, replayed) = reader_with_anchor(1);
2565 let mut cache = Cache::default();
2566 let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
2567 let restored_id = restored.account_id;
2568
2569 let report =
2570 restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
2571 assert_eq!(anchor.expect("anchor").high_watermark, 1);
2572 let account = AccountAny::from_events(std::slice::from_ref(&restored))
2573 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
2574 cache
2575 .add_account(account)
2576 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
2577 })
2578 .expect("replay");
2579
2580 let account = cache.account_owned(&restored_id).expect("account restored");
2581 let events = account.events();
2582
2583 assert_eq!(report.plan.from_seq, 2);
2584 assert_eq!(report.applied_entries, 1);
2585 assert_eq!(report.ignored_entries, 0);
2586 assert_eq!(events, vec![restored, replayed]);
2587 }
2588
2589 #[rstest]
2590 fn replay_does_not_apply_entries_at_or_below_anchor_watermark() {
2591 let (reader, _) = reader_with_anchor(2);
2592 let mut cache = Cache::default();
2593 let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
2594 let restored_id = restored.account_id;
2595
2596 let report =
2597 restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
2598 assert_eq!(anchor.expect("anchor").high_watermark, 2);
2599 let account = AccountAny::from_events(std::slice::from_ref(&restored))
2600 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
2601 cache
2602 .add_account(account)
2603 .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
2604 })
2605 .expect("replay");
2606
2607 let account = cache.account_owned(&restored_id).expect("account restored");
2608
2609 assert!(report.plan.is_empty());
2610 assert_eq!(report.applied_entries, 0);
2611 assert_eq!(report.ignored_entries, 0);
2612 assert_eq!(account.events(), vec![restored]);
2613 }
2614
2615 #[rstest]
2616 fn replay_from_start_applies_account_state_without_bus_publish() {
2617 let state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
2618 let account_id = AccountId::from("SIM-001");
2619 let bus_calls = Rc::new(Cell::new(0));
2620 msgbus::set_bus_tap(Rc::new(CountingTap::new(Rc::clone(&bus_calls))));
2621 let _guard = BusTapGuard;
2622 let mut backend = MemoryBackend::new();
2623 backend.open_run(manifest("run-replay")).expect("open");
2624 backend
2625 .append_batch(&[append_account_state(1, &state)])
2626 .expect("append");
2627 let reader = EventStoreReader::new(backend);
2628 let mut cache = Cache::default();
2629
2630 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
2631 let account = cache.account_owned(&account_id).expect("account replayed");
2632
2633 assert_eq!(report.plan.anchor, None);
2634 assert_eq!(report.plan.from_seq, 1);
2635 assert_eq!(report.applied_entries, 1);
2636 assert_eq!(bus_calls.get(), 0);
2637 assert_eq!(account.last_event(), Some(state));
2638 assert_eq!(account.base_currency(), Some(Currency::USD()));
2639 }
2640
2641 #[rstest]
2642 fn unsupported_payload_is_ignored() {
2643 let mut backend = MemoryBackend::new();
2644 backend.open_run(manifest("run-replay")).expect("open");
2645 backend
2646 .append_batch(&[append_payload(
2647 1,
2648 "RunStarted",
2649 Bytes::copy_from_slice(UUID4::new().to_string().as_bytes()),
2650 )])
2651 .expect("append");
2652 let reader = EventStoreReader::new(backend);
2653 let mut cache = Cache::default();
2654
2655 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
2656
2657 assert_eq!(report.applied_entries, 0);
2658 assert_eq!(report.ignored_entries, 1);
2659 }
2660
2661 #[rstest]
2662 fn default_capture_payload_types_are_classified_for_cache_replay() {
2663 let mut classified = AHashSet::new();
2664 let mut overlap = Vec::new();
2665
2666 for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
2667 classified.insert(*payload_type);
2668 }
2669
2670 for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
2671 if !classified.insert(*payload_type) {
2672 overlap.push(*payload_type);
2673 }
2674 }
2675
2676 let mut seen_defaults = AHashSet::new();
2677 let duplicate_defaults: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
2678 .iter()
2679 .copied()
2680 .filter(|payload_type| !seen_defaults.insert(*payload_type))
2681 .collect();
2682 let unclassified: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
2683 .iter()
2684 .copied()
2685 .filter(|payload_type| !classified.contains(payload_type))
2686 .collect();
2687 let extra: Vec<_> = classified
2688 .iter()
2689 .copied()
2690 .filter(|payload_type| !seen_defaults.contains(payload_type))
2691 .collect();
2692
2693 assert!(
2694 duplicate_defaults.is_empty(),
2695 "default capture payload types must be unique: {duplicate_defaults:?}",
2696 );
2697 assert!(
2698 overlap.is_empty(),
2699 "cache replay and forensic-only classes must not overlap: {overlap:?}",
2700 );
2701 assert!(
2702 unclassified.is_empty(),
2703 "default capture payload types must be cache replayed or forensic-only: {unclassified:?}",
2704 );
2705 assert!(
2706 extra.is_empty(),
2707 "cache replay classification must not list uncaptured payload types: {extra:?}",
2708 );
2709 }
2710
2711 #[rstest]
2712 fn cache_replay_capture_payload_types_have_replay_rules() {
2713 for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
2714 let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
2715 let mut cache = Cache::default();
2716
2717 let err = apply_cache_replay_entry(&mut cache, &entry)
2718 .expect_err("cache replay payload type must have a decode rule");
2719
2720 match err {
2721 CacheReplayError::Decode {
2722 payload_type: actual,
2723 ..
2724 } => {
2725 assert_eq!(actual, *payload_type);
2726 }
2727 other => panic!("expected Decode for {payload_type}, was {other:?}"),
2728 }
2729 }
2730 }
2731
2732 #[rstest]
2733 fn forensic_only_capture_payload_types_are_not_cache_replayed() {
2734 for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
2735 let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
2736 let mut cache = Cache::default();
2737
2738 let applied = apply_cache_replay_entry(&mut cache, &entry)
2739 .expect("forensic-only payload type must not be decoded by cache replay");
2740
2741 assert!(
2742 !applied,
2743 "forensic-only payload type must be ignored by cache replay: {payload_type}",
2744 );
2745 }
2746 }
2747
2748 #[rstest]
2749 fn cache_public_mutators_have_recovery_classification() {
2750 let mut classified = AHashSet::new();
2751 let mut duplicates = Vec::new();
2752
2753 for row in CACHE_MUTATION_COVERAGE {
2754 if !classified.insert(row.method) {
2755 duplicates.push(row.method);
2756 }
2757 }
2758
2759 for method in CACHE_MUTATION_EXCLUSIONS {
2760 if !classified.insert(*method) {
2761 duplicates.push(*method);
2762 }
2763 }
2764
2765 let public_methods = cache_public_methods();
2766 let mutable_methods = cache_public_mutable_methods();
2767 let missing = sorted_missing_methods(&mutable_methods, &classified);
2768 let stale = sorted_stale_methods(&classified, &public_methods);
2769
2770 assert!(
2771 duplicates.is_empty(),
2772 "cache mutation recovery classifications must be unique: {duplicates:?}",
2773 );
2774 assert!(
2775 missing.is_empty(),
2776 "public Cache mutators must be classified for recovery: {missing:?}",
2777 );
2778 assert!(
2779 stale.is_empty(),
2780 "cache mutation recovery classifications reference missing methods: {stale:?}",
2781 );
2782 }
2783
2784 #[rstest]
2785 fn cache_mutation_replay_classification_matches_payload_buckets() {
2786 for row in CACHE_MUTATION_COVERAGE {
2787 match row.class {
2788 CacheMutationRecoveryClass::EventStoreCapturedAndReplayed => {
2789 assert!(
2790 !row.payload_types.is_empty(),
2791 "cache-replayed mutation must cite captured payloads: {}",
2792 row.method,
2793 );
2794
2795 for payload_type in row.payload_types {
2796 assert!(
2797 CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
2798 "cache mutation {} cites non-replayed payload {payload_type}",
2799 row.method,
2800 );
2801 }
2802 }
2803 CacheMutationRecoveryClass::ForensicOnly => {
2804 assert!(
2805 !row.payload_types.is_empty(),
2806 "forensic-only mutation must cite forensic payloads: {}",
2807 row.method,
2808 );
2809
2810 for payload_type in row.payload_types {
2811 assert!(
2812 FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
2813 "cache mutation {} cites non-forensic payload {payload_type}",
2814 row.method,
2815 );
2816 }
2817 }
2818 CacheMutationRecoveryClass::SnapshotOwned
2819 | CacheMutationRecoveryClass::MissingLiveRecovery => {
2820 assert!(
2821 row.payload_types.is_empty(),
2822 "non-event-store cache mutation {} should not cite payloads",
2823 row.method,
2824 );
2825 }
2826 }
2827 }
2828 }
2829
2830 #[rstest]
2831 fn submit_order_list_replay_restores_order_list() {
2832 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
2833 let instrument_id = instrument.id();
2834 let first_init = OrderInitializedSpec::builder()
2835 .instrument_id(instrument_id)
2836 .client_order_id(ClientOrderId::from("O-LIST-001"))
2837 .build();
2838 let second_init = OrderInitializedSpec::builder()
2839 .instrument_id(instrument_id)
2840 .client_order_id(ClientOrderId::from("O-LIST-002"))
2841 .build();
2842 let order_list = OrderList::new(
2843 OrderListId::from("OL-001"),
2844 instrument_id,
2845 first_init.strategy_id,
2846 vec![first_init.client_order_id, second_init.client_order_id],
2847 UnixNanos::from(1),
2848 );
2849 let command = SubmitOrderList::new(
2850 first_init.trader_id,
2851 Some(ClientId::from("SIM")),
2852 first_init.strategy_id,
2853 order_list.clone(),
2854 vec![first_init, second_init],
2855 None,
2856 None,
2857 None,
2858 UUID4::new(),
2859 UnixNanos::from(2),
2860 None,
2861 );
2862 let entry = append_serde_payload(1, PAYLOAD_TYPE_SUBMIT_ORDER_LIST, &command).entry;
2863 let mut cache = Cache::default();
2864
2865 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply order list");
2866 let replayed = cache
2867 .order_list(&order_list.id)
2868 .expect("order list replayed");
2869
2870 assert!(applied);
2871 assert_eq!(replayed, &order_list);
2872 }
2873
2874 #[rstest]
2875 fn data_response_replay_restores_instruments_and_market_data() {
2876 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
2877 let instrument_id = instrument.id();
2878 let client_id = ClientId::from("DATA");
2879 let quote = QuoteTick::new(
2880 instrument_id,
2881 Price::from("1.00000"),
2882 Price::from("1.00010"),
2883 Quantity::from("100000"),
2884 Quantity::from("100000"),
2885 UnixNanos::from(10),
2886 UnixNanos::from(11),
2887 );
2888 let trade = TradeTick::new(
2889 instrument_id,
2890 Price::from("1.00005"),
2891 Quantity::from("50000"),
2892 AggressorSide::Buyer,
2893 TradeId::from("T-DATA-001"),
2894 UnixNanos::from(12),
2895 UnixNanos::from(13),
2896 );
2897 let funding_rate = FundingRateUpdate::new(
2898 instrument_id,
2899 "0.0001".parse().expect("funding rate"),
2900 Some(480),
2901 Some(UnixNanos::from(60)),
2902 UnixNanos::from(14),
2903 UnixNanos::from(15),
2904 );
2905 let bar_type = BarType::new(
2906 instrument_id,
2907 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2908 AggregationSource::External,
2909 );
2910 let bar = Bar::new(
2911 bar_type,
2912 Price::from("1.00000"),
2913 Price::from("1.00020"),
2914 Price::from("0.99990"),
2915 Price::from("1.00010"),
2916 Quantity::from("150000"),
2917 UnixNanos::from(16),
2918 UnixNanos::from(17),
2919 );
2920 let reader = reader_with_entries(
2921 "run-data-response-replay",
2922 &[
2923 append_serde_payload(
2924 1,
2925 PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
2926 &InstrumentResponse::new(
2927 UUID4::new(),
2928 client_id,
2929 instrument_id,
2930 instrument.clone(),
2931 None,
2932 None,
2933 UnixNanos::from(1),
2934 None,
2935 ),
2936 ),
2937 append_serde_payload(
2938 2,
2939 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
2940 &InstrumentsResponse::new(
2941 UUID4::new(),
2942 client_id,
2943 instrument_id.venue,
2944 vec![instrument],
2945 None,
2946 None,
2947 UnixNanos::from(2),
2948 None,
2949 ),
2950 ),
2951 append_serde_payload(
2952 3,
2953 PAYLOAD_TYPE_QUOTES_RESPONSE,
2954 &QuotesResponse::new(
2955 UUID4::new(),
2956 client_id,
2957 instrument_id,
2958 vec![quote],
2959 None,
2960 None,
2961 UnixNanos::from(3),
2962 None,
2963 ),
2964 ),
2965 append_serde_payload(
2966 4,
2967 PAYLOAD_TYPE_TRADES_RESPONSE,
2968 &TradesResponse::new(
2969 UUID4::new(),
2970 client_id,
2971 instrument_id,
2972 vec![trade],
2973 None,
2974 None,
2975 UnixNanos::from(4),
2976 None,
2977 ),
2978 ),
2979 append_serde_payload(
2980 5,
2981 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
2982 &FundingRatesResponse::new(
2983 UUID4::new(),
2984 client_id,
2985 instrument_id,
2986 vec![funding_rate],
2987 None,
2988 None,
2989 UnixNanos::from(5),
2990 None,
2991 ),
2992 ),
2993 append_serde_payload(
2994 6,
2995 PAYLOAD_TYPE_BARS_RESPONSE,
2996 &BarsResponse::new(
2997 UUID4::new(),
2998 client_id,
2999 bar_type,
3000 vec![bar],
3001 None,
3002 None,
3003 UnixNanos::from(6),
3004 None,
3005 ),
3006 ),
3007 ],
3008 );
3009 let mut cache = Cache::default();
3010
3011 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3012
3013 assert_eq!(report.applied_entries, 6);
3014 assert_eq!(report.ignored_entries, 0);
3015 assert_eq!(
3016 cache.instrument(&instrument_id).map(Instrument::id),
3017 Some(instrument_id)
3018 );
3019 assert_eq!(cache.quotes(&instrument_id), Some(vec![quote]));
3020 assert_eq!(cache.trades(&instrument_id), Some(vec![trade]));
3021 assert_eq!(
3022 cache.funding_rates(&instrument_id),
3023 Some(vec![funding_rate])
3024 );
3025 assert_eq!(cache.bars(&bar_type), Some(vec![bar]));
3026 }
3027
3028 #[rstest]
3029 fn empty_data_response_replay_is_noop() {
3030 let instrument_id = InstrumentAny::CurrencyPair(audusd_sim()).id();
3031 let client_id = ClientId::from("DATA");
3032 let bar_type = BarType::new(
3033 instrument_id,
3034 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3035 AggregationSource::External,
3036 );
3037 let reader = reader_with_entries(
3038 "run-empty-data-response-replay",
3039 &[
3040 append_serde_payload(
3041 1,
3042 PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3043 &InstrumentsResponse::new(
3044 UUID4::new(),
3045 client_id,
3046 instrument_id.venue,
3047 Vec::new(),
3048 None,
3049 None,
3050 UnixNanos::from(1),
3051 None,
3052 ),
3053 ),
3054 append_serde_payload(
3055 2,
3056 PAYLOAD_TYPE_QUOTES_RESPONSE,
3057 &QuotesResponse::new(
3058 UUID4::new(),
3059 client_id,
3060 instrument_id,
3061 Vec::new(),
3062 None,
3063 None,
3064 UnixNanos::from(2),
3065 None,
3066 ),
3067 ),
3068 append_serde_payload(
3069 3,
3070 PAYLOAD_TYPE_TRADES_RESPONSE,
3071 &TradesResponse::new(
3072 UUID4::new(),
3073 client_id,
3074 instrument_id,
3075 Vec::new(),
3076 None,
3077 None,
3078 UnixNanos::from(3),
3079 None,
3080 ),
3081 ),
3082 append_serde_payload(
3083 4,
3084 PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3085 &FundingRatesResponse::new(
3086 UUID4::new(),
3087 client_id,
3088 instrument_id,
3089 Vec::new(),
3090 None,
3091 None,
3092 UnixNanos::from(4),
3093 None,
3094 ),
3095 ),
3096 append_serde_payload(
3097 5,
3098 PAYLOAD_TYPE_BARS_RESPONSE,
3099 &BarsResponse::new(
3100 UUID4::new(),
3101 client_id,
3102 bar_type,
3103 Vec::new(),
3104 None,
3105 None,
3106 UnixNanos::from(5),
3107 None,
3108 ),
3109 ),
3110 ],
3111 );
3112 let mut cache = Cache::default();
3113
3114 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3115
3116 assert_eq!(report.applied_entries, 5);
3117 assert_eq!(report.ignored_entries, 0);
3118 assert!(cache.instrument(&instrument_id).is_none());
3119 assert_eq!(cache.quotes(&instrument_id), None);
3120 assert_eq!(cache.trades(&instrument_id), None);
3121 assert_eq!(cache.funding_rates(&instrument_id), None);
3122 assert_eq!(cache.bars(&bar_type), None);
3123 }
3124
3125 #[rstest]
3126 fn order_fill_replay_updates_order_and_creates_position() {
3127 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3128 let position_id = PositionId::from("P-001");
3129 let initialized = OrderInitializedSpec::builder()
3130 .instrument_id(instrument.id())
3131 .build();
3132 let client_order_id = initialized.client_order_id;
3133 let submitted = OrderSubmittedSpec::builder()
3134 .instrument_id(instrument.id())
3135 .client_order_id(client_order_id)
3136 .build();
3137 let accepted = OrderAcceptedSpec::builder()
3138 .instrument_id(instrument.id())
3139 .client_order_id(client_order_id)
3140 .account_id(submitted.account_id)
3141 .build();
3142 let filled = OrderFilledSpec::builder()
3143 .instrument_id(instrument.id())
3144 .client_order_id(client_order_id)
3145 .venue_order_id(accepted.venue_order_id)
3146 .account_id(submitted.account_id)
3147 .position_id(position_id)
3148 .commission(Money::from("1 USD"))
3149 .build();
3150 let filled_event = OrderEventAny::Filled(filled);
3151 let reader = reader_with_entries(
3152 "run-order-replay",
3153 &[
3154 append_order_event(1, &OrderEventAny::Initialized(initialized)),
3155 append_order_event(2, &OrderEventAny::Submitted(submitted)),
3156 append_order_event(3, &OrderEventAny::Accepted(accepted)),
3157 append_order_event(4, &filled_event),
3158 ],
3159 );
3160 let mut cache = Cache::default();
3161 cache.add_instrument(instrument).expect("add instrument");
3162
3163 let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3164 let order = cache.order_owned(&client_order_id).expect("order replayed");
3165 let position = cache
3166 .position_owned(&position_id)
3167 .expect("position replayed");
3168
3169 assert_eq!(report.applied_entries, 4);
3170 assert_eq!(report.ignored_entries, 0);
3171 assert_eq!(order.status(), OrderStatus::Filled);
3172 assert_eq!(order.event_count(), 4);
3173 assert_eq!(order.last_event(), &filled_event);
3174 assert_eq!(position.event_count(), 1);
3175 assert_eq!(position.last_event(), Some(filled));
3176 assert_eq!(position.trade_ids(), vec![filled.trade_id]);
3177 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
3178 }
3179
3180 #[rstest]
3181 fn position_lifecycle_replay_updates_existing_position() {
3182 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3183 let position_id = PositionId::from("P-001");
3184 let opened_fill = OrderFilledSpec::builder()
3185 .instrument_id(instrument.id())
3186 .client_order_id(ClientOrderId::from("O-OPEN"))
3187 .venue_order_id(VenueOrderId::from("V-OPEN"))
3188 .trade_id(TradeId::from("T-OPEN"))
3189 .position_id(position_id)
3190 .last_qty(Quantity::from("1"))
3191 .last_px(Price::from("1.00000"))
3192 .build();
3193 let mut live_position = Position::new(&instrument, opened_fill);
3194 let opened = PositionOpened::create(
3195 &live_position,
3196 &opened_fill,
3197 UUID4::new(),
3198 UnixNanos::from(10),
3199 );
3200
3201 let changed_fill = OrderFilledSpec::builder()
3202 .instrument_id(instrument.id())
3203 .client_order_id(ClientOrderId::from("O-CHANGE"))
3204 .venue_order_id(VenueOrderId::from("V-CHANGE"))
3205 .trade_id(TradeId::from("T-CHANGE"))
3206 .position_id(position_id)
3207 .last_qty(Quantity::from("2"))
3208 .last_px(Price::from("1.10000"))
3209 .build();
3210 live_position.apply(&changed_fill);
3211 let changed = PositionChanged::create(
3212 &live_position,
3213 &changed_fill,
3214 UUID4::new(),
3215 UnixNanos::from(20),
3216 );
3217
3218 let closed_fill = OrderFilledSpec::builder()
3219 .instrument_id(instrument.id())
3220 .client_order_id(ClientOrderId::from("O-CLOSE"))
3221 .venue_order_id(VenueOrderId::from("V-CLOSE"))
3222 .trade_id(TradeId::from("T-CLOSE"))
3223 .order_side(OrderSide::Sell)
3224 .position_id(position_id)
3225 .last_qty(Quantity::from("3"))
3226 .last_px(Price::from("1.20000"))
3227 .build();
3228 live_position.apply(&closed_fill);
3229 let closed = PositionClosed::create(
3230 &live_position,
3231 &closed_fill,
3232 UUID4::new(),
3233 UnixNanos::from(30),
3234 );
3235
3236 let mut stale_position = Position::new(&instrument, opened_fill);
3237 stale_position.signed_qty = 9.0;
3238 stale_position.quantity = Quantity::from("9");
3239 let mut cache = Cache::default();
3240 cache
3241 .add_position(&stale_position, OmsType::Unspecified)
3242 .expect("seed stale position");
3243
3244 let opened_entry =
3245 append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
3246 let changed_entry =
3247 append_position_event(2, &PositionEvent::PositionChanged(changed.clone())).entry;
3248 let closed_entry =
3249 append_position_event(3, &PositionEvent::PositionClosed(closed.clone())).entry;
3250
3251 assert!(apply_cache_replay_entry(&mut cache, &opened_entry).expect("apply opened"));
3252 let replayed = cache
3253 .position_owned(&position_id)
3254 .expect("position after opened");
3255 assert_eq!(replayed.signed_qty.to_bits(), opened.signed_qty.to_bits());
3256 assert_eq!(replayed.quantity, opened.quantity);
3257 assert_eq!(replayed.ts_last, opened.ts_event);
3258
3259 assert!(apply_cache_replay_entry(&mut cache, &changed_entry).expect("apply changed"));
3260 let replayed = cache
3261 .position_owned(&position_id)
3262 .expect("position after changed");
3263 assert_eq!(replayed.signed_qty.to_bits(), changed.signed_qty.to_bits());
3264 assert_eq!(replayed.quantity, changed.quantity);
3265 assert_eq!(replayed.peak_qty, changed.peak_quantity);
3266 assert_eq!(
3267 replayed.avg_px_open.to_bits(),
3268 changed.avg_px_open.to_bits()
3269 );
3270 assert!(replayed.is_open());
3271
3272 assert!(apply_cache_replay_entry(&mut cache, &closed_entry).expect("apply closed"));
3273 let replayed = cache
3274 .position_owned(&position_id)
3275 .expect("position after closed");
3276 assert_eq!(replayed.signed_qty.to_bits(), closed.signed_qty.to_bits());
3277 assert_eq!(replayed.quantity, closed.quantity);
3278 assert_eq!(replayed.closing_order_id, closed.closing_order_id);
3279 assert_eq!(replayed.duration_ns, closed.duration);
3280 assert!(replayed.is_closed());
3281 assert!(cache.is_position_closed(&position_id));
3282 }
3283
3284 #[rstest]
3285 fn position_adjustment_replay_updates_existing_position() {
3286 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3287 let position_id = PositionId::from("P-001");
3288 let fill = OrderFilledSpec::builder()
3289 .instrument_id(instrument.id())
3290 .position_id(position_id)
3291 .build();
3292 let position = Position::new(&instrument, fill);
3293 let adjustment = PositionAdjusted::new(
3294 fill.trader_id,
3295 fill.strategy_id,
3296 fill.instrument_id,
3297 position_id,
3298 fill.account_id,
3299 PositionAdjustmentType::Funding,
3300 None,
3301 Some(Money::from("2 USD")),
3302 Some(Ustr::from("funding")),
3303 UUID4::new(),
3304 UnixNanos::from(10),
3305 UnixNanos::from(11),
3306 );
3307 let entry = append_position_event(1, &PositionEvent::PositionAdjusted(adjustment)).entry;
3308 let mut cache = Cache::default();
3309 cache
3310 .add_position(&position, OmsType::Unspecified)
3311 .expect("seed position");
3312
3313 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
3314 let position = cache
3315 .position_owned(&position_id)
3316 .expect("position updated");
3317
3318 assert!(applied);
3319 assert_eq!(position.adjustments, vec![adjustment]);
3320 assert_eq!(position.realized_pnl, Some(Money::from("2 USD")));
3321 assert_eq!(position.ts_last, adjustment.ts_event);
3322 }
3323
3324 #[rstest]
3325 fn position_event_for_unknown_position_is_counted_as_ignored() {
3326 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3327 let position_id = PositionId::from("P-MISSING");
3328 let fill = OrderFilledSpec::builder()
3329 .instrument_id(instrument.id())
3330 .position_id(position_id)
3331 .build();
3332 let position = Position::new(&instrument, fill);
3333 let opened = PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
3334 let entry = append_position_event(1, &PositionEvent::PositionOpened(opened)).entry;
3335 let mut cache = Cache::default();
3336
3337 let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
3338
3339 assert!(
3340 !applied,
3341 "missing position must count as ignored, not applied"
3342 );
3343 }
3344
3345 #[rstest]
3346 fn order_filled_with_no_order_side_is_an_apply_error_not_a_panic() {
3347 let mut fill = OrderFilledSpec::builder()
3351 .position_id(PositionId::from("P-001"))
3352 .build();
3353 fill.order_side = OrderSide::NoOrderSide;
3354 let entry = append_serde_payload(1, PAYLOAD_TYPE_ORDER_FILLED, &fill).entry;
3355 let mut cache = Cache::default();
3356
3357 let err = apply_cache_replay_entry(&mut cache, &entry).expect_err("must reject");
3358
3359 match err {
3360 CacheReplayError::Apply { seq, message, .. } => {
3361 assert_eq!(seq, 1);
3362 assert!(message.contains("NoOrderSide"), "message was: {message}");
3363 }
3364 other => panic!("expected Apply, was {other:?}"),
3365 }
3366 }
3367
3368 #[rstest]
3369 fn duplicate_position_fill_is_not_applied_twice() {
3370 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3371 let position_id = PositionId::from("P-001");
3372 let fill = OrderFilledSpec::builder()
3373 .instrument_id(instrument.id())
3374 .position_id(position_id)
3375 .commission(Money::from("1 USD"))
3376 .build();
3377 let position = Position::new(&instrument, fill);
3378 let entry = append_order_event(1, &OrderEventAny::Filled(fill)).entry;
3379 let mut cache = Cache::default();
3380 cache
3381 .add_position(&position, OmsType::Unspecified)
3382 .expect("seed position");
3383
3384 apply_fill_to_position(&mut cache, &entry, &fill).expect("apply fill");
3385 let position = cache
3386 .position_owned(&position_id)
3387 .expect("position updated");
3388
3389 assert_eq!(position.event_count(), 1);
3390 assert_eq!(position.trade_ids(), vec![fill.trade_id]);
3391 assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
3392 }
3393
3394 #[rstest]
3395 fn corrupt_supported_payload_returns_decode_error() {
3396 let reader = reader_with_entries(
3397 "run-decode-error",
3398 &[append_payload(
3399 1,
3400 PAYLOAD_TYPE_ACCOUNT_STATE,
3401 Bytes::copy_from_slice(&[0xc1]),
3402 )],
3403 );
3404 let mut cache = Cache::default();
3405
3406 let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("decode error");
3407
3408 match err {
3409 CacheReplayError::Decode {
3410 seq, payload_type, ..
3411 } => {
3412 assert_eq!(seq, 1);
3413 assert_eq!(payload_type, PAYLOAD_TYPE_ACCOUNT_STATE);
3414 }
3415 other => panic!("expected Decode, was {other:?}"),
3416 }
3417 }
3418
3419 #[rstest]
3420 fn missing_order_event_returns_apply_error() {
3421 let submitted = OrderSubmittedSpec::builder().build();
3422 let reader = reader_with_entries(
3423 "run-apply-error",
3424 &[append_order_event(1, &OrderEventAny::Submitted(submitted))],
3425 );
3426 let mut cache = Cache::default();
3427
3428 let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("apply error");
3429
3430 match err {
3431 CacheReplayError::Apply {
3432 seq,
3433 payload_type,
3434 message,
3435 } => {
3436 assert_eq!(seq, 1);
3437 assert_eq!(payload_type, PAYLOAD_TYPE_ORDER_SUBMITTED);
3438 assert!(
3439 message.contains("not found"),
3440 "message should include cache apply failure: {message}",
3441 );
3442 }
3443 other => panic!("expected Apply, was {other:?}"),
3444 }
3445 }
3446
3447 #[rstest]
3448 fn restore_cache_from_sealed_run_restores_snapshot_and_tail() {
3449 let tmp = TempDir::new().expect("tempdir");
3450 let run_id = "sealed-replay";
3451 let instance_id = "trader-001";
3452 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3453 let fill = OrderFilledSpec::builder()
3454 .instrument_id(instrument.id())
3455 .position_id(PositionId::from("P-SEALED-REPLAY-1"))
3456 .build();
3457 let position = Position::new(&instrument, fill);
3458 let mut snapshot_cache = Cache::default();
3459 let snapshot_ref = snapshot_cache
3460 .snapshot_position(&position)
3461 .expect("snapshot position");
3462 let anchored_state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3463 let replayed_state = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
3464
3465 {
3466 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
3467 backend.open_run(manifest(run_id)).expect("open run");
3468 backend
3469 .append_batch(&[append_account_state(1, &anchored_state)])
3470 .expect("append anchored state");
3471 backend
3472 .record_snapshot_anchor(SnapshotAnchor::new(
3473 1,
3474 snapshot_ref.blob_ref.clone(),
3475 compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
3476 ))
3477 .expect("record snapshot anchor");
3478 backend
3479 .append_batch(&[append_account_state(2, &replayed_state)])
3480 .expect("append replay tail");
3481 backend.seal(RunStatus::Ended).expect("seal run");
3482 }
3483
3484 let mut cache = Cache::default();
3485 cache
3486 .add(&snapshot_ref.blob_ref, snapshot_ref.blob.clone())
3487 .expect("seed snapshot blob");
3488
3489 let report = restore_cache_from_sealed_run(
3490 &mut cache,
3491 tmp.path().to_path_buf(),
3492 instance_id,
3493 run_id,
3494 )
3495 .expect("restore sealed run");
3496
3497 let frames = cache
3498 .position_snapshot_bytes(&position.id)
3499 .expect("restored position snapshot");
3500 let account = cache
3501 .account_owned(&replayed_state.account_id)
3502 .expect("replayed account");
3503
3504 assert_eq!(report.manifest.run_id, run_id);
3505 assert_eq!(report.manifest.status, RunStatus::Ended);
3506 assert_eq!(report.cache.plan.from_seq, 2);
3507 assert_eq!(report.cache.applied_entries, 1);
3508 assert_eq!(report.cache.ignored_entries, 0);
3509 assert_eq!(frames.len(), 1);
3510 assert_eq!(frames[0].as_slice(), snapshot_ref.blob.as_ref());
3511 assert_eq!(account.events(), vec![replayed_state]);
3512 }
3513
3514 #[rstest]
3515 fn restore_cache_from_sealed_run_rejects_snapshot_hash_mismatch() {
3516 let tmp = TempDir::new().expect("tempdir");
3517 let run_id = "sealed-replay-bad-snapshot";
3518 let instance_id = "trader-001";
3519 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3520 let fill = OrderFilledSpec::builder()
3521 .instrument_id(instrument.id())
3522 .position_id(PositionId::from("P-SEALED-REPLAY-BAD-SNAPSHOT-1"))
3523 .build();
3524 let position = Position::new(&instrument, fill);
3525 let mut snapshot_cache = Cache::default();
3526 let snapshot_ref = snapshot_cache
3527 .snapshot_position(&position)
3528 .expect("snapshot position");
3529
3530 {
3531 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
3532 backend.open_run(manifest(run_id)).expect("open run");
3533 backend
3534 .record_snapshot_anchor(SnapshotAnchor::new(
3535 0,
3536 snapshot_ref.blob_ref.clone(),
3537 compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
3538 ))
3539 .expect("record snapshot anchor");
3540 backend.seal(RunStatus::Ended).expect("seal run");
3541 }
3542
3543 let mut cache = Cache::default();
3544 cache
3545 .add(
3546 &snapshot_ref.blob_ref,
3547 Bytes::from_static(b"tampered snapshot"),
3548 )
3549 .expect("seed tampered snapshot blob");
3550
3551 let err = restore_cache_from_sealed_run(
3552 &mut cache,
3553 tmp.path().to_path_buf(),
3554 instance_id,
3555 run_id,
3556 )
3557 .expect_err("hash mismatch");
3558
3559 match err {
3560 CacheReplayError::SnapshotRestore { blob_ref, message } => {
3561 assert_eq!(blob_ref, snapshot_ref.blob_ref);
3562 assert!(
3563 message.contains("content_hash mismatch"),
3564 "message should explain hash mismatch: {message}",
3565 );
3566 }
3567 other => panic!("expected SnapshotRestore, was {other:?}"),
3568 }
3569 }
3570
3571 #[rstest]
3572 fn open_event_store_replay_source_rejects_running_run() {
3573 let tmp = TempDir::new().expect("tempdir");
3574 let run_id = "running-replay";
3575 {
3576 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
3577 backend.open_run(manifest(run_id)).expect("open run");
3578 }
3579
3580 let err = open_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
3581 .expect_err("running source must fail");
3582
3583 assert!(
3584 err.to_string().contains("not sealed"),
3585 "error should name sealed-run requirement: {err}",
3586 );
3587 }
3588
3589 #[rstest]
3590 fn validate_event_store_replay_source_rejects_quarantined_run() {
3591 let tmp = TempDir::new().expect("tempdir");
3592 let run_id = "quarantined-replay";
3593 {
3594 let mut backend = RedbBackend::new(tmp.path().to_path_buf());
3595 backend.open_run(manifest(run_id)).expect("open run");
3596 backend
3597 .append_batch(&[append_payload(1, "RunStarted", Bytes::new())])
3598 .expect("append");
3599 backend.seal(RunStatus::Quarantined).expect("seal run");
3600 }
3601
3602 let err =
3603 validate_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
3604 .expect_err("quarantined source must fail");
3605
3606 assert!(
3607 err.to_string().contains("quarantined"),
3608 "error should reject quarantined replay sources: {err}",
3609 );
3610 }
3611}