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