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