Skip to main content

nautilus_event_store/
replay.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Bootstrap replay for restoring cache state after a cache-owned snapshot.
17//!
18//! This module is deliberately state-only: it consumes event-store entries, decodes the
19//! cache-affecting payloads, and mutates [`nautilus_common::cache::Cache`] directly. It
20//! does not publish to the live message bus, send commands, invoke adapters, or submit
21//! entries back into the event store.
22
23use 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/// Summary of a cache snapshot-tail replay.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct CacheReplayReport {
98    /// Replay bounds derived from the latest snapshot anchor.
99    pub plan: SnapshotReplayPlan,
100    /// Number of entries applied to cache state.
101    pub applied_entries: usize,
102    /// Number of event-store entries that do not have a cache replay rule yet.
103    pub ignored_entries: usize,
104}
105
106/// Summary of an event-store replay source and cache restore.
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct EventStoreReplayReport {
109    /// Manifest of the sealed replay source.
110    pub manifest: RunManifest,
111    /// Cache snapshot-tail replay result.
112    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/// Inclusive event-store `seq` bounds for replay input scans.
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub struct ReplaySeqRange {
182    /// First event-store `seq` to scan.
183    pub from_seq: u64,
184    /// Last event-store `seq` to scan.
185    pub to_seq: u64,
186}
187
188impl ReplaySeqRange {
189    /// Builds inclusive event-store `seq` bounds.
190    #[must_use]
191    pub const fn new(from_seq: u64, to_seq: u64) -> Self {
192        Self { from_seq, to_seq }
193    }
194}
195
196/// Inclusive nanosecond time bounds for catalog slice selection.
197#[derive(Clone, Copy, Debug, PartialEq, Eq)]
198pub struct ReplayTimeRange {
199    /// First catalog timestamp to include.
200    pub start: UnixNanos,
201    /// Last catalog timestamp to include.
202    pub end: UnixNanos,
203}
204
205impl ReplayTimeRange {
206    /// Builds inclusive nanosecond time bounds.
207    #[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/// Caller-selected data catalog slice before replay window defaults are applied.
226#[derive(Clone, Debug, PartialEq, Eq)]
227pub struct CatalogSliceSelector {
228    /// Catalog data class or directory name, such as `quotes`, `trades`, or `bars`.
229    pub data_cls: String,
230    /// Optional catalog identifiers, such as instrument IDs or bar type strings.
231    pub identifiers: Vec<String>,
232    /// Optional lower timestamp bound. When absent, the event-store scan lower bound applies.
233    pub start: Option<UnixNanos>,
234    /// Optional upper timestamp bound. When absent, the event-store scan upper bound applies.
235    pub end: Option<UnixNanos>,
236    /// Whether loading should fail when the catalog reports no files for this slice.
237    pub required: bool,
238}
239
240impl CatalogSliceSelector {
241    /// Builds a selector for `data_cls` with no identifiers or explicit time bounds.
242    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    /// Adds one catalog identifier to the selector.
253    #[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    /// Sets explicit inclusive catalog time bounds.
260    #[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    /// Marks the selector as required.
268    #[must_use]
269    pub const fn require_coverage(mut self) -> Self {
270        self.required = true;
271        self
272    }
273}
274
275/// Resolved catalog query after replay time bounds have been applied.
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct CatalogSliceQuery {
278    /// Catalog data class or directory name, such as `quotes`, `trades`, or `bars`.
279    pub data_cls: String,
280    /// Catalog identifiers, such as instrument IDs or bar type strings.
281    pub identifiers: Vec<String>,
282    /// Inclusive lower timestamp bound.
283    pub start: UnixNanos,
284    /// Inclusive upper timestamp bound.
285    pub end: UnixNanos,
286    /// Whether loading should fail when the catalog reports no files for this slice.
287    pub required: bool,
288}
289
290impl CatalogSliceQuery {
291    /// Returns identifiers in the shape expected by catalog APIs.
292    #[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/// Catalog file and interval coverage for a planned slice.
303#[derive(Clone, Debug, Default, PartialEq, Eq)]
304pub struct CatalogSliceCoverage {
305    /// Catalog files selected for the slice.
306    pub files: Vec<String>,
307    /// Covered timestamp intervals reported by the catalog.
308    pub intervals: Vec<ReplayTimeRange>,
309}
310
311impl CatalogSliceCoverage {
312    /// Builds coverage from selected catalog files.
313    #[must_use]
314    pub fn from_files(files: Vec<String>) -> Self {
315        Self {
316            files,
317            intervals: Vec::new(),
318        }
319    }
320
321    /// Returns whether the catalog found no files for the slice.
322    #[must_use]
323    pub fn is_missing(&self) -> bool {
324        self.files.is_empty()
325    }
326}
327
328/// Planned catalog slice joined to a replay input scan.
329#[derive(Clone, Debug, PartialEq, Eq)]
330pub struct CatalogSlicePlan {
331    /// Resolved catalog query.
332    pub query: CatalogSliceQuery,
333    /// Catalog coverage reported during planning.
334    pub coverage: CatalogSliceCoverage,
335}
336
337impl CatalogSlicePlan {
338    /// Returns whether the catalog reported no files for this slice.
339    #[must_use]
340    pub fn is_missing(&self) -> bool {
341        self.coverage.is_missing()
342    }
343}
344
345/// Typed catalog data loaded for replay context.
346#[derive(Clone, Copy, Debug, PartialEq, Eq)]
347pub enum CatalogReplayData {
348    /// Quote tick loaded from the `quotes` catalog.
349    Quote(QuoteTick),
350    /// Trade tick loaded from the `trades` catalog.
351    Trade(TradeTick),
352    /// Bar loaded from the `bars` catalog.
353    Bar(Bar),
354}
355
356impl CatalogReplayData {
357    /// Returns the catalog data class for this record.
358    #[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    /// Returns the catalog identifier for this record.
368    #[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    /// Returns the initialization timestamp for this record.
378    #[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/// Catalog record loaded for replay context.
407#[derive(Clone, Debug, PartialEq, Eq)]
408pub struct CatalogReplayRecord {
409    /// Catalog data class or directory name for the record.
410    pub data_cls: String,
411    /// Optional catalog identifier for the record.
412    pub identifier: Option<String>,
413    /// Record timestamp used for contextual joins.
414    pub ts_init: UnixNanos,
415    /// Typed catalog data loaded for contextual analysis.
416    pub data: CatalogReplayData,
417}
418
419impl CatalogReplayRecord {
420    /// Builds a typed catalog replay record.
421    #[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/// Loaded catalog records for one planned slice.
433#[derive(Clone, Debug, PartialEq, Eq)]
434pub struct CatalogReplaySlice {
435    /// Planned catalog slice metadata.
436    pub plan: CatalogSlicePlan,
437    /// Loaded catalog records.
438    pub records: Vec<CatalogReplayRecord>,
439}
440
441/// Planned replay inputs for an event-store scan with optional catalog context.
442#[derive(Clone, Debug, PartialEq, Eq)]
443pub struct ReplayInputPlan {
444    /// Requested event-store `seq` bounds.
445    pub requested_range: ReplaySeqRange,
446    /// Actual event-store range found inside the requested bounds.
447    pub event_range: Option<ReplaySeqRange>,
448    /// Number of event-store entries found inside the requested bounds.
449    pub event_count: usize,
450    /// Minimum and maximum event-store `ts_init` values inside the requested bounds.
451    pub event_time_range: Option<ReplayTimeRange>,
452    /// Catalog slices joined to the event-store scan.
453    pub catalog_slices: Vec<CatalogSlicePlan>,
454}
455
456impl ReplayInputPlan {
457    /// Returns all catalog slices that had no selected files.
458    #[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/// Loaded replay inputs with event-store entries and optional catalog context.
468#[derive(Clone, Debug, PartialEq, Eq)]
469pub struct ReplayInputs {
470    /// Event-store entries in durable `seq` order.
471    pub entries: Vec<EventStoreEntry>,
472    /// Catalog slices loaded as contextual input.
473    pub catalog_slices: Vec<CatalogReplaySlice>,
474}
475
476/// Read-only catalog source used by catalog-joined replay input loaders.
477pub trait ReplayCatalog {
478    /// Catalog-specific error type.
479    type Error: Display;
480
481    /// Plans one catalog slice without mutating catalog state.
482    ///
483    /// # Errors
484    ///
485    /// Returns the catalog implementation's error when slice planning fails.
486    fn plan_slice(
487        &mut self,
488        query: &CatalogSliceQuery,
489    ) -> Result<CatalogSliceCoverage, Self::Error>;
490
491    /// Loads records for one planned catalog slice without live venue access.
492    ///
493    /// Implementations return records in catalog order so marker cursor joins can take a
494    /// deterministic prefix from a cumulative stream slice.
495    ///
496    /// # Errors
497    ///
498    /// Returns the catalog implementation's error when slice loading fails.
499    fn load_slice(
500        &mut self,
501        plan: &CatalogSlicePlan,
502    ) -> Result<Vec<CatalogReplayRecord>, Self::Error>;
503}
504
505/// Errors surfaced while planning or loading replay inputs.
506#[derive(Debug, thiserror::Error)]
507pub enum ReplayInputError {
508    /// The event-store reader failed.
509    #[error(transparent)]
510    EventStore(#[from] EventStoreError),
511    /// The requested event-store `seq` range is invalid.
512    #[error("invalid replay seq range {from_seq}..={to_seq}: {message}")]
513    InvalidSeqRange {
514        /// Requested lower `seq`.
515        from_seq: u64,
516        /// Requested upper `seq`.
517        to_seq: u64,
518        /// Validation failure.
519        message: String,
520    },
521    /// A catalog-joined replay plan had no selected catalog slices.
522    #[error("catalog replay requires at least one selected catalog slice")]
523    EmptyCatalogSelection,
524    /// A catalog slice needs time bounds, but neither selector nor event-store scan supplied them.
525    #[error(
526        "catalog slice {data_cls} requires explicit time bounds because the replay scan is empty"
527    )]
528    MissingCatalogTimeBounds {
529        /// Catalog data class or directory name.
530        data_cls: String,
531    },
532    /// A catalog slice has an invalid timestamp range.
533    #[error("invalid catalog time range for {data_cls}: {start}..={end}")]
534    InvalidCatalogTimeRange {
535        /// Catalog data class or directory name.
536        data_cls: String,
537        /// Lower timestamp bound.
538        start: u64,
539        /// Upper timestamp bound.
540        end: u64,
541    },
542    /// A required catalog slice had no files.
543    #[error("required catalog slice {data_cls} is missing for identifiers {identifiers:?}")]
544    MissingCatalogSlice {
545        /// Catalog data class or directory name.
546        data_cls: String,
547        /// Catalog identifiers.
548        identifiers: Vec<String>,
549    },
550    /// The catalog source failed.
551    #[error("catalog slice {data_cls}: {message}")]
552    Catalog {
553        /// Catalog data class or directory name.
554        data_cls: String,
555        /// Catalog error message.
556        message: String,
557    },
558}
559
560/// Errors surfaced while restoring a cache snapshot tail.
561#[derive(Debug, thiserror::Error)]
562pub enum CacheReplayError {
563    /// The event-store reader failed.
564    #[error(transparent)]
565    EventStore(#[from] EventStoreError),
566    /// The caller-provided snapshot restore hook failed.
567    #[error("restore cache snapshot {blob_ref}: {message}")]
568    SnapshotRestore {
569        /// Cache-owned snapshot blob reference.
570        blob_ref: String,
571        /// Error message returned by the restore hook.
572        message: String,
573    },
574    /// The replay scan yielded an entry outside the derived restore bounds.
575    #[error("entry seq {seq} is before replay start seq {from_seq}")]
576    UnexpectedSeq {
577        /// Entry sequence yielded by the scan.
578        seq: u64,
579        /// First sequence this replay is allowed to apply.
580        from_seq: u64,
581    },
582    /// A captured payload failed to decode.
583    #[error("decode seq {seq} payload_type {payload_type}: {message}")]
584    Decode {
585        /// Event-store sequence number.
586        seq: u64,
587        /// Captured payload type tag.
588        payload_type: String,
589        /// Decode error message.
590        message: String,
591    },
592    /// Applying a decoded payload to the cache failed.
593    #[error("apply seq {seq} payload_type {payload_type}: {message}")]
594    Apply {
595        /// Event-store sequence number.
596        seq: u64,
597        /// Captured payload type tag.
598        payload_type: String,
599        /// Apply error message.
600        message: String,
601    },
602}
603
604impl CacheReplayError {
605    /// Builds a snapshot-restore error for `anchor`.
606    #[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
615/// Replays the cache snapshot tail after the caller restores the cache-owned snapshot blob.
616///
617/// The restore hook runs before the tail iterator is consumed. When `anchor` is `Some`,
618/// the hook should fetch and apply the cache-owned blob identified by
619/// [`SnapshotAnchor::blob_ref`] and validate it against
620/// [`SnapshotAnchor::content_hash`]. When `anchor` is `None`, restore starts from
621/// event-store seq `1` and the hook may be a no-op.
622///
623/// This is a bootstrap path: it mutates cache state directly and never publishes replay
624/// entries to the live message bus.
625///
626/// # Errors
627///
628/// Returns [`CacheReplayError::EventStore`] when the reader fails, `restore_snapshot`'s
629/// error when the cache snapshot restore hook fails, [`CacheReplayError::Decode`] when
630/// a supported payload cannot be decoded, and [`CacheReplayError::Apply`] when the
631/// decoded payload cannot be applied to the cache.
632pub 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
671/// Replays the cache snapshot tail when the cache snapshot has already been restored.
672///
673/// This is a convenience wrapper for callers that load the cache-owned snapshot blob
674/// before entering the event-store replay path.
675///
676/// # Errors
677///
678/// See [`restore_cache_snapshot_and_replay_tail`].
679pub 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
689/// Plans event-store-only forensics replay inputs.
690///
691/// The plan scans the requested range in durable `seq` order and records only summary
692/// metadata. Use [`load_forensics_replay_inputs`] to materialize the entries.
693///
694/// # Errors
695///
696/// Returns [`ReplayInputError::InvalidSeqRange`] when `range` is invalid and
697/// [`ReplayInputError::EventStore`] when the reader scan fails.
698pub 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
715/// Loads event-store-only forensics replay inputs.
716///
717/// Entries are returned in durable `seq` order. This function does not touch the data catalog,
718/// live venues, strategy code, reconciliation, or clocks.
719///
720/// # Errors
721///
722/// Returns [`ReplayInputError::EventStore`] when the reader scan fails.
723pub 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
737/// Plans replay inputs by joining event-store entries with selected catalog slices.
738///
739/// The event-store range supplies durable replay order. Catalog slices are contextual input
740/// selected by the caller; their timestamps bound data lookup but never replace `seq` ordering.
741///
742/// # Errors
743///
744/// Returns [`ReplayInputError::EmptyCatalogSelection`] when no catalog slices are selected,
745/// [`ReplayInputError::InvalidSeqRange`] when `range` is invalid,
746/// [`ReplayInputError::MissingCatalogTimeBounds`] when an unbounded selector cannot inherit
747/// bounds from an empty event-store scan, [`ReplayInputError::InvalidCatalogTimeRange`] when a
748/// resolved slice has `start > end`, [`ReplayInputError::Catalog`] when catalog planning fails,
749/// and [`ReplayInputError::EventStore`] when the reader scan fails.
750pub 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
763/// Loads catalog replay inputs from an existing plan.
764///
765/// Event-store entries are returned in durable `seq` order. Catalog records are loaded through
766/// the caller-provided catalog source only; this function does not query live venues or run engine
767/// logic.
768///
769/// # Errors
770///
771/// Returns [`ReplayInputError::MissingCatalogSlice`] when a required slice is missing,
772/// [`ReplayInputError::Catalog`] when catalog loading fails, and
773/// [`ReplayInputError::EventStore`] when the reader scan fails.
774pub 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
786/// Restores cache state from a sealed run without publishing to the bus or touching live venues.
787///
788/// The loader opens `<base_dir>/<instance_id>/<run_id>.redb` through the sealed-run reader path,
789/// rejects quarantined sources, restores the cache-owned snapshot blob when an anchor exists, and
790/// applies the event-store tail in `seq` order. It does not open adapters, reconcile against a
791/// venue, submit new entries, or query the data catalog.
792///
793/// # Errors
794///
795/// Returns [`CacheReplayError::EventStore`] when the run is missing, not sealed, quarantined, or
796/// unreadable; see [`restore_cache_snapshot_and_replay_tail`] for snapshot, decode, and apply
797/// failures.
798pub 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
814/// Opens a sealed run for replay without touching live venues.
815///
816/// # Errors
817///
818/// Returns [`CacheReplayError::EventStore`] when the run is missing, not sealed, quarantined, or
819/// unreadable.
820pub 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
831/// Validates that a configured replay source exists, is sealed, and is not quarantined.
832///
833/// # Errors
834///
835/// Returns [`CacheReplayError::EventStore`] when the run is missing, not sealed, quarantined, or
836/// unreadable.
837pub 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
1071/// Restores the cache-owned snapshot blob identified by `anchor`.
1072///
1073/// # Errors
1074///
1075/// Returns [`CacheReplayError::SnapshotRestore`] when the blob is missing, fails to load, fails its
1076/// content hash check, or fails to restore into the cache.
1077pub 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
1106/// Applies one event-store entry to cache state when a replay rule exists.
1107///
1108/// Returns `Ok(true)` when the entry changed cache state and `Ok(false)` when the
1109/// payload is outside the current cache bootstrap replay surface, or when a position
1110/// event's target position is absent from the cache (logged as a warning so the report's
1111/// ignored count surfaces the divergence instead of claiming a full apply).
1112///
1113/// # Errors
1114///
1115/// Returns [`CacheReplayError::Decode`] when a supported payload cannot be decoded and
1116/// [`CacheReplayError::Apply`] when the decoded payload cannot be applied to the cache.
1117pub 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            // The fill side panics deep inside Position/Order application; the hash
1180            // proves the bytes match what was written, not that the producer wrote a
1181            // semantically valid fill, so guard before the model invariants fire.
1182            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
1439// A position event whose position is absent cannot apply; counting it as applied would
1440// let a restore report full success while an open position is missing from the cache.
1441fn 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        // The entry hash proves the stored bytes match what was written, not that the
3348        // producer wrote a valid fill; OrderSide::NoOrderSide deserializes cleanly and
3349        // without the guard panics deep inside Position/Order application.
3350        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}