1use std::collections::BTreeMap;
8
9use chrono::NaiveDateTime;
10use qs_core::{
11 CloseReason, CostKind, EffectiveStop, EntryLevelResolution, ExecutionFill, ExecutionModel,
12 InstrumentCosts, 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 #[serde(default)]
166 pub costs: BTreeMap<String, InstrumentCosts>,
167 #[serde(default)]
169 pub unconverted_cost_events: u64,
170 #[serde(default)]
172 pub zero_spread_bar_quotes: u64,
173 pub stale_quote_after_millis: Option<i64>,
175 #[serde(default = "default_pnl_epsilon")]
176 pub pnl_epsilon: f64,
177 pub tags: BTreeMap<String, String>,
179 #[serde(default)]
183 pub run_tags: BTreeMap<String, String>,
184 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186 pub position_tags: BTreeMap<String, BTreeMap<String, String>>,
187}
188
189impl Default for ExecutionMetadata {
190 fn default() -> Self {
191 Self {
192 run_id: None,
193 execution_model: ExecutionModel::default(),
194 initial_balance: 0.0,
195 account_currency: None,
196 currency_plan: None,
197 contract_sizes: BTreeMap::new(),
198 instrument_manifest: None,
199 instrument_sizing: Vec::new(),
200 market_entry_sizing_basis: MarketEntrySizingBasis::default(),
201 market_entry_sizing: Vec::new(),
202 entry_profile_default: None,
203 entry_profile_routes: BTreeMap::new(),
204 entry_profile_resolutions: Vec::new(),
205 costs: BTreeMap::new(),
206 unconverted_cost_events: 0,
207 zero_spread_bar_quotes: 0,
208 stale_quote_after_millis: None,
209 pnl_epsilon: DEFAULT_PNL_EPSILON,
210 tags: BTreeMap::new(),
211 run_tags: BTreeMap::new(),
212 position_tags: BTreeMap::new(),
213 }
214 }
215}
216
217pub fn deterministic_event_id(scope: &str, kind: &str, sequence: u64) -> String {
222 format!("{scope}:{kind}:{sequence:08}")
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct RecordedFill {
229 pub id: String,
230 #[serde(default)]
231 pub action_id: Option<String>,
232 pub position_id: String,
233 pub symbol: String,
234 #[serde(default)]
236 pub signal_ts: Option<NaiveDateTime>,
237 pub effective_ts: NaiveDateTime,
239 #[serde(default)]
241 pub execution_ts: Option<NaiveDateTime>,
242 pub quote_ts: NaiveDateTime,
244 #[serde(default)]
246 pub quote_age_millis: Option<i64>,
247 pub size: f64,
248 pub bid: f64,
249 pub ask: f64,
250 pub fill: ExecutionFill,
252}
253
254impl RecordedFill {
255 #[allow(clippy::too_many_arguments)]
256 pub fn from_quote(
257 position_id: impl Into<String>,
258 action_id: Option<String>,
259 sequence: u64,
260 signal_ts: Option<NaiveDateTime>,
261 effective_ts: NaiveDateTime,
262 size: f64,
263 quote: &PriceQuote,
264 fill: ExecutionFill,
265 ) -> Self {
266 Self::from_quote_at(
267 position_id,
268 action_id,
269 sequence,
270 signal_ts,
271 effective_ts,
272 quote.ts,
273 size,
274 quote,
275 fill,
276 )
277 }
278
279 #[allow(clippy::too_many_arguments)]
280 pub fn from_quote_at(
281 position_id: impl Into<String>,
282 action_id: Option<String>,
283 sequence: u64,
284 signal_ts: Option<NaiveDateTime>,
285 effective_ts: NaiveDateTime,
286 execution_ts: NaiveDateTime,
287 size: f64,
288 quote: &PriceQuote,
289 fill: ExecutionFill,
290 ) -> Self {
291 let position_id = position_id.into();
292 Self {
293 id: deterministic_event_id(&position_id, "fill", sequence),
294 action_id,
295 position_id,
296 symbol: quote.symbol.clone(),
297 signal_ts,
298 effective_ts,
299 execution_ts: Some(execution_ts),
300 quote_ts: quote.ts,
301 quote_age_millis: Some(
302 execution_ts
303 .signed_duration_since(quote.ts)
304 .num_milliseconds(),
305 ),
306 size,
307 bid: quote.bid,
308 ask: quote.ask,
309 fill,
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct CloseEvent {
318 pub id: String,
319 pub action_id: Option<String>,
320 pub fill_id: Option<String>,
321 pub position_id: String,
322 pub symbol: String,
323 pub side: Side,
324 pub ts: NaiveDateTime,
325 pub size: f64,
326 pub price: f64,
327 #[serde(default)]
329 pub entry_price: Option<f64>,
330 pub pnl: f64,
332 #[serde(default)]
334 pub commission: f64,
335 #[serde(default)]
336 pub native_pnl: Option<f64>,
337 #[serde(default)]
338 pub native_currency: Option<String>,
339 #[serde(default)]
340 pub pnl_conversion: Option<ConversionResult>,
341 pub reason: CloseReason,
342 pub remaining_size: Option<f64>,
344}
345
346impl Default for CloseEvent {
347 fn default() -> Self {
348 Self {
349 id: String::new(),
350 action_id: None,
351 fill_id: None,
352 position_id: String::new(),
353 symbol: String::new(),
354 side: Side::Buy,
355 ts: NaiveDateTime::default(),
356 size: 0.0,
357 price: 0.0,
358 entry_price: None,
359 pnl: 0.0,
360 commission: 0.0,
361 native_pnl: None,
362 native_currency: None,
363 pnl_conversion: None,
364 reason: CloseReason::Manual,
365 remaining_size: None,
366 }
367 }
368}
369
370impl CloseEvent {
371 #[allow(clippy::too_many_arguments)]
372 pub fn new(
373 position_id: impl Into<String>,
374 sequence: u64,
375 symbol: impl Into<String>,
376 side: Side,
377 ts: NaiveDateTime,
378 size: f64,
379 price: f64,
380 pnl: f64,
381 reason: CloseReason,
382 ) -> Self {
383 let position_id = position_id.into();
384 Self {
385 id: deterministic_event_id(&position_id, "close", sequence),
386 position_id,
387 symbol: symbol.into(),
388 side,
389 ts,
390 size,
391 price,
392 pnl,
393 native_pnl: Some(pnl),
394 reason,
395 ..Self::default()
396 }
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct CostEvent {
403 pub id: String,
404 pub position_id: String,
405 pub symbol: String,
406 pub side: Side,
407 pub ts: NaiveDateTime,
408 pub kind: CostKind,
409 pub amount: f64,
411 #[serde(default)]
413 pub native_amount: Option<f64>,
414 #[serde(default)]
415 pub native_currency: Option<String>,
416 #[serde(default)]
417 pub conversion: Option<ConversionResult>,
418 pub size: f64,
420 #[serde(default)]
422 pub nights: Option<u32>,
423}
424
425impl CostEvent {
426 #[allow(clippy::too_many_arguments)]
427 pub fn new(
428 position_id: impl Into<String>,
429 sequence: u64,
430 symbol: impl Into<String>,
431 side: Side,
432 ts: NaiveDateTime,
433 kind: CostKind,
434 amount: f64,
435 size: f64,
436 ) -> Self {
437 let position_id = position_id.into();
438 Self {
439 id: deterministic_event_id(&position_id, kind.as_str(), sequence),
440 position_id,
441 symbol: symbol.into(),
442 side,
443 ts,
444 kind,
445 amount,
446 native_amount: None,
447 native_currency: None,
448 conversion: None,
449 size,
450 nights: None,
451 }
452 }
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
457#[serde(rename_all = "snake_case")]
458pub enum RiskBasisStatus {
459 Available,
460 Partial,
462 #[default]
463 MissingStop,
464 InvalidInput,
465 NonProtectiveStop,
466 ZeroRisk,
467}
468
469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471#[serde(default)]
472pub struct RiskTranche {
473 pub fill_id: Option<String>,
474 pub size: f64,
475 pub entry_price: f64,
476 pub initial_stop: Option<f64>,
477 pub contract_size: f64,
478 pub risk_per_unit: Option<f64>,
479 pub risk_amount: Option<f64>,
480 #[serde(default)]
481 pub native_risk_amount: Option<f64>,
482 #[serde(default)]
483 pub native_currency: Option<String>,
484 #[serde(default)]
485 pub risk_conversion: Option<ConversionResult>,
486 pub status: RiskBasisStatus,
487}
488
489impl Default for RiskTranche {
490 fn default() -> Self {
491 Self {
492 fill_id: None,
493 size: 0.0,
494 entry_price: 0.0,
495 initial_stop: None,
496 contract_size: 1.0,
497 risk_per_unit: None,
498 risk_amount: None,
499 native_risk_amount: None,
500 native_currency: None,
501 risk_conversion: None,
502 status: RiskBasisStatus::MissingStop,
503 }
504 }
505}
506
507impl RiskTranche {
508 pub fn calculate(
509 fill_id: Option<String>,
510 side: Side,
511 size: f64,
512 entry_price: f64,
513 initial_stop: Option<f64>,
514 contract_size: f64,
515 epsilon: f64,
516 ) -> Self {
517 let mut tranche = Self {
518 fill_id,
519 size,
520 entry_price,
521 initial_stop,
522 contract_size,
523 ..Self::default()
524 };
525 let epsilon = normalized_epsilon(epsilon);
526
527 if !size.is_finite()
528 || size <= 0.0
529 || !entry_price.is_finite()
530 || !contract_size.is_finite()
531 || contract_size <= 0.0
532 {
533 tranche.status = RiskBasisStatus::InvalidInput;
534 return tranche;
535 }
536
537 let Some(stop) = initial_stop else {
538 return tranche;
539 };
540 if !stop.is_finite() {
541 tranche.status = RiskBasisStatus::InvalidInput;
542 return tranche;
543 }
544
545 let signed_distance = match side {
546 Side::Buy => entry_price - stop,
547 Side::Sell => stop - entry_price,
548 };
549 if signed_distance < -epsilon {
550 tranche.status = RiskBasisStatus::NonProtectiveStop;
551 return tranche;
552 }
553 if signed_distance.abs() <= epsilon {
554 tranche.status = RiskBasisStatus::ZeroRisk;
555 tranche.risk_per_unit = Some(0.0);
556 tranche.risk_amount = Some(0.0);
557 tranche.native_risk_amount = Some(0.0);
558 return tranche;
559 }
560
561 tranche.status = RiskBasisStatus::Available;
562 tranche.risk_per_unit = Some(signed_distance);
563 let native_risk = signed_distance * size * contract_size;
564 tranche.risk_amount = Some(native_risk);
565 tranche.native_risk_amount = Some(native_risk);
566 tranche
567 }
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
572#[serde(rename_all = "snake_case")]
573pub enum NetPnlOutcome {
574 Win,
575 Loss,
576 #[default]
577 Breakeven,
578}
579
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
582#[serde(default)]
583pub struct CompletedPosition {
584 pub position_id: String,
585 pub symbol: String,
586 pub side: Side,
587 pub group: Option<String>,
588 pub trade_id: Option<String>,
589 pub open_ts: NaiveDateTime,
590 pub close_ts: NaiveDateTime,
591 pub entry_size: f64,
592 pub average_entry_price: f64,
593 pub net_pnl: f64,
595 #[serde(default)]
597 pub gross_pnl: Option<f64>,
598 #[serde(default)]
600 pub commission_total: f64,
601 #[serde(default)]
603 pub swap_total: f64,
604 #[serde(default)]
605 pub native_net_pnl: Option<f64>,
606 #[serde(default)]
607 pub native_currency: Option<String>,
608 pub outcome: NetPnlOutcome,
609 #[serde(default = "default_pnl_epsilon")]
610 pub pnl_epsilon: f64,
611 pub initial_stop: Option<f64>,
612 pub effective_stop: Option<EffectiveStop>,
613 pub risk_basis_status: RiskBasisStatus,
614 pub risk_tranches: Vec<RiskTranche>,
615 pub realized_r: Option<f64>,
617 pub mae: Option<f64>,
619 pub mfe: Option<f64>,
621 pub close_reasons: Vec<CloseReason>,
623 pub close_events: Vec<CloseEvent>,
624}
625
626impl Default for CompletedPosition {
627 fn default() -> Self {
628 Self {
629 position_id: String::new(),
630 symbol: String::new(),
631 side: Side::Buy,
632 group: None,
633 trade_id: None,
634 open_ts: NaiveDateTime::default(),
635 close_ts: NaiveDateTime::default(),
636 entry_size: 0.0,
637 average_entry_price: 0.0,
638 net_pnl: 0.0,
639 gross_pnl: None,
640 commission_total: 0.0,
641 swap_total: 0.0,
642 native_net_pnl: None,
643 native_currency: None,
644 outcome: NetPnlOutcome::Breakeven,
645 pnl_epsilon: DEFAULT_PNL_EPSILON,
646 initial_stop: None,
647 effective_stop: None,
648 risk_basis_status: RiskBasisStatus::MissingStop,
649 risk_tranches: Vec::new(),
650 realized_r: None,
651 mae: None,
652 mfe: None,
653 close_reasons: Vec::new(),
654 close_events: Vec::new(),
655 }
656 }
657}
658
659impl CompletedPosition {
660 #[allow(clippy::too_many_arguments)]
661 pub fn from_close_events(
662 position_id: impl Into<String>,
663 symbol: impl Into<String>,
664 side: Side,
665 open_ts: NaiveDateTime,
666 close_ts: NaiveDateTime,
667 entry_size: f64,
668 average_entry_price: f64,
669 initial_stop: Option<f64>,
670 effective_stop: Option<EffectiveStop>,
671 risk_tranches: Vec<RiskTranche>,
672 close_events: Vec<CloseEvent>,
673 mae: Option<f64>,
674 mfe: Option<f64>,
675 epsilon: f64,
676 ) -> Self {
677 let epsilon = normalized_epsilon(epsilon);
678 let net_pnl = close_events.iter().map(|event| event.pnl).sum();
679 let native_net_pnl = close_events.iter().try_fold(0.0, |total, event| {
680 event.native_pnl.map(|native_pnl| total + native_pnl)
681 });
682 let native_currency = close_events
683 .first()
684 .and_then(|event| event.native_currency.clone())
685 .filter(|currency| {
686 close_events
687 .iter()
688 .all(|event| event.native_currency.as_ref() == Some(currency))
689 });
690 let close_reasons = distinct_close_reasons(&close_events);
691 let (risk_basis_status, initial_risk) = summarize_risk(&risk_tranches, epsilon);
692 let realized_r = initial_risk
693 .filter(|risk| *risk > epsilon)
694 .map(|risk| net_pnl / risk);
695
696 Self {
697 position_id: position_id.into(),
698 symbol: symbol.into(),
699 side,
700 open_ts,
701 close_ts,
702 entry_size,
703 average_entry_price,
704 net_pnl,
705 native_net_pnl,
706 native_currency,
707 outcome: Self::classify(net_pnl, epsilon),
708 pnl_epsilon: epsilon,
709 initial_stop,
710 effective_stop,
711 risk_basis_status,
712 risk_tranches,
713 realized_r,
714 mae,
715 mfe,
716 close_reasons,
717 close_events,
718 ..Self::default()
719 }
720 }
721
722 pub fn classify(net_pnl: f64, epsilon: f64) -> NetPnlOutcome {
723 let epsilon = normalized_epsilon(epsilon);
724 if net_pnl > epsilon {
725 NetPnlOutcome::Win
726 } else if net_pnl < -epsilon {
727 NetPnlOutcome::Loss
728 } else {
729 NetPnlOutcome::Breakeven
730 }
731 }
732
733 pub fn initial_risk(&self) -> Option<f64> {
734 summarize_risk(&self.risk_tranches, self.pnl_epsilon).1
735 }
736
737 pub fn charge_position_costs(&mut self, entry_commission: f64, swap: f64) {
741 let exit_commission: f64 = self.close_events.iter().map(|event| event.commission).sum();
742 if entry_commission == 0.0 && swap == 0.0 && exit_commission == 0.0 {
743 return;
744 }
745 self.commission_total = entry_commission + exit_commission;
746 self.swap_total = swap;
747 self.gross_pnl = Some(self.net_pnl + exit_commission);
748 self.net_pnl -= entry_commission + swap;
749 self.outcome = Self::classify(self.net_pnl, self.pnl_epsilon);
750 let epsilon = self.pnl_epsilon;
751 self.realized_r = self
752 .initial_risk()
753 .filter(|risk| *risk > epsilon)
754 .map(|risk| self.net_pnl / risk);
755 }
756}
757
758fn normalized_epsilon(epsilon: f64) -> f64 {
759 if epsilon.is_finite() {
760 epsilon.abs()
761 } else {
762 DEFAULT_PNL_EPSILON
763 }
764}
765
766fn distinct_close_reasons(events: &[CloseEvent]) -> Vec<CloseReason> {
767 let mut reasons = Vec::new();
768 for event in events {
769 if !reasons.contains(&event.reason) {
770 reasons.push(event.reason);
771 }
772 }
773 reasons
774}
775
776pub(crate) fn summarize_risk(
777 tranches: &[RiskTranche],
778 epsilon: f64,
779) -> (RiskBasisStatus, Option<f64>) {
780 if tranches.is_empty() {
781 return (RiskBasisStatus::MissingStop, None);
782 }
783
784 let available = tranches
785 .iter()
786 .filter(|tranche| tranche.status == RiskBasisStatus::Available)
787 .count();
788 if available == tranches.len() {
789 let total: f64 = tranches
790 .iter()
791 .filter_map(|tranche| tranche.risk_amount)
792 .sum();
793 if !total.is_finite() {
794 return (RiskBasisStatus::InvalidInput, None);
795 }
796 if total <= epsilon {
797 return (RiskBasisStatus::ZeroRisk, None);
798 }
799 return (RiskBasisStatus::Available, Some(total));
800 }
801 if available > 0 {
802 return (RiskBasisStatus::Partial, None);
803 }
804
805 let status = tranches
806 .iter()
807 .map(|tranche| tranche.status)
808 .find(|status| *status != RiskBasisStatus::MissingStop)
809 .unwrap_or(RiskBasisStatus::MissingStop);
810 (status, None)
811}
812
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
818#[serde(default)]
819pub struct OpenPositionSnapshot {
820 pub position_id: String,
821 pub symbol: String,
822 pub side: Side,
823 pub group: Option<String>,
824 pub trade_id: Option<String>,
825 pub open_ts: Option<NaiveDateTime>,
826 pub average_entry_price: f64,
827 pub remaining_size: f64,
828 pub initial_stop: Option<f64>,
829 pub effective_stop: Option<EffectiveStop>,
830 pub realized_pnl: f64,
832 #[serde(default)]
833 pub native_realized_pnl: Option<f64>,
834 #[serde(default)]
835 pub native_currency: Option<String>,
836 #[serde(default)]
837 pub account_currency: Option<String>,
838 pub quote_ts: Option<NaiveDateTime>,
839 pub mark_price: Option<f64>,
840 pub unrealized_pnl: Option<f64>,
841 #[serde(default)]
842 pub native_unrealized_pnl: Option<f64>,
843 #[serde(default)]
844 pub unrealized_pnl_conversion: Option<ConversionResult>,
845 pub gross_exposure: Option<f64>,
846 #[serde(default)]
847 pub native_signed_exposure: Option<f64>,
848 #[serde(default)]
849 pub gross_exposure_conversion: Option<ConversionResult>,
850 pub open_risk: Option<f64>,
851 #[serde(default)]
852 pub native_open_risk: Option<f64>,
853 #[serde(default)]
854 pub open_risk_conversion: Option<ConversionResult>,
855 pub campaign_mae: Option<f64>,
856 pub campaign_mfe: Option<f64>,
857}
858
859impl Default for OpenPositionSnapshot {
860 fn default() -> Self {
861 Self {
862 position_id: String::new(),
863 symbol: String::new(),
864 side: Side::Buy,
865 group: None,
866 trade_id: None,
867 open_ts: None,
868 average_entry_price: 0.0,
869 remaining_size: 0.0,
870 initial_stop: None,
871 effective_stop: None,
872 realized_pnl: 0.0,
873 native_realized_pnl: None,
874 native_currency: None,
875 account_currency: None,
876 quote_ts: None,
877 mark_price: None,
878 unrealized_pnl: None,
879 native_unrealized_pnl: None,
880 unrealized_pnl_conversion: None,
881 gross_exposure: None,
882 native_signed_exposure: None,
883 gross_exposure_conversion: None,
884 open_risk: None,
885 native_open_risk: None,
886 open_risk_conversion: None,
887 campaign_mae: None,
888 campaign_mfe: None,
889 }
890 }
891}
892
893impl OpenPositionSnapshot {
894 pub fn new(
895 position_id: impl Into<String>,
896 symbol: impl Into<String>,
897 side: Side,
898 average_entry_price: f64,
899 remaining_size: f64,
900 ) -> Self {
901 Self {
902 position_id: position_id.into(),
903 symbol: symbol.into(),
904 side,
905 average_entry_price,
906 remaining_size,
907 ..Self::default()
908 }
909 }
910
911 pub(crate) fn clear_mark(&mut self) {
912 self.quote_ts = None;
913 self.mark_price = None;
914 self.unrealized_pnl = None;
915 self.native_unrealized_pnl = None;
916 self.unrealized_pnl_conversion = None;
917 self.gross_exposure = None;
918 self.native_signed_exposure = None;
919 self.gross_exposure_conversion = None;
920 self.open_risk = None;
921 self.native_open_risk = None;
922 self.open_risk_conversion = None;
923 self.campaign_mae = None;
924 self.campaign_mfe = None;
925 }
926}
927
928#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
930#[serde(rename_all = "snake_case")]
931pub enum PendingOrderLifecycleState {
932 #[default]
933 Placed,
934 Filled,
935 Cancelled,
936 UnfilledAtEnd,
937}
938
939impl PendingOrderLifecycleState {
940 pub fn is_terminal(self) -> bool {
942 !matches!(self, Self::Placed)
943 }
944}
945
946#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
952#[serde(default)]
953pub struct PendingOrderLifecycleEvent {
954 pub id: String,
955 pub sequence: u64,
956 pub position_id: String,
957 pub placement_action_id: Option<String>,
958 pub terminal_action_id: Option<String>,
959 pub state: PendingOrderLifecycleState,
960 pub symbol: String,
961 pub side: Side,
962 pub order_type: OrderType,
963 pub requested_size: f64,
964 pub filled_size: Option<f64>,
965 pub requested_price: Option<f64>,
966 pub fill_price: Option<f64>,
967 pub signal_ts: Option<NaiveDateTime>,
968 pub placed_ts: Option<NaiveDateTime>,
969 pub effective_ts: Option<NaiveDateTime>,
970 pub terminal_ts: Option<NaiveDateTime>,
971 pub wait_latency_ms: Option<i64>,
972 pub fill_ratio: Option<f64>,
973}
974
975impl Default for PendingOrderLifecycleEvent {
976 fn default() -> Self {
977 Self {
978 id: String::new(),
979 sequence: 0,
980 position_id: String::new(),
981 placement_action_id: None,
982 terminal_action_id: None,
983 state: PendingOrderLifecycleState::Placed,
984 symbol: String::new(),
985 side: Side::Buy,
986 order_type: OrderType::Limit,
987 requested_size: 0.0,
988 filled_size: None,
989 requested_price: None,
990 fill_price: None,
991 signal_ts: None,
992 placed_ts: None,
993 effective_ts: None,
994 terminal_ts: None,
995 wait_latency_ms: None,
996 fill_ratio: None,
997 }
998 }
999}
1000
1001#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1003#[serde(default)]
1004pub struct PendingOrderSnapshot {
1005 pub position_id: String,
1006 pub action_id: Option<String>,
1007 pub symbol: String,
1008 pub side: Side,
1009 pub order_type: OrderType,
1010 pub requested_price: Option<f64>,
1011 pub size: f64,
1012 pub signal_ts: Option<NaiveDateTime>,
1013 pub effective_ts: Option<NaiveDateTime>,
1014 pub initial_stop: Option<f64>,
1015 pub group: Option<String>,
1016 pub trade_id: Option<String>,
1017}
1018
1019impl Default for PendingOrderSnapshot {
1020 fn default() -> Self {
1021 Self {
1022 position_id: String::new(),
1023 action_id: None,
1024 symbol: String::new(),
1025 side: Side::Buy,
1026 order_type: OrderType::Limit,
1027 requested_price: None,
1028 size: 0.0,
1029 signal_ts: None,
1030 effective_ts: None,
1031 initial_stop: None,
1032 group: None,
1033 trade_id: None,
1034 }
1035 }
1036}
1037
1038#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1040#[serde(default)]
1041pub struct FutureBacktestArtifacts {
1042 #[serde(default = "default_format_version")]
1043 pub format_version: u32,
1044 pub execution: ExecutionMetadata,
1045 pub fills: Vec<RecordedFill>,
1046 pub close_events: Vec<CloseEvent>,
1047 #[serde(default)]
1049 pub cost_events: Vec<CostEvent>,
1050 pub completed_positions: Vec<CompletedPosition>,
1051 pub open_positions: Vec<OpenPositionSnapshot>,
1052 pub pending_orders: Vec<PendingOrderSnapshot>,
1053 pub pending_order_lifecycle: Vec<PendingOrderLifecycleEvent>,
1054 pub lifecycle: LifecycleLedger,
1055 pub equity_curve: Vec<EquityPoint>,
1056 pub mtm_output_summary: MtmOutputSummary,
1057 pub max_drawdown: Option<f64>,
1058 pub max_drawdown_pct: Option<f64>,
1059}
1060
1061impl Default for FutureBacktestArtifacts {
1062 fn default() -> Self {
1063 Self {
1064 format_version: FUTURE_ARTIFACT_FORMAT_VERSION,
1065 execution: ExecutionMetadata::default(),
1066 fills: Vec::new(),
1067 close_events: Vec::new(),
1068 cost_events: Vec::new(),
1069 completed_positions: Vec::new(),
1070 open_positions: Vec::new(),
1071 pending_orders: Vec::new(),
1072 pending_order_lifecycle: Vec::new(),
1073 lifecycle: LifecycleLedger::default(),
1074 equity_curve: Vec::new(),
1075 mtm_output_summary: MtmOutputSummary::default(),
1076 max_drawdown: None,
1077 max_drawdown_pct: None,
1078 }
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use chrono::NaiveDate;
1086 use qs_core::{ExecutionConvention, FillModel, FillPurpose, SlippageModel, StopOrigin};
1087
1088 fn ts(second: u32) -> NaiveDateTime {
1089 NaiveDate::from_ymd_opt(2026, 1, 2)
1090 .unwrap()
1091 .and_hms_opt(3, 4, second)
1092 .unwrap()
1093 }
1094
1095 fn execution_fill(side: Side, price: f64) -> ExecutionFill {
1096 ExecutionFill {
1097 purpose: FillPurpose::MarketEntry,
1098 side,
1099 price,
1100 quote_price: price,
1101 requested_price: None,
1102 slippage_pips: 0.0,
1103 }
1104 }
1105
1106 #[test]
1107 fn execution_metadata_is_serializable_and_defaults_new_fields() {
1108 let decoded: ExecutionMetadata = serde_json::from_str("{}").unwrap();
1109 assert_eq!(decoded.pnl_epsilon, DEFAULT_PNL_EPSILON);
1110 assert_eq!(decoded.execution_model, ExecutionModel::default());
1111 assert_eq!(decoded.instrument_manifest, None);
1112 assert!(decoded.instrument_sizing.is_empty());
1113 assert_eq!(
1114 decoded.market_entry_sizing_basis,
1115 MarketEntrySizingBasis::FillPrice
1116 );
1117 assert!(decoded.market_entry_sizing.is_empty());
1118
1119 let metadata = ExecutionMetadata {
1120 execution_model: ExecutionModel::new(
1121 ExecutionConvention::FutureQuoteV1,
1122 FillModel::BidAsk,
1123 SlippageModel::adverse(0.2),
1124 ),
1125 initial_balance: 50_000.0,
1126 account_currency: Some("USD".into()),
1127 ..ExecutionMetadata::default()
1128 };
1129 let roundtrip: ExecutionMetadata =
1130 serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
1131 assert_eq!(roundtrip, metadata);
1132 }
1133
1134 #[test]
1135 fn recorded_fill_has_stable_id_and_quote_context() {
1136 let quote = PriceQuote {
1137 symbol: "EURUSD".into(),
1138 ts: ts(2),
1139 bid: 1.0998,
1140 ask: 1.1000,
1141 };
1142 let first = RecordedFill::from_quote(
1143 "position-7",
1144 Some("action-3".into()),
1145 4,
1146 Some(ts(0)),
1147 ts(1),
1148 0.5,
1149 "e,
1150 execution_fill(Side::Buy, 1.1000),
1151 );
1152 let second = RecordedFill::from_quote(
1153 "position-7",
1154 Some("action-3".into()),
1155 4,
1156 Some(ts(0)),
1157 ts(1),
1158 0.5,
1159 "e,
1160 execution_fill(Side::Buy, 1.1000),
1161 );
1162
1163 assert_eq!(first.id, "position-7:fill:00000004");
1164 assert_eq!(first, second);
1165 assert_eq!(first.symbol, "EURUSD");
1166 assert_eq!(first.quote_ts, ts(2));
1167 assert_eq!((first.ask - 1.1000).abs(), 0.0);
1168 }
1169
1170 #[test]
1171 fn risk_tranches_validate_direction_and_calculate_money_risk() {
1172 let long = RiskTranche::calculate(
1173 Some("fill-1".into()),
1174 Side::Buy,
1175 2.0,
1176 100.0,
1177 Some(95.0),
1178 10.0,
1179 DEFAULT_PNL_EPSILON,
1180 );
1181 assert_eq!(long.status, RiskBasisStatus::Available);
1182 assert_eq!(long.risk_per_unit, Some(5.0));
1183 assert_eq!(long.risk_amount, Some(100.0));
1184
1185 let short = RiskTranche::calculate(
1186 None,
1187 Side::Sell,
1188 1.0,
1189 100.0,
1190 Some(105.0),
1191 10.0,
1192 DEFAULT_PNL_EPSILON,
1193 );
1194 assert_eq!(short.risk_amount, Some(50.0));
1195
1196 let non_protective = RiskTranche::calculate(
1197 None,
1198 Side::Buy,
1199 1.0,
1200 100.0,
1201 Some(101.0),
1202 1.0,
1203 DEFAULT_PNL_EPSILON,
1204 );
1205 assert_eq!(non_protective.status, RiskBasisStatus::NonProtectiveStop);
1206 assert_eq!(non_protective.risk_amount, None);
1207 }
1208
1209 #[test]
1210 fn completed_position_sums_closes_classifies_and_realizes_r() {
1211 let closes = vec![
1212 CloseEvent::new(
1213 "p1",
1214 0,
1215 "XAUUSD",
1216 Side::Buy,
1217 ts(3),
1218 0.5,
1219 101.0,
1220 50.0,
1221 CloseReason::Target,
1222 ),
1223 CloseEvent::new(
1224 "p1",
1225 1,
1226 "XAUUSD",
1227 Side::Buy,
1228 ts(4),
1229 0.5,
1230 99.0,
1231 -20.0,
1232 CloseReason::Manual,
1233 ),
1234 CloseEvent::new(
1235 "p1",
1236 2,
1237 "XAUUSD",
1238 Side::Buy,
1239 ts(5),
1240 0.1,
1241 99.0,
1242 0.0,
1243 CloseReason::Manual,
1244 ),
1245 ];
1246 let risk = RiskTranche::calculate(
1247 Some("entry".into()),
1248 Side::Buy,
1249 1.0,
1250 100.0,
1251 Some(99.0),
1252 100.0,
1253 DEFAULT_PNL_EPSILON,
1254 );
1255 let completed = CompletedPosition::from_close_events(
1256 "p1",
1257 "XAUUSD",
1258 Side::Buy,
1259 ts(0),
1260 ts(5),
1261 1.0,
1262 100.0,
1263 Some(99.0),
1264 Some(EffectiveStop::new(100.0, StopOrigin::Breakeven)),
1265 vec![risk],
1266 closes,
1267 Some(-40.0),
1268 Some(70.0),
1269 DEFAULT_PNL_EPSILON,
1270 );
1271
1272 assert_eq!(completed.net_pnl, 30.0);
1273 assert_eq!(completed.outcome, NetPnlOutcome::Win);
1274 assert_eq!(completed.initial_risk(), Some(100.0));
1275 assert_eq!(completed.realized_r, Some(0.3));
1276 assert_eq!(
1277 completed.close_reasons,
1278 vec![CloseReason::Target, CloseReason::Manual]
1279 );
1280 assert_eq!(completed.mae, Some(-40.0));
1281 assert_eq!(completed.mfe, Some(70.0));
1282 }
1283
1284 #[test]
1285 fn net_pnl_outcome_uses_absolute_epsilon() {
1286 assert_eq!(
1287 CompletedPosition::classify(0.0005, 0.001),
1288 NetPnlOutcome::Breakeven
1289 );
1290 assert_eq!(
1291 CompletedPosition::classify(-0.002, -0.001),
1292 NetPnlOutcome::Loss
1293 );
1294 assert_eq!(
1295 CompletedPosition::classify(0.002, 0.001),
1296 NetPnlOutcome::Win
1297 );
1298 }
1299
1300 #[test]
1301 fn partial_risk_basis_does_not_report_misleading_r() {
1302 let valid = RiskTranche::calculate(
1303 None,
1304 Side::Buy,
1305 1.0,
1306 10.0,
1307 Some(9.0),
1308 1.0,
1309 DEFAULT_PNL_EPSILON,
1310 );
1311 let missing =
1312 RiskTranche::calculate(None, Side::Buy, 1.0, 10.0, None, 1.0, DEFAULT_PNL_EPSILON);
1313 let completed = CompletedPosition::from_close_events(
1314 "p",
1315 "S",
1316 Side::Buy,
1317 ts(0),
1318 ts(1),
1319 2.0,
1320 10.0,
1321 Some(9.0),
1322 None,
1323 vec![valid, missing],
1324 vec![CloseEvent::new(
1325 "p",
1326 0,
1327 "S",
1328 Side::Buy,
1329 ts(1),
1330 2.0,
1331 11.0,
1332 2.0,
1333 CloseReason::Manual,
1334 )],
1335 None,
1336 None,
1337 DEFAULT_PNL_EPSILON,
1338 );
1339 assert_eq!(completed.risk_basis_status, RiskBasisStatus::Partial);
1340 assert_eq!(completed.realized_r, None);
1341 }
1342
1343 #[test]
1344 fn aggregate_deserializes_additive_fields_from_empty_object() {
1345 let artifacts: FutureBacktestArtifacts = serde_json::from_str("{}").unwrap();
1346 assert_eq!(artifacts.format_version, FUTURE_ARTIFACT_FORMAT_VERSION);
1347 assert!(artifacts.fills.is_empty());
1348 assert!(artifacts.completed_positions.is_empty());
1349 assert!(artifacts.equity_curve.is_empty());
1350 assert_eq!(artifacts.mtm_output_summary, MtmOutputSummary::default());
1351 assert_eq!(artifacts.max_drawdown, None);
1352 }
1353
1354 #[test]
1355 fn snapshots_preserve_defaults_for_forward_compatible_fields() {
1356 let open: OpenPositionSnapshot = serde_json::from_str(
1357 r#"{"position_id":"p","symbol":"EURUSD","side":"Buy","average_entry_price":1.1,"remaining_size":1.0}"#,
1358 )
1359 .unwrap();
1360 assert_eq!(open.realized_pnl, 0.0);
1361 assert_eq!(open.mark_price, None);
1362 assert_eq!(open.campaign_mae, None);
1363
1364 let pending: PendingOrderSnapshot = serde_json::from_str("{}").unwrap();
1365 assert_eq!(pending.order_type, OrderType::Limit);
1366 assert_eq!(pending.initial_stop, None);
1367
1368 let lifecycle: PendingOrderLifecycleEvent = serde_json::from_str("{}").unwrap();
1369 assert_eq!(lifecycle.state, PendingOrderLifecycleState::Placed);
1370 assert_eq!(lifecycle.filled_size, None);
1371 assert_eq!(lifecycle.terminal_ts, None);
1372 }
1373}