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