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