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