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, CostKind, EffectiveStop, EntryLevelResolution, ExecutionFill, ExecutionModel,
12    InstrumentCosts, 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    /// Per-symbol commission and swap specification applied during the run.
165    #[serde(default)]
166    pub costs: BTreeMap<String, InstrumentCosts>,
167    /// Swap charges skipped because no causal conversion quote was available.
168    #[serde(default)]
169    pub unconverted_cost_events: u64,
170    /// Bar quotes executed with a zero spread because neither the bar nor a fallback supplied one.
171    #[serde(default)]
172    pub zero_spread_bar_quotes: u64,
173    /// Quotes older than this at an equity observation are counted as stale.
174    pub stale_quote_after_millis: Option<i64>,
175    #[serde(default = "default_pnl_epsilon")]
176    pub pnl_epsilon: f64,
177    /// Engine-produced diagnostics for this run, deterministically ordered.
178    pub tags: BTreeMap<String, String>,
179    /// Caller-supplied labels for this run, copied onto every completed position's evaluation dimensions.
180    ///
181    /// These are separate from `tags` so that a caller cannot overwrite an engine diagnostic and an engine diagnostic cannot become a breakdown dimension.
182    #[serde(default)]
183    pub run_tags: BTreeMap<String, String>,
184    /// Labels that belong to single positions rather than to the whole run, keyed by position ID, such as the instance of a multi-instance run that opened the position. They join the run tags as breakdown dimensions of that position.
185    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186    pub position_tags: BTreeMap<String, BTreeMap<String, String>>,
187}
188
189impl Default for ExecutionMetadata {
190    fn default() -> Self {
191        Self {
192            run_id: None,
193            execution_model: ExecutionModel::default(),
194            initial_balance: 0.0,
195            account_currency: None,
196            currency_plan: None,
197            contract_sizes: BTreeMap::new(),
198            instrument_manifest: None,
199            instrument_sizing: Vec::new(),
200            market_entry_sizing_basis: MarketEntrySizingBasis::default(),
201            market_entry_sizing: Vec::new(),
202            entry_profile_default: None,
203            entry_profile_routes: BTreeMap::new(),
204            entry_profile_resolutions: Vec::new(),
205            costs: BTreeMap::new(),
206            unconverted_cost_events: 0,
207            zero_spread_bar_quotes: 0,
208            stale_quote_after_millis: None,
209            pnl_epsilon: DEFAULT_PNL_EPSILON,
210            tags: BTreeMap::new(),
211            run_tags: BTreeMap::new(),
212            position_tags: BTreeMap::new(),
213        }
214    }
215}
216
217/// Return a stable, human-readable event id for a scoped zero-based sequence.
218///
219/// Determinism comes from caller-supplied stable scope and sequence values; no
220/// process-randomized hash, wall clock, or global counter is used.
221pub fn deterministic_event_id(scope: &str, kind: &str, sequence: u64) -> String {
222    format!("{scope}:{kind}:{sequence:08}")
223}
224
225/// An execution fill enriched with all timing and quote context needed for an
226/// execution audit.
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct RecordedFill {
229    pub id: String,
230    #[serde(default)]
231    pub action_id: Option<String>,
232    pub position_id: String,
233    pub symbol: String,
234    /// Source signal time. Engine-generated exits may have no source signal.
235    #[serde(default)]
236    pub signal_ts: Option<NaiveDateTime>,
237    /// Time at which the action became eligible for execution.
238    pub effective_ts: NaiveDateTime,
239    /// Timestamp at which the fill changed execution/account state.
240    #[serde(default)]
241    pub execution_ts: Option<NaiveDateTime>,
242    /// Timestamp of the source quote used by the execution pricer.
243    pub quote_ts: NaiveDateTime,
244    /// Age of the source quote at execution. Older payloads omit this field.
245    #[serde(default)]
246    pub quote_age_millis: Option<i64>,
247    pub size: f64,
248    pub bid: f64,
249    pub ask: f64,
250    /// Price-selection and slippage result from `qs_core`.
251    pub fill: ExecutionFill,
252}
253
254impl RecordedFill {
255    #[allow(clippy::too_many_arguments)]
256    pub fn from_quote(
257        position_id: impl Into<String>,
258        action_id: Option<String>,
259        sequence: u64,
260        signal_ts: Option<NaiveDateTime>,
261        effective_ts: NaiveDateTime,
262        size: f64,
263        quote: &PriceQuote,
264        fill: ExecutionFill,
265    ) -> Self {
266        Self::from_quote_at(
267            position_id,
268            action_id,
269            sequence,
270            signal_ts,
271            effective_ts,
272            quote.ts,
273            size,
274            quote,
275            fill,
276        )
277    }
278
279    #[allow(clippy::too_many_arguments)]
280    pub fn from_quote_at(
281        position_id: impl Into<String>,
282        action_id: Option<String>,
283        sequence: u64,
284        signal_ts: Option<NaiveDateTime>,
285        effective_ts: NaiveDateTime,
286        execution_ts: NaiveDateTime,
287        size: f64,
288        quote: &PriceQuote,
289        fill: ExecutionFill,
290    ) -> Self {
291        let position_id = position_id.into();
292        Self {
293            id: deterministic_event_id(&position_id, "fill", sequence),
294            action_id,
295            position_id,
296            symbol: quote.symbol.clone(),
297            signal_ts,
298            effective_ts,
299            execution_ts: Some(execution_ts),
300            quote_ts: quote.ts,
301            quote_age_millis: Some(
302                execution_ts
303                    .signed_duration_since(quote.ts)
304                    .num_milliseconds(),
305            ),
306            size,
307            bid: quote.bid,
308            ask: quote.ask,
309            fill,
310        }
311    }
312}
313
314/// A realized close, including partial closes, in additive account currency.
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316#[serde(default)]
317pub struct CloseEvent {
318    pub id: String,
319    pub action_id: Option<String>,
320    pub fill_id: Option<String>,
321    pub position_id: String,
322    pub symbol: String,
323    pub side: Side,
324    pub ts: NaiveDateTime,
325    pub size: f64,
326    pub price: f64,
327    /// Remaining-inventory average cost used to realize this close.
328    #[serde(default)]
329    pub entry_price: Option<f64>,
330    /// Realized profit and loss for this close, already net of `commission`.
331    pub pnl: f64,
332    /// Account-currency exit commission already subtracted from `pnl`.
333    #[serde(default)]
334    pub commission: f64,
335    #[serde(default)]
336    pub native_pnl: Option<f64>,
337    #[serde(default)]
338    pub native_currency: Option<String>,
339    #[serde(default)]
340    pub pnl_conversion: Option<ConversionResult>,
341    pub reason: CloseReason,
342    /// Remaining size after this close when known.
343    pub remaining_size: Option<f64>,
344}
345
346impl Default for CloseEvent {
347    fn default() -> Self {
348        Self {
349            id: String::new(),
350            action_id: None,
351            fill_id: None,
352            position_id: String::new(),
353            symbol: String::new(),
354            side: Side::Buy,
355            ts: NaiveDateTime::default(),
356            size: 0.0,
357            price: 0.0,
358            entry_price: None,
359            pnl: 0.0,
360            commission: 0.0,
361            native_pnl: None,
362            native_currency: None,
363            pnl_conversion: None,
364            reason: CloseReason::Manual,
365            remaining_size: None,
366        }
367    }
368}
369
370impl CloseEvent {
371    #[allow(clippy::too_many_arguments)]
372    pub fn new(
373        position_id: impl Into<String>,
374        sequence: u64,
375        symbol: impl Into<String>,
376        side: Side,
377        ts: NaiveDateTime,
378        size: f64,
379        price: f64,
380        pnl: f64,
381        reason: CloseReason,
382    ) -> Self {
383        let position_id = position_id.into();
384        Self {
385            id: deterministic_event_id(&position_id, "close", sequence),
386            position_id,
387            symbol: symbol.into(),
388            side,
389            ts,
390            size,
391            price,
392            pnl,
393            native_pnl: Some(pnl),
394            reason,
395            ..Self::default()
396        }
397    }
398}
399
400/// One commission or swap charge applied to the account during replay.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct CostEvent {
403    pub id: String,
404    pub position_id: String,
405    pub symbol: String,
406    pub side: Side,
407    pub ts: NaiveDateTime,
408    pub kind: CostKind,
409    /// Signed account-currency charge, where positive reduced the balance.
410    pub amount: f64,
411    /// Signed charge before account conversion, when the charge was computed in the instrument's native currency.
412    #[serde(default)]
413    pub native_amount: Option<f64>,
414    #[serde(default)]
415    pub native_currency: Option<String>,
416    #[serde(default)]
417    pub conversion: Option<ConversionResult>,
418    /// Lots the charge was computed on.
419    pub size: f64,
420    /// Number of rollover nights, for swap charges only.
421    #[serde(default)]
422    pub nights: Option<u32>,
423}
424
425impl CostEvent {
426    #[allow(clippy::too_many_arguments)]
427    pub fn new(
428        position_id: impl Into<String>,
429        sequence: u64,
430        symbol: impl Into<String>,
431        side: Side,
432        ts: NaiveDateTime,
433        kind: CostKind,
434        amount: f64,
435        size: f64,
436    ) -> Self {
437        let position_id = position_id.into();
438        Self {
439            id: deterministic_event_id(&position_id, kind.as_str(), sequence),
440            position_id,
441            symbol: symbol.into(),
442            side,
443            ts,
444            kind,
445            amount,
446            native_amount: None,
447            native_currency: None,
448            conversion: None,
449            size,
450            nights: None,
451        }
452    }
453}
454
455/// Availability and validity of a position's initial monetary risk basis.
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
457#[serde(rename_all = "snake_case")]
458pub enum RiskBasisStatus {
459    Available,
460    /// Some, but not all, entry tranches have a valid risk basis.
461    Partial,
462    #[default]
463    MissingStop,
464    InvalidInput,
465    NonProtectiveStop,
466    ZeroRisk,
467}
468
469/// Initial risk attached to one entry/scale-in tranche.
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
471#[serde(default)]
472pub struct RiskTranche {
473    pub fill_id: Option<String>,
474    pub size: f64,
475    pub entry_price: f64,
476    pub initial_stop: Option<f64>,
477    pub contract_size: f64,
478    pub risk_per_unit: Option<f64>,
479    pub risk_amount: Option<f64>,
480    #[serde(default)]
481    pub native_risk_amount: Option<f64>,
482    #[serde(default)]
483    pub native_currency: Option<String>,
484    #[serde(default)]
485    pub risk_conversion: Option<ConversionResult>,
486    pub status: RiskBasisStatus,
487}
488
489impl Default for RiskTranche {
490    fn default() -> Self {
491        Self {
492            fill_id: None,
493            size: 0.0,
494            entry_price: 0.0,
495            initial_stop: None,
496            contract_size: 1.0,
497            risk_per_unit: None,
498            risk_amount: None,
499            native_risk_amount: None,
500            native_currency: None,
501            risk_conversion: None,
502            status: RiskBasisStatus::MissingStop,
503        }
504    }
505}
506
507impl RiskTranche {
508    pub fn calculate(
509        fill_id: Option<String>,
510        side: Side,
511        size: f64,
512        entry_price: f64,
513        initial_stop: Option<f64>,
514        contract_size: f64,
515        epsilon: f64,
516    ) -> Self {
517        let mut tranche = Self {
518            fill_id,
519            size,
520            entry_price,
521            initial_stop,
522            contract_size,
523            ..Self::default()
524        };
525        let epsilon = normalized_epsilon(epsilon);
526
527        if !size.is_finite()
528            || size <= 0.0
529            || !entry_price.is_finite()
530            || !contract_size.is_finite()
531            || contract_size <= 0.0
532        {
533            tranche.status = RiskBasisStatus::InvalidInput;
534            return tranche;
535        }
536
537        let Some(stop) = initial_stop else {
538            return tranche;
539        };
540        if !stop.is_finite() {
541            tranche.status = RiskBasisStatus::InvalidInput;
542            return tranche;
543        }
544
545        let signed_distance = match side {
546            Side::Buy => entry_price - stop,
547            Side::Sell => stop - entry_price,
548        };
549        if signed_distance < -epsilon {
550            tranche.status = RiskBasisStatus::NonProtectiveStop;
551            return tranche;
552        }
553        if signed_distance.abs() <= epsilon {
554            tranche.status = RiskBasisStatus::ZeroRisk;
555            tranche.risk_per_unit = Some(0.0);
556            tranche.risk_amount = Some(0.0);
557            tranche.native_risk_amount = Some(0.0);
558            return tranche;
559        }
560
561        tranche.status = RiskBasisStatus::Available;
562        tranche.risk_per_unit = Some(signed_distance);
563        let native_risk = signed_distance * size * contract_size;
564        tranche.risk_amount = Some(native_risk);
565        tranche.native_risk_amount = Some(native_risk);
566        tranche
567    }
568}
569
570/// Epsilon-aware classification of additive net P&L.
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
572#[serde(rename_all = "snake_case")]
573pub enum NetPnlOutcome {
574    Win,
575    Loss,
576    #[default]
577    Breakeven,
578}
579
580/// Complete campaign-level outcome for one position.
581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
582#[serde(default)]
583pub struct CompletedPosition {
584    pub position_id: String,
585    pub symbol: String,
586    pub side: Side,
587    pub group: Option<String>,
588    pub trade_id: Option<String>,
589    pub open_ts: NaiveDateTime,
590    pub close_ts: NaiveDateTime,
591    pub entry_size: f64,
592    pub average_entry_price: f64,
593    /// Realized profit and loss net of every commission and swap charged to this position.
594    pub net_pnl: f64,
595    /// Realized profit and loss before any commission or swap, present only when a cost was charged.
596    #[serde(default)]
597    pub gross_pnl: Option<f64>,
598    /// Total account-currency commission charged on entry and exit fills.
599    #[serde(default)]
600    pub commission_total: f64,
601    /// Total account-currency swap charged while the position was open.
602    #[serde(default)]
603    pub swap_total: f64,
604    #[serde(default)]
605    pub native_net_pnl: Option<f64>,
606    #[serde(default)]
607    pub native_currency: Option<String>,
608    pub outcome: NetPnlOutcome,
609    #[serde(default = "default_pnl_epsilon")]
610    pub pnl_epsilon: f64,
611    pub initial_stop: Option<f64>,
612    pub effective_stop: Option<EffectiveStop>,
613    pub risk_basis_status: RiskBasisStatus,
614    pub risk_tranches: Vec<RiskTranche>,
615    /// Net P&L divided by total valid initial monetary risk.
616    pub realized_r: Option<f64>,
617    /// Minimum campaign P&L observed from a zero baseline (normally <= 0).
618    pub mae: Option<f64>,
619    /// Maximum campaign P&L observed from a zero baseline (normally >= 0).
620    pub mfe: Option<f64>,
621    /// Distinct close reasons in first-observed order.
622    pub close_reasons: Vec<CloseReason>,
623    pub close_events: Vec<CloseEvent>,
624}
625
626impl Default for CompletedPosition {
627    fn default() -> Self {
628        Self {
629            position_id: String::new(),
630            symbol: String::new(),
631            side: Side::Buy,
632            group: None,
633            trade_id: None,
634            open_ts: NaiveDateTime::default(),
635            close_ts: NaiveDateTime::default(),
636            entry_size: 0.0,
637            average_entry_price: 0.0,
638            net_pnl: 0.0,
639            gross_pnl: None,
640            commission_total: 0.0,
641            swap_total: 0.0,
642            native_net_pnl: None,
643            native_currency: None,
644            outcome: NetPnlOutcome::Breakeven,
645            pnl_epsilon: DEFAULT_PNL_EPSILON,
646            initial_stop: None,
647            effective_stop: None,
648            risk_basis_status: RiskBasisStatus::MissingStop,
649            risk_tranches: Vec::new(),
650            realized_r: None,
651            mae: None,
652            mfe: None,
653            close_reasons: Vec::new(),
654            close_events: Vec::new(),
655        }
656    }
657}
658
659impl CompletedPosition {
660    #[allow(clippy::too_many_arguments)]
661    pub fn from_close_events(
662        position_id: impl Into<String>,
663        symbol: impl Into<String>,
664        side: Side,
665        open_ts: NaiveDateTime,
666        close_ts: NaiveDateTime,
667        entry_size: f64,
668        average_entry_price: f64,
669        initial_stop: Option<f64>,
670        effective_stop: Option<EffectiveStop>,
671        risk_tranches: Vec<RiskTranche>,
672        close_events: Vec<CloseEvent>,
673        mae: Option<f64>,
674        mfe: Option<f64>,
675        epsilon: f64,
676    ) -> Self {
677        let epsilon = normalized_epsilon(epsilon);
678        let net_pnl = close_events.iter().map(|event| event.pnl).sum();
679        let native_net_pnl = close_events.iter().try_fold(0.0, |total, event| {
680            event.native_pnl.map(|native_pnl| total + native_pnl)
681        });
682        let native_currency = close_events
683            .first()
684            .and_then(|event| event.native_currency.clone())
685            .filter(|currency| {
686                close_events
687                    .iter()
688                    .all(|event| event.native_currency.as_ref() == Some(currency))
689            });
690        let close_reasons = distinct_close_reasons(&close_events);
691        let (risk_basis_status, initial_risk) = summarize_risk(&risk_tranches, epsilon);
692        let realized_r = initial_risk
693            .filter(|risk| *risk > epsilon)
694            .map(|risk| net_pnl / risk);
695
696        Self {
697            position_id: position_id.into(),
698            symbol: symbol.into(),
699            side,
700            open_ts,
701            close_ts,
702            entry_size,
703            average_entry_price,
704            net_pnl,
705            native_net_pnl,
706            native_currency,
707            outcome: Self::classify(net_pnl, epsilon),
708            pnl_epsilon: epsilon,
709            initial_stop,
710            effective_stop,
711            risk_basis_status,
712            risk_tranches,
713            realized_r,
714            mae,
715            mfe,
716            close_reasons,
717            close_events,
718            ..Self::default()
719        }
720    }
721
722    pub fn classify(net_pnl: f64, epsilon: f64) -> NetPnlOutcome {
723        let epsilon = normalized_epsilon(epsilon);
724        if net_pnl > epsilon {
725            NetPnlOutcome::Win
726        } else if net_pnl < -epsilon {
727            NetPnlOutcome::Loss
728        } else {
729            NetPnlOutcome::Breakeven
730        }
731    }
732
733    pub fn initial_risk(&self) -> Option<f64> {
734        summarize_risk(&self.risk_tranches, self.pnl_epsilon).1
735    }
736
737    /// Fold costs that are not attributable to a single close event into the net result.
738    ///
739    /// Exit commission is already subtracted from each close event's profit and loss, so only entry commission and swap are applied here. The call is a no-op when nothing was charged, which keeps cost-free runs byte-identical to runs produced before costs existed.
740    pub fn charge_position_costs(&mut self, entry_commission: f64, swap: f64) {
741        let exit_commission: f64 = self.close_events.iter().map(|event| event.commission).sum();
742        if entry_commission == 0.0 && swap == 0.0 && exit_commission == 0.0 {
743            return;
744        }
745        self.commission_total = entry_commission + exit_commission;
746        self.swap_total = swap;
747        self.gross_pnl = Some(self.net_pnl + exit_commission);
748        self.net_pnl -= entry_commission + swap;
749        self.outcome = Self::classify(self.net_pnl, self.pnl_epsilon);
750        let epsilon = self.pnl_epsilon;
751        self.realized_r = self
752            .initial_risk()
753            .filter(|risk| *risk > epsilon)
754            .map(|risk| self.net_pnl / risk);
755    }
756}
757
758fn normalized_epsilon(epsilon: f64) -> f64 {
759    if epsilon.is_finite() {
760        epsilon.abs()
761    } else {
762        DEFAULT_PNL_EPSILON
763    }
764}
765
766fn distinct_close_reasons(events: &[CloseEvent]) -> Vec<CloseReason> {
767    let mut reasons = Vec::new();
768    for event in events {
769        if !reasons.contains(&event.reason) {
770            reasons.push(event.reason);
771        }
772    }
773    reasons
774}
775
776pub(crate) fn summarize_risk(
777    tranches: &[RiskTranche],
778    epsilon: f64,
779) -> (RiskBasisStatus, Option<f64>) {
780    if tranches.is_empty() {
781        return (RiskBasisStatus::MissingStop, None);
782    }
783
784    let available = tranches
785        .iter()
786        .filter(|tranche| tranche.status == RiskBasisStatus::Available)
787        .count();
788    if available == tranches.len() {
789        let total: f64 = tranches
790            .iter()
791            .filter_map(|tranche| tranche.risk_amount)
792            .sum();
793        if !total.is_finite() {
794            return (RiskBasisStatus::InvalidInput, None);
795        }
796        if total <= epsilon {
797            return (RiskBasisStatus::ZeroRisk, None);
798        }
799        return (RiskBasisStatus::Available, Some(total));
800    }
801    if available > 0 {
802        return (RiskBasisStatus::Partial, None);
803    }
804
805    let status = tranches
806        .iter()
807        .map(|tranche| tranche.status)
808        .find(|status| *status != RiskBasisStatus::MissingStop)
809        .unwrap_or(RiskBasisStatus::MissingStop);
810    (status, None)
811}
812
813/// Serializable runner-supplied state for one currently open position.
814///
815/// Fields populated by [`crate::portfolio::PortfolioRecorder`] are optional so
816/// an unpriced or partially priced snapshot remains representable.
817#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
818#[serde(default)]
819pub struct OpenPositionSnapshot {
820    pub position_id: String,
821    pub symbol: String,
822    pub side: Side,
823    pub group: Option<String>,
824    pub trade_id: Option<String>,
825    pub open_ts: Option<NaiveDateTime>,
826    pub average_entry_price: f64,
827    pub remaining_size: f64,
828    pub initial_stop: Option<f64>,
829    pub effective_stop: Option<EffectiveStop>,
830    /// Campaign realized P&L before the current mark (for partial closes).
831    pub realized_pnl: f64,
832    #[serde(default)]
833    pub native_realized_pnl: Option<f64>,
834    #[serde(default)]
835    pub native_currency: Option<String>,
836    #[serde(default)]
837    pub account_currency: Option<String>,
838    pub quote_ts: Option<NaiveDateTime>,
839    pub mark_price: Option<f64>,
840    pub unrealized_pnl: Option<f64>,
841    #[serde(default)]
842    pub native_unrealized_pnl: Option<f64>,
843    #[serde(default)]
844    pub unrealized_pnl_conversion: Option<ConversionResult>,
845    pub gross_exposure: Option<f64>,
846    #[serde(default)]
847    pub native_signed_exposure: Option<f64>,
848    #[serde(default)]
849    pub gross_exposure_conversion: Option<ConversionResult>,
850    pub open_risk: Option<f64>,
851    #[serde(default)]
852    pub native_open_risk: Option<f64>,
853    #[serde(default)]
854    pub open_risk_conversion: Option<ConversionResult>,
855    pub campaign_mae: Option<f64>,
856    pub campaign_mfe: Option<f64>,
857}
858
859impl Default for OpenPositionSnapshot {
860    fn default() -> Self {
861        Self {
862            position_id: String::new(),
863            symbol: String::new(),
864            side: Side::Buy,
865            group: None,
866            trade_id: None,
867            open_ts: None,
868            average_entry_price: 0.0,
869            remaining_size: 0.0,
870            initial_stop: None,
871            effective_stop: None,
872            realized_pnl: 0.0,
873            native_realized_pnl: None,
874            native_currency: None,
875            account_currency: None,
876            quote_ts: None,
877            mark_price: None,
878            unrealized_pnl: None,
879            native_unrealized_pnl: None,
880            unrealized_pnl_conversion: None,
881            gross_exposure: None,
882            native_signed_exposure: None,
883            gross_exposure_conversion: None,
884            open_risk: None,
885            native_open_risk: None,
886            open_risk_conversion: None,
887            campaign_mae: None,
888            campaign_mfe: None,
889        }
890    }
891}
892
893impl OpenPositionSnapshot {
894    pub fn new(
895        position_id: impl Into<String>,
896        symbol: impl Into<String>,
897        side: Side,
898        average_entry_price: f64,
899        remaining_size: f64,
900    ) -> Self {
901        Self {
902            position_id: position_id.into(),
903            symbol: symbol.into(),
904            side,
905            average_entry_price,
906            remaining_size,
907            ..Self::default()
908        }
909    }
910
911    pub(crate) fn clear_mark(&mut self) {
912        self.quote_ts = None;
913        self.mark_price = None;
914        self.unrealized_pnl = None;
915        self.native_unrealized_pnl = None;
916        self.unrealized_pnl_conversion = None;
917        self.gross_exposure = None;
918        self.native_signed_exposure = None;
919        self.gross_exposure_conversion = None;
920        self.open_risk = None;
921        self.native_open_risk = None;
922        self.open_risk_conversion = None;
923        self.campaign_mae = None;
924        self.campaign_mfe = None;
925    }
926}
927
928/// State transition emitted by the FutureQuote pending-order lifecycle.
929#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
930#[serde(rename_all = "snake_case")]
931pub enum PendingOrderLifecycleState {
932    #[default]
933    Placed,
934    Filled,
935    Cancelled,
936    UnfilledAtEnd,
937}
938
939impl PendingOrderLifecycleState {
940    /// Whether this state permanently terminates a placed pending order.
941    pub fn is_terminal(self) -> bool {
942        !matches!(self, Self::Placed)
943    }
944}
945
946/// One append-only transition in the FutureQuote pending-order lifecycle.
947///
948/// A successfully placed order emits one `Placed` event and exactly one terminal
949/// event. Terminal metrics are absent on `Placed`; cancelled and end-of-run
950/// orders report a zero filled size and fill ratio.
951#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
952#[serde(default)]
953pub struct PendingOrderLifecycleEvent {
954    pub id: String,
955    pub sequence: u64,
956    pub position_id: String,
957    pub placement_action_id: Option<String>,
958    pub terminal_action_id: Option<String>,
959    pub state: PendingOrderLifecycleState,
960    pub symbol: String,
961    pub side: Side,
962    pub order_type: OrderType,
963    pub requested_size: f64,
964    pub filled_size: Option<f64>,
965    pub requested_price: Option<f64>,
966    pub fill_price: Option<f64>,
967    pub signal_ts: Option<NaiveDateTime>,
968    pub placed_ts: Option<NaiveDateTime>,
969    pub effective_ts: Option<NaiveDateTime>,
970    pub terminal_ts: Option<NaiveDateTime>,
971    pub wait_latency_ms: Option<i64>,
972    pub fill_ratio: Option<f64>,
973}
974
975impl Default for PendingOrderLifecycleEvent {
976    fn default() -> Self {
977        Self {
978            id: String::new(),
979            sequence: 0,
980            position_id: String::new(),
981            placement_action_id: None,
982            terminal_action_id: None,
983            state: PendingOrderLifecycleState::Placed,
984            symbol: String::new(),
985            side: Side::Buy,
986            order_type: OrderType::Limit,
987            requested_size: 0.0,
988            filled_size: None,
989            requested_price: None,
990            fill_price: None,
991            signal_ts: None,
992            placed_ts: None,
993            effective_ts: None,
994            terminal_ts: None,
995            wait_latency_ms: None,
996            fill_ratio: None,
997        }
998    }
999}
1000
1001/// Serializable state for an order that has not filled at the end of a run.
1002#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1003#[serde(default)]
1004pub struct PendingOrderSnapshot {
1005    pub position_id: String,
1006    pub action_id: Option<String>,
1007    pub symbol: String,
1008    pub side: Side,
1009    pub order_type: OrderType,
1010    pub requested_price: Option<f64>,
1011    pub size: f64,
1012    pub signal_ts: Option<NaiveDateTime>,
1013    pub effective_ts: Option<NaiveDateTime>,
1014    pub initial_stop: Option<f64>,
1015    pub group: Option<String>,
1016    pub trade_id: Option<String>,
1017}
1018
1019impl Default for PendingOrderSnapshot {
1020    fn default() -> Self {
1021        Self {
1022            position_id: String::new(),
1023            action_id: None,
1024            symbol: String::new(),
1025            side: Side::Buy,
1026            order_type: OrderType::Limit,
1027            requested_price: None,
1028            size: 0.0,
1029            signal_ts: None,
1030            effective_ts: None,
1031            initial_stop: None,
1032            group: None,
1033            trade_id: None,
1034        }
1035    }
1036}
1037
1038/// Complete additive artifact payload for a future backtest run.
1039#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1040#[serde(default)]
1041pub struct FutureBacktestArtifacts {
1042    #[serde(default = "default_format_version")]
1043    pub format_version: u32,
1044    pub execution: ExecutionMetadata,
1045    pub fills: Vec<RecordedFill>,
1046    pub close_events: Vec<CloseEvent>,
1047    /// Commission and swap charges applied during the run, in application order.
1048    #[serde(default)]
1049    pub cost_events: Vec<CostEvent>,
1050    pub completed_positions: Vec<CompletedPosition>,
1051    pub open_positions: Vec<OpenPositionSnapshot>,
1052    pub pending_orders: Vec<PendingOrderSnapshot>,
1053    pub pending_order_lifecycle: Vec<PendingOrderLifecycleEvent>,
1054    pub lifecycle: LifecycleLedger,
1055    pub equity_curve: Vec<EquityPoint>,
1056    pub mtm_output_summary: MtmOutputSummary,
1057    pub max_drawdown: Option<f64>,
1058    pub max_drawdown_pct: Option<f64>,
1059}
1060
1061impl Default for FutureBacktestArtifacts {
1062    fn default() -> Self {
1063        Self {
1064            format_version: FUTURE_ARTIFACT_FORMAT_VERSION,
1065            execution: ExecutionMetadata::default(),
1066            fills: Vec::new(),
1067            close_events: Vec::new(),
1068            cost_events: Vec::new(),
1069            completed_positions: Vec::new(),
1070            open_positions: Vec::new(),
1071            pending_orders: Vec::new(),
1072            pending_order_lifecycle: Vec::new(),
1073            lifecycle: LifecycleLedger::default(),
1074            equity_curve: Vec::new(),
1075            mtm_output_summary: MtmOutputSummary::default(),
1076            max_drawdown: None,
1077            max_drawdown_pct: None,
1078        }
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use chrono::NaiveDate;
1086    use qs_core::{ExecutionConvention, FillModel, FillPurpose, SlippageModel, StopOrigin};
1087
1088    fn ts(second: u32) -> NaiveDateTime {
1089        NaiveDate::from_ymd_opt(2026, 1, 2)
1090            .unwrap()
1091            .and_hms_opt(3, 4, second)
1092            .unwrap()
1093    }
1094
1095    fn execution_fill(side: Side, price: f64) -> ExecutionFill {
1096        ExecutionFill {
1097            purpose: FillPurpose::MarketEntry,
1098            side,
1099            price,
1100            quote_price: price,
1101            requested_price: None,
1102            slippage_pips: 0.0,
1103        }
1104    }
1105
1106    #[test]
1107    fn execution_metadata_is_serializable_and_defaults_new_fields() {
1108        let decoded: ExecutionMetadata = serde_json::from_str("{}").unwrap();
1109        assert_eq!(decoded.pnl_epsilon, DEFAULT_PNL_EPSILON);
1110        assert_eq!(decoded.execution_model, ExecutionModel::default());
1111        assert_eq!(decoded.instrument_manifest, None);
1112        assert!(decoded.instrument_sizing.is_empty());
1113        assert_eq!(
1114            decoded.market_entry_sizing_basis,
1115            MarketEntrySizingBasis::FillPrice
1116        );
1117        assert!(decoded.market_entry_sizing.is_empty());
1118
1119        let metadata = ExecutionMetadata {
1120            execution_model: ExecutionModel::new(
1121                ExecutionConvention::FutureQuoteV1,
1122                FillModel::BidAsk,
1123                SlippageModel::adverse(0.2),
1124            ),
1125            initial_balance: 50_000.0,
1126            account_currency: Some("USD".into()),
1127            ..ExecutionMetadata::default()
1128        };
1129        let roundtrip: ExecutionMetadata =
1130            serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
1131        assert_eq!(roundtrip, metadata);
1132    }
1133
1134    #[test]
1135    fn recorded_fill_has_stable_id_and_quote_context() {
1136        let quote = PriceQuote {
1137            symbol: "EURUSD".into(),
1138            ts: ts(2),
1139            bid: 1.0998,
1140            ask: 1.1000,
1141        };
1142        let first = RecordedFill::from_quote(
1143            "position-7",
1144            Some("action-3".into()),
1145            4,
1146            Some(ts(0)),
1147            ts(1),
1148            0.5,
1149            &quote,
1150            execution_fill(Side::Buy, 1.1000),
1151        );
1152        let second = RecordedFill::from_quote(
1153            "position-7",
1154            Some("action-3".into()),
1155            4,
1156            Some(ts(0)),
1157            ts(1),
1158            0.5,
1159            &quote,
1160            execution_fill(Side::Buy, 1.1000),
1161        );
1162
1163        assert_eq!(first.id, "position-7:fill:00000004");
1164        assert_eq!(first, second);
1165        assert_eq!(first.symbol, "EURUSD");
1166        assert_eq!(first.quote_ts, ts(2));
1167        assert_eq!((first.ask - 1.1000).abs(), 0.0);
1168    }
1169
1170    #[test]
1171    fn risk_tranches_validate_direction_and_calculate_money_risk() {
1172        let long = RiskTranche::calculate(
1173            Some("fill-1".into()),
1174            Side::Buy,
1175            2.0,
1176            100.0,
1177            Some(95.0),
1178            10.0,
1179            DEFAULT_PNL_EPSILON,
1180        );
1181        assert_eq!(long.status, RiskBasisStatus::Available);
1182        assert_eq!(long.risk_per_unit, Some(5.0));
1183        assert_eq!(long.risk_amount, Some(100.0));
1184
1185        let short = RiskTranche::calculate(
1186            None,
1187            Side::Sell,
1188            1.0,
1189            100.0,
1190            Some(105.0),
1191            10.0,
1192            DEFAULT_PNL_EPSILON,
1193        );
1194        assert_eq!(short.risk_amount, Some(50.0));
1195
1196        let non_protective = RiskTranche::calculate(
1197            None,
1198            Side::Buy,
1199            1.0,
1200            100.0,
1201            Some(101.0),
1202            1.0,
1203            DEFAULT_PNL_EPSILON,
1204        );
1205        assert_eq!(non_protective.status, RiskBasisStatus::NonProtectiveStop);
1206        assert_eq!(non_protective.risk_amount, None);
1207    }
1208
1209    #[test]
1210    fn completed_position_sums_closes_classifies_and_realizes_r() {
1211        let closes = vec![
1212            CloseEvent::new(
1213                "p1",
1214                0,
1215                "XAUUSD",
1216                Side::Buy,
1217                ts(3),
1218                0.5,
1219                101.0,
1220                50.0,
1221                CloseReason::Target,
1222            ),
1223            CloseEvent::new(
1224                "p1",
1225                1,
1226                "XAUUSD",
1227                Side::Buy,
1228                ts(4),
1229                0.5,
1230                99.0,
1231                -20.0,
1232                CloseReason::Manual,
1233            ),
1234            CloseEvent::new(
1235                "p1",
1236                2,
1237                "XAUUSD",
1238                Side::Buy,
1239                ts(5),
1240                0.1,
1241                99.0,
1242                0.0,
1243                CloseReason::Manual,
1244            ),
1245        ];
1246        let risk = RiskTranche::calculate(
1247            Some("entry".into()),
1248            Side::Buy,
1249            1.0,
1250            100.0,
1251            Some(99.0),
1252            100.0,
1253            DEFAULT_PNL_EPSILON,
1254        );
1255        let completed = CompletedPosition::from_close_events(
1256            "p1",
1257            "XAUUSD",
1258            Side::Buy,
1259            ts(0),
1260            ts(5),
1261            1.0,
1262            100.0,
1263            Some(99.0),
1264            Some(EffectiveStop::new(100.0, StopOrigin::Breakeven)),
1265            vec![risk],
1266            closes,
1267            Some(-40.0),
1268            Some(70.0),
1269            DEFAULT_PNL_EPSILON,
1270        );
1271
1272        assert_eq!(completed.net_pnl, 30.0);
1273        assert_eq!(completed.outcome, NetPnlOutcome::Win);
1274        assert_eq!(completed.initial_risk(), Some(100.0));
1275        assert_eq!(completed.realized_r, Some(0.3));
1276        assert_eq!(
1277            completed.close_reasons,
1278            vec![CloseReason::Target, CloseReason::Manual]
1279        );
1280        assert_eq!(completed.mae, Some(-40.0));
1281        assert_eq!(completed.mfe, Some(70.0));
1282    }
1283
1284    #[test]
1285    fn net_pnl_outcome_uses_absolute_epsilon() {
1286        assert_eq!(
1287            CompletedPosition::classify(0.0005, 0.001),
1288            NetPnlOutcome::Breakeven
1289        );
1290        assert_eq!(
1291            CompletedPosition::classify(-0.002, -0.001),
1292            NetPnlOutcome::Loss
1293        );
1294        assert_eq!(
1295            CompletedPosition::classify(0.002, 0.001),
1296            NetPnlOutcome::Win
1297        );
1298    }
1299
1300    #[test]
1301    fn partial_risk_basis_does_not_report_misleading_r() {
1302        let valid = RiskTranche::calculate(
1303            None,
1304            Side::Buy,
1305            1.0,
1306            10.0,
1307            Some(9.0),
1308            1.0,
1309            DEFAULT_PNL_EPSILON,
1310        );
1311        let missing =
1312            RiskTranche::calculate(None, Side::Buy, 1.0, 10.0, None, 1.0, DEFAULT_PNL_EPSILON);
1313        let completed = CompletedPosition::from_close_events(
1314            "p",
1315            "S",
1316            Side::Buy,
1317            ts(0),
1318            ts(1),
1319            2.0,
1320            10.0,
1321            Some(9.0),
1322            None,
1323            vec![valid, missing],
1324            vec![CloseEvent::new(
1325                "p",
1326                0,
1327                "S",
1328                Side::Buy,
1329                ts(1),
1330                2.0,
1331                11.0,
1332                2.0,
1333                CloseReason::Manual,
1334            )],
1335            None,
1336            None,
1337            DEFAULT_PNL_EPSILON,
1338        );
1339        assert_eq!(completed.risk_basis_status, RiskBasisStatus::Partial);
1340        assert_eq!(completed.realized_r, None);
1341    }
1342
1343    #[test]
1344    fn aggregate_deserializes_additive_fields_from_empty_object() {
1345        let artifacts: FutureBacktestArtifacts = serde_json::from_str("{}").unwrap();
1346        assert_eq!(artifacts.format_version, FUTURE_ARTIFACT_FORMAT_VERSION);
1347        assert!(artifacts.fills.is_empty());
1348        assert!(artifacts.completed_positions.is_empty());
1349        assert!(artifacts.equity_curve.is_empty());
1350        assert_eq!(artifacts.mtm_output_summary, MtmOutputSummary::default());
1351        assert_eq!(artifacts.max_drawdown, None);
1352    }
1353
1354    #[test]
1355    fn snapshots_preserve_defaults_for_forward_compatible_fields() {
1356        let open: OpenPositionSnapshot = serde_json::from_str(
1357            r#"{"position_id":"p","symbol":"EURUSD","side":"Buy","average_entry_price":1.1,"remaining_size":1.0}"#,
1358        )
1359        .unwrap();
1360        assert_eq!(open.realized_pnl, 0.0);
1361        assert_eq!(open.mark_price, None);
1362        assert_eq!(open.campaign_mae, None);
1363
1364        let pending: PendingOrderSnapshot = serde_json::from_str("{}").unwrap();
1365        assert_eq!(pending.order_type, OrderType::Limit);
1366        assert_eq!(pending.initial_stop, None);
1367
1368        let lifecycle: PendingOrderLifecycleEvent = serde_json::from_str("{}").unwrap();
1369        assert_eq!(lifecycle.state, PendingOrderLifecycleState::Placed);
1370        assert_eq!(lifecycle.filled_size, None);
1371        assert_eq!(lifecycle.terminal_ts, None);
1372    }
1373}