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/// Price basis used to size market entries.
54#[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/// Sizing inputs and outputs recorded for one successfully executed market entry.
63#[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/// Exact catalog-backed sizing adjustment recorded before one Entry action.
82#[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/// Reproducibility metadata for the execution/accounting model used by a run.
92#[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    /// Immutable primary/conversion universe, P&L currencies, routes, and warmup quotes.
100    pub currency_plan: Option<RunCurrencyPlan>,
101    /// Monetary point-value multiplier by symbol.
102    pub contract_sizes: BTreeMap<String, f64>,
103    /// Optional typed instrument and stored-series manifest for explicit catalog-backed runs.
104    pub instrument_manifest: Option<ReplayInstrumentManifest>,
105    /// Exact requested and adjusted quantities for catalog-backed Entry sizing.
106    pub instrument_sizing: Vec<InstrumentSizingArtifact>,
107    /// Configured price basis for market-entry sizing.
108    pub market_entry_sizing_basis: MarketEntrySizingBasis,
109    /// Per-entry sizing audits for successfully executed market entries.
110    pub market_entry_sizing: Vec<MarketEntrySizingAudit>,
111    /// Quotes older than this at an equity observation are counted as stale.
112    pub stale_quote_after_millis: Option<i64>,
113    #[serde(default = "default_pnl_epsilon")]
114    pub pnl_epsilon: f64,
115    /// Application-defined, deterministically ordered metadata.
116    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
139/// Return a stable, human-readable event id for a scoped zero-based sequence.
140///
141/// Determinism comes from caller-supplied stable scope and sequence values; no
142/// process-randomized hash, wall clock, or global counter is used.
143pub fn deterministic_event_id(scope: &str, kind: &str, sequence: u64) -> String {
144    format!("{scope}:{kind}:{sequence:08}")
145}
146
147/// An execution fill enriched with all timing and quote context needed for an
148/// execution audit.
149#[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    /// Source signal time. Engine-generated exits may have no source signal.
157    #[serde(default)]
158    pub signal_ts: Option<NaiveDateTime>,
159    /// Time at which the action became eligible for execution.
160    pub effective_ts: NaiveDateTime,
161    /// Timestamp at which the fill changed execution/account state.
162    #[serde(default)]
163    pub execution_ts: Option<NaiveDateTime>,
164    /// Timestamp of the source quote used by the execution pricer.
165    pub quote_ts: NaiveDateTime,
166    /// Age of the source quote at execution. Older payloads omit this field.
167    #[serde(default)]
168    pub quote_age_millis: Option<i64>,
169    pub size: f64,
170    pub bid: f64,
171    pub ask: f64,
172    /// Price-selection and slippage result from `qs_core`.
173    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/// A realized close, including partial closes, in additive account currency.
237#[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    /// Remaining-inventory average cost used to realize this close.
250    #[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    /// Remaining size after this close when known.
261    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/// Availability and validity of a position's initial monetary risk basis.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
319#[serde(rename_all = "snake_case")]
320pub enum RiskBasisStatus {
321    Available,
322    /// Some, but not all, entry tranches have a valid risk basis.
323    Partial,
324    #[default]
325    MissingStop,
326    InvalidInput,
327    NonProtectiveStop,
328    ZeroRisk,
329}
330
331/// Initial risk attached to one entry/scale-in tranche.
332#[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/// Epsilon-aware classification of additive net P&L.
433#[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/// Complete campaign-level outcome for one position.
443#[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    /// Net P&L divided by total valid initial monetary risk.
468    pub realized_r: Option<f64>,
469    /// Minimum campaign P&L observed from a zero baseline (normally <= 0).
470    pub mae: Option<f64>,
471    /// Maximum campaign P&L observed from a zero baseline (normally >= 0).
472    pub mfe: Option<f64>,
473    /// Distinct close reasons in first-observed order.
474    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/// Serializable runner-supplied state for one currently open position.
640///
641/// Fields populated by [`crate::portfolio::PortfolioRecorder`] are optional so
642/// an unpriced or partially priced snapshot remains representable.
643#[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    /// Campaign realized P&L before the current mark (for partial closes).
657    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/// State transition emitted by the FutureQuote pending-order lifecycle.
755#[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    /// Whether this state permanently terminates a placed pending order.
767    pub fn is_terminal(self) -> bool {
768        !matches!(self, Self::Placed)
769    }
770}
771
772/// One append-only transition in the FutureQuote pending-order lifecycle.
773///
774/// A successfully placed order emits one `Placed` event and exactly one terminal
775/// event. Terminal metrics are absent on `Placed`; cancelled and end-of-run
776/// orders report a zero filled size and fill ratio.
777#[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/// Serializable state for an order that has not filled at the end of a run.
828#[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/// Complete additive artifact payload for a future backtest run.
865#[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            &quote,
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            &quote,
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}