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