1use std::collections::BTreeMap;
8
9use chrono::NaiveDateTime;
10use qs_core::{
11 CloseReason, EffectiveStop, EntryLevelResolution, ExecutionFill, ExecutionModel,
12 ManagementProfile, OrderType, PriceQuote, Side, TargetResolution,
13};
14use qs_instruments::{
15 Decimal, GridAdjustment, InstrumentSpec, Money, ResolvedInstrumentRef, StoredSeriesBinding,
16};
17use serde::{Deserialize, Serialize};
18
19use crate::currency::{ConversionResult, RunCurrencyPlan};
20use crate::ledger::{ActionDispositionStatus, LifecycleLedger};
21use crate::mtm::MtmOutputSummary;
22use crate::portfolio::EquityPoint;
23
24pub const FUTURE_ARTIFACT_FORMAT_VERSION: u32 = 1;
26
27pub const DEFAULT_PNL_EPSILON: f64 = 1.0e-9;
29
30fn default_format_version() -> u32 {
31 FUTURE_ARTIFACT_FORMAT_VERSION
32}
33
34fn default_pnl_epsilon() -> f64 {
35 DEFAULT_PNL_EPSILON
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct ReplayInstrumentArtifact {
42 pub resolved: ResolvedInstrumentRef,
43 pub spec: InstrumentSpec,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(default, deny_unknown_fields)]
49pub struct ReplayInstrumentManifest {
50 pub instruments: BTreeMap<String, ReplayInstrumentArtifact>,
51 pub stored_series: Vec<StoredSeriesBinding>,
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case", deny_unknown_fields)]
57pub enum MarketEntrySizingBasis {
58 #[default]
59 FillPrice,
60 SignalEntryPrice,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct MarketEntrySizingAudit {
67 pub action_id: String,
68 pub trade_id: Option<String>,
69 pub configured_basis: MarketEntrySizingBasis,
70 pub applied_basis: MarketEntrySizingBasis,
71 pub fallback_to_fill: bool,
72 pub original_signal_price: Option<f64>,
73 pub sizing_reference_price: f64,
74 pub execution_price: f64,
75 pub protective_stop: Option<f64>,
76 pub requested_account_risk: Option<f64>,
77 pub native_loss_per_lot: Option<f64>,
78 pub account_loss_per_lot: Option<f64>,
79 pub final_lot: f64,
80 #[serde(default)]
83 pub levels_crossed_at_fill: Vec<String>,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum EntryProfileSelectionSource {
90 Mapped,
91 RunDefault,
92 Unprofiled,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum EntryResolutionStage {
99 MarketExecution,
100 PendingPlacement,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct EntryProfileResolutionAudit {
107 pub action_id: String,
108 pub trade_id: Option<String>,
109 pub entry_class: Option<String>,
110 pub selection_source: EntryProfileSelectionSource,
111 pub selected_profile_name: Option<String>,
112 pub resolution_stage: EntryResolutionStage,
113 pub original_signal_price: Option<f64>,
114 pub level_reference_price: Option<f64>,
115 pub level_resolution: Option<EntryLevelResolution>,
116 pub target_resolution: Option<TargetResolution>,
117 #[serde(default)]
118 pub configured_weights: Vec<f64>,
119 #[serde(default)]
120 pub allocated_target_steps: Vec<u64>,
121 #[serde(default)]
122 pub remainder_steps: u64,
123 pub outcome: ActionDispositionStatus,
124 pub rejection_stage: Option<String>,
125 pub reason: Option<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct InstrumentSizingArtifact {
132 pub symbol: String,
133 pub operation_ts: NaiveDateTime,
134 pub quantity: GridAdjustment<Decimal>,
135 pub final_notional: Option<Money>,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(default)]
141pub struct ExecutionMetadata {
142 pub run_id: Option<String>,
143 pub execution_model: ExecutionModel,
144 pub initial_balance: f64,
145 pub account_currency: Option<String>,
146 pub currency_plan: Option<RunCurrencyPlan>,
148 pub contract_sizes: BTreeMap<String, f64>,
150 pub instrument_manifest: Option<ReplayInstrumentManifest>,
152 pub instrument_sizing: Vec<InstrumentSizingArtifact>,
154 pub market_entry_sizing_basis: MarketEntrySizingBasis,
156 pub market_entry_sizing: Vec<MarketEntrySizingAudit>,
158 pub entry_profile_default: Option<ManagementProfile>,
160 pub entry_profile_routes: BTreeMap<String, ManagementProfile>,
162 pub entry_profile_resolutions: Vec<EntryProfileResolutionAudit>,
164 pub stale_quote_after_millis: Option<i64>,
166 #[serde(default = "default_pnl_epsilon")]
167 pub pnl_epsilon: f64,
168 pub tags: BTreeMap<String, String>,
170}
171
172impl Default for ExecutionMetadata {
173 fn default() -> Self {
174 Self {
175 run_id: None,
176 execution_model: ExecutionModel::default(),
177 initial_balance: 0.0,
178 account_currency: None,
179 currency_plan: None,
180 contract_sizes: BTreeMap::new(),
181 instrument_manifest: None,
182 instrument_sizing: Vec::new(),
183 market_entry_sizing_basis: MarketEntrySizingBasis::default(),
184 market_entry_sizing: Vec::new(),
185 entry_profile_default: None,
186 entry_profile_routes: BTreeMap::new(),
187 entry_profile_resolutions: Vec::new(),
188 stale_quote_after_millis: None,
189 pnl_epsilon: DEFAULT_PNL_EPSILON,
190 tags: BTreeMap::new(),
191 }
192 }
193}
194
195pub fn deterministic_event_id(scope: &str, kind: &str, sequence: u64) -> String {
200 format!("{scope}:{kind}:{sequence:08}")
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct RecordedFill {
207 pub id: String,
208 #[serde(default)]
209 pub action_id: Option<String>,
210 pub position_id: String,
211 pub symbol: String,
212 #[serde(default)]
214 pub signal_ts: Option<NaiveDateTime>,
215 pub effective_ts: NaiveDateTime,
217 #[serde(default)]
219 pub execution_ts: Option<NaiveDateTime>,
220 pub quote_ts: NaiveDateTime,
222 #[serde(default)]
224 pub quote_age_millis: Option<i64>,
225 pub size: f64,
226 pub bid: f64,
227 pub ask: f64,
228 pub fill: ExecutionFill,
230}
231
232impl RecordedFill {
233 #[allow(clippy::too_many_arguments)]
234 pub fn from_quote(
235 position_id: impl Into<String>,
236 action_id: Option<String>,
237 sequence: u64,
238 signal_ts: Option<NaiveDateTime>,
239 effective_ts: NaiveDateTime,
240 size: f64,
241 quote: &PriceQuote,
242 fill: ExecutionFill,
243 ) -> Self {
244 Self::from_quote_at(
245 position_id,
246 action_id,
247 sequence,
248 signal_ts,
249 effective_ts,
250 quote.ts,
251 size,
252 quote,
253 fill,
254 )
255 }
256
257 #[allow(clippy::too_many_arguments)]
258 pub fn from_quote_at(
259 position_id: impl Into<String>,
260 action_id: Option<String>,
261 sequence: u64,
262 signal_ts: Option<NaiveDateTime>,
263 effective_ts: NaiveDateTime,
264 execution_ts: NaiveDateTime,
265 size: f64,
266 quote: &PriceQuote,
267 fill: ExecutionFill,
268 ) -> Self {
269 let position_id = position_id.into();
270 Self {
271 id: deterministic_event_id(&position_id, "fill", sequence),
272 action_id,
273 position_id,
274 symbol: quote.symbol.clone(),
275 signal_ts,
276 effective_ts,
277 execution_ts: Some(execution_ts),
278 quote_ts: quote.ts,
279 quote_age_millis: Some(
280 execution_ts
281 .signed_duration_since(quote.ts)
282 .num_milliseconds(),
283 ),
284 size,
285 bid: quote.bid,
286 ask: quote.ask,
287 fill,
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(default)]
295pub struct CloseEvent {
296 pub id: String,
297 pub action_id: Option<String>,
298 pub fill_id: Option<String>,
299 pub position_id: String,
300 pub symbol: String,
301 pub side: Side,
302 pub ts: NaiveDateTime,
303 pub size: f64,
304 pub price: f64,
305 #[serde(default)]
307 pub entry_price: Option<f64>,
308 pub pnl: f64,
309 #[serde(default)]
310 pub native_pnl: Option<f64>,
311 #[serde(default)]
312 pub native_currency: Option<String>,
313 #[serde(default)]
314 pub pnl_conversion: Option<ConversionResult>,
315 pub reason: CloseReason,
316 pub remaining_size: Option<f64>,
318}
319
320impl Default for CloseEvent {
321 fn default() -> Self {
322 Self {
323 id: String::new(),
324 action_id: None,
325 fill_id: None,
326 position_id: String::new(),
327 symbol: String::new(),
328 side: Side::Buy,
329 ts: NaiveDateTime::default(),
330 size: 0.0,
331 price: 0.0,
332 entry_price: None,
333 pnl: 0.0,
334 native_pnl: None,
335 native_currency: None,
336 pnl_conversion: None,
337 reason: CloseReason::Manual,
338 remaining_size: None,
339 }
340 }
341}
342
343impl CloseEvent {
344 #[allow(clippy::too_many_arguments)]
345 pub fn new(
346 position_id: impl Into<String>,
347 sequence: u64,
348 symbol: impl Into<String>,
349 side: Side,
350 ts: NaiveDateTime,
351 size: f64,
352 price: f64,
353 pnl: f64,
354 reason: CloseReason,
355 ) -> Self {
356 let position_id = position_id.into();
357 Self {
358 id: deterministic_event_id(&position_id, "close", sequence),
359 position_id,
360 symbol: symbol.into(),
361 side,
362 ts,
363 size,
364 price,
365 pnl,
366 native_pnl: Some(pnl),
367 reason,
368 ..Self::default()
369 }
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
375#[serde(rename_all = "snake_case")]
376pub enum RiskBasisStatus {
377 Available,
378 Partial,
380 #[default]
381 MissingStop,
382 InvalidInput,
383 NonProtectiveStop,
384 ZeroRisk,
385}
386
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
389#[serde(default)]
390pub struct RiskTranche {
391 pub fill_id: Option<String>,
392 pub size: f64,
393 pub entry_price: f64,
394 pub initial_stop: Option<f64>,
395 pub contract_size: f64,
396 pub risk_per_unit: Option<f64>,
397 pub risk_amount: Option<f64>,
398 #[serde(default)]
399 pub native_risk_amount: Option<f64>,
400 #[serde(default)]
401 pub native_currency: Option<String>,
402 #[serde(default)]
403 pub risk_conversion: Option<ConversionResult>,
404 pub status: RiskBasisStatus,
405}
406
407impl Default for RiskTranche {
408 fn default() -> Self {
409 Self {
410 fill_id: None,
411 size: 0.0,
412 entry_price: 0.0,
413 initial_stop: None,
414 contract_size: 1.0,
415 risk_per_unit: None,
416 risk_amount: None,
417 native_risk_amount: None,
418 native_currency: None,
419 risk_conversion: None,
420 status: RiskBasisStatus::MissingStop,
421 }
422 }
423}
424
425impl RiskTranche {
426 pub fn calculate(
427 fill_id: Option<String>,
428 side: Side,
429 size: f64,
430 entry_price: f64,
431 initial_stop: Option<f64>,
432 contract_size: f64,
433 epsilon: f64,
434 ) -> Self {
435 let mut tranche = Self {
436 fill_id,
437 size,
438 entry_price,
439 initial_stop,
440 contract_size,
441 ..Self::default()
442 };
443 let epsilon = normalized_epsilon(epsilon);
444
445 if !size.is_finite()
446 || size <= 0.0
447 || !entry_price.is_finite()
448 || !contract_size.is_finite()
449 || contract_size <= 0.0
450 {
451 tranche.status = RiskBasisStatus::InvalidInput;
452 return tranche;
453 }
454
455 let Some(stop) = initial_stop else {
456 return tranche;
457 };
458 if !stop.is_finite() {
459 tranche.status = RiskBasisStatus::InvalidInput;
460 return tranche;
461 }
462
463 let signed_distance = match side {
464 Side::Buy => entry_price - stop,
465 Side::Sell => stop - entry_price,
466 };
467 if signed_distance < -epsilon {
468 tranche.status = RiskBasisStatus::NonProtectiveStop;
469 return tranche;
470 }
471 if signed_distance.abs() <= epsilon {
472 tranche.status = RiskBasisStatus::ZeroRisk;
473 tranche.risk_per_unit = Some(0.0);
474 tranche.risk_amount = Some(0.0);
475 tranche.native_risk_amount = Some(0.0);
476 return tranche;
477 }
478
479 tranche.status = RiskBasisStatus::Available;
480 tranche.risk_per_unit = Some(signed_distance);
481 let native_risk = signed_distance * size * contract_size;
482 tranche.risk_amount = Some(native_risk);
483 tranche.native_risk_amount = Some(native_risk);
484 tranche
485 }
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
490#[serde(rename_all = "snake_case")]
491pub enum NetPnlOutcome {
492 Win,
493 Loss,
494 #[default]
495 Breakeven,
496}
497
498#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
500#[serde(default)]
501pub struct CompletedPosition {
502 pub position_id: String,
503 pub symbol: String,
504 pub side: Side,
505 pub group: Option<String>,
506 pub trade_id: Option<String>,
507 pub open_ts: NaiveDateTime,
508 pub close_ts: NaiveDateTime,
509 pub entry_size: f64,
510 pub average_entry_price: f64,
511 pub net_pnl: f64,
512 #[serde(default)]
513 pub native_net_pnl: Option<f64>,
514 #[serde(default)]
515 pub native_currency: Option<String>,
516 pub outcome: NetPnlOutcome,
517 #[serde(default = "default_pnl_epsilon")]
518 pub pnl_epsilon: f64,
519 pub initial_stop: Option<f64>,
520 pub effective_stop: Option<EffectiveStop>,
521 pub risk_basis_status: RiskBasisStatus,
522 pub risk_tranches: Vec<RiskTranche>,
523 pub realized_r: Option<f64>,
525 pub mae: Option<f64>,
527 pub mfe: Option<f64>,
529 pub close_reasons: Vec<CloseReason>,
531 pub close_events: Vec<CloseEvent>,
532}
533
534impl Default for CompletedPosition {
535 fn default() -> Self {
536 Self {
537 position_id: String::new(),
538 symbol: String::new(),
539 side: Side::Buy,
540 group: None,
541 trade_id: None,
542 open_ts: NaiveDateTime::default(),
543 close_ts: NaiveDateTime::default(),
544 entry_size: 0.0,
545 average_entry_price: 0.0,
546 net_pnl: 0.0,
547 native_net_pnl: None,
548 native_currency: None,
549 outcome: NetPnlOutcome::Breakeven,
550 pnl_epsilon: DEFAULT_PNL_EPSILON,
551 initial_stop: None,
552 effective_stop: None,
553 risk_basis_status: RiskBasisStatus::MissingStop,
554 risk_tranches: Vec::new(),
555 realized_r: None,
556 mae: None,
557 mfe: None,
558 close_reasons: Vec::new(),
559 close_events: Vec::new(),
560 }
561 }
562}
563
564impl CompletedPosition {
565 #[allow(clippy::too_many_arguments)]
566 pub fn from_close_events(
567 position_id: impl Into<String>,
568 symbol: impl Into<String>,
569 side: Side,
570 open_ts: NaiveDateTime,
571 close_ts: NaiveDateTime,
572 entry_size: f64,
573 average_entry_price: f64,
574 initial_stop: Option<f64>,
575 effective_stop: Option<EffectiveStop>,
576 risk_tranches: Vec<RiskTranche>,
577 close_events: Vec<CloseEvent>,
578 mae: Option<f64>,
579 mfe: Option<f64>,
580 epsilon: f64,
581 ) -> Self {
582 let epsilon = normalized_epsilon(epsilon);
583 let net_pnl = close_events.iter().map(|event| event.pnl).sum();
584 let native_net_pnl = close_events.iter().try_fold(0.0, |total, event| {
585 event.native_pnl.map(|native_pnl| total + native_pnl)
586 });
587 let native_currency = close_events
588 .first()
589 .and_then(|event| event.native_currency.clone())
590 .filter(|currency| {
591 close_events
592 .iter()
593 .all(|event| event.native_currency.as_ref() == Some(currency))
594 });
595 let close_reasons = distinct_close_reasons(&close_events);
596 let (risk_basis_status, initial_risk) = summarize_risk(&risk_tranches, epsilon);
597 let realized_r = initial_risk
598 .filter(|risk| *risk > epsilon)
599 .map(|risk| net_pnl / risk);
600
601 Self {
602 position_id: position_id.into(),
603 symbol: symbol.into(),
604 side,
605 open_ts,
606 close_ts,
607 entry_size,
608 average_entry_price,
609 net_pnl,
610 native_net_pnl,
611 native_currency,
612 outcome: Self::classify(net_pnl, epsilon),
613 pnl_epsilon: epsilon,
614 initial_stop,
615 effective_stop,
616 risk_basis_status,
617 risk_tranches,
618 realized_r,
619 mae,
620 mfe,
621 close_reasons,
622 close_events,
623 ..Self::default()
624 }
625 }
626
627 pub fn classify(net_pnl: f64, epsilon: f64) -> NetPnlOutcome {
628 let epsilon = normalized_epsilon(epsilon);
629 if net_pnl > epsilon {
630 NetPnlOutcome::Win
631 } else if net_pnl < -epsilon {
632 NetPnlOutcome::Loss
633 } else {
634 NetPnlOutcome::Breakeven
635 }
636 }
637
638 pub fn initial_risk(&self) -> Option<f64> {
639 summarize_risk(&self.risk_tranches, self.pnl_epsilon).1
640 }
641}
642
643fn normalized_epsilon(epsilon: f64) -> f64 {
644 if epsilon.is_finite() {
645 epsilon.abs()
646 } else {
647 DEFAULT_PNL_EPSILON
648 }
649}
650
651fn distinct_close_reasons(events: &[CloseEvent]) -> Vec<CloseReason> {
652 let mut reasons = Vec::new();
653 for event in events {
654 if !reasons.contains(&event.reason) {
655 reasons.push(event.reason);
656 }
657 }
658 reasons
659}
660
661fn summarize_risk(tranches: &[RiskTranche], epsilon: f64) -> (RiskBasisStatus, Option<f64>) {
662 if tranches.is_empty() {
663 return (RiskBasisStatus::MissingStop, None);
664 }
665
666 let available = tranches
667 .iter()
668 .filter(|tranche| tranche.status == RiskBasisStatus::Available)
669 .count();
670 if available == tranches.len() {
671 let total: f64 = tranches
672 .iter()
673 .filter_map(|tranche| tranche.risk_amount)
674 .sum();
675 if !total.is_finite() {
676 return (RiskBasisStatus::InvalidInput, None);
677 }
678 if total <= epsilon {
679 return (RiskBasisStatus::ZeroRisk, None);
680 }
681 return (RiskBasisStatus::Available, Some(total));
682 }
683 if available > 0 {
684 return (RiskBasisStatus::Partial, None);
685 }
686
687 let status = tranches
688 .iter()
689 .map(|tranche| tranche.status)
690 .find(|status| *status != RiskBasisStatus::MissingStop)
691 .unwrap_or(RiskBasisStatus::MissingStop);
692 (status, None)
693}
694
695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
700#[serde(default)]
701pub struct OpenPositionSnapshot {
702 pub position_id: String,
703 pub symbol: String,
704 pub side: Side,
705 pub group: Option<String>,
706 pub trade_id: Option<String>,
707 pub open_ts: Option<NaiveDateTime>,
708 pub average_entry_price: f64,
709 pub remaining_size: f64,
710 pub initial_stop: Option<f64>,
711 pub effective_stop: Option<EffectiveStop>,
712 pub realized_pnl: f64,
714 #[serde(default)]
715 pub native_realized_pnl: Option<f64>,
716 #[serde(default)]
717 pub native_currency: Option<String>,
718 #[serde(default)]
719 pub account_currency: Option<String>,
720 pub quote_ts: Option<NaiveDateTime>,
721 pub mark_price: Option<f64>,
722 pub unrealized_pnl: Option<f64>,
723 #[serde(default)]
724 pub native_unrealized_pnl: Option<f64>,
725 #[serde(default)]
726 pub unrealized_pnl_conversion: Option<ConversionResult>,
727 pub gross_exposure: Option<f64>,
728 #[serde(default)]
729 pub native_signed_exposure: Option<f64>,
730 #[serde(default)]
731 pub gross_exposure_conversion: Option<ConversionResult>,
732 pub open_risk: Option<f64>,
733 #[serde(default)]
734 pub native_open_risk: Option<f64>,
735 #[serde(default)]
736 pub open_risk_conversion: Option<ConversionResult>,
737 pub campaign_mae: Option<f64>,
738 pub campaign_mfe: Option<f64>,
739}
740
741impl Default for OpenPositionSnapshot {
742 fn default() -> Self {
743 Self {
744 position_id: String::new(),
745 symbol: String::new(),
746 side: Side::Buy,
747 group: None,
748 trade_id: None,
749 open_ts: None,
750 average_entry_price: 0.0,
751 remaining_size: 0.0,
752 initial_stop: None,
753 effective_stop: None,
754 realized_pnl: 0.0,
755 native_realized_pnl: None,
756 native_currency: None,
757 account_currency: None,
758 quote_ts: None,
759 mark_price: None,
760 unrealized_pnl: None,
761 native_unrealized_pnl: None,
762 unrealized_pnl_conversion: None,
763 gross_exposure: None,
764 native_signed_exposure: None,
765 gross_exposure_conversion: None,
766 open_risk: None,
767 native_open_risk: None,
768 open_risk_conversion: None,
769 campaign_mae: None,
770 campaign_mfe: None,
771 }
772 }
773}
774
775impl OpenPositionSnapshot {
776 pub fn new(
777 position_id: impl Into<String>,
778 symbol: impl Into<String>,
779 side: Side,
780 average_entry_price: f64,
781 remaining_size: f64,
782 ) -> Self {
783 Self {
784 position_id: position_id.into(),
785 symbol: symbol.into(),
786 side,
787 average_entry_price,
788 remaining_size,
789 ..Self::default()
790 }
791 }
792
793 pub(crate) fn clear_mark(&mut self) {
794 self.quote_ts = None;
795 self.mark_price = None;
796 self.unrealized_pnl = None;
797 self.native_unrealized_pnl = None;
798 self.unrealized_pnl_conversion = None;
799 self.gross_exposure = None;
800 self.native_signed_exposure = None;
801 self.gross_exposure_conversion = None;
802 self.open_risk = None;
803 self.native_open_risk = None;
804 self.open_risk_conversion = None;
805 self.campaign_mae = None;
806 self.campaign_mfe = None;
807 }
808}
809
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
812#[serde(rename_all = "snake_case")]
813pub enum PendingOrderLifecycleState {
814 #[default]
815 Placed,
816 Filled,
817 Cancelled,
818 UnfilledAtEnd,
819}
820
821impl PendingOrderLifecycleState {
822 pub fn is_terminal(self) -> bool {
824 !matches!(self, Self::Placed)
825 }
826}
827
828#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
834#[serde(default)]
835pub struct PendingOrderLifecycleEvent {
836 pub id: String,
837 pub sequence: u64,
838 pub position_id: String,
839 pub placement_action_id: Option<String>,
840 pub terminal_action_id: Option<String>,
841 pub state: PendingOrderLifecycleState,
842 pub symbol: String,
843 pub side: Side,
844 pub order_type: OrderType,
845 pub requested_size: f64,
846 pub filled_size: Option<f64>,
847 pub requested_price: Option<f64>,
848 pub fill_price: Option<f64>,
849 pub signal_ts: Option<NaiveDateTime>,
850 pub placed_ts: Option<NaiveDateTime>,
851 pub effective_ts: Option<NaiveDateTime>,
852 pub terminal_ts: Option<NaiveDateTime>,
853 pub wait_latency_ms: Option<i64>,
854 pub fill_ratio: Option<f64>,
855}
856
857impl Default for PendingOrderLifecycleEvent {
858 fn default() -> Self {
859 Self {
860 id: String::new(),
861 sequence: 0,
862 position_id: String::new(),
863 placement_action_id: None,
864 terminal_action_id: None,
865 state: PendingOrderLifecycleState::Placed,
866 symbol: String::new(),
867 side: Side::Buy,
868 order_type: OrderType::Limit,
869 requested_size: 0.0,
870 filled_size: None,
871 requested_price: None,
872 fill_price: None,
873 signal_ts: None,
874 placed_ts: None,
875 effective_ts: None,
876 terminal_ts: None,
877 wait_latency_ms: None,
878 fill_ratio: None,
879 }
880 }
881}
882
883#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
885#[serde(default)]
886pub struct PendingOrderSnapshot {
887 pub position_id: String,
888 pub action_id: Option<String>,
889 pub symbol: String,
890 pub side: Side,
891 pub order_type: OrderType,
892 pub requested_price: Option<f64>,
893 pub size: f64,
894 pub signal_ts: Option<NaiveDateTime>,
895 pub effective_ts: Option<NaiveDateTime>,
896 pub initial_stop: Option<f64>,
897 pub group: Option<String>,
898 pub trade_id: Option<String>,
899}
900
901impl Default for PendingOrderSnapshot {
902 fn default() -> Self {
903 Self {
904 position_id: String::new(),
905 action_id: None,
906 symbol: String::new(),
907 side: Side::Buy,
908 order_type: OrderType::Limit,
909 requested_price: None,
910 size: 0.0,
911 signal_ts: None,
912 effective_ts: None,
913 initial_stop: None,
914 group: None,
915 trade_id: None,
916 }
917 }
918}
919
920#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
922#[serde(default)]
923pub struct FutureBacktestArtifacts {
924 #[serde(default = "default_format_version")]
925 pub format_version: u32,
926 pub execution: ExecutionMetadata,
927 pub fills: Vec<RecordedFill>,
928 pub close_events: Vec<CloseEvent>,
929 pub completed_positions: Vec<CompletedPosition>,
930 pub open_positions: Vec<OpenPositionSnapshot>,
931 pub pending_orders: Vec<PendingOrderSnapshot>,
932 pub pending_order_lifecycle: Vec<PendingOrderLifecycleEvent>,
933 pub lifecycle: LifecycleLedger,
934 pub equity_curve: Vec<EquityPoint>,
935 pub mtm_output_summary: MtmOutputSummary,
936 pub max_drawdown: Option<f64>,
937 pub max_drawdown_pct: Option<f64>,
938}
939
940impl Default for FutureBacktestArtifacts {
941 fn default() -> Self {
942 Self {
943 format_version: FUTURE_ARTIFACT_FORMAT_VERSION,
944 execution: ExecutionMetadata::default(),
945 fills: Vec::new(),
946 close_events: Vec::new(),
947 completed_positions: Vec::new(),
948 open_positions: Vec::new(),
949 pending_orders: Vec::new(),
950 pending_order_lifecycle: Vec::new(),
951 lifecycle: LifecycleLedger::default(),
952 equity_curve: Vec::new(),
953 mtm_output_summary: MtmOutputSummary::default(),
954 max_drawdown: None,
955 max_drawdown_pct: None,
956 }
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use chrono::NaiveDate;
964 use qs_core::{ExecutionConvention, FillModel, FillPurpose, SlippageModel, StopOrigin};
965
966 fn ts(second: u32) -> NaiveDateTime {
967 NaiveDate::from_ymd_opt(2026, 1, 2)
968 .unwrap()
969 .and_hms_opt(3, 4, second)
970 .unwrap()
971 }
972
973 fn execution_fill(side: Side, price: f64) -> ExecutionFill {
974 ExecutionFill {
975 purpose: FillPurpose::MarketEntry,
976 side,
977 price,
978 quote_price: price,
979 requested_price: None,
980 slippage_pips: 0.0,
981 }
982 }
983
984 #[test]
985 fn execution_metadata_is_serializable_and_defaults_new_fields() {
986 let decoded: ExecutionMetadata = serde_json::from_str("{}").unwrap();
987 assert_eq!(decoded.pnl_epsilon, DEFAULT_PNL_EPSILON);
988 assert_eq!(decoded.execution_model, ExecutionModel::default());
989 assert_eq!(decoded.instrument_manifest, None);
990 assert!(decoded.instrument_sizing.is_empty());
991 assert_eq!(
992 decoded.market_entry_sizing_basis,
993 MarketEntrySizingBasis::FillPrice
994 );
995 assert!(decoded.market_entry_sizing.is_empty());
996
997 let metadata = ExecutionMetadata {
998 execution_model: ExecutionModel::new(
999 ExecutionConvention::FutureQuoteV1,
1000 FillModel::BidAsk,
1001 SlippageModel::adverse(0.2),
1002 ),
1003 initial_balance: 50_000.0,
1004 account_currency: Some("USD".into()),
1005 ..ExecutionMetadata::default()
1006 };
1007 let roundtrip: ExecutionMetadata =
1008 serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
1009 assert_eq!(roundtrip, metadata);
1010 }
1011
1012 #[test]
1013 fn recorded_fill_has_stable_id_and_quote_context() {
1014 let quote = PriceQuote {
1015 symbol: "EURUSD".into(),
1016 ts: ts(2),
1017 bid: 1.0998,
1018 ask: 1.1000,
1019 };
1020 let first = RecordedFill::from_quote(
1021 "position-7",
1022 Some("action-3".into()),
1023 4,
1024 Some(ts(0)),
1025 ts(1),
1026 0.5,
1027 "e,
1028 execution_fill(Side::Buy, 1.1000),
1029 );
1030 let second = RecordedFill::from_quote(
1031 "position-7",
1032 Some("action-3".into()),
1033 4,
1034 Some(ts(0)),
1035 ts(1),
1036 0.5,
1037 "e,
1038 execution_fill(Side::Buy, 1.1000),
1039 );
1040
1041 assert_eq!(first.id, "position-7:fill:00000004");
1042 assert_eq!(first, second);
1043 assert_eq!(first.symbol, "EURUSD");
1044 assert_eq!(first.quote_ts, ts(2));
1045 assert_eq!((first.ask - 1.1000).abs(), 0.0);
1046 }
1047
1048 #[test]
1049 fn risk_tranches_validate_direction_and_calculate_money_risk() {
1050 let long = RiskTranche::calculate(
1051 Some("fill-1".into()),
1052 Side::Buy,
1053 2.0,
1054 100.0,
1055 Some(95.0),
1056 10.0,
1057 DEFAULT_PNL_EPSILON,
1058 );
1059 assert_eq!(long.status, RiskBasisStatus::Available);
1060 assert_eq!(long.risk_per_unit, Some(5.0));
1061 assert_eq!(long.risk_amount, Some(100.0));
1062
1063 let short = RiskTranche::calculate(
1064 None,
1065 Side::Sell,
1066 1.0,
1067 100.0,
1068 Some(105.0),
1069 10.0,
1070 DEFAULT_PNL_EPSILON,
1071 );
1072 assert_eq!(short.risk_amount, Some(50.0));
1073
1074 let non_protective = RiskTranche::calculate(
1075 None,
1076 Side::Buy,
1077 1.0,
1078 100.0,
1079 Some(101.0),
1080 1.0,
1081 DEFAULT_PNL_EPSILON,
1082 );
1083 assert_eq!(non_protective.status, RiskBasisStatus::NonProtectiveStop);
1084 assert_eq!(non_protective.risk_amount, None);
1085 }
1086
1087 #[test]
1088 fn completed_position_sums_closes_classifies_and_realizes_r() {
1089 let closes = vec![
1090 CloseEvent::new(
1091 "p1",
1092 0,
1093 "XAUUSD",
1094 Side::Buy,
1095 ts(3),
1096 0.5,
1097 101.0,
1098 50.0,
1099 CloseReason::Target,
1100 ),
1101 CloseEvent::new(
1102 "p1",
1103 1,
1104 "XAUUSD",
1105 Side::Buy,
1106 ts(4),
1107 0.5,
1108 99.0,
1109 -20.0,
1110 CloseReason::Manual,
1111 ),
1112 CloseEvent::new(
1113 "p1",
1114 2,
1115 "XAUUSD",
1116 Side::Buy,
1117 ts(5),
1118 0.1,
1119 99.0,
1120 0.0,
1121 CloseReason::Manual,
1122 ),
1123 ];
1124 let risk = RiskTranche::calculate(
1125 Some("entry".into()),
1126 Side::Buy,
1127 1.0,
1128 100.0,
1129 Some(99.0),
1130 100.0,
1131 DEFAULT_PNL_EPSILON,
1132 );
1133 let completed = CompletedPosition::from_close_events(
1134 "p1",
1135 "XAUUSD",
1136 Side::Buy,
1137 ts(0),
1138 ts(5),
1139 1.0,
1140 100.0,
1141 Some(99.0),
1142 Some(EffectiveStop::new(100.0, StopOrigin::Breakeven)),
1143 vec![risk],
1144 closes,
1145 Some(-40.0),
1146 Some(70.0),
1147 DEFAULT_PNL_EPSILON,
1148 );
1149
1150 assert_eq!(completed.net_pnl, 30.0);
1151 assert_eq!(completed.outcome, NetPnlOutcome::Win);
1152 assert_eq!(completed.initial_risk(), Some(100.0));
1153 assert_eq!(completed.realized_r, Some(0.3));
1154 assert_eq!(
1155 completed.close_reasons,
1156 vec![CloseReason::Target, CloseReason::Manual]
1157 );
1158 assert_eq!(completed.mae, Some(-40.0));
1159 assert_eq!(completed.mfe, Some(70.0));
1160 }
1161
1162 #[test]
1163 fn net_pnl_outcome_uses_absolute_epsilon() {
1164 assert_eq!(
1165 CompletedPosition::classify(0.0005, 0.001),
1166 NetPnlOutcome::Breakeven
1167 );
1168 assert_eq!(
1169 CompletedPosition::classify(-0.002, -0.001),
1170 NetPnlOutcome::Loss
1171 );
1172 assert_eq!(
1173 CompletedPosition::classify(0.002, 0.001),
1174 NetPnlOutcome::Win
1175 );
1176 }
1177
1178 #[test]
1179 fn partial_risk_basis_does_not_report_misleading_r() {
1180 let valid = RiskTranche::calculate(
1181 None,
1182 Side::Buy,
1183 1.0,
1184 10.0,
1185 Some(9.0),
1186 1.0,
1187 DEFAULT_PNL_EPSILON,
1188 );
1189 let missing =
1190 RiskTranche::calculate(None, Side::Buy, 1.0, 10.0, None, 1.0, DEFAULT_PNL_EPSILON);
1191 let completed = CompletedPosition::from_close_events(
1192 "p",
1193 "S",
1194 Side::Buy,
1195 ts(0),
1196 ts(1),
1197 2.0,
1198 10.0,
1199 Some(9.0),
1200 None,
1201 vec![valid, missing],
1202 vec![CloseEvent::new(
1203 "p",
1204 0,
1205 "S",
1206 Side::Buy,
1207 ts(1),
1208 2.0,
1209 11.0,
1210 2.0,
1211 CloseReason::Manual,
1212 )],
1213 None,
1214 None,
1215 DEFAULT_PNL_EPSILON,
1216 );
1217 assert_eq!(completed.risk_basis_status, RiskBasisStatus::Partial);
1218 assert_eq!(completed.realized_r, None);
1219 }
1220
1221 #[test]
1222 fn aggregate_deserializes_additive_fields_from_empty_object() {
1223 let artifacts: FutureBacktestArtifacts = serde_json::from_str("{}").unwrap();
1224 assert_eq!(artifacts.format_version, FUTURE_ARTIFACT_FORMAT_VERSION);
1225 assert!(artifacts.fills.is_empty());
1226 assert!(artifacts.completed_positions.is_empty());
1227 assert!(artifacts.equity_curve.is_empty());
1228 assert_eq!(artifacts.mtm_output_summary, MtmOutputSummary::default());
1229 assert_eq!(artifacts.max_drawdown, None);
1230 }
1231
1232 #[test]
1233 fn snapshots_preserve_defaults_for_forward_compatible_fields() {
1234 let open: OpenPositionSnapshot = serde_json::from_str(
1235 r#"{"position_id":"p","symbol":"EURUSD","side":"Buy","average_entry_price":1.1,"remaining_size":1.0}"#,
1236 )
1237 .unwrap();
1238 assert_eq!(open.realized_pnl, 0.0);
1239 assert_eq!(open.mark_price, None);
1240 assert_eq!(open.campaign_mae, None);
1241
1242 let pending: PendingOrderSnapshot = serde_json::from_str("{}").unwrap();
1243 assert_eq!(pending.order_type, OrderType::Limit);
1244 assert_eq!(pending.initial_stop, None);
1245
1246 let lifecycle: PendingOrderLifecycleEvent = serde_json::from_str("{}").unwrap();
1247 assert_eq!(lifecycle.state, PendingOrderLifecycleState::Placed);
1248 assert_eq!(lifecycle.filled_size, None);
1249 assert_eq!(lifecycle.terminal_ts, None);
1250 }
1251}