Skip to main content

qs_backtest/
artifacts.rs

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