Skip to main content

nautilus_event_store/
replay.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Bootstrap replay for restoring cache state after a cache-owned snapshot.
17//!
18//! This module is deliberately state-only: it consumes event-store entries, decodes the
19//! cache-affecting payloads, and mutates [`nautilus_common::cache::Cache`] directly. It
20//! does not publish to the live message bus, send commands, invoke adapters, or submit
21//! entries back into the event store.
22
23use std::{fmt::Display, path::PathBuf};
24
25use indexmap::IndexMap;
26use nautilus_common::{
27    cache::Cache,
28    messages::{
29        data::{
30            BarsResponse, FundingRatesResponse, InstrumentResponse, InstrumentsResponse,
31            QuotesResponse, TradesResponse,
32        },
33        execution::SubmitOrderList,
34    },
35};
36use nautilus_core::{DurationNanos, UUID4, UnixNanos};
37use nautilus_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_MODIFY_ORDER,
58    PAYLOAD_TYPE_OPTION_CHAIN_REFERENCE_PRICE_RESPONSE, PAYLOAD_TYPE_ORDER_STATUS_REPORT,
59    PAYLOAD_TYPE_ORDER_WITH_FILLS, PAYLOAD_TYPE_POSITION_STATUS_REPORT, PAYLOAD_TYPE_QUERY_ACCOUNT,
60    PAYLOAD_TYPE_QUERY_ORDER, PAYLOAD_TYPE_REQUEST_COMMAND, PAYLOAD_TYPE_SUBMIT_ORDER,
61    PAYLOAD_TYPE_SUBSCRIBE_COMMAND, 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_OPTION_CHAIN_REFERENCE_PRICE_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 <= position.quantity
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.non_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
1793            let magnitude = remaining_commission.abs().min(commission.abs());
1794
1795            let removed = if remaining_commission.is_negative() {
1796                -magnitude
1797            } else {
1798                magnitude
1799            };
1800
1801            allocations
1802                .entry(fragment.position_id)
1803                .and_modify(|allocation| {
1804                    allocation.1 = Some(
1805                        allocation
1806                            .1
1807                            .map_or(removed, |commission| commission + removed),
1808                    );
1809                })
1810                .or_insert((
1811                    Quantity::zero(fill_voided.voided_qty.precision),
1812                    Some(removed),
1813                ));
1814            remaining_commission = remaining_commission - removed;
1815        }
1816
1817        if !remaining_commission.is_zero() {
1818            return Err(apply_error(
1819                entry,
1820                format!(
1821                    "position fragments do not cover voided commission for fill {}",
1822                    fill_voided.trade_id
1823                ),
1824            ));
1825        }
1826    }
1827    Ok(allocations)
1828}
1829
1830fn apply_position_opened(
1831    cache: &mut Cache,
1832    entry: &EventStoreEntry,
1833    opened: &PositionOpened,
1834) -> Result<bool, CacheReplayError> {
1835    let Some(mut position) = cache.position_owned(&opened.position_id) else {
1836        warn_position_skip(entry, opened.position_id);
1837        return Ok(false);
1838    };
1839
1840    position.trader_id = opened.trader_id;
1841    position.strategy_id = opened.strategy_id;
1842    position.instrument_id = opened.instrument_id;
1843    position.id = opened.position_id;
1844    position.account_id = opened.account_id;
1845    position.opening_order_id = opened.opening_order_id;
1846    position.closing_order_id = None;
1847    position.entry = opened.entry;
1848    position.side = opened.side;
1849    position.signed_qty = opened.signed_qty;
1850    position.quantity = opened.quantity;
1851    position.peak_qty = opened.quantity;
1852    position.quote_currency = opened.currency;
1853    position.ts_opened = opened.ts_event;
1854    position.ts_last = opened.ts_event;
1855    position.ts_closed = None;
1856    position.duration_ns = DurationNanos::default();
1857    position.avg_px_open = opened.avg_px_open;
1858    position.avg_px_close = None;
1859    position.realized_return = 0.0;
1860    position.realized_pnl = opened.realized_pnl;
1861
1862    apply_result(entry, cache.update_position(&position))?;
1863    Ok(true)
1864}
1865
1866fn apply_position_changed(
1867    cache: &mut Cache,
1868    entry: &EventStoreEntry,
1869    changed: &PositionChanged,
1870) -> Result<bool, CacheReplayError> {
1871    let Some(mut position) = cache.position_owned(&changed.position_id) else {
1872        warn_position_skip(entry, changed.position_id);
1873        return Ok(false);
1874    };
1875
1876    position.trader_id = changed.trader_id;
1877    position.strategy_id = changed.strategy_id;
1878    position.instrument_id = changed.instrument_id;
1879    position.id = changed.position_id;
1880    position.account_id = changed.account_id;
1881    position.opening_order_id = changed.opening_order_id;
1882    position.entry = changed.entry;
1883    position.side = changed.side;
1884    position.signed_qty = changed.signed_qty;
1885    position.quantity = changed.quantity;
1886    position.peak_qty = changed.peak_quantity;
1887    position.quote_currency = changed.currency;
1888    position.ts_opened = changed.ts_opened;
1889    position.ts_last = changed.ts_event;
1890    position.ts_closed = None;
1891    position.avg_px_open = changed.avg_px_open;
1892    position.avg_px_close = changed.avg_px_close;
1893    position.realized_return = changed.realized_return;
1894    position.realized_pnl = changed.realized_pnl;
1895
1896    apply_result(entry, cache.update_position(&position))?;
1897    Ok(true)
1898}
1899
1900fn apply_position_closed(
1901    cache: &mut Cache,
1902    entry: &EventStoreEntry,
1903    closed: &PositionClosed,
1904) -> Result<bool, CacheReplayError> {
1905    let Some(mut position) = cache.position_owned(&closed.position_id) else {
1906        warn_position_skip(entry, closed.position_id);
1907        return Ok(false);
1908    };
1909
1910    position.trader_id = closed.trader_id;
1911    position.strategy_id = closed.strategy_id;
1912    position.instrument_id = closed.instrument_id;
1913    position.id = closed.position_id;
1914    position.account_id = closed.account_id;
1915    position.opening_order_id = closed.opening_order_id;
1916    position.closing_order_id = closed.closing_order_id;
1917    position.entry = closed.entry;
1918    position.side = closed.side;
1919    position.signed_qty = closed.signed_qty;
1920    position.quantity = closed.quantity;
1921    position.peak_qty = closed.peak_quantity;
1922    position.quote_currency = closed.currency;
1923    position.ts_opened = closed.ts_opened;
1924    position.ts_last = closed.ts_event;
1925    position.ts_closed = closed.ts_closed;
1926    position.duration_ns = closed.duration;
1927    position.avg_px_open = closed.avg_px_open;
1928    position.avg_px_close = closed.avg_px_close;
1929    position.realized_return = closed.realized_return;
1930    position.realized_pnl = closed.realized_pnl;
1931
1932    apply_result(entry, cache.update_position(&position))?;
1933    Ok(true)
1934}
1935
1936fn apply_position_adjustment(
1937    cache: &mut Cache,
1938    entry: &EventStoreEntry,
1939    adjustment: PositionAdjusted,
1940) -> Result<bool, CacheReplayError> {
1941    let Some(mut position) = cache.position_owned(&adjustment.position_id) else {
1942        warn_position_skip(entry, adjustment.position_id);
1943        return Ok(false);
1944    };
1945
1946    position.apply_adjustment(adjustment);
1947    apply_result(entry, cache.update_position(&position))?;
1948    Ok(true)
1949}
1950
1951// A position event whose position is absent cannot apply; counting it as applied would
1952// let a restore report full success while an open position is missing from the cache.
1953fn warn_position_skip(entry: &EventStoreEntry, position_id: PositionId) {
1954    log::warn!(
1955        "Replay seq {} skipped {}: position {position_id} not in cache",
1956        entry.seq,
1957        entry.payload_type,
1958    );
1959}
1960
1961fn decode_payload<T>(entry: &EventStoreEntry) -> Result<T, CacheReplayError>
1962where
1963    T: DeserializeOwned,
1964{
1965    rmp_serde::from_slice(&entry.payload).map_err(|e| CacheReplayError::Decode {
1966        seq: entry.seq,
1967        payload_type: entry.payload_type.to_string(),
1968        message: e.to_string(),
1969    })
1970}
1971
1972fn apply_result<T, E>(entry: &EventStoreEntry, result: Result<T, E>) -> Result<T, CacheReplayError>
1973where
1974    E: Display,
1975{
1976    result.map_err(|e| apply_error(entry, e))
1977}
1978
1979fn apply_error(entry: &EventStoreEntry, error: impl Display) -> CacheReplayError {
1980    CacheReplayError::Apply {
1981        seq: entry.seq,
1982        payload_type: entry.payload_type.to_string(),
1983        message: error.to_string(),
1984    }
1985}
1986
1987fn reject_quarantined_replay_source(
1988    run_id: &str,
1989    status: RunStatus,
1990) -> Result<(), CacheReplayError> {
1991    if matches!(status, RunStatus::Quarantined) {
1992        let error = EventStoreError::Backend(format!("replay source {run_id} is quarantined"));
1993        return Err(CacheReplayError::from(error));
1994    }
1995
1996    Ok(())
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001    use std::{any::Any, cell::Cell, rc::Rc};
2002
2003    use ahash::AHashSet;
2004    use bytes::Bytes;
2005    use indexmap::IndexMap;
2006    use nautilus_common::msgbus::{self, BusTap, Endpoint, MStr, Topic as BusTopic};
2007    use nautilus_core::{UUID4, UnixNanos};
2008    use nautilus_model::{
2009        accounts::AccountAny,
2010        data::{Bar, BarSpecification, BarType, FundingRateUpdate, QuoteTick, TradeTick},
2011        enums::{
2012            AggregationSource, AggressorSide, BarAggregation, OrderSide, OrderStatus,
2013            PositionAdjustmentType, PositionSide, PriceType,
2014        },
2015        events::{
2016            PositionEvent,
2017            account::stubs::{cash_account_state, cash_account_state_million_usd},
2018            order::spec::{
2019                OrderAcceptedSpec, OrderFillVoidedSpec, OrderFilledSpec, OrderInitializedSpec,
2020                OrderSubmittedSpec,
2021            },
2022        },
2023        identifiers::{
2024            AccountId, ClientId, ClientOrderId, InstrumentId, OrderListId, PositionId, TradeId,
2025            VenueOrderId,
2026        },
2027        instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
2028        orders::{Order, OrderList},
2029        types::{Currency, Money, Price, Quantity},
2030    };
2031    use rstest::rstest;
2032    use serde::Serialize;
2033    use tempfile::TempDir;
2034    use ustr::Ustr;
2035
2036    use super::*;
2037    use crate::{
2038        backend::{AppendEntry, MemoryBackend, RedbBackend},
2039        capture::{
2040            builtins::{
2041                DEFAULT_CAPTURE_PAYLOAD_TYPES, encode_order_event_any, encode_position_event,
2042            },
2043            encode_account_state,
2044        },
2045        entry::Topic as EntryTopic,
2046        hash::compute_entry_hash,
2047        headers::Headers,
2048        manifest::{RegisteredComponents, RunManifest, RunStatus},
2049        snapshot::SnapshotAnchor,
2050    };
2051
2052    fn manifest(run_id: &str) -> RunManifest {
2053        RunManifest {
2054            run_id: run_id.to_string(),
2055            parent_run_id: None,
2056            instance_id: "trader-001".to_string(),
2057            binary_hash: "deadbeef".to_string(),
2058            schema_version: 1,
2059            crate_versions: "feedface".to_string(),
2060            feature_flags: Vec::new(),
2061            adapter_versions: IndexMap::new(),
2062            config_hash: "cafebabe".to_string(),
2063            registered_components: RegisteredComponents::default(),
2064            seed: None,
2065            start_ts_init: UnixNanos::from(0),
2066            end_ts_init: None,
2067            high_watermark: 0,
2068            status: RunStatus::Running,
2069        }
2070    }
2071
2072    fn append_payload(seq: u64, payload_type: &str, payload: Bytes) -> AppendEntry {
2073        append_payload_with_ts(seq, seq, payload_type, payload)
2074    }
2075
2076    fn append_serde_payload<T: Serialize>(seq: u64, payload_type: &str, value: &T) -> AppendEntry {
2077        let payload = rmp_serde::to_vec_named(value).expect("encode replay payload");
2078        append_payload(seq, payload_type, Bytes::from(payload))
2079    }
2080
2081    fn append_payload_with_ts(
2082        seq: u64,
2083        ts_init: u64,
2084        payload_type: &str,
2085        payload: Bytes,
2086    ) -> AppendEntry {
2087        let topic = EntryTopic::from("events.account.SIM");
2088        let ts = UnixNanos::from(ts_init);
2089        let headers = Headers::empty();
2090        let hash = compute_entry_hash(
2091            seq,
2092            ts,
2093            ts,
2094            topic.as_ref(),
2095            payload_type,
2096            &payload,
2097            &headers,
2098        );
2099        let entry = EventStoreEntry::new(
2100            hash,
2101            seq,
2102            headers,
2103            topic,
2104            Ustr::from(payload_type),
2105            payload,
2106            ts,
2107            ts,
2108        );
2109        AppendEntry::without_indices(entry)
2110    }
2111
2112    fn append_account_state(seq: u64, state: &AccountState) -> AppendEntry {
2113        let encoded = encode_account_state(state).expect("encode account state");
2114        append_payload(seq, PAYLOAD_TYPE_ACCOUNT_STATE, encoded.payload)
2115    }
2116
2117    fn append_order_event(seq: u64, event: &OrderEventAny) -> AppendEntry {
2118        let encoded = encode_order_event_any(event).expect("encode order event");
2119        let payload_type = encoded.payload_type.expect("order payload type");
2120        append_payload(seq, payload_type.as_str(), encoded.payload)
2121    }
2122
2123    fn append_position_event(seq: u64, event: &PositionEvent) -> AppendEntry {
2124        let encoded = encode_position_event(event).expect("encode position event");
2125        let payload_type = encoded.payload_type.expect("position payload type");
2126        append_payload(seq, payload_type.as_str(), encoded.payload)
2127    }
2128
2129    fn reader_with_entries(
2130        run_id: &str,
2131        entries: &[AppendEntry],
2132    ) -> EventStoreReader<MemoryBackend> {
2133        let mut backend = MemoryBackend::new();
2134        backend.open_run(manifest(run_id)).expect("open");
2135        backend.append_batch(entries).expect("append");
2136        EventStoreReader::new(backend)
2137    }
2138
2139    fn reader_with_anchor(anchor_seq: u64) -> (EventStoreReader<MemoryBackend>, AccountState) {
2140        let anchored = cash_account_state();
2141        let replayed = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
2142        let mut backend = MemoryBackend::new();
2143        backend.open_run(manifest("run-replay")).expect("open");
2144        backend
2145            .append_batch(&[
2146                append_account_state(1, &anchored),
2147                append_account_state(2, &replayed),
2148            ])
2149            .expect("append");
2150        backend
2151            .record_snapshot_anchor(SnapshotAnchor::new(anchor_seq, "cache://account", "hash"))
2152            .expect("record anchor");
2153        (EventStoreReader::new(backend), replayed)
2154    }
2155
2156    fn catalog_quote_record(ts_init: u64) -> CatalogReplayRecord {
2157        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2158        CatalogReplayRecord::from_data(CatalogReplayData::Quote(QuoteTick::new(
2159            instrument_id,
2160            Price::from("1.0001"),
2161            Price::from("1.0002"),
2162            Quantity::from("100"),
2163            Quantity::from("100"),
2164            UnixNanos::from(ts_init),
2165            UnixNanos::from(ts_init),
2166        )))
2167    }
2168
2169    fn catalog_trade_record(ts_init: u64) -> CatalogReplayRecord {
2170        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2171        CatalogReplayRecord::from_data(CatalogReplayData::Trade(TradeTick::new(
2172            instrument_id,
2173            Price::from("1.0001"),
2174            Quantity::from("100"),
2175            AggressorSide::Buy,
2176            TradeId::from("T-1"),
2177            UnixNanos::from(ts_init),
2178            UnixNanos::from(ts_init),
2179        )))
2180    }
2181
2182    #[derive(Debug)]
2183    struct CountingTap {
2184        calls: Rc<Cell<usize>>,
2185    }
2186
2187    impl CountingTap {
2188        fn new(calls: Rc<Cell<usize>>) -> Self {
2189            Self { calls }
2190        }
2191
2192        fn increment(&self) {
2193            self.calls.set(self.calls.get() + 1);
2194        }
2195    }
2196
2197    impl BusTap for CountingTap {
2198        fn on_publish(&self, _topic: MStr<BusTopic>, _message: &dyn Any) {
2199            self.increment();
2200        }
2201
2202        fn on_send(&self, _endpoint: MStr<Endpoint>, _message: &dyn Any) {
2203            self.increment();
2204        }
2205    }
2206
2207    #[derive(Debug)]
2208    struct FakeReplayCatalog {
2209        coverage: CatalogSliceCoverage,
2210        records: Vec<CatalogReplayRecord>,
2211        plan_queries: Vec<CatalogSliceQuery>,
2212        load_plans: Vec<CatalogSlicePlan>,
2213    }
2214
2215    impl FakeReplayCatalog {
2216        fn new(coverage: CatalogSliceCoverage, records: Vec<CatalogReplayRecord>) -> Self {
2217            Self {
2218                coverage,
2219                records,
2220                plan_queries: Vec::new(),
2221                load_plans: Vec::new(),
2222            }
2223        }
2224    }
2225
2226    impl ReplayCatalog for FakeReplayCatalog {
2227        type Error = String;
2228
2229        fn plan_slice(
2230            &mut self,
2231            query: &CatalogSliceQuery,
2232        ) -> Result<CatalogSliceCoverage, Self::Error> {
2233            self.plan_queries.push(query.clone());
2234            Ok(self.coverage.clone())
2235        }
2236
2237        fn load_slice(
2238            &mut self,
2239            plan: &CatalogSlicePlan,
2240        ) -> Result<Vec<CatalogReplayRecord>, Self::Error> {
2241            self.load_plans.push(plan.clone());
2242            Ok(self.records.clone())
2243        }
2244    }
2245
2246    struct BusTapGuard;
2247
2248    impl Drop for BusTapGuard {
2249        fn drop(&mut self) {
2250            msgbus::clear_bus_tap();
2251        }
2252    }
2253
2254    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2255    enum CacheMutationRecoveryClass {
2256        SnapshotOwned,
2257        EventStoreCapturedAndReplayed,
2258        ForensicOnly,
2259        MissingLiveRecovery,
2260    }
2261
2262    #[derive(Clone, Copy, Debug)]
2263    struct CacheMutationCoverage {
2264        method: &'static str,
2265        class: CacheMutationRecoveryClass,
2266        payload_types: &'static [&'static str],
2267    }
2268
2269    const CACHE_MUTATION_COVERAGE: &[CacheMutationCoverage] = &[
2270        cache_mutation(
2271            // Startup configuration is reapplied during strategy registration, but runtime claim
2272            // changes are not captured or persisted for recovery.
2273            "set_external_order_claims",
2274            CacheMutationRecoveryClass::MissingLiveRecovery,
2275            &[],
2276        ),
2277        cache_mutation(
2278            "register_external_order_claims",
2279            CacheMutationRecoveryClass::MissingLiveRecovery,
2280            &[],
2281        ),
2282        cache_mutation(
2283            "set_database",
2284            CacheMutationRecoveryClass::SnapshotOwned,
2285            &[],
2286        ),
2287        cache_mutation(
2288            "cache_general",
2289            CacheMutationRecoveryClass::SnapshotOwned,
2290            &[],
2291        ),
2292        cache_mutation("cache_all", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2293        cache_mutation(
2294            "cache_currencies",
2295            CacheMutationRecoveryClass::SnapshotOwned,
2296            &[],
2297        ),
2298        cache_mutation(
2299            "cache_instruments",
2300            CacheMutationRecoveryClass::SnapshotOwned,
2301            &[],
2302        ),
2303        cache_mutation(
2304            "cache_synthetics",
2305            CacheMutationRecoveryClass::SnapshotOwned,
2306            &[],
2307        ),
2308        cache_mutation(
2309            "cache_accounts",
2310            CacheMutationRecoveryClass::SnapshotOwned,
2311            &[],
2312        ),
2313        cache_mutation(
2314            "cache_orders",
2315            CacheMutationRecoveryClass::SnapshotOwned,
2316            &[],
2317        ),
2318        cache_mutation(
2319            "cache_positions",
2320            CacheMutationRecoveryClass::SnapshotOwned,
2321            &[],
2322        ),
2323        cache_mutation(
2324            "build_index",
2325            CacheMutationRecoveryClass::SnapshotOwned,
2326            &[],
2327        ),
2328        cache_mutation(
2329            "purge_closed_orders",
2330            CacheMutationRecoveryClass::SnapshotOwned,
2331            &[],
2332        ),
2333        cache_mutation(
2334            "purge_closed_positions",
2335            CacheMutationRecoveryClass::SnapshotOwned,
2336            &[],
2337        ),
2338        cache_mutation(
2339            "purge_order",
2340            CacheMutationRecoveryClass::SnapshotOwned,
2341            &[],
2342        ),
2343        cache_mutation(
2344            "purge_position",
2345            CacheMutationRecoveryClass::SnapshotOwned,
2346            &[],
2347        ),
2348        cache_mutation(
2349            "settle_position_snapshots",
2350            CacheMutationRecoveryClass::SnapshotOwned,
2351            &[],
2352        ),
2353        cache_mutation(
2354            "purge_instrument",
2355            CacheMutationRecoveryClass::SnapshotOwned,
2356            &[],
2357        ),
2358        cache_mutation(
2359            "purge_instrument_skip_order_guard",
2360            CacheMutationRecoveryClass::SnapshotOwned,
2361            &[],
2362        ),
2363        cache_mutation(
2364            "purge_account_events",
2365            CacheMutationRecoveryClass::SnapshotOwned,
2366            &[],
2367        ),
2368        cache_mutation(
2369            "clear_index",
2370            CacheMutationRecoveryClass::SnapshotOwned,
2371            &[],
2372        ),
2373        cache_mutation("reset", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2374        cache_mutation("dispose", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2375        cache_mutation("flush_db", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2376        cache_mutation("add", CacheMutationRecoveryClass::SnapshotOwned, &[]),
2377        cache_mutation(
2378            "add_order_book",
2379            CacheMutationRecoveryClass::ForensicOnly,
2380            &[PAYLOAD_TYPE_BOOK_RESPONSE],
2381        ),
2382        cache_mutation(
2383            "add_own_order_book",
2384            CacheMutationRecoveryClass::SnapshotOwned,
2385            &[],
2386        ),
2387        cache_mutation(
2388            "add_mark_price",
2389            CacheMutationRecoveryClass::MissingLiveRecovery,
2390            &[],
2391        ),
2392        cache_mutation(
2393            "add_index_price",
2394            CacheMutationRecoveryClass::MissingLiveRecovery,
2395            &[],
2396        ),
2397        cache_mutation(
2398            "add_funding_rate",
2399            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2400            &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
2401        ),
2402        cache_mutation(
2403            "add_funding_rates",
2404            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2405            &[PAYLOAD_TYPE_FUNDING_RATES_RESPONSE],
2406        ),
2407        cache_mutation(
2408            "add_instrument_status",
2409            CacheMutationRecoveryClass::MissingLiveRecovery,
2410            &[],
2411        ),
2412        cache_mutation(
2413            "add_instrument_close",
2414            CacheMutationRecoveryClass::SnapshotOwned,
2415            &[],
2416        ),
2417        cache_mutation(
2418            "add_quote",
2419            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2420            &[PAYLOAD_TYPE_QUOTES_RESPONSE],
2421        ),
2422        cache_mutation(
2423            "add_quotes",
2424            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2425            &[PAYLOAD_TYPE_QUOTES_RESPONSE],
2426        ),
2427        cache_mutation(
2428            "add_trade",
2429            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2430            &[PAYLOAD_TYPE_TRADES_RESPONSE],
2431        ),
2432        cache_mutation(
2433            "add_trades",
2434            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2435            &[PAYLOAD_TYPE_TRADES_RESPONSE],
2436        ),
2437        cache_mutation(
2438            "add_bar",
2439            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2440            &[PAYLOAD_TYPE_BARS_RESPONSE],
2441        ),
2442        cache_mutation(
2443            "add_bars",
2444            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2445            &[PAYLOAD_TYPE_BARS_RESPONSE],
2446        ),
2447        cache_mutation(
2448            "add_greeks",
2449            CacheMutationRecoveryClass::MissingLiveRecovery,
2450            &[],
2451        ),
2452        cache_mutation(
2453            "add_option_greeks",
2454            CacheMutationRecoveryClass::MissingLiveRecovery,
2455            &[],
2456        ),
2457        cache_mutation(
2458            "add_yield_curve",
2459            CacheMutationRecoveryClass::MissingLiveRecovery,
2460            &[],
2461        ),
2462        cache_mutation(
2463            "add_currency",
2464            CacheMutationRecoveryClass::SnapshotOwned,
2465            &[],
2466        ),
2467        cache_mutation(
2468            "add_instrument",
2469            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2470            &[
2471                PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
2472                PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
2473            ],
2474        ),
2475        cache_mutation(
2476            "add_synthetic",
2477            CacheMutationRecoveryClass::SnapshotOwned,
2478            &[],
2479        ),
2480        cache_mutation(
2481            "add_account",
2482            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2483            &[PAYLOAD_TYPE_ACCOUNT_STATE],
2484        ),
2485        cache_mutation(
2486            "add_venue_order_id",
2487            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2488            &[PAYLOAD_TYPE_ORDER_ACCEPTED, PAYLOAD_TYPE_ORDER_UPDATED],
2489        ),
2490        cache_mutation(
2491            // Replay restores the current generation only; superseded reverse aliases are
2492            // re-registered by live mass-status reconciliation.
2493            "index_venue_order_id",
2494            CacheMutationRecoveryClass::MissingLiveRecovery,
2495            &[],
2496        ),
2497        cache_mutation(
2498            "add_order",
2499            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2500            &[PAYLOAD_TYPE_ORDER_INITIALIZED],
2501        ),
2502        cache_mutation(
2503            // Cache databases persist the resolved client index, but current EventStore
2504            // command payloads do not carry the client selected by runtime routing.
2505            "claim_order_clients",
2506            CacheMutationRecoveryClass::MissingLiveRecovery,
2507            &[],
2508        ),
2509        cache_mutation(
2510            "add_order_list",
2511            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2512            &[PAYLOAD_TYPE_SUBMIT_ORDER_LIST],
2513        ),
2514        cache_mutation(
2515            "add_position_id",
2516            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2517            &[
2518                PAYLOAD_TYPE_ORDER_FILLED,
2519                PAYLOAD_TYPE_POSITION_OPENED,
2520                PAYLOAD_TYPE_POSITION_CHANGED,
2521                PAYLOAD_TYPE_POSITION_CLOSED,
2522            ],
2523        ),
2524        cache_mutation(
2525            "add_position",
2526            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2527            &[PAYLOAD_TYPE_ORDER_FILLED],
2528        ),
2529        cache_mutation(
2530            "add_position_without_order",
2531            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2532            &[PAYLOAD_TYPE_ORDER_FILLED],
2533        ),
2534        cache_mutation(
2535            "replace_position",
2536            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2537            &[PAYLOAD_TYPE_ORDER_FILLED],
2538        ),
2539        cache_mutation(
2540            "update_account",
2541            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2542            &[PAYLOAD_TYPE_ACCOUNT_STATE],
2543        ),
2544        cache_mutation(
2545            "take_account",
2546            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2547            &[PAYLOAD_TYPE_ACCOUNT_STATE],
2548        ),
2549        cache_mutation(
2550            "cache_account_owned",
2551            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2552            &[PAYLOAD_TYPE_ACCOUNT_STATE],
2553        ),
2554        cache_mutation(
2555            "update_account_owned",
2556            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2557            &[PAYLOAD_TYPE_ACCOUNT_STATE],
2558        ),
2559        cache_mutation(
2560            "update_account_state",
2561            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2562            &[PAYLOAD_TYPE_ACCOUNT_STATE],
2563        ),
2564        cache_mutation(
2565            "replace_order",
2566            CacheMutationRecoveryClass::ForensicOnly,
2567            &[
2568                PAYLOAD_TYPE_ORDER_STATUS_REPORT,
2569                PAYLOAD_TYPE_ORDER_WITH_FILLS,
2570                PAYLOAD_TYPE_EXECUTION_MASS_STATUS,
2571            ],
2572        ),
2573        cache_mutation(
2574            "update_order",
2575            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2576            &[
2577                PAYLOAD_TYPE_ORDER_DENIED,
2578                PAYLOAD_TYPE_ORDER_EMULATED,
2579                PAYLOAD_TYPE_ORDER_RELEASED,
2580                PAYLOAD_TYPE_ORDER_SUBMITTED,
2581                PAYLOAD_TYPE_ORDER_ACCEPTED,
2582                PAYLOAD_TYPE_ORDER_REJECTED,
2583                PAYLOAD_TYPE_ORDER_CANCELED,
2584                PAYLOAD_TYPE_ORDER_EXPIRED,
2585                PAYLOAD_TYPE_ORDER_TRIGGERED,
2586                PAYLOAD_TYPE_ORDER_PENDING_UPDATE,
2587                PAYLOAD_TYPE_ORDER_PENDING_CANCEL,
2588                PAYLOAD_TYPE_ORDER_MODIFY_REJECTED,
2589                PAYLOAD_TYPE_ORDER_CANCEL_REJECTED,
2590                PAYLOAD_TYPE_ORDER_UPDATED,
2591                PAYLOAD_TYPE_ORDER_FILLED,
2592                PAYLOAD_TYPE_ORDER_FILL_VOIDED,
2593            ],
2594        ),
2595        cache_mutation(
2596            "update_order_pending_cancel_local",
2597            CacheMutationRecoveryClass::MissingLiveRecovery,
2598            &[],
2599        ),
2600        cache_mutation(
2601            "update_position",
2602            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2603            &[
2604                PAYLOAD_TYPE_ORDER_FILLED,
2605                PAYLOAD_TYPE_ORDER_FILL_VOIDED,
2606                PAYLOAD_TYPE_POSITION_OPENED,
2607                PAYLOAD_TYPE_POSITION_CHANGED,
2608                PAYLOAD_TYPE_POSITION_CLOSED,
2609                PAYLOAD_TYPE_POSITION_ADJUSTED,
2610            ],
2611        ),
2612        cache_mutation(
2613            "update_position_from_fill",
2614            CacheMutationRecoveryClass::EventStoreCapturedAndReplayed,
2615            &[PAYLOAD_TYPE_ORDER_FILLED],
2616        ),
2617        cache_mutation(
2618            "snapshot_position",
2619            CacheMutationRecoveryClass::SnapshotOwned,
2620            &[],
2621        ),
2622        cache_mutation(
2623            "snapshot_position_encoded",
2624            CacheMutationRecoveryClass::SnapshotOwned,
2625            &[],
2626        ),
2627        cache_mutation(
2628            "snapshot_position_state",
2629            CacheMutationRecoveryClass::SnapshotOwned,
2630            &[],
2631        ),
2632        cache_mutation(
2633            "load_snapshot_blob",
2634            CacheMutationRecoveryClass::SnapshotOwned,
2635            &[],
2636        ),
2637        cache_mutation(
2638            "restore_snapshot_blob",
2639            CacheMutationRecoveryClass::SnapshotOwned,
2640            &[],
2641        ),
2642        cache_mutation(
2643            "order_mut",
2644            CacheMutationRecoveryClass::MissingLiveRecovery,
2645            &[],
2646        ),
2647        cache_mutation(
2648            "position_mut",
2649            CacheMutationRecoveryClass::MissingLiveRecovery,
2650            &[],
2651        ),
2652        cache_mutation(
2653            "order_book_mut",
2654            CacheMutationRecoveryClass::ForensicOnly,
2655            &[
2656                PAYLOAD_TYPE_BOOK_DELTAS_RESPONSE,
2657                PAYLOAD_TYPE_BOOK_DEPTH_RESPONSE,
2658            ],
2659        ),
2660        cache_mutation(
2661            "own_order_book_mut",
2662            CacheMutationRecoveryClass::SnapshotOwned,
2663            &[],
2664        ),
2665        cache_mutation(
2666            "set_mark_xrate",
2667            CacheMutationRecoveryClass::MissingLiveRecovery,
2668            &[],
2669        ),
2670        cache_mutation(
2671            "clear_mark_xrate",
2672            CacheMutationRecoveryClass::MissingLiveRecovery,
2673            &[],
2674        ),
2675        cache_mutation(
2676            "clear_mark_xrates",
2677            CacheMutationRecoveryClass::MissingLiveRecovery,
2678            &[],
2679        ),
2680        cache_mutation(
2681            "account_mut",
2682            CacheMutationRecoveryClass::MissingLiveRecovery,
2683            &[],
2684        ),
2685        cache_mutation(
2686            "update_own_order_book",
2687            CacheMutationRecoveryClass::SnapshotOwned,
2688            &[],
2689        ),
2690        cache_mutation(
2691            "force_remove_from_own_order_book",
2692            CacheMutationRecoveryClass::SnapshotOwned,
2693            &[],
2694        ),
2695        cache_mutation(
2696            "audit_own_order_books",
2697            CacheMutationRecoveryClass::SnapshotOwned,
2698            &[],
2699        ),
2700    ];
2701
2702    const CACHE_MUTATION_EXCLUSIONS: &[&str] = &["check_integrity"];
2703
2704    const fn cache_mutation(
2705        method: &'static str,
2706        class: CacheMutationRecoveryClass,
2707        payload_types: &'static [&'static str],
2708    ) -> CacheMutationCoverage {
2709        CacheMutationCoverage {
2710            method,
2711            class,
2712            payload_types,
2713        }
2714    }
2715
2716    fn cache_public_methods() -> AHashSet<&'static str> {
2717        collect_cache_public_methods(false)
2718    }
2719
2720    fn cache_public_mutable_methods() -> AHashSet<&'static str> {
2721        collect_cache_public_methods(true)
2722    }
2723
2724    /// Every file carrying an `impl Cache` block, since `include_str!` cannot glob a directory.
2725    /// Add a file here when the cache module is split further, or its methods drop out of this
2726    /// classification guard.
2727    const CACHE_IMPL_SOURCES: &[&str] = &[
2728        include_str!("../../common/src/cache/mod.rs"),
2729        include_str!("../../common/src/cache/position.rs"),
2730    ];
2731
2732    fn collect_cache_public_methods(require_mut_self: bool) -> AHashSet<&'static str> {
2733        let mut methods = AHashSet::new();
2734        let mut pending_name: Option<&'static str> = None;
2735        let mut pending_signature = String::new();
2736
2737        for line in CACHE_IMPL_SOURCES.iter().flat_map(|source| source.lines()) {
2738            let trimmed = line.trim_start();
2739
2740            if pending_name.is_none() {
2741                let Some(rest) = trimmed
2742                    .strip_prefix("pub fn ")
2743                    .or_else(|| trimmed.strip_prefix("pub async fn "))
2744                else {
2745                    continue;
2746                };
2747                pending_name = rest.split('(').next();
2748                pending_signature.clear();
2749                pending_signature.push_str(trimmed);
2750            } else {
2751                pending_signature.push(' ');
2752                pending_signature.push_str(trimmed);
2753            }
2754
2755            if trimmed.contains('{') {
2756                if let Some(name) = pending_name.take()
2757                    && (!require_mut_self || pending_signature.contains("&mut self"))
2758                {
2759                    methods.insert(name);
2760                }
2761                pending_signature.clear();
2762            }
2763        }
2764
2765        methods
2766    }
2767
2768    fn sorted_missing_methods<'a>(
2769        actual: &'a AHashSet<&'static str>,
2770        classified: &'a AHashSet<&'static str>,
2771    ) -> Vec<&'static str> {
2772        let mut missing: Vec<_> = actual
2773            .iter()
2774            .copied()
2775            .filter(|method| !classified.contains(method))
2776            .collect();
2777        missing.sort_unstable();
2778        missing
2779    }
2780
2781    fn sorted_stale_methods<'a>(
2782        classified: &'a AHashSet<&'static str>,
2783        actual: &'a AHashSet<&'static str>,
2784    ) -> Vec<&'static str> {
2785        let mut stale: Vec<_> = classified
2786            .iter()
2787            .copied()
2788            .filter(|method| !actual.contains(method))
2789            .collect();
2790        stale.sort_unstable();
2791        stale
2792    }
2793
2794    #[rstest]
2795    fn catalog_replay_inputs_join_event_entries_with_selected_catalog_slice() {
2796        let reader = reader_with_entries(
2797            "run-catalog",
2798            &[
2799                append_payload_with_ts(1, 120, "RunStarted", Bytes::from_static(b"started")),
2800                append_payload_with_ts(2, 100, "SubmitOrder", Bytes::from_static(b"submit")),
2801            ],
2802        );
2803        let record = catalog_quote_record(110);
2804        let mut catalog = FakeReplayCatalog::new(
2805            CatalogSliceCoverage::from_files(vec!["quotes/AUDUSD.SIM/100_120.parquet".into()]),
2806            vec![record.clone()],
2807        );
2808
2809        let plan = plan_catalog_replay_inputs(
2810            &reader,
2811            &mut catalog,
2812            ReplaySeqRange::new(1, 2),
2813            &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2814        )
2815        .expect("plan catalog replay");
2816
2817        assert_eq!(plan.event_range, Some(ReplaySeqRange::new(1, 2)));
2818        assert_eq!(plan.event_count, 2);
2819        assert_eq!(
2820            plan.event_time_range,
2821            Some(ReplayTimeRange::new(
2822                UnixNanos::from(100),
2823                UnixNanos::from(120),
2824            )),
2825        );
2826        assert!(!plan.catalog_slices[0].is_missing());
2827        assert_eq!(catalog.plan_queries.len(), 1);
2828        assert_eq!(catalog.plan_queries[0].data_cls, "quotes");
2829        assert_eq!(
2830            catalog.plan_queries[0].identifiers,
2831            vec!["AUD/USD.SIM".to_string()],
2832        );
2833        assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(100));
2834        assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(120));
2835
2836        let loaded =
2837            load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
2838        let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
2839
2840        assert_eq!(seqs, vec![1, 2]);
2841        assert_eq!(loaded.catalog_slices.len(), 1);
2842        assert_eq!(loaded.catalog_slices[0].records, vec![record]);
2843        assert_eq!(catalog.load_plans.len(), 1);
2844    }
2845
2846    #[rstest]
2847    fn catalog_plan_marks_missing_catalog_slice() {
2848        let reader = reader_with_entries(
2849            "run-missing-catalog",
2850            &[append_payload_with_ts(
2851                1,
2852                1_000,
2853                "RunStarted",
2854                Bytes::from_static(b"started"),
2855            )],
2856        );
2857        let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2858
2859        let plan = plan_catalog_replay_inputs(
2860            &reader,
2861            &mut catalog,
2862            ReplaySeqRange::new(1, 1),
2863            &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
2864        )
2865        .expect("plan catalog replay");
2866        let missing = plan.missing_catalog_slices();
2867
2868        assert_eq!(missing.len(), 1);
2869        assert_eq!(missing[0].query.data_cls, "trades");
2870        assert_eq!(
2871            missing[0].query.identifiers,
2872            vec!["AUD/USD.SIM".to_string()],
2873        );
2874        assert_eq!(missing[0].query.start, UnixNanos::from(1_000));
2875        assert_eq!(missing[0].query.end, UnixNanos::from(1_000));
2876    }
2877
2878    #[rstest]
2879    fn required_missing_catalog_slice_rejects_load() {
2880        let reader = reader_with_entries(
2881            "run-required-missing",
2882            &[append_payload_with_ts(
2883                1,
2884                1_000,
2885                "RunStarted",
2886                Bytes::from_static(b"started"),
2887            )],
2888        );
2889        let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2890        let plan = plan_catalog_replay_inputs(
2891            &reader,
2892            &mut catalog,
2893            ReplaySeqRange::new(1, 1),
2894            &[CatalogSliceSelector::new("quotes")
2895                .with_identifier("AUD/USD.SIM")
2896                .require_coverage()],
2897        )
2898        .expect("plan missing slice");
2899
2900        let err = load_catalog_replay_inputs(&reader, &mut catalog, &plan)
2901            .expect_err("required missing slice must fail");
2902
2903        match err {
2904            ReplayInputError::MissingCatalogSlice {
2905                data_cls,
2906                identifiers,
2907            } => {
2908                assert_eq!(data_cls, "quotes");
2909                assert_eq!(identifiers, vec!["AUD/USD.SIM".to_string()]);
2910            }
2911            other => panic!("expected MissingCatalogSlice, was {other:?}"),
2912        }
2913    }
2914
2915    #[rstest]
2916    fn optional_missing_catalog_slice_loads_as_empty_without_catalog_load() {
2917        let reader = reader_with_entries(
2918            "run-optional-missing",
2919            &[append_payload_with_ts(
2920                1,
2921                1_000,
2922                "RunStarted",
2923                Bytes::from_static(b"started"),
2924            )],
2925        );
2926        let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2927        let plan = plan_catalog_replay_inputs(
2928            &reader,
2929            &mut catalog,
2930            ReplaySeqRange::new(1, 1),
2931            &[CatalogSliceSelector::new("quotes").with_identifier("AUD/USD.SIM")],
2932        )
2933        .expect("plan optional missing slice");
2934
2935        let loaded =
2936            load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load optional");
2937
2938        assert_eq!(loaded.catalog_slices.len(), 1);
2939        assert!(loaded.catalog_slices[0].plan.is_missing());
2940        assert!(loaded.catalog_slices[0].records.is_empty());
2941        assert!(catalog.load_plans.is_empty());
2942    }
2943
2944    #[rstest]
2945    fn catalog_joined_planner_rejects_empty_catalog_selection() {
2946        let reader = reader_with_entries(
2947            "run-empty-selection",
2948            &[append_payload_with_ts(
2949                1,
2950                1_000,
2951                "RunStarted",
2952                Bytes::from_static(b"started"),
2953            )],
2954        );
2955        let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
2956
2957        let err = plan_catalog_replay_inputs(&reader, &mut catalog, ReplaySeqRange::new(1, 1), &[])
2958            .expect_err("empty catalog selection must fail");
2959
2960        match err {
2961            ReplayInputError::EmptyCatalogSelection => {}
2962            other => panic!("expected EmptyCatalogSelection, was {other:?}"),
2963        }
2964        assert!(catalog.plan_queries.is_empty());
2965    }
2966
2967    #[rstest]
2968    fn catalog_selector_explicit_time_bounds_override_event_span() {
2969        let reader = reader_with_entries(
2970            "run-explicit-bounds",
2971            &[append_payload_with_ts(
2972                1,
2973                1_000,
2974                "RunStarted",
2975                Bytes::from_static(b"started"),
2976            )],
2977        );
2978        let mut catalog = FakeReplayCatalog::new(
2979            CatalogSliceCoverage::from_files(vec!["bars/AUDUSD.SIM/900_950.parquet".into()]),
2980            Vec::new(),
2981        );
2982
2983        let plan = plan_catalog_replay_inputs(
2984            &reader,
2985            &mut catalog,
2986            ReplaySeqRange::new(1, 1),
2987            &[CatalogSliceSelector::new("bars")
2988                .with_identifier("AUD/USD.SIM-1-MINUTE-BID-EXTERNAL")
2989                .with_time_bounds(UnixNanos::from(900), UnixNanos::from(950))],
2990        )
2991        .expect("plan explicit bounds");
2992
2993        assert_eq!(plan.catalog_slices[0].query.start, UnixNanos::from(900));
2994        assert_eq!(plan.catalog_slices[0].query.end, UnixNanos::from(950));
2995        assert_eq!(catalog.plan_queries[0].start, UnixNanos::from(900));
2996        assert_eq!(catalog.plan_queries[0].end, UnixNanos::from(950));
2997    }
2998
2999    #[rstest]
3000    fn catalog_replay_inputs_load_catalog_records() {
3001        let reader = reader_with_entries(
3002            "run-catalog-load",
3003            &[
3004                append_payload_with_ts(1, 100, "RunStarted", Bytes::from_static(b"started")),
3005                append_payload_with_ts(2, 110, "OrderFilled", Bytes::from_static(b"filled")),
3006            ],
3007        );
3008        let record = catalog_trade_record(105);
3009        let mut catalog = FakeReplayCatalog::new(
3010            CatalogSliceCoverage::from_files(vec!["trades/AUDUSD.SIM/100_110.parquet".into()]),
3011            vec![record.clone()],
3012        );
3013        let plan = plan_catalog_replay_inputs(
3014            &reader,
3015            &mut catalog,
3016            ReplaySeqRange::new(1, 2),
3017            &[CatalogSliceSelector::new("trades").with_identifier("AUD/USD.SIM")],
3018        )
3019        .expect("plan catalog replay");
3020
3021        assert_eq!(
3022            plan.catalog_slices[0].query.identifiers_option(),
3023            Some(vec!["AUD/USD.SIM".to_string()]),
3024        );
3025
3026        let loaded =
3027            load_catalog_replay_inputs(&reader, &mut catalog, &plan).expect("load catalog");
3028        let seqs: Vec<_> = loaded.entries.iter().map(|entry| entry.seq).collect();
3029
3030        assert_eq!(seqs, vec![1, 2]);
3031        assert_eq!(loaded.catalog_slices[0].records, vec![record]);
3032        assert_eq!(catalog.load_plans.len(), 1);
3033    }
3034
3035    #[rstest]
3036    fn unbounded_catalog_selector_rejects_empty_event_scan() {
3037        let reader = reader_with_entries("run-empty", &[]);
3038        let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
3039
3040        let err = plan_catalog_replay_inputs(
3041            &reader,
3042            &mut catalog,
3043            ReplaySeqRange::new(1, 10),
3044            &[CatalogSliceSelector::new("quotes")],
3045        )
3046        .expect_err("empty replay scan must need explicit bounds");
3047
3048        match err {
3049            ReplayInputError::MissingCatalogTimeBounds { data_cls } => {
3050                assert_eq!(data_cls, "quotes");
3051            }
3052            other => panic!("expected MissingCatalogTimeBounds, was {other:?}"),
3053        }
3054    }
3055
3056    #[rstest]
3057    fn invalid_catalog_time_bounds_are_rejected_before_catalog_access() {
3058        let reader = reader_with_entries(
3059            "run-invalid-bounds",
3060            &[append_payload_with_ts(
3061                1,
3062                1_000,
3063                "RunStarted",
3064                Bytes::from_static(b"started"),
3065            )],
3066        );
3067        let mut catalog = FakeReplayCatalog::new(CatalogSliceCoverage::default(), Vec::new());
3068
3069        let err = plan_catalog_replay_inputs(
3070            &reader,
3071            &mut catalog,
3072            ReplaySeqRange::new(1, 1),
3073            &[CatalogSliceSelector::new("quotes")
3074                .with_time_bounds(UnixNanos::from(200), UnixNanos::from(100))],
3075        )
3076        .expect_err("invalid catalog bounds must fail");
3077
3078        match err {
3079            ReplayInputError::InvalidCatalogTimeRange {
3080                data_cls,
3081                start,
3082                end,
3083            } => {
3084                assert_eq!(data_cls, "quotes");
3085                assert_eq!(start, 200);
3086                assert_eq!(end, 100);
3087            }
3088            other => panic!("expected InvalidCatalogTimeRange, was {other:?}"),
3089        }
3090        assert!(catalog.plan_queries.is_empty());
3091    }
3092
3093    #[rstest]
3094    fn forensics_replay_inputs_do_not_require_catalog_source() {
3095        let reader = reader_with_entries(
3096            "run-forensics",
3097            &[append_payload_with_ts(
3098                1,
3099                500,
3100                "RunStarted",
3101                Bytes::from_static(b"started"),
3102            )],
3103        );
3104
3105        let plan = plan_forensics_replay_inputs(&reader, ReplaySeqRange::new(1, 1))
3106            .expect("plan forensics");
3107        let loaded = load_forensics_replay_inputs(&reader, &plan).expect("load forensics");
3108
3109        assert!(plan.catalog_slices.is_empty());
3110        assert_eq!(loaded.entries.len(), 1);
3111        assert!(loaded.catalog_slices.is_empty());
3112    }
3113
3114    #[rstest]
3115    #[case::zero_start(ReplaySeqRange::new(0, 1), "seq is 1-based")]
3116    #[case::from_after_to(ReplaySeqRange::new(2, 1), "from_seq exceeds to_seq")]
3117    fn invalid_replay_seq_range_rejected(
3118        #[case] range: ReplaySeqRange,
3119        #[case] expected_message: &str,
3120    ) {
3121        let reader = reader_with_entries("run-invalid-seq", &[]);
3122
3123        let err =
3124            plan_forensics_replay_inputs(&reader, range).expect_err("invalid seq range must fail");
3125
3126        match err {
3127            ReplayInputError::InvalidSeqRange {
3128                from_seq,
3129                to_seq,
3130                message,
3131            } => {
3132                assert_eq!(from_seq, range.from_seq);
3133                assert_eq!(to_seq, range.to_seq);
3134                assert_eq!(message, expected_message);
3135            }
3136            other => panic!("expected InvalidSeqRange, was {other:?}"),
3137        }
3138    }
3139
3140    #[rstest]
3141    fn replay_restores_snapshot_before_applying_tail() {
3142        let (reader, replayed) = reader_with_anchor(1);
3143        let mut cache = Cache::default();
3144        let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3145        let restored_id = restored.account_id;
3146
3147        let report =
3148            restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
3149                assert_eq!(anchor.expect("anchor").high_watermark, 1);
3150                let account = AccountAny::from_events(std::slice::from_ref(&restored))
3151                    .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
3152                cache
3153                    .add_account(account)
3154                    .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
3155            })
3156            .expect("replay");
3157
3158        let account = cache.account_owned(&restored_id).expect("account restored");
3159        let events = account.events();
3160
3161        assert_eq!(report.plan.from_seq, 2);
3162        assert_eq!(report.applied_entries, 1);
3163        assert_eq!(report.ignored_entries, 0);
3164        assert_eq!(events, vec![restored, replayed]);
3165    }
3166
3167    #[rstest]
3168    fn replay_does_not_apply_entries_at_or_below_anchor_watermark() {
3169        let (reader, _) = reader_with_anchor(2);
3170        let mut cache = Cache::default();
3171        let restored = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3172        let restored_id = restored.account_id;
3173
3174        let report =
3175            restore_cache_snapshot_and_replay_tail(&mut cache, &reader, |cache, anchor| {
3176                assert_eq!(anchor.expect("anchor").high_watermark, 2);
3177                let account = AccountAny::from_events(std::slice::from_ref(&restored))
3178                    .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))?;
3179                cache
3180                    .add_account(account)
3181                    .map_err(|e| CacheReplayError::snapshot_restore(anchor.unwrap(), e))
3182            })
3183            .expect("replay");
3184
3185        let account = cache.account_owned(&restored_id).expect("account restored");
3186
3187        assert!(report.plan.is_empty());
3188        assert_eq!(report.applied_entries, 0);
3189        assert_eq!(report.ignored_entries, 0);
3190        assert_eq!(account.events(), vec![restored]);
3191    }
3192
3193    #[rstest]
3194    fn replay_from_start_applies_account_state_without_bus_publish() {
3195        let state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
3196        let account_id = AccountId::from("SIM-001");
3197        let bus_calls = Rc::new(Cell::new(0));
3198        msgbus::set_bus_tap(Rc::new(CountingTap::new(Rc::clone(&bus_calls))));
3199        let _guard = BusTapGuard;
3200        let mut backend = MemoryBackend::new();
3201        backend.open_run(manifest("run-replay")).expect("open");
3202        backend
3203            .append_batch(&[append_account_state(1, &state)])
3204            .expect("append");
3205        let reader = EventStoreReader::new(backend);
3206        let mut cache = Cache::default();
3207
3208        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3209        let account = cache.account_owned(&account_id).expect("account replayed");
3210
3211        assert_eq!(report.plan.anchor, None);
3212        assert_eq!(report.plan.from_seq, 1);
3213        assert_eq!(report.applied_entries, 1);
3214        assert_eq!(bus_calls.get(), 0);
3215        assert_eq!(account.last_event(), Some(state));
3216        assert_eq!(account.base_currency(), Some(Currency::USD()));
3217    }
3218
3219    #[rstest]
3220    fn unsupported_payload_is_ignored() {
3221        let mut backend = MemoryBackend::new();
3222        backend.open_run(manifest("run-replay")).expect("open");
3223        backend
3224            .append_batch(&[append_payload(
3225                1,
3226                "RunStarted",
3227                Bytes::copy_from_slice(UUID4::new().to_string().as_bytes()),
3228            )])
3229            .expect("append");
3230        let reader = EventStoreReader::new(backend);
3231        let mut cache = Cache::default();
3232
3233        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3234
3235        assert_eq!(report.applied_entries, 0);
3236        assert_eq!(report.ignored_entries, 1);
3237    }
3238
3239    #[rstest]
3240    fn default_capture_payload_types_are_classified_for_cache_replay() {
3241        let mut classified = AHashSet::new();
3242        let mut overlap = Vec::new();
3243
3244        for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
3245            classified.insert(*payload_type);
3246        }
3247
3248        for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
3249            if !classified.insert(*payload_type) {
3250                overlap.push(*payload_type);
3251            }
3252        }
3253
3254        let mut seen_defaults = AHashSet::new();
3255        let duplicate_defaults: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
3256            .iter()
3257            .copied()
3258            .filter(|payload_type| !seen_defaults.insert(*payload_type))
3259            .collect();
3260        let unclassified: Vec<_> = DEFAULT_CAPTURE_PAYLOAD_TYPES
3261            .iter()
3262            .copied()
3263            .filter(|payload_type| !classified.contains(payload_type))
3264            .collect();
3265        let extra: Vec<_> = classified
3266            .iter()
3267            .copied()
3268            .filter(|payload_type| !seen_defaults.contains(payload_type))
3269            .collect();
3270
3271        assert!(
3272            duplicate_defaults.is_empty(),
3273            "default capture payload types must be unique: {duplicate_defaults:?}",
3274        );
3275        assert!(
3276            overlap.is_empty(),
3277            "cache replay and forensic-only classes must not overlap: {overlap:?}",
3278        );
3279        assert!(
3280            unclassified.is_empty(),
3281            "default capture payload types must be cache replayed or forensic-only: {unclassified:?}",
3282        );
3283        assert!(
3284            extra.is_empty(),
3285            "cache replay classification must not list uncaptured payload types: {extra:?}",
3286        );
3287    }
3288
3289    #[rstest]
3290    fn cache_replay_capture_payload_types_have_replay_rules() {
3291        for payload_type in CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES {
3292            let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
3293            let mut cache = Cache::default();
3294
3295            let err = apply_cache_replay_entry(&mut cache, &entry)
3296                .expect_err("cache replay payload type must have a decode rule");
3297
3298            match err {
3299                CacheReplayError::Decode {
3300                    payload_type: actual,
3301                    ..
3302                } => {
3303                    assert_eq!(actual, *payload_type);
3304                }
3305                other => panic!("expected Decode for {payload_type}, was {other:?}"),
3306            }
3307        }
3308    }
3309
3310    #[rstest]
3311    fn forensic_only_capture_payload_types_are_not_cache_replayed() {
3312        for payload_type in FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES {
3313            let entry = append_payload(1, payload_type, Bytes::from_static(&[0xc1])).entry;
3314            let mut cache = Cache::default();
3315
3316            let applied = apply_cache_replay_entry(&mut cache, &entry)
3317                .expect("forensic-only payload type must not be decoded by cache replay");
3318
3319            assert!(
3320                !applied,
3321                "forensic-only payload type must be ignored by cache replay: {payload_type}",
3322            );
3323        }
3324    }
3325
3326    #[rstest]
3327    fn legacy_forward_prices_response_is_ignored_by_cache_replay() {
3328        let entry = append_payload(1, "ForwardPricesResponse", Bytes::from_static(&[0xc1])).entry;
3329        let mut cache = Cache::default();
3330
3331        let applied = apply_cache_replay_entry(&mut cache, &entry)
3332            .expect("legacy forensic payload must not be decoded by cache replay");
3333
3334        assert!(!applied);
3335    }
3336
3337    #[rstest]
3338    fn cache_public_mutators_have_recovery_classification() {
3339        let mut classified = AHashSet::new();
3340        let mut duplicates = Vec::new();
3341
3342        for row in CACHE_MUTATION_COVERAGE {
3343            if !classified.insert(row.method) {
3344                duplicates.push(row.method);
3345            }
3346        }
3347
3348        for method in CACHE_MUTATION_EXCLUSIONS {
3349            if !classified.insert(*method) {
3350                duplicates.push(*method);
3351            }
3352        }
3353
3354        let public_methods = cache_public_methods();
3355        let mutable_methods = cache_public_mutable_methods();
3356        let missing = sorted_missing_methods(&mutable_methods, &classified);
3357        let stale = sorted_stale_methods(&classified, &public_methods);
3358
3359        assert!(
3360            duplicates.is_empty(),
3361            "cache mutation recovery classifications must be unique: {duplicates:?}",
3362        );
3363        assert!(
3364            missing.is_empty(),
3365            "public Cache mutators must be classified for recovery: {missing:?}",
3366        );
3367        assert!(
3368            stale.is_empty(),
3369            "cache mutation recovery classifications reference missing methods: {stale:?}",
3370        );
3371    }
3372
3373    #[rstest]
3374    fn cache_mutation_replay_classification_matches_payload_buckets() {
3375        for row in CACHE_MUTATION_COVERAGE {
3376            match row.class {
3377                CacheMutationRecoveryClass::EventStoreCapturedAndReplayed => {
3378                    assert!(
3379                        !row.payload_types.is_empty(),
3380                        "cache-replayed mutation must cite captured payloads: {}",
3381                        row.method,
3382                    );
3383
3384                    for payload_type in row.payload_types {
3385                        assert!(
3386                            CACHE_REPLAY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
3387                            "cache mutation {} cites non-replayed payload {payload_type}",
3388                            row.method,
3389                        );
3390                    }
3391                }
3392                CacheMutationRecoveryClass::ForensicOnly => {
3393                    assert!(
3394                        !row.payload_types.is_empty(),
3395                        "forensic-only mutation must cite forensic payloads: {}",
3396                        row.method,
3397                    );
3398
3399                    for payload_type in row.payload_types {
3400                        assert!(
3401                            FORENSIC_ONLY_CAPTURE_PAYLOAD_TYPES.contains(payload_type),
3402                            "cache mutation {} cites non-forensic payload {payload_type}",
3403                            row.method,
3404                        );
3405                    }
3406                }
3407                CacheMutationRecoveryClass::SnapshotOwned
3408                | CacheMutationRecoveryClass::MissingLiveRecovery => {
3409                    assert!(
3410                        row.payload_types.is_empty(),
3411                        "non-event-store cache mutation {} should not cite payloads",
3412                        row.method,
3413                    );
3414                }
3415            }
3416        }
3417    }
3418
3419    #[rstest]
3420    fn submit_order_list_replay_restores_order_list() {
3421        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3422        let instrument_id = instrument.id();
3423        let first_init = OrderInitializedSpec::builder()
3424            .instrument_id(instrument_id)
3425            .client_order_id(ClientOrderId::from("O-LIST-001"))
3426            .build();
3427        let second_init = OrderInitializedSpec::builder()
3428            .instrument_id(instrument_id)
3429            .client_order_id(ClientOrderId::from("O-LIST-002"))
3430            .build();
3431        let order_list = OrderList::new(
3432            OrderListId::from("OL-001"),
3433            instrument_id,
3434            first_init.strategy_id,
3435            vec![first_init.client_order_id, second_init.client_order_id],
3436            UnixNanos::from(1),
3437        );
3438        let command = SubmitOrderList::new(
3439            first_init.trader_id,
3440            Some(ClientId::from("SIM")),
3441            first_init.strategy_id,
3442            order_list.clone(),
3443            vec![first_init, second_init],
3444            None,
3445            None,
3446            None,
3447            UUID4::new(),
3448            UnixNanos::from(2),
3449            None,
3450        );
3451        let entry = append_serde_payload(1, PAYLOAD_TYPE_SUBMIT_ORDER_LIST, &command).entry;
3452        let mut cache = Cache::default();
3453
3454        let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply order list");
3455        let replayed = cache
3456            .order_list(&order_list.id)
3457            .expect("order list replayed");
3458
3459        assert!(applied);
3460        assert_eq!(replayed, &order_list);
3461    }
3462
3463    #[rstest]
3464    fn data_response_replay_restores_instruments_and_market_data() {
3465        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3466        let instrument_id = instrument.id();
3467        let client_id = ClientId::from("DATA");
3468        let quote = QuoteTick::new(
3469            instrument_id,
3470            Price::from("1.00000"),
3471            Price::from("1.00010"),
3472            Quantity::from("100000"),
3473            Quantity::from("100000"),
3474            UnixNanos::from(10),
3475            UnixNanos::from(11),
3476        );
3477        let trade = TradeTick::new(
3478            instrument_id,
3479            Price::from("1.00005"),
3480            Quantity::from("50000"),
3481            AggressorSide::Buy,
3482            TradeId::from("T-DATA-001"),
3483            UnixNanos::from(12),
3484            UnixNanos::from(13),
3485        );
3486        let funding_rate = FundingRateUpdate::new(
3487            instrument_id,
3488            "0.0001".parse().expect("funding rate"),
3489            Some(480),
3490            Some(UnixNanos::from(60)),
3491            UnixNanos::from(14),
3492            UnixNanos::from(15),
3493        );
3494        let bar_type = BarType::new(
3495            instrument_id,
3496            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3497            AggregationSource::External,
3498        );
3499        let bar = Bar::new(
3500            bar_type,
3501            Price::from("1.00000"),
3502            Price::from("1.00020"),
3503            Price::from("0.99990"),
3504            Price::from("1.00010"),
3505            Quantity::from("150000"),
3506            UnixNanos::from(16),
3507            UnixNanos::from(17),
3508        );
3509        let reader = reader_with_entries(
3510            "run-data-response-replay",
3511            &[
3512                append_serde_payload(
3513                    1,
3514                    PAYLOAD_TYPE_INSTRUMENT_RESPONSE,
3515                    &InstrumentResponse::new(
3516                        UUID4::new(),
3517                        client_id,
3518                        instrument_id,
3519                        instrument.clone(),
3520                        None,
3521                        None,
3522                        UnixNanos::from(1),
3523                        None,
3524                    ),
3525                ),
3526                append_serde_payload(
3527                    2,
3528                    PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3529                    &InstrumentsResponse::new(
3530                        UUID4::new(),
3531                        client_id,
3532                        instrument_id.venue,
3533                        vec![instrument],
3534                        None,
3535                        None,
3536                        UnixNanos::from(2),
3537                        None,
3538                    ),
3539                ),
3540                append_serde_payload(
3541                    3,
3542                    PAYLOAD_TYPE_QUOTES_RESPONSE,
3543                    &QuotesResponse::new(
3544                        UUID4::new(),
3545                        client_id,
3546                        instrument_id,
3547                        vec![quote],
3548                        None,
3549                        None,
3550                        UnixNanos::from(3),
3551                        None,
3552                    ),
3553                ),
3554                append_serde_payload(
3555                    4,
3556                    PAYLOAD_TYPE_TRADES_RESPONSE,
3557                    &TradesResponse::new(
3558                        UUID4::new(),
3559                        client_id,
3560                        instrument_id,
3561                        vec![trade],
3562                        None,
3563                        None,
3564                        UnixNanos::from(4),
3565                        None,
3566                    ),
3567                ),
3568                append_serde_payload(
3569                    5,
3570                    PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3571                    &FundingRatesResponse::new(
3572                        UUID4::new(),
3573                        client_id,
3574                        instrument_id,
3575                        vec![funding_rate],
3576                        None,
3577                        None,
3578                        UnixNanos::from(5),
3579                        None,
3580                    ),
3581                ),
3582                append_serde_payload(
3583                    6,
3584                    PAYLOAD_TYPE_BARS_RESPONSE,
3585                    &BarsResponse::new(
3586                        UUID4::new(),
3587                        client_id,
3588                        bar_type,
3589                        vec![bar],
3590                        None,
3591                        None,
3592                        UnixNanos::from(6),
3593                        None,
3594                    ),
3595                ),
3596            ],
3597        );
3598        let mut cache = Cache::default();
3599
3600        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3601
3602        assert_eq!(report.applied_entries, 6);
3603        assert_eq!(report.ignored_entries, 0);
3604        assert_eq!(
3605            cache.instrument(&instrument_id).map(Instrument::id),
3606            Some(instrument_id)
3607        );
3608        assert_eq!(cache.quotes(&instrument_id), Some(vec![quote]));
3609        assert_eq!(cache.trades(&instrument_id), Some(vec![trade]));
3610        assert_eq!(
3611            cache.funding_rates(&instrument_id),
3612            Some(vec![funding_rate])
3613        );
3614        assert_eq!(cache.bars(&bar_type), Some(vec![bar]));
3615    }
3616
3617    #[rstest]
3618    fn empty_data_response_replay_is_noop() {
3619        let instrument_id = InstrumentAny::CurrencyPair(audusd_sim()).id();
3620        let client_id = ClientId::from("DATA");
3621        let bar_type = BarType::new(
3622            instrument_id,
3623            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
3624            AggregationSource::External,
3625        );
3626        let reader = reader_with_entries(
3627            "run-empty-data-response-replay",
3628            &[
3629                append_serde_payload(
3630                    1,
3631                    PAYLOAD_TYPE_INSTRUMENTS_RESPONSE,
3632                    &InstrumentsResponse::new(
3633                        UUID4::new(),
3634                        client_id,
3635                        instrument_id.venue,
3636                        Vec::new(),
3637                        None,
3638                        None,
3639                        UnixNanos::from(1),
3640                        None,
3641                    ),
3642                ),
3643                append_serde_payload(
3644                    2,
3645                    PAYLOAD_TYPE_QUOTES_RESPONSE,
3646                    &QuotesResponse::new(
3647                        UUID4::new(),
3648                        client_id,
3649                        instrument_id,
3650                        Vec::new(),
3651                        None,
3652                        None,
3653                        UnixNanos::from(2),
3654                        None,
3655                    ),
3656                ),
3657                append_serde_payload(
3658                    3,
3659                    PAYLOAD_TYPE_TRADES_RESPONSE,
3660                    &TradesResponse::new(
3661                        UUID4::new(),
3662                        client_id,
3663                        instrument_id,
3664                        Vec::new(),
3665                        None,
3666                        None,
3667                        UnixNanos::from(3),
3668                        None,
3669                    ),
3670                ),
3671                append_serde_payload(
3672                    4,
3673                    PAYLOAD_TYPE_FUNDING_RATES_RESPONSE,
3674                    &FundingRatesResponse::new(
3675                        UUID4::new(),
3676                        client_id,
3677                        instrument_id,
3678                        Vec::new(),
3679                        None,
3680                        None,
3681                        UnixNanos::from(4),
3682                        None,
3683                    ),
3684                ),
3685                append_serde_payload(
3686                    5,
3687                    PAYLOAD_TYPE_BARS_RESPONSE,
3688                    &BarsResponse::new(
3689                        UUID4::new(),
3690                        client_id,
3691                        bar_type,
3692                        Vec::new(),
3693                        None,
3694                        None,
3695                        UnixNanos::from(5),
3696                        None,
3697                    ),
3698                ),
3699            ],
3700        );
3701        let mut cache = Cache::default();
3702
3703        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3704
3705        assert_eq!(report.applied_entries, 5);
3706        assert_eq!(report.ignored_entries, 0);
3707        assert!(cache.instrument(&instrument_id).is_none());
3708        assert_eq!(cache.quotes(&instrument_id), None);
3709        assert_eq!(cache.trades(&instrument_id), None);
3710        assert_eq!(cache.funding_rates(&instrument_id), None);
3711        assert_eq!(cache.bars(&bar_type), None);
3712    }
3713
3714    #[rstest]
3715    fn order_fill_replay_updates_order_and_creates_position() {
3716        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3717        let position_id = PositionId::from("P-001");
3718        let initialized = OrderInitializedSpec::builder()
3719            .instrument_id(instrument.id())
3720            .build();
3721        let client_order_id = initialized.client_order_id;
3722        let submitted = OrderSubmittedSpec::builder()
3723            .instrument_id(instrument.id())
3724            .client_order_id(client_order_id)
3725            .build();
3726        let accepted = OrderAcceptedSpec::builder()
3727            .instrument_id(instrument.id())
3728            .client_order_id(client_order_id)
3729            .account_id(submitted.account_id)
3730            .build();
3731        let filled = OrderFilledSpec::builder()
3732            .instrument_id(instrument.id())
3733            .client_order_id(client_order_id)
3734            .venue_order_id(accepted.venue_order_id)
3735            .account_id(submitted.account_id)
3736            .position_id(position_id)
3737            .commission(Money::from("1 USD"))
3738            .build();
3739        let filled_event = OrderEventAny::Filled(filled.clone());
3740        let reader = reader_with_entries(
3741            "run-order-replay",
3742            &[
3743                append_order_event(1, &OrderEventAny::Initialized(initialized)),
3744                append_order_event(2, &OrderEventAny::Submitted(submitted)),
3745                append_order_event(3, &OrderEventAny::Accepted(accepted)),
3746                append_order_event(4, &filled_event),
3747            ],
3748        );
3749        let mut cache = Cache::default();
3750        cache.add_instrument(instrument).expect("add instrument");
3751
3752        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3753        let order = cache.order_owned(&client_order_id).expect("order replayed");
3754        let position = cache
3755            .position_owned(&position_id)
3756            .expect("position replayed");
3757
3758        assert_eq!(report.applied_entries, 4);
3759        assert_eq!(report.ignored_entries, 0);
3760        assert_eq!(order.status(), OrderStatus::Filled);
3761        assert_eq!(order.event_count(), 4);
3762        assert_eq!(order.last_event(), &filled_event);
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    }
3768
3769    #[rstest]
3770    fn orderless_leg_fill_replay_creates_position_without_order_mapping() {
3771        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3772        let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3773        let position_id = PositionId::from("P-ORDERLESS-LEG");
3774        let filled = OrderFilledSpec::builder()
3775            .instrument_id(instrument.id())
3776            .client_order_id(client_order_id)
3777            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-AUDUSD"))
3778            .position_id(position_id)
3779            .commission(Money::from("1 USD"))
3780            .build();
3781        let reader = reader_with_entries(
3782            "run-orderless-leg-fill-replay",
3783            &[append_order_event(
3784                1,
3785                &OrderEventAny::Filled(filled.clone()),
3786            )],
3787        );
3788        let mut cache = Cache::default();
3789        cache.add_instrument(instrument).expect("add instrument");
3790
3791        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3792        let position = cache
3793            .position_owned(&position_id)
3794            .expect("orderless leg position replayed");
3795
3796        assert_eq!(report.applied_entries, 1);
3797        assert_eq!(report.ignored_entries, 0);
3798        assert!(cache.order_owned(&client_order_id).is_none());
3799        assert_eq!(cache.position_id(&client_order_id), None);
3800        assert_eq!(position.event_count(), 1);
3801        assert_eq!(position.last_event(), Some(filled.clone()));
3802        assert_eq!(position.trade_ids(), vec![filled.trade_id]);
3803        assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
3804        assert!(cache.check_integrity());
3805    }
3806
3807    #[rstest]
3808    fn orderless_netting_reopen_replay_does_not_treat_closed_position_as_flip() {
3809        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3810        let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3811        let position_id = PositionId::from("P-ORDERLESS-NETTING");
3812        let opening_fill = OrderFilledSpec::builder()
3813            .instrument_id(instrument.id())
3814            .client_order_id(client_order_id)
3815            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-1"))
3816            .trade_id(TradeId::from("T-SPREAD-LEG-1"))
3817            .order_side(OrderSide::Buy)
3818            .last_qty(Quantity::from(1))
3819            .position_id(position_id)
3820            .build();
3821        let closing_fill = OrderFilledSpec::builder()
3822            .instrument_id(instrument.id())
3823            .client_order_id(client_order_id)
3824            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-2"))
3825            .trade_id(TradeId::from("T-SPREAD-LEG-2"))
3826            .order_side(OrderSide::Sell)
3827            .last_qty(Quantity::from(1))
3828            .position_id(position_id)
3829            .build();
3830        let mut closed_position = Position::new(&instrument, opening_fill);
3831        closed_position.apply(&closing_fill);
3832        assert!(closed_position.is_closed());
3833
3834        let reopening_fill = OrderFilledSpec::builder()
3835            .instrument_id(instrument.id())
3836            .client_order_id(client_order_id)
3837            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-3"))
3838            .trade_id(TradeId::from("T-SPREAD-LEG-3"))
3839            .order_side(OrderSide::Sell)
3840            .last_qty(Quantity::from(1))
3841            .position_id(position_id)
3842            .build();
3843        let mut reopened_position = closed_position.clone();
3844        reopened_position.apply(&reopening_fill);
3845        let reopened = PositionOpened::create(
3846            &reopened_position,
3847            &reopening_fill,
3848            UUID4::new(),
3849            reopening_fill.ts_init,
3850        );
3851        let reader = reader_with_entries(
3852            "run-orderless-netting-reopen-replay",
3853            &[
3854                append_order_event(1, &OrderEventAny::Filled(reopening_fill.clone())),
3855                append_position_event(2, &PositionEvent::PositionOpened(reopened)),
3856            ],
3857        );
3858        let mut cache = Cache::default();
3859        cache.add_instrument(instrument).expect("add instrument");
3860        cache
3861            .add_position_without_order(&closed_position, OmsType::Netting)
3862            .expect("seed closed orderless position");
3863
3864        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3865        let position = cache
3866            .position_owned(&position_id)
3867            .expect("netting position reopened");
3868
3869        assert_eq!(report.applied_entries, 2);
3870        assert_eq!(report.ignored_entries, 0);
3871        assert!(position.is_open());
3872        assert_eq!(position.side, PositionSide::Short);
3873        assert_eq!(position.entry, OrderSide::Sell);
3874        assert_eq!(position.quantity, Quantity::from(1));
3875        assert_eq!(position.opening_order_id, client_order_id);
3876        assert_eq!(position.closing_order_id, None);
3877        assert_eq!(position.event_count(), 1);
3878        assert_eq!(position.trade_ids(), vec![reopening_fill.trade_id]);
3879        assert_eq!(position.last_event(), Some(reopening_fill));
3880        assert_eq!(cache.oms_type(&position_id), Some(OmsType::Netting));
3881        assert!(cache.orders_for_position(&position_id).is_empty());
3882        assert_eq!(cache.position_id(&client_order_id), None);
3883        assert!(cache.check_integrity());
3884    }
3885
3886    #[rstest]
3887    fn orderless_hedging_flip_replay_recreates_replacement_position() {
3888        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
3889        let client_order_id = ClientOrderId::from("SPREAD-LEG-AUDUSD");
3890        let first_position_id = PositionId::from("P-ORDERLESS-LEG-1");
3891        let replacement_position_id = PositionId::from("P-ORDERLESS-LEG-2");
3892        let opening_fill = OrderFilledSpec::builder()
3893            .instrument_id(instrument.id())
3894            .client_order_id(client_order_id)
3895            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-1"))
3896            .trade_id(TradeId::from("T-SPREAD-LEG-1"))
3897            .order_side(OrderSide::Buy)
3898            .last_qty(Quantity::from(1))
3899            .last_px(Price::from("1.00000"))
3900            .position_id(first_position_id)
3901            .commission(Money::from("1 USD"))
3902            .build();
3903        let first_position = Position::new(&instrument, opening_fill.clone());
3904        let first_opened = PositionOpened::create(
3905            &first_position,
3906            &opening_fill,
3907            UUID4::new(),
3908            opening_fill.ts_init,
3909        );
3910
3911        let flip_fill = OrderFilledSpec::builder()
3912            .instrument_id(instrument.id())
3913            .client_order_id(client_order_id)
3914            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-2"))
3915            .trade_id(TradeId::from("T-SPREAD-LEG-2"))
3916            .order_side(OrderSide::Sell)
3917            .last_qty(Quantity::from(2))
3918            .last_px(Price::from("1.10000"))
3919            .position_id(first_position_id)
3920            .commission(Money::from("2 USD"))
3921            .build();
3922        let mut closing_fragment = flip_fill.clone();
3923        closing_fragment.last_qty = Quantity::from(1);
3924        closing_fragment.commission = Some(Money::from("1 USD"));
3925        let mut closed_position = first_position;
3926        closed_position.apply(&closing_fragment);
3927        let first_closed = PositionClosed::create(
3928            &closed_position,
3929            &closing_fragment,
3930            UUID4::new(),
3931            flip_fill.ts_init,
3932        );
3933
3934        let mut opening_fragment = flip_fill.clone();
3935        opening_fragment.last_qty = Quantity::from(1);
3936        opening_fragment.position_id = Some(replacement_position_id);
3937        opening_fragment.commission = Some(Money::from("1 USD"));
3938        opening_fragment.event_id = UUID4::new();
3939        opening_fragment.causation_id = Some(flip_fill.event_id);
3940        let mut replacement_position = Position::new(&instrument, opening_fragment.clone());
3941        let replacement_opened = PositionOpened::create(
3942            &replacement_position,
3943            &opening_fragment,
3944            UUID4::new(),
3945            opening_fragment.ts_init,
3946        );
3947
3948        let subsequent_fill = OrderFilledSpec::builder()
3949            .instrument_id(instrument.id())
3950            .client_order_id(client_order_id)
3951            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-3"))
3952            .trade_id(TradeId::from("T-SPREAD-LEG-3"))
3953            .order_side(OrderSide::Sell)
3954            .last_qty(Quantity::from(1))
3955            .last_px(Price::from("1.20000"))
3956            .position_id(replacement_position_id)
3957            .commission(Money::from("1 USD"))
3958            .build();
3959        replacement_position.apply(&subsequent_fill);
3960        let replacement_changed = PositionChanged::create(
3961            &replacement_position,
3962            &subsequent_fill,
3963            UUID4::new(),
3964            subsequent_fill.ts_init,
3965        );
3966        let reader = reader_with_entries(
3967            "run-orderless-hedging-flip-replay",
3968            &[
3969                append_order_event(1, &OrderEventAny::Filled(opening_fill)),
3970                append_position_event(2, &PositionEvent::PositionOpened(first_opened)),
3971                append_order_event(3, &OrderEventAny::Filled(flip_fill.clone())),
3972                append_position_event(4, &PositionEvent::PositionClosed(first_closed)),
3973                append_position_event(5, &PositionEvent::PositionOpened(replacement_opened)),
3974                append_order_event(6, &OrderEventAny::Filled(subsequent_fill.clone())),
3975                append_position_event(7, &PositionEvent::PositionChanged(replacement_changed)),
3976            ],
3977        );
3978        let mut cache = Cache::default();
3979        cache.add_instrument(instrument).expect("add instrument");
3980
3981        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
3982
3983        assert_eq!(report.applied_entries, 7);
3984        assert_eq!(report.ignored_entries, 0);
3985        let closed = cache
3986            .position_owned(&first_position_id)
3987            .expect("closed predecessor replayed");
3988        assert!(closed.is_closed());
3989        assert_eq!(closed.event_count(), 2);
3990        let closing_fragments = closed.fill_fragments(client_order_id, flip_fill.trade_id);
3991        assert_eq!(closing_fragments.len(), 1);
3992        assert_eq!(closing_fragments[0].last_qty, Quantity::from(1));
3993        assert_eq!(closing_fragments[0].commission, Some(Money::from("1 USD")));
3994        assert_eq!(closing_fragments[0].event_id, flip_fill.event_id);
3995
3996        let replacement = cache
3997            .position_owned(&replacement_position_id)
3998            .expect("open replacement replayed");
3999        assert!(replacement.is_open());
4000        assert_eq!(replacement.side, PositionSide::Short);
4001        assert_eq!(replacement.quantity, Quantity::from(2));
4002        assert_eq!(replacement.event_count(), 2);
4003        assert_eq!(
4004            cache.oms_type(&replacement_position_id),
4005            Some(OmsType::Hedging)
4006        );
4007        assert!(replacement.trade_ids().contains(&flip_fill.trade_id));
4008        assert!(replacement.trade_ids().contains(&subsequent_fill.trade_id));
4009        let opening_fragments = replacement.fill_fragments(client_order_id, flip_fill.trade_id);
4010        assert_eq!(opening_fragments.len(), 1);
4011        assert_eq!(opening_fragments[0].last_qty, Quantity::from(1));
4012        assert_eq!(opening_fragments[0].commission, Some(Money::from("1 USD")));
4013        assert_eq!(opening_fragments[0].causation_id, Some(flip_fill.event_id));
4014
4015        assert!(cache.orders_for_position(&first_position_id).is_empty());
4016        assert!(
4017            cache
4018                .orders_for_position(&replacement_position_id)
4019                .is_empty()
4020        );
4021        assert_eq!(cache.position_id(&client_order_id), None);
4022        assert!(cache.check_integrity());
4023    }
4024
4025    #[rstest]
4026    fn single_entry_orderless_flip_is_rejected_before_mutating_position() {
4027        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4028        let position_id = PositionId::from("P-ORDERLESS-SINGLE-ENTRY");
4029        let opening_fill = OrderFilledSpec::builder()
4030            .instrument_id(instrument.id())
4031            .client_order_id(ClientOrderId::from("SPREAD-LEG-SINGLE"))
4032            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-SINGLE-1"))
4033            .trade_id(TradeId::from("T-SPREAD-LEG-SINGLE-1"))
4034            .order_side(OrderSide::Buy)
4035            .last_qty(Quantity::from(1))
4036            .position_id(position_id)
4037            .build();
4038        let original = Position::new(&instrument, opening_fill.clone());
4039        let flip_fill = OrderFilledSpec::builder()
4040            .instrument_id(instrument.id())
4041            .client_order_id(opening_fill.client_order_id)
4042            .venue_order_id(VenueOrderId::from("V-SPREAD-LEG-SINGLE-2"))
4043            .trade_id(TradeId::from("T-SPREAD-LEG-SINGLE-2"))
4044            .order_side(OrderSide::Sell)
4045            .last_qty(Quantity::from(2))
4046            .position_id(position_id)
4047            .build();
4048        let entry = append_order_event(1, &OrderEventAny::Filled(flip_fill)).entry;
4049        let mut cache = Cache::default();
4050        cache
4051            .add_instrument(instrument)
4052            .expect("add replay instrument");
4053        cache
4054            .add_position_without_order(&original, OmsType::Hedging)
4055            .expect("seed orderless position");
4056
4057        let error = apply_cache_replay_entry(&mut cache, &entry)
4058            .expect_err("single-entry API cannot defer the opening fragment");
4059        let after = cache
4060            .position_owned(&position_id)
4061            .expect("position retained");
4062
4063        assert!(error.to_string().contains("snapshot-tail replay context"));
4064        assert_eq!(after.side, original.side);
4065        assert_eq!(after.quantity, original.quantity);
4066        assert_eq!(after.event_count(), original.event_count());
4067        assert_eq!(after.trade_ids(), original.trade_ids());
4068    }
4069
4070    #[rstest]
4071    fn order_fill_replay_without_instrument_counts_fill_as_ignored() {
4072        // The position side cannot open without the instrument; the fill must count
4073        // as ignored rather than claim a full apply.
4074        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4075        let position_id = PositionId::from("P-NO-INSTR");
4076        let initialized = OrderInitializedSpec::builder()
4077            .instrument_id(instrument.id())
4078            .build();
4079        let client_order_id = initialized.client_order_id;
4080        let submitted = OrderSubmittedSpec::builder()
4081            .instrument_id(instrument.id())
4082            .client_order_id(client_order_id)
4083            .build();
4084        let accepted = OrderAcceptedSpec::builder()
4085            .instrument_id(instrument.id())
4086            .client_order_id(client_order_id)
4087            .account_id(submitted.account_id)
4088            .build();
4089        let filled = OrderFilledSpec::builder()
4090            .instrument_id(instrument.id())
4091            .client_order_id(client_order_id)
4092            .venue_order_id(accepted.venue_order_id)
4093            .account_id(submitted.account_id)
4094            .position_id(position_id)
4095            .build();
4096        let reader = reader_with_entries(
4097            "run-fill-no-instrument",
4098            &[
4099                append_order_event(1, &OrderEventAny::Initialized(initialized)),
4100                append_order_event(2, &OrderEventAny::Submitted(submitted)),
4101                append_order_event(3, &OrderEventAny::Accepted(accepted)),
4102                append_order_event(4, &OrderEventAny::Filled(filled)),
4103            ],
4104        );
4105        let mut cache = Cache::default();
4106
4107        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4108
4109        assert_eq!(report.applied_entries, 3);
4110        assert_eq!(report.ignored_entries, 1);
4111        assert!(cache.position_owned(&position_id).is_none());
4112    }
4113
4114    #[rstest]
4115    fn order_fill_void_replay_updates_order_and_position() {
4116        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4117        let position_id = PositionId::from("P-VOID-001");
4118        let initialized = OrderInitializedSpec::builder()
4119            .instrument_id(instrument.id())
4120            .build();
4121        let client_order_id = initialized.client_order_id;
4122        let submitted = OrderSubmittedSpec::builder()
4123            .instrument_id(instrument.id())
4124            .client_order_id(client_order_id)
4125            .build();
4126        let accepted = OrderAcceptedSpec::builder()
4127            .instrument_id(instrument.id())
4128            .client_order_id(client_order_id)
4129            .account_id(submitted.account_id)
4130            .build();
4131        let filled = OrderFilledSpec::builder()
4132            .instrument_id(instrument.id())
4133            .client_order_id(client_order_id)
4134            .venue_order_id(accepted.venue_order_id)
4135            .account_id(submitted.account_id)
4136            .position_id(position_id)
4137            .commission(Money::from("1 USD"))
4138            .build();
4139        let fill_voided = OrderFillVoidedSpec::builder()
4140            .trader_id(filled.trader_id)
4141            .strategy_id(filled.strategy_id)
4142            .instrument_id(filled.instrument_id)
4143            .client_order_id(filled.client_order_id)
4144            .venue_order_id(filled.venue_order_id)
4145            .account_id(filled.account_id)
4146            .trade_id(filled.trade_id)
4147            .voided_qty(Quantity::from(50_000))
4148            .commission_voided(Money::from("0.40 USD"))
4149            .order_side(filled.order_side)
4150            .order_type(filled.order_type)
4151            .last_px(filled.last_px)
4152            .currency(filled.currency)
4153            .liquidity_side(filled.liquidity_side)
4154            .position_id(position_id)
4155            .is_reopened(true)
4156            .build();
4157        let reader = reader_with_entries(
4158            "run-fill-void-replay",
4159            &[
4160                append_order_event(1, &OrderEventAny::Initialized(initialized)),
4161                append_order_event(2, &OrderEventAny::Submitted(submitted)),
4162                append_order_event(3, &OrderEventAny::Accepted(accepted)),
4163                append_order_event(4, &OrderEventAny::Filled(filled)),
4164                append_order_event(5, &OrderEventAny::FillVoided(fill_voided.clone())),
4165            ],
4166        );
4167        let mut cache = Cache::default();
4168        cache.add_instrument(instrument).expect("add instrument");
4169
4170        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4171        let order = cache.order_owned(&client_order_id).expect("order replayed");
4172        let position = cache
4173            .position_owned(&position_id)
4174            .expect("position replayed");
4175
4176        assert_eq!(report.applied_entries, 5);
4177        assert_eq!(report.ignored_entries, 0);
4178        assert_eq!(order.status(), OrderStatus::PartiallyFilled);
4179        assert_eq!(order.filled_qty(), Quantity::from(50_000));
4180        assert_eq!(order.voided_qty(), Quantity::from(50_000));
4181        assert_eq!(position.quantity, Quantity::from(50_000));
4182        assert_eq!(position.commissions(), vec![Money::from("0.60 USD")]);
4183        assert_eq!(position.fill_voids.len(), 1);
4184        assert_eq!(position.fill_voids[0].event, fill_voided);
4185    }
4186
4187    #[rstest]
4188    fn order_fill_void_replay_updates_order_without_position() {
4189        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4190        let initialized = OrderInitializedSpec::builder()
4191            .instrument_id(instrument.id())
4192            .build();
4193        let client_order_id = initialized.client_order_id;
4194        let submitted = OrderSubmittedSpec::builder()
4195            .instrument_id(instrument.id())
4196            .client_order_id(client_order_id)
4197            .build();
4198        let accepted = OrderAcceptedSpec::builder()
4199            .instrument_id(instrument.id())
4200            .client_order_id(client_order_id)
4201            .account_id(submitted.account_id)
4202            .build();
4203        let filled = OrderFilledSpec::builder()
4204            .instrument_id(instrument.id())
4205            .client_order_id(client_order_id)
4206            .venue_order_id(accepted.venue_order_id)
4207            .account_id(submitted.account_id)
4208            .build();
4209        let fill_voided = OrderFillVoidedSpec::builder()
4210            .trader_id(filled.trader_id)
4211            .strategy_id(filled.strategy_id)
4212            .instrument_id(filled.instrument_id)
4213            .client_order_id(filled.client_order_id)
4214            .venue_order_id(filled.venue_order_id)
4215            .account_id(filled.account_id)
4216            .trade_id(filled.trade_id)
4217            .voided_qty(Quantity::from(50_000))
4218            .order_side(filled.order_side)
4219            .order_type(filled.order_type)
4220            .last_px(filled.last_px)
4221            .currency(filled.currency)
4222            .liquidity_side(filled.liquidity_side)
4223            .is_reopened(true)
4224            .build();
4225        let reader = reader_with_entries(
4226            "run-order-only-fill-void-replay",
4227            &[
4228                append_order_event(1, &OrderEventAny::Initialized(initialized)),
4229                append_order_event(2, &OrderEventAny::Submitted(submitted)),
4230                append_order_event(3, &OrderEventAny::Accepted(accepted)),
4231                append_order_event(4, &OrderEventAny::Filled(filled)),
4232                append_order_event(5, &OrderEventAny::FillVoided(fill_voided)),
4233            ],
4234        );
4235        let mut cache = Cache::default();
4236        cache.add_instrument(instrument).expect("add instrument");
4237
4238        let report = replay_cache_snapshot_tail(&mut cache, &reader).expect("replay");
4239        let order = cache.order_owned(&client_order_id).expect("order replayed");
4240
4241        assert_eq!(report.applied_entries, 5);
4242        assert_eq!(report.ignored_entries, 0);
4243        assert_eq!(order.status(), OrderStatus::PartiallyFilled);
4244        assert_eq!(order.filled_qty(), Quantity::from(50_000));
4245        assert_eq!(order.voided_qty(), Quantity::from(50_000));
4246        assert_eq!(cache.positions_total_count(None, None, None, None, None), 0);
4247    }
4248
4249    #[rstest]
4250    fn position_lifecycle_replay_updates_existing_position() {
4251        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4252        let position_id = PositionId::from("P-001");
4253        let opened_fill = OrderFilledSpec::builder()
4254            .instrument_id(instrument.id())
4255            .client_order_id(ClientOrderId::from("O-OPEN"))
4256            .venue_order_id(VenueOrderId::from("V-OPEN"))
4257            .trade_id(TradeId::from("T-OPEN"))
4258            .position_id(position_id)
4259            .last_qty(Quantity::from("1"))
4260            .last_px(Price::from("1.00000"))
4261            .build();
4262        let mut live_position = Position::new(&instrument, opened_fill.clone());
4263        let opened = PositionOpened::create(
4264            &live_position,
4265            &opened_fill,
4266            UUID4::new(),
4267            UnixNanos::from(10),
4268        );
4269
4270        let changed_fill = OrderFilledSpec::builder()
4271            .instrument_id(instrument.id())
4272            .client_order_id(ClientOrderId::from("O-CHANGE"))
4273            .venue_order_id(VenueOrderId::from("V-CHANGE"))
4274            .trade_id(TradeId::from("T-CHANGE"))
4275            .position_id(position_id)
4276            .last_qty(Quantity::from("2"))
4277            .last_px(Price::from("1.10000"))
4278            .build();
4279        live_position.apply(&changed_fill);
4280        let changed = PositionChanged::create(
4281            &live_position,
4282            &changed_fill,
4283            UUID4::new(),
4284            UnixNanos::from(20),
4285        );
4286
4287        let closed_fill = OrderFilledSpec::builder()
4288            .instrument_id(instrument.id())
4289            .client_order_id(ClientOrderId::from("O-CLOSE"))
4290            .venue_order_id(VenueOrderId::from("V-CLOSE"))
4291            .trade_id(TradeId::from("T-CLOSE"))
4292            .order_side(OrderSide::Sell)
4293            .position_id(position_id)
4294            .last_qty(Quantity::from("3"))
4295            .last_px(Price::from("1.20000"))
4296            .build();
4297        live_position.apply(&closed_fill);
4298        let closed = PositionClosed::create(
4299            &live_position,
4300            &closed_fill,
4301            UUID4::new(),
4302            UnixNanos::from(30),
4303        );
4304
4305        let mut stale_position = Position::new(&instrument, opened_fill);
4306        stale_position.signed_qty = 9.0;
4307        stale_position.quantity = Quantity::from("9");
4308        let mut cache = Cache::default();
4309        cache
4310            .add_position(&stale_position, OmsType::Unspecified)
4311            .expect("seed stale position");
4312
4313        let opened_entry =
4314            append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
4315        let changed_entry =
4316            append_position_event(2, &PositionEvent::PositionChanged(changed.clone())).entry;
4317        let closed_entry =
4318            append_position_event(3, &PositionEvent::PositionClosed(closed.clone())).entry;
4319
4320        assert!(apply_cache_replay_entry(&mut cache, &opened_entry).expect("apply opened"));
4321        let replayed = cache
4322            .position_owned(&position_id)
4323            .expect("position after opened");
4324        assert_eq!(replayed.signed_qty.to_bits(), opened.signed_qty.to_bits());
4325        assert_eq!(replayed.quantity, opened.quantity);
4326        assert_eq!(replayed.ts_last, opened.ts_event);
4327
4328        assert!(apply_cache_replay_entry(&mut cache, &changed_entry).expect("apply changed"));
4329        let replayed = cache
4330            .position_owned(&position_id)
4331            .expect("position after changed");
4332        assert_eq!(replayed.signed_qty.to_bits(), changed.signed_qty.to_bits());
4333        assert_eq!(replayed.quantity, changed.quantity);
4334        assert_eq!(replayed.peak_qty, changed.peak_quantity);
4335        assert_eq!(
4336            replayed.avg_px_open.to_bits(),
4337            changed.avg_px_open.to_bits()
4338        );
4339        assert!(replayed.is_open());
4340
4341        assert!(apply_cache_replay_entry(&mut cache, &closed_entry).expect("apply closed"));
4342        let replayed = cache
4343            .position_owned(&position_id)
4344            .expect("position after closed");
4345        assert_eq!(replayed.signed_qty.to_bits(), closed.signed_qty.to_bits());
4346        assert_eq!(replayed.quantity, closed.quantity);
4347        assert_eq!(replayed.closing_order_id, closed.closing_order_id);
4348        assert_eq!(replayed.duration_ns, closed.duration);
4349        assert!(replayed.is_closed());
4350        assert!(cache.is_position_closed(&position_id));
4351    }
4352
4353    #[rstest]
4354    fn position_opened_replay_replaces_realized_pnl() {
4355        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4356        let position_id = PositionId::from("P-001");
4357        let fill = OrderFilledSpec::builder()
4358            .instrument_id(instrument.id())
4359            .position_id(position_id)
4360            .commission(Money::from("1 USD"))
4361            .build();
4362        let position = Position::new(&instrument, fill.clone());
4363        let mut opened =
4364            PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
4365        assert_eq!(opened.realized_pnl, Some(Money::from("-1 USD")));
4366
4367        let mut stale_position = position;
4368        stale_position.realized_pnl = Some(Money::from("9 USD"));
4369        let mut cache = Cache::default();
4370        cache
4371            .add_position(&stale_position, OmsType::Unspecified)
4372            .expect("seed stale position");
4373        let entry = append_position_event(1, &PositionEvent::PositionOpened(opened.clone())).entry;
4374
4375        assert!(apply_cache_replay_entry(&mut cache, &entry).expect("apply opened"));
4376        assert_eq!(
4377            cache
4378                .position_owned(&position_id)
4379                .expect("position after opened")
4380                .realized_pnl,
4381            Some(Money::from("-1 USD")),
4382        );
4383
4384        opened.realized_pnl = None;
4385        let entry = append_position_event(2, &PositionEvent::PositionOpened(opened)).entry;
4386
4387        assert!(apply_cache_replay_entry(&mut cache, &entry).expect("apply opened without PnL"));
4388        assert_eq!(
4389            cache
4390                .position_owned(&position_id)
4391                .expect("position after opened without PnL")
4392                .realized_pnl,
4393            None,
4394        );
4395    }
4396
4397    #[rstest]
4398    fn position_adjustment_replay_updates_existing_position() {
4399        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4400        let position_id = PositionId::from("P-001");
4401        let fill = OrderFilledSpec::builder()
4402            .instrument_id(instrument.id())
4403            .position_id(position_id)
4404            .build();
4405        let position = Position::new(&instrument, fill.clone());
4406        let adjustment = PositionAdjusted::new(
4407            fill.trader_id,
4408            fill.strategy_id,
4409            fill.instrument_id,
4410            position_id,
4411            fill.account_id,
4412            PositionAdjustmentType::Funding,
4413            None,
4414            Some(Money::from("2 USD")),
4415            Some(Ustr::from("funding")),
4416            UUID4::new(),
4417            UnixNanos::from(10),
4418            UnixNanos::from(11),
4419        );
4420        let entry = append_position_event(1, &PositionEvent::PositionAdjusted(adjustment)).entry;
4421        let mut cache = Cache::default();
4422        cache
4423            .add_position(&position, OmsType::Unspecified)
4424            .expect("seed position");
4425
4426        let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
4427        let position = cache
4428            .position_owned(&position_id)
4429            .expect("position updated");
4430
4431        assert!(applied);
4432        assert_eq!(position.adjustments, vec![adjustment]);
4433        assert_eq!(position.realized_pnl, Some(Money::from("2 USD")));
4434        assert_eq!(position.ts_last, adjustment.ts_event);
4435    }
4436
4437    #[rstest]
4438    fn position_event_for_unknown_position_is_counted_as_ignored() {
4439        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4440        let position_id = PositionId::from("P-MISSING");
4441        let fill = OrderFilledSpec::builder()
4442            .instrument_id(instrument.id())
4443            .position_id(position_id)
4444            .build();
4445        let position = Position::new(&instrument, fill.clone());
4446        let opened = PositionOpened::create(&position, &fill, UUID4::new(), UnixNanos::from(10));
4447        let entry = append_position_event(1, &PositionEvent::PositionOpened(opened)).entry;
4448        let mut cache = Cache::default();
4449
4450        let applied = apply_cache_replay_entry(&mut cache, &entry).expect("apply");
4451
4452        assert!(
4453            !applied,
4454            "missing position must count as ignored, not applied"
4455        );
4456    }
4457
4458    #[rstest]
4459    fn order_filled_with_no_order_side_is_an_apply_error_not_a_panic() {
4460        // The entry hash proves the stored bytes match what was written, not that the
4461        // producer wrote a valid fill; the legacy sentinel deserializes cleanly and
4462        // without the guard panics deep inside Position/Order application.
4463        let payload = IndexMap::from([("order_side", "NO_ORDER_SIDE")]);
4464        let entry = append_serde_payload(1, PAYLOAD_TYPE_ORDER_FILLED, &payload).entry;
4465        let mut cache = Cache::default();
4466
4467        let err = apply_cache_replay_entry(&mut cache, &entry).expect_err("must reject");
4468
4469        match err {
4470            CacheReplayError::Apply { seq, message, .. } => {
4471                assert_eq!(seq, 1);
4472                assert!(message.contains("NoOrderSide"), "message was: {message}");
4473            }
4474            other => panic!("expected Apply, was {other:?}"),
4475        }
4476    }
4477
4478    #[rstest]
4479    fn duplicate_position_fill_is_not_applied_twice() {
4480        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4481        let position_id = PositionId::from("P-001");
4482        let fill = OrderFilledSpec::builder()
4483            .instrument_id(instrument.id())
4484            .position_id(position_id)
4485            .commission(Money::from("1 USD"))
4486            .build();
4487        let position = Position::new(&instrument, fill.clone());
4488        let entry = append_order_event(1, &OrderEventAny::Filled(fill.clone())).entry;
4489        let mut cache = Cache::default();
4490        cache
4491            .add_position(&position, OmsType::Unspecified)
4492            .expect("seed position");
4493
4494        let applied = apply_fill_to_position(&mut cache, &entry, &fill, false).expect("apply fill");
4495        let position = cache
4496            .position_owned(&position_id)
4497            .expect("position updated");
4498
4499        assert!(
4500            applied,
4501            "duplicate trade within an open episode is the idempotent no-op and counts as applied"
4502        );
4503        assert_eq!(position.event_count(), 1);
4504        assert_eq!(position.trade_ids(), vec![fill.trade_id]);
4505        assert_eq!(position.commissions(), vec![Money::from("1 USD")]);
4506    }
4507
4508    #[rstest]
4509    fn flat_position_with_reused_trade_id_is_ignored_like_live() {
4510        // Live `Position::apply_fill` ignores a fill whose trade id already sits in
4511        // the position's carried replay history, so replay must not skip it early or
4512        // reopen the position either: `apply` ignores it and state matches live.
4513        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4514        let position_id = PositionId::from("P-REOPEN");
4515        let open_fill = OrderFilledSpec::builder()
4516            .instrument_id(instrument.id())
4517            .position_id(position_id)
4518            .order_side(OrderSide::Buy)
4519            .trade_id(TradeId::from("T-1"))
4520            .commission(Money::from("2 USD"))
4521            .build();
4522        let close_fill = OrderFilledSpec::builder()
4523            .instrument_id(instrument.id())
4524            .position_id(position_id)
4525            .order_side(OrderSide::Sell)
4526            .trade_id(TradeId::from("T-2"))
4527            .build();
4528        let mut position = Position::new(&instrument, open_fill);
4529        position.apply(&close_fill);
4530        assert_eq!(position.side, PositionSide::Flat);
4531
4532        let dup_fill = OrderFilledSpec::builder()
4533            .instrument_id(instrument.id())
4534            .position_id(position_id)
4535            .order_side(OrderSide::Buy)
4536            .trade_id(TradeId::from("T-1"))
4537            .commission(Money::from("1 USD"))
4538            .build();
4539        let entry = append_order_event(3, &OrderEventAny::Filled(dup_fill.clone())).entry;
4540        let mut cache = Cache::default();
4541        cache
4542            .add_position(&position, OmsType::Unspecified)
4543            .expect("seed position");
4544
4545        let applied = apply_fill_to_position(&mut cache, &entry, &dup_fill, false).expect("apply");
4546        let position = cache
4547            .position_owned(&position_id)
4548            .expect("position updated");
4549
4550        assert!(
4551            applied,
4552            "a historical duplicate is the idempotent no-op and counts as applied"
4553        );
4554        assert_eq!(position.side, PositionSide::Flat);
4555        assert_eq!(position.event_count(), 2);
4556        assert_eq!(position.trade_ids().len(), 2);
4557        assert_eq!(position.commissions(), vec![Money::from("2 USD")]);
4558    }
4559
4560    #[rstest]
4561    fn fill_for_missing_instrument_is_counted_as_ignored() {
4562        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4563        let position_id = PositionId::from("P-NO-INSTR");
4564        let fill = OrderFilledSpec::builder()
4565            .instrument_id(instrument.id())
4566            .position_id(position_id)
4567            .build();
4568        let entry = append_order_event(1, &OrderEventAny::Filled(fill.clone())).entry;
4569        let mut cache = Cache::default();
4570
4571        let applied = apply_fill_to_position(&mut cache, &entry, &fill, false).expect("apply");
4572
4573        assert!(
4574            !applied,
4575            "a position that cannot open must count as ignored, was claimed applied"
4576        );
4577        assert!(cache.position_owned(&position_id).is_none());
4578    }
4579
4580    #[rstest]
4581    fn corrupt_supported_payload_returns_decode_error() {
4582        let reader = reader_with_entries(
4583            "run-decode-error",
4584            &[append_payload(
4585                1,
4586                PAYLOAD_TYPE_ACCOUNT_STATE,
4587                Bytes::copy_from_slice(&[0xc1]),
4588            )],
4589        );
4590        let mut cache = Cache::default();
4591
4592        let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("decode error");
4593
4594        match err {
4595            CacheReplayError::Decode {
4596                seq, payload_type, ..
4597            } => {
4598                assert_eq!(seq, 1);
4599                assert_eq!(payload_type, PAYLOAD_TYPE_ACCOUNT_STATE);
4600            }
4601            other => panic!("expected Decode, was {other:?}"),
4602        }
4603    }
4604
4605    #[rstest]
4606    fn missing_order_event_returns_apply_error() {
4607        let submitted = OrderSubmittedSpec::builder().build();
4608        let reader = reader_with_entries(
4609            "run-apply-error",
4610            &[append_order_event(1, &OrderEventAny::Submitted(submitted))],
4611        );
4612        let mut cache = Cache::default();
4613
4614        let err = replay_cache_snapshot_tail(&mut cache, &reader).expect_err("apply error");
4615
4616        match err {
4617            CacheReplayError::Apply {
4618                seq,
4619                payload_type,
4620                message,
4621            } => {
4622                assert_eq!(seq, 1);
4623                assert_eq!(payload_type, PAYLOAD_TYPE_ORDER_SUBMITTED);
4624                assert!(
4625                    message.contains("not found"),
4626                    "message should include cache apply failure: {message}",
4627                );
4628            }
4629            other => panic!("expected Apply, was {other:?}"),
4630        }
4631    }
4632
4633    #[rstest]
4634    fn restore_cache_from_sealed_run_restores_snapshot_and_tail() {
4635        let tmp = TempDir::new().expect("tempdir");
4636        let run_id = "sealed-replay";
4637        let instance_id = "trader-001";
4638        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4639        let fill = OrderFilledSpec::builder()
4640            .instrument_id(instrument.id())
4641            .position_id(PositionId::from("P-SEALED-REPLAY-1"))
4642            .build();
4643        let position = Position::new(&instrument, fill);
4644        let mut snapshot_cache = Cache::default();
4645        let snapshot_ref = snapshot_cache
4646            .snapshot_position_encoded(&position)
4647            .expect("snapshot position");
4648        let anchored_state = cash_account_state_million_usd("100 USD", "0 USD", "100 USD");
4649        let replayed_state = cash_account_state_million_usd("200 USD", "0 USD", "200 USD");
4650
4651        {
4652            let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4653            backend.open_run(manifest(run_id)).expect("open run");
4654            backend
4655                .append_batch(&[append_account_state(1, &anchored_state)])
4656                .expect("append anchored state");
4657            backend
4658                .record_snapshot_anchor(SnapshotAnchor::new(
4659                    1,
4660                    snapshot_ref.blob_ref.clone(),
4661                    compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
4662                ))
4663                .expect("record snapshot anchor");
4664            backend
4665                .append_batch(&[append_account_state(2, &replayed_state)])
4666                .expect("append replay tail");
4667            backend.seal(RunStatus::Ended).expect("seal run");
4668        }
4669
4670        let mut cache = Cache::default();
4671        cache
4672            .add(&snapshot_ref.blob_ref, snapshot_ref.blob.clone())
4673            .expect("seed snapshot blob");
4674
4675        let report = restore_cache_from_sealed_run(
4676            &mut cache,
4677            tmp.path().to_path_buf(),
4678            instance_id,
4679            run_id,
4680        )
4681        .expect("restore sealed run");
4682
4683        let frames = cache
4684            .position_snapshot_bytes(&position.id)
4685            .expect("restored position snapshot");
4686        let account = cache
4687            .account_owned(&replayed_state.account_id)
4688            .expect("replayed account");
4689
4690        assert_eq!(report.manifest.run_id, run_id);
4691        assert_eq!(report.manifest.status, RunStatus::Ended);
4692        assert_eq!(report.cache.plan.from_seq, 2);
4693        assert_eq!(report.cache.applied_entries, 1);
4694        assert_eq!(report.cache.ignored_entries, 0);
4695        assert_eq!(frames.len(), 1);
4696        assert_eq!(frames[0].as_slice(), snapshot_ref.blob.as_ref());
4697        assert_eq!(account.events(), vec![replayed_state]);
4698    }
4699
4700    #[rstest]
4701    fn restore_cache_from_sealed_run_rejects_snapshot_hash_mismatch() {
4702        let tmp = TempDir::new().expect("tempdir");
4703        let run_id = "sealed-replay-bad-snapshot";
4704        let instance_id = "trader-001";
4705        let instrument = InstrumentAny::CurrencyPair(audusd_sim());
4706        let fill = OrderFilledSpec::builder()
4707            .instrument_id(instrument.id())
4708            .position_id(PositionId::from("P-SEALED-REPLAY-BAD-SNAPSHOT-1"))
4709            .build();
4710        let position = Position::new(&instrument, fill);
4711        let mut snapshot_cache = Cache::default();
4712        let snapshot_ref = snapshot_cache
4713            .snapshot_position_encoded(&position)
4714            .expect("snapshot position");
4715
4716        {
4717            let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4718            backend.open_run(manifest(run_id)).expect("open run");
4719            backend
4720                .record_snapshot_anchor(SnapshotAnchor::new(
4721                    0,
4722                    snapshot_ref.blob_ref.clone(),
4723                    compute_snapshot_content_hash(snapshot_ref.blob.as_ref()),
4724                ))
4725                .expect("record snapshot anchor");
4726            backend.seal(RunStatus::Ended).expect("seal run");
4727        }
4728
4729        let mut cache = Cache::default();
4730        cache
4731            .add(
4732                &snapshot_ref.blob_ref,
4733                Bytes::from_static(b"tampered snapshot"),
4734            )
4735            .expect("seed tampered snapshot blob");
4736
4737        let err = restore_cache_from_sealed_run(
4738            &mut cache,
4739            tmp.path().to_path_buf(),
4740            instance_id,
4741            run_id,
4742        )
4743        .expect_err("hash mismatch");
4744
4745        match err {
4746            CacheReplayError::SnapshotRestore { blob_ref, message } => {
4747                assert_eq!(blob_ref, snapshot_ref.blob_ref);
4748                assert!(
4749                    message.contains("content_hash mismatch"),
4750                    "message should explain hash mismatch: {message}",
4751                );
4752            }
4753            other => panic!("expected SnapshotRestore, was {other:?}"),
4754        }
4755    }
4756
4757    #[rstest]
4758    fn open_event_store_replay_source_rejects_running_run() {
4759        let tmp = TempDir::new().expect("tempdir");
4760        let run_id = "running-replay";
4761        {
4762            let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4763            backend.open_run(manifest(run_id)).expect("open run");
4764        }
4765
4766        let err = open_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
4767            .expect_err("running source must fail");
4768
4769        assert!(
4770            err.to_string().contains("not sealed"),
4771            "error should name sealed-run requirement: {err}",
4772        );
4773    }
4774
4775    #[rstest]
4776    fn validate_event_store_replay_source_rejects_quarantined_run() {
4777        let tmp = TempDir::new().expect("tempdir");
4778        let run_id = "quarantined-replay";
4779        {
4780            let mut backend = RedbBackend::new(tmp.path().to_path_buf());
4781            backend.open_run(manifest(run_id)).expect("open run");
4782            backend
4783                .append_batch(&[append_payload(1, "RunStarted", Bytes::new())])
4784                .expect("append");
4785            backend.seal(RunStatus::Quarantined).expect("seal run");
4786        }
4787
4788        let err =
4789            validate_event_store_replay_source(tmp.path().to_path_buf(), "trader-001", run_id)
4790                .expect_err("quarantined source must fail");
4791
4792        assert!(
4793            err.to_string().contains("quarantined"),
4794            "error should reject quarantined replay sources: {err}",
4795        );
4796    }
4797}