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