Skip to main content

qs_backtest/evaluation/
model.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5/// Describes whether a metric can be interpreted by a consumer.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum MetricStatus {
9    Available,
10    InsufficientData,
11    NotApplicable,
12    InvalidInput,
13}
14
15/// A metric accompanied by an explicit availability status.
16///
17/// Consumers should branch on `status`, rather than assigning a meaning to a
18/// missing value. Available metrics always contain a value; other statuses do
19/// not.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct MetricValue<T> {
22    pub status: MetricStatus,
23    pub value: Option<T>,
24    pub reason: Option<String>,
25}
26
27impl<T> Default for MetricValue<T> {
28    fn default() -> Self {
29        Self::insufficient_data("metric was not present in serialized input")
30    }
31}
32
33impl<T> MetricValue<T> {
34    pub fn available(value: T) -> Self {
35        Self {
36            status: MetricStatus::Available,
37            value: Some(value),
38            reason: None,
39        }
40    }
41
42    pub fn insufficient_data(reason: impl Into<String>) -> Self {
43        Self::unavailable(MetricStatus::InsufficientData, reason)
44    }
45
46    pub fn not_applicable(reason: impl Into<String>) -> Self {
47        Self::unavailable(MetricStatus::NotApplicable, reason)
48    }
49
50    pub fn invalid_input(reason: impl Into<String>) -> Self {
51        Self::unavailable(MetricStatus::InvalidInput, reason)
52    }
53
54    fn unavailable(status: MetricStatus, reason: impl Into<String>) -> Self {
55        Self {
56            status,
57            value: None,
58            reason: Some(reason.into()),
59        }
60    }
61}
62
63/// Normalized position direction, independent of the execution engine's side
64/// type.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum PositionSide {
68    Long,
69    Short,
70}
71
72/// Dimensions used for filtering and deterministic breakdowns.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct PositionDimensions {
75    pub symbol: String,
76    pub side: PositionSide,
77    #[serde(default)]
78    pub group: Option<String>,
79    /// A position may have multiple close reasons after partial closes.
80    #[serde(default)]
81    pub close_reasons: Vec<String>,
82    /// Provider-specific categorical dimensions (setup, session, regime, etc.).
83    #[serde(default)]
84    pub tags: BTreeMap<String, String>,
85}
86
87/// R-normalized maximum favorable and adverse excursion values.
88#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
89pub struct ExcursionInput {
90    #[serde(default)]
91    pub favorable_r: Option<f64>,
92    #[serde(default)]
93    pub adverse_r: Option<f64>,
94}
95
96/// Per-position commission and swap already applied to `outcome`.
97#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
98pub struct PositionCostsInput {
99    /// Account-currency commission charged on this position's entry and exit fills.
100    pub commission: f64,
101    /// Account-currency swap charged while this position was open.
102    pub swap: f64,
103}
104
105impl PositionCostsInput {
106    pub fn total(self) -> f64 {
107        self.commission + self.swap
108    }
109
110    pub fn is_charged(self) -> bool {
111        self.commission != 0.0 || self.swap != 0.0
112    }
113}
114
115/// Optional per-position execution observations.
116#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
117pub struct ExecutionDiagnosticsInput {
118    /// Positive values conventionally mean adverse slippage.
119    #[serde(default)]
120    pub slippage_bps: Option<f64>,
121    #[serde(default)]
122    pub latency_ms: Option<f64>,
123    /// Filled quantity divided by requested quantity, normally in `[0, 1]`.
124    #[serde(default)]
125    pub fill_ratio: Option<f64>,
126}
127
128/// Provider-supplied classification of a completed-position outcome.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum OutcomeClassification {
132    Win,
133    Loss,
134    Breakeven,
135}
136
137/// Generic completed-position input for provider evaluation.
138///
139/// `outcome` is deliberately unit-agnostic: it can be account currency, points,
140/// or another consistently applied additive result. `ordinal` defines lifecycle
141/// order for rolling metrics (for example, a close timestamp in milliseconds).
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct PositionOutcome {
144    pub id: String,
145    /// Optional provider or venue identifier associated with this position.
146    #[serde(default)]
147    pub trade_id: Option<String>,
148    pub ordinal: i64,
149    pub dimensions: PositionDimensions,
150    pub outcome: f64,
151    /// Provider classification, allowing the same configured breakeven tolerance
152    /// used during accounting to be preserved. Missing values use exact-zero
153    /// classification for backward compatibility.
154    #[serde(default)]
155    pub outcome_classification: Option<OutcomeClassification>,
156    #[serde(default)]
157    pub r_multiple: Option<f64>,
158    #[serde(default)]
159    pub excursions: Option<ExcursionInput>,
160    #[serde(default)]
161    pub execution: Option<ExecutionDiagnosticsInput>,
162    /// Costs already subtracted from `outcome`, when the run charged any.
163    #[serde(default)]
164    pub costs: Option<PositionCostsInput>,
165}
166
167impl PositionOutcome {
168    pub fn classification(&self) -> OutcomeClassification {
169        self.outcome_classification.unwrap_or({
170            if self.outcome > 0.0 {
171                OutcomeClassification::Win
172            } else if self.outcome < 0.0 {
173                OutcomeClassification::Loss
174            } else {
175                OutcomeClassification::Breakeven
176            }
177        })
178    }
179}
180
181/// Aggregate lifecycle counters supplied by a provider integration.
182///
183/// These counters are intentionally independent of completed-position rows, so
184/// rejected, expired, or still-open candidates can be represented.
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(default)]
187pub struct LifecycleCounts {
188    pub candidates: u64,
189    pub accepted: u64,
190    pub opened: u64,
191    pub completed: u64,
192    pub rejected: u64,
193    /// Accepted pending entries that reached the typed `Filled` terminal state.
194    pub filled: u64,
195    /// Accepted pending entries that reached the typed `Cancelled` terminal state.
196    pub cancelled: u64,
197    /// Accepted pending entries still unfilled when replay ended.
198    pub unfilled_at_end: u64,
199    pub open_at_end: u64,
200}
201
202/// Parser/source coverage supplied by an integration that can observe raw input.
203///
204/// The status counts partition `raw_messages`. Every parsed message emits at
205/// least one signal, and entry signals are a subset of all emitted signals.
206#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct SourceCoverageCounts {
209    pub raw_messages: u64,
210    pub parsed_messages: u64,
211    pub skipped_messages: u64,
212    pub failed_messages: u64,
213    pub emitted_signals: u64,
214    pub emitted_entry_signals: u64,
215}
216
217impl SourceCoverageCounts {
218    pub fn validation_error(self) -> Option<String> {
219        let Some(classified) = self
220            .parsed_messages
221            .checked_add(self.skipped_messages)
222            .and_then(|count| count.checked_add(self.failed_messages))
223        else {
224            return Some("parsed/skipped/failed message counts overflow u64".into());
225        };
226        if classified != self.raw_messages {
227            return Some(format!(
228                "raw_messages ({}) must equal parsed_messages + skipped_messages + failed_messages ({classified})",
229                self.raw_messages
230            ));
231        }
232        if self.emitted_signals < self.parsed_messages {
233            return Some(format!(
234                "emitted_signals ({}) cannot be less than parsed_messages ({})",
235                self.emitted_signals, self.parsed_messages
236            ));
237        }
238        if self.emitted_entry_signals > self.emitted_signals {
239            return Some(format!(
240                "emitted_entry_signals ({}) cannot exceed emitted_signals ({})",
241                self.emitted_entry_signals, self.emitted_signals
242            ));
243        }
244        None
245    }
246}
247
248/// Selects grouped positions, including positions that have no group.
249#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum GroupFilter {
252    Named(String),
253    Ungrouped,
254}
255
256/// Typed position filter.
257///
258/// Values within one field are ORed. Populated fields (and individual tag keys)
259/// are ANDed with each other. Empty fields impose no constraint.
260#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261pub struct PositionFilter {
262    #[serde(default)]
263    pub symbols: Vec<String>,
264    #[serde(default)]
265    pub sides: Vec<PositionSide>,
266    #[serde(default)]
267    pub groups: Vec<GroupFilter>,
268    #[serde(default)]
269    pub close_reasons: Vec<String>,
270    /// Each key is a separate dimension. Values for that key are ORed.
271    #[serde(default)]
272    pub tags: BTreeMap<String, Vec<String>>,
273}
274
275impl PositionFilter {
276    pub fn matches(&self, position: &PositionOutcome) -> bool {
277        let dimensions = &position.dimensions;
278
279        let symbol_matches = self.symbols.is_empty()
280            || self
281                .symbols
282                .iter()
283                .any(|symbol| symbol == &dimensions.symbol);
284        let side_matches = self.sides.is_empty() || self.sides.contains(&dimensions.side);
285        let group_matches = self.groups.is_empty()
286            || self.groups.iter().any(|group| match group {
287                GroupFilter::Named(name) => dimensions.group.as_ref() == Some(name),
288                GroupFilter::Ungrouped => dimensions.group.is_none(),
289            });
290        let close_reason_matches = self.close_reasons.is_empty()
291            || self.close_reasons.iter().any(|expected| {
292                dimensions
293                    .close_reasons
294                    .iter()
295                    .any(|actual| actual == expected)
296            });
297        let tags_match = self.tags.iter().all(|(key, accepted_values)| {
298            accepted_values.is_empty()
299                || dimensions
300                    .tags
301                    .get(key)
302                    .is_some_and(|actual| accepted_values.iter().any(|value| value == actual))
303        });
304
305        symbol_matches && side_matches && group_matches && close_reason_matches && tags_match
306    }
307}
308
309/// A requested categorical breakdown. Duplicate requests are evaluated once.
310#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum BreakdownDimension {
313    Symbol,
314    Side,
315    Group,
316    CloseReason,
317    Tag(String),
318}
319
320/// Typed and sortable breakdown key.
321#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum BreakdownValue {
324    Text(String),
325    Side(PositionSide),
326    Missing,
327}
328
329/// Configuration for deterministic bootstrap confidence intervals.
330#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
331#[serde(default)]
332pub struct BootstrapConfig {
333    pub samples: usize,
334    pub confidence_level: f64,
335    pub seed: u64,
336    pub minimum_sample_size: usize,
337}
338
339impl Default for BootstrapConfig {
340    fn default() -> Self {
341        Self {
342            samples: 2_000,
343            confidence_level: 0.95,
344            seed: 0xA076_1D64_78BD_642F,
345            minimum_sample_size: 5,
346        }
347    }
348}
349
350/// Provider and source identifiers attached to an evaluation without changing
351/// the normalized position rows.
352#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(default, deny_unknown_fields)]
354pub struct EvaluationContext {
355    pub provider_id: Option<String>,
356    pub source_id: Option<String>,
357}
358
359impl EvaluationContext {
360    pub fn is_empty(&self) -> bool {
361        self.provider_id.is_none() && self.source_id.is_none()
362    }
363}
364
365/// Independently selectable provider-report sections.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum EvaluationSection {
369    Coverage,
370    PositionPerformance,
371    RMetrics,
372    Excursions,
373    Execution,
374    Costs,
375    Robustness,
376    Breakdowns,
377}
378
379impl EvaluationSection {
380    pub const ALL: [Self; 8] = [
381        Self::Coverage,
382        Self::PositionPerformance,
383        Self::RMetrics,
384        Self::Excursions,
385        Self::Execution,
386        Self::Costs,
387        Self::Robustness,
388        Self::Breakdowns,
389    ];
390
391    pub fn all() -> BTreeSet<Self> {
392        Self::ALL.into_iter().collect()
393    }
394}
395
396fn default_evaluation_sections() -> BTreeSet<EvaluationSection> {
397    EvaluationSection::all()
398}
399
400const fn default_rolling_window() -> usize {
401    20
402}
403
404const fn default_minimum_breakdown_bucket_count() -> usize {
405    1
406}
407
408/// Typed report configuration, deliberately separate from normalized position
409/// and lifecycle inputs.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411#[serde(default)]
412pub struct EvaluationOptions {
413    pub context: EvaluationContext,
414    /// Optional parser/source funnel counts. Absence means source coverage was unavailable.
415    pub source_coverage: Option<SourceCoverageCounts>,
416    /// Missing selectors request all sections; an explicit empty set requests none.
417    #[serde(default = "default_evaluation_sections")]
418    pub sections: BTreeSet<EvaluationSection>,
419    pub filter: PositionFilter,
420    pub breakdowns: Vec<BreakdownDimension>,
421    pub bootstrap: BootstrapConfig,
422    /// Number of chronologically ordered completed positions per rolling window.
423    pub rolling_window: usize,
424    /// Buckets with fewer selected positions are omitted before row limiting.
425    pub minimum_breakdown_bucket_count: usize,
426    /// Global deterministic cap across all requested breakdown bucket rows.
427    pub maximum_breakdown_rows: Option<usize>,
428    /// Include normalized rows selected by the same evaluation filter.
429    pub include_position_rows: bool,
430    /// Deterministic cap for included normalized position rows.
431    pub maximum_position_rows: Option<usize>,
432}
433
434impl Default for EvaluationOptions {
435    fn default() -> Self {
436        Self {
437            context: EvaluationContext::default(),
438            source_coverage: None,
439            sections: EvaluationSection::all(),
440            filter: PositionFilter::default(),
441            breakdowns: Vec::new(),
442            bootstrap: BootstrapConfig::default(),
443            rolling_window: default_rolling_window(),
444            minimum_breakdown_bucket_count: default_minimum_breakdown_bucket_count(),
445            maximum_breakdown_rows: None,
446            include_position_rows: false,
447            maximum_position_rows: None,
448        }
449    }
450}
451
452/// Complete input to [`super::evaluate`]. `options` is flattened so payloads
453/// produced before `EvaluationOptions` was introduced retain the same serde shape.
454#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
455pub struct EvaluationRequest {
456    pub positions: Vec<PositionOutcome>,
457    #[serde(default)]
458    pub lifecycle: Option<LifecycleCounts>,
459    #[serde(flatten, default)]
460    pub options: EvaluationOptions,
461}
462
463impl std::ops::Deref for EvaluationRequest {
464    type Target = EvaluationOptions;
465
466    fn deref(&self) -> &Self::Target {
467        &self.options
468    }
469}
470
471impl std::ops::DerefMut for EvaluationRequest {
472    fn deref_mut(&mut self) -> &mut Self::Target {
473        &mut self.options
474    }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
478pub struct ConfidenceInterval {
479    pub estimate: f64,
480    pub lower: f64,
481    pub upper: f64,
482    pub confidence_level: f64,
483}
484
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486pub struct CoverageSection {
487    pub provided_positions: usize,
488    pub selected_positions: usize,
489    pub filtered_out_positions: usize,
490    pub valid_outcomes: usize,
491    pub invalid_outcomes: usize,
492    /// `None` explicitly means raw parser/source outcomes were unavailable.
493    pub source: Option<SourceCoverageCounts>,
494    pub lifecycle: Option<LifecycleCounts>,
495    pub acceptance_rate: MetricValue<f64>,
496    pub open_rate: MetricValue<f64>,
497    pub completion_rate: MetricValue<f64>,
498    pub r_coverage: MetricValue<f64>,
499    pub excursion_coverage: MetricValue<f64>,
500    pub execution_coverage: MetricValue<f64>,
501}
502
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504pub struct PositionPerformanceSection {
505    pub position_count: usize,
506    pub wins: usize,
507    pub losses: usize,
508    pub breakeven: usize,
509    pub total_outcome: MetricValue<f64>,
510    pub mean_outcome: MetricValue<f64>,
511    pub median_outcome: MetricValue<f64>,
512    pub win_rate: MetricValue<f64>,
513    pub win_rate_confidence: MetricValue<ConfidenceInterval>,
514    pub gross_positive: MetricValue<f64>,
515    pub gross_negative: MetricValue<f64>,
516    pub profit_factor: MetricValue<f64>,
517    pub payoff_ratio: MetricValue<f64>,
518    pub best_outcome: MetricValue<f64>,
519    pub worst_outcome: MetricValue<f64>,
520    pub mean_outcome_confidence: MetricValue<ConfidenceInterval>,
521}
522
523/// Deterministic type-7 quantiles of finite realized-R observations.
524#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
525pub struct RQuantiles {
526    pub p05: f64,
527    pub p10: f64,
528    pub p25: f64,
529    pub p50: f64,
530    pub p75: f64,
531    pub p90: f64,
532    pub p95: f64,
533}
534
535/// One chronologically ordered point on the cumulative realized-R curve.
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
537pub struct CumulativeRPoint {
538    pub position_id: String,
539    pub ordinal: i64,
540    pub realized_r: f64,
541    pub cumulative_r: f64,
542}
543
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
545pub struct RMetricsSection {
546    pub observed_count: usize,
547    pub missing_or_invalid_count: usize,
548    pub total_r: MetricValue<f64>,
549    pub mean_r: MetricValue<f64>,
550    pub median_r: MetricValue<f64>,
551    pub standard_deviation_r: MetricValue<f64>,
552    pub positive_r_rate: MetricValue<f64>,
553    pub positive_r_rate_confidence: MetricValue<ConfidenceInterval>,
554    pub mean_r_confidence: MetricValue<ConfidenceInterval>,
555    /// Sum of positive R divided by the absolute sum of negative R.
556    #[serde(default)]
557    pub profit_factor: MetricValue<f64>,
558    #[serde(default)]
559    pub average_winner_r: MetricValue<f64>,
560    /// Arithmetic mean of negative R observations (retains its negative sign).
561    #[serde(default)]
562    pub average_loser_r: MetricValue<f64>,
563    #[serde(default)]
564    pub best_r: MetricValue<f64>,
565    #[serde(default)]
566    pub worst_r: MetricValue<f64>,
567    #[serde(default)]
568    pub quantiles: MetricValue<RQuantiles>,
569    /// Ordered by `(ordinal, position_id, realized_r)` for deterministic output.
570    #[serde(default)]
571    pub cumulative_r_curve: MetricValue<Vec<CumulativeRPoint>>,
572    /// Largest peak-to-trough decline on the cumulative realized-R curve.
573    #[serde(default)]
574    pub max_realized_r_drawdown: MetricValue<f64>,
575}
576
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578pub struct ExcursionMetricsSection {
579    pub favorable_observed_count: usize,
580    pub adverse_observed_count: usize,
581    pub mean_favorable_r: MetricValue<f64>,
582    pub median_favorable_r: MetricValue<f64>,
583    pub mean_adverse_r: MetricValue<f64>,
584    pub median_adverse_r: MetricValue<f64>,
585}
586
587/// Commission and swap actually charged across the selected positions.
588///
589/// Every outcome in this evaluation is already net of these amounts, so the section exists to show how large the deduction was, not to adjust anything.
590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
591pub struct CostMetricsSection {
592    pub positions_with_costs: usize,
593    pub total_commission: f64,
594    pub total_swap: f64,
595    pub total_cost: f64,
596    /// Sum of outcomes before costs.
597    pub gross_outcome: f64,
598    /// Sum of outcomes after costs, matching the outcomes used everywhere else in this report.
599    pub net_outcome: f64,
600    /// `total_cost / gross_outcome.abs()`, unavailable when the gross outcome is zero.
601    pub cost_share_of_gross: MetricValue<f64>,
602    pub mean_cost_per_position: MetricValue<f64>,
603}
604
605#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
606pub struct ExecutionDiagnosticsSection {
607    pub positions_with_diagnostics: usize,
608    pub slippage_observed_count: usize,
609    pub latency_observed_count: usize,
610    pub fill_ratio_observed_count: usize,
611    pub mean_slippage_bps: MetricValue<f64>,
612    pub median_slippage_bps: MetricValue<f64>,
613    pub adverse_slippage_rate: MetricValue<f64>,
614    pub mean_latency_ms: MetricValue<f64>,
615    pub median_latency_ms: MetricValue<f64>,
616    pub mean_fill_ratio: MetricValue<f64>,
617}
618
619#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
620pub struct RemovalImpact {
621    pub removed_count: usize,
622    pub original_total: f64,
623    pub removed_total: f64,
624    pub remaining_total: f64,
625    pub remaining_mean: f64,
626}
627
628#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
629pub struct RollingOutcome {
630    pub start_ordinal: i64,
631    pub end_ordinal: i64,
632    pub position_count: usize,
633    pub total_outcome: f64,
634    pub mean_outcome: f64,
635}
636
637#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
638pub struct RollingOutcomes {
639    pub window_size: usize,
640    pub windows: Vec<RollingOutcome>,
641    pub worst_window_mean: MetricValue<f64>,
642    pub best_window_mean: MetricValue<f64>,
643    pub positive_window_rate: MetricValue<f64>,
644}
645
646/// Shares of gross positive completed-position P&L contributed by the top N winners.
647#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
648#[serde(default)]
649pub struct PnlConcentrationSection {
650    pub top_1: MetricValue<f64>,
651    pub top_3: MetricValue<f64>,
652    pub top_5: MetricValue<f64>,
653    pub top_10: MetricValue<f64>,
654}
655
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657pub struct IntrinsicRobustnessSection {
658    pub best_one_removed: MetricValue<RemovalImpact>,
659    pub best_five_percent_removed: MetricValue<RemovalImpact>,
660    /// Share of gross positive outcome contributed by the best position.
661    pub best_one_positive_concentration: MetricValue<f64>,
662    /// Share of gross positive outcome contributed by the best 5% of positions.
663    pub best_five_percent_positive_concentration: MetricValue<f64>,
664    /// Fixed-count concentration complements the sample-size-relative 5% metric.
665    #[serde(default)]
666    pub pnl_concentration: PnlConcentrationSection,
667    pub rolling_outcomes: RollingOutcomes,
668}
669
670#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
671pub struct BreakdownBucket {
672    pub value: BreakdownValue,
673    pub performance: PositionPerformanceSection,
674    pub r_metrics: RMetricsSection,
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678pub struct EvaluationBreakdown {
679    pub dimension: BreakdownDimension,
680    /// Sorted by `BreakdownValue`; this ordering does not depend on hash seeds or
681    /// source position order.
682    pub buckets: Vec<BreakdownBucket>,
683}
684
685/// Visibility into minimum-count filtering and global breakdown row truncation.
686#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
687#[serde(default)]
688pub struct BreakdownRowSummary {
689    pub available_rows: usize,
690    pub included_rows: usize,
691    pub truncated: bool,
692}
693
694impl BreakdownRowSummary {
695    pub fn is_empty(&self) -> bool {
696        self.available_rows == 0 && self.included_rows == 0 && !self.truncated
697    }
698}
699
700/// Filtered normalized position rows included for metric reconciliation.
701#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
702#[serde(default)]
703pub struct EvaluationPositionRows {
704    pub available_rows: usize,
705    pub included_rows: usize,
706    pub truncated: bool,
707    pub rows: Vec<PositionOutcome>,
708}
709
710fn requested_sections_default() -> BTreeSet<EvaluationSection> {
711    EvaluationSection::all()
712}
713
714fn requested_all_sections(sections: &BTreeSet<EvaluationSection>) -> bool {
715    *sections == EvaluationSection::all()
716}
717
718/// Provider-evaluation result. It intentionally has no aggregate score, rank, or
719/// rating; consumers decide which individual sections matter for their use case.
720///
721/// Requested sections serialize exactly as they did before section selection was
722/// introduced. Unrequested sections are omitted and remain `None` when decoded.
723#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
724pub struct EvaluationReport {
725    #[serde(default, skip_serializing_if = "EvaluationContext::is_empty")]
726    pub context: EvaluationContext,
727    #[serde(
728        default = "requested_sections_default",
729        skip_serializing_if = "requested_all_sections"
730    )]
731    pub requested_sections: BTreeSet<EvaluationSection>,
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub coverage: Option<CoverageSection>,
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub position_performance: Option<PositionPerformanceSection>,
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub r_metrics: Option<RMetricsSection>,
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub excursions: Option<ExcursionMetricsSection>,
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub execution: Option<ExecutionDiagnosticsSection>,
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub costs: Option<CostMetricsSection>,
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub robustness: Option<IntrinsicRobustnessSection>,
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub breakdowns: Option<Vec<EvaluationBreakdown>>,
748    #[serde(default, skip_serializing_if = "BreakdownRowSummary::is_empty")]
749    pub breakdown_rows: BreakdownRowSummary,
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub position_rows: Option<EvaluationPositionRows>,
752}