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