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