Skip to main content

qs_backtest/evaluation/
evaluator.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use super::stats::{mean, median, quantile_sorted, sample_standard_deviation};
4use super::{
5    BootstrapConfig, BreakdownBucket, BreakdownDimension, BreakdownRowSummary, BreakdownValue,
6    CoverageSection, CumulativeRPoint, EvaluationBreakdown, EvaluationPositionRows,
7    EvaluationReport, EvaluationRequest, EvaluationSection, ExcursionMetricsSection,
8    ExecutionDiagnosticsSection, IntrinsicRobustnessSection, LifecycleCounts, MetricValue,
9    OutcomeClassification, PnlConcentrationSection, PositionOutcome, PositionPerformanceSection,
10    RMetricsSection, RQuantiles, RemovalImpact, RollingOutcome, RollingOutcomes,
11    bootstrap_mean_confidence, wilson_interval,
12};
13
14/// Evaluates normalized provider outcomes without coupling to a backtest report.
15///
16/// Invalid non-finite `outcome` values remain visible in coverage but are omitted
17/// from outcome-dependent calculations. Optional R, excursion, and execution
18/// observations are independently included when finite.
19pub fn evaluate(request: &EvaluationRequest) -> EvaluationReport {
20    let selected: Vec<&PositionOutcome> = request
21        .positions
22        .iter()
23        .filter(|position| request.filter.matches(position))
24        .collect();
25
26    let section_requested = |section| request.sections.contains(&section);
27    let (breakdowns, breakdown_rows) = if section_requested(EvaluationSection::Breakdowns) {
28        let (breakdowns, summary) = breakdowns(request, &selected);
29        (Some(breakdowns), summary)
30    } else {
31        (None, BreakdownRowSummary::default())
32    };
33
34    EvaluationReport {
35        context: request.context.clone(),
36        requested_sections: request.sections.clone(),
37        coverage: section_requested(EvaluationSection::Coverage)
38            .then(|| coverage(request, &selected)),
39        position_performance: section_requested(EvaluationSection::PositionPerformance)
40            .then(|| performance(&selected, request.bootstrap)),
41        r_metrics: section_requested(EvaluationSection::RMetrics)
42            .then(|| r_metrics(&selected, request.bootstrap)),
43        excursions: section_requested(EvaluationSection::Excursions)
44            .then(|| excursion_metrics(&selected)),
45        execution: section_requested(EvaluationSection::Execution)
46            .then(|| execution_metrics(&selected)),
47        robustness: section_requested(EvaluationSection::Robustness)
48            .then(|| robustness(&selected, request.rolling_window)),
49        breakdowns,
50        breakdown_rows,
51        position_rows: request
52            .include_position_rows
53            .then(|| selected_position_rows(&selected, request.maximum_position_rows)),
54    }
55}
56
57fn selected_position_rows(
58    positions: &[&PositionOutcome],
59    maximum_rows: Option<usize>,
60) -> EvaluationPositionRows {
61    let mut rows: Vec<_> = positions
62        .iter()
63        .map(|position| (*position).clone())
64        .collect();
65    rows.sort_by(|left, right| {
66        left.ordinal
67            .cmp(&right.ordinal)
68            .then_with(|| left.id.cmp(&right.id))
69    });
70    let available_rows = rows.len();
71    if let Some(maximum_rows) = maximum_rows {
72        rows.truncate(maximum_rows);
73    }
74    EvaluationPositionRows {
75        available_rows,
76        included_rows: rows.len(),
77        truncated: rows.len() < available_rows,
78        rows,
79    }
80}
81
82fn coverage(request: &EvaluationRequest, positions: &[&PositionOutcome]) -> CoverageSection {
83    let valid_outcomes = positions
84        .iter()
85        .filter(|position| position.outcome.is_finite())
86        .count();
87    let r_count = positions
88        .iter()
89        .filter(|position| position.r_multiple.is_some_and(f64::is_finite))
90        .count();
91    let excursion_count = positions
92        .iter()
93        .filter(|position| {
94            position.excursions.is_some_and(|excursion| {
95                excursion.favorable_r.is_some_and(f64::is_finite)
96                    || excursion.adverse_r.is_some_and(f64::is_finite)
97            })
98        })
99        .count();
100    let execution_count = positions
101        .iter()
102        .filter(|position| {
103            position.execution.is_some_and(|execution| {
104                execution.slippage_bps.is_some_and(f64::is_finite)
105                    || execution.latency_ms.is_some_and(f64::is_finite)
106                    || execution.fill_ratio.is_some_and(f64::is_finite)
107            })
108        })
109        .count();
110
111    let (acceptance_rate, open_rate, completion_rate) = match request.lifecycle {
112        Some(lifecycle) => lifecycle_rates(lifecycle),
113        None => (
114            MetricValue::not_applicable("lifecycle counts were not provided"),
115            MetricValue::not_applicable("lifecycle counts were not provided"),
116            MetricValue::not_applicable("lifecycle counts were not provided"),
117        ),
118    };
119
120    CoverageSection {
121        provided_positions: request.positions.len(),
122        selected_positions: positions.len(),
123        filtered_out_positions: request.positions.len() - positions.len(),
124        valid_outcomes,
125        invalid_outcomes: positions.len() - valid_outcomes,
126        source: request.source_coverage,
127        lifecycle: request.lifecycle,
128        acceptance_rate,
129        open_rate,
130        completion_rate,
131        r_coverage: observation_coverage(r_count, positions.len(), "R observations"),
132        excursion_coverage: observation_coverage(
133            excursion_count,
134            positions.len(),
135            "excursion observations",
136        ),
137        execution_coverage: observation_coverage(
138            execution_count,
139            positions.len(),
140            "execution observations",
141        ),
142    }
143}
144
145fn lifecycle_rates(
146    lifecycle: LifecycleCounts,
147) -> (MetricValue<f64>, MetricValue<f64>, MetricValue<f64>) {
148    (
149        bounded_rate(
150            lifecycle.accepted,
151            lifecycle.candidates,
152            "accepted",
153            "candidates",
154        ),
155        bounded_rate(lifecycle.opened, lifecycle.accepted, "opened", "accepted"),
156        bounded_rate(lifecycle.completed, lifecycle.opened, "completed", "opened"),
157    )
158}
159
160fn bounded_rate(
161    numerator: u64,
162    denominator: u64,
163    numerator_name: &str,
164    denominator_name: &str,
165) -> MetricValue<f64> {
166    if numerator > denominator {
167        return MetricValue::invalid_input(format!(
168            "{numerator_name} cannot exceed {denominator_name}"
169        ));
170    }
171    if denominator == 0 {
172        return MetricValue::insufficient_data(format!(
173            "{denominator_name} must be greater than zero"
174        ));
175    }
176    MetricValue::available(numerator as f64 / denominator as f64)
177}
178
179fn observation_coverage(count: usize, total: usize, name: &str) -> MetricValue<f64> {
180    if total == 0 {
181        MetricValue::insufficient_data(format!(
182            "at least one selected position is required for {name} coverage"
183        ))
184    } else {
185        MetricValue::available(count as f64 / total as f64)
186    }
187}
188
189fn performance(
190    positions: &[&PositionOutcome],
191    bootstrap: BootstrapConfig,
192) -> PositionPerformanceSection {
193    let positions: Vec<&PositionOutcome> = positions
194        .iter()
195        .copied()
196        .filter(|position| position.outcome.is_finite())
197        .collect();
198    let wins = positions
199        .iter()
200        .filter(|position| position.classification() == OutcomeClassification::Win)
201        .count();
202    let losses = positions
203        .iter()
204        .filter(|position| position.classification() == OutcomeClassification::Loss)
205        .count();
206    let breakeven = positions.len() - wins - losses;
207
208    if positions.is_empty() {
209        return PositionPerformanceSection {
210            position_count: 0,
211            wins,
212            losses,
213            breakeven,
214            total_outcome: no_outcomes(),
215            mean_outcome: no_outcomes(),
216            median_outcome: no_outcomes(),
217            win_rate: no_outcomes(),
218            win_rate_confidence: MetricValue::insufficient_data(
219                "at least one finite outcome is required",
220            ),
221            gross_positive: no_outcomes(),
222            gross_negative: no_outcomes(),
223            profit_factor: no_outcomes(),
224            payoff_ratio: no_outcomes(),
225            best_outcome: no_outcomes(),
226            worst_outcome: no_outcomes(),
227            mean_outcome_confidence: bootstrap_mean_confidence(&[], bootstrap),
228        };
229    }
230
231    let outcomes: Vec<f64> = positions.iter().map(|position| position.outcome).collect();
232    let total = outcomes.iter().sum::<f64>();
233    let gross_positive = positions
234        .iter()
235        .filter(|position| position.classification() == OutcomeClassification::Win)
236        .map(|position| position.outcome.abs())
237        .sum::<f64>();
238    let gross_negative = positions
239        .iter()
240        .filter(|position| position.classification() == OutcomeClassification::Loss)
241        .map(|position| position.outcome.abs())
242        .sum::<f64>();
243    let average_win = (wins > 0).then(|| gross_positive / wins as f64);
244    let average_loss = (losses > 0).then(|| gross_negative / losses as f64);
245
246    PositionPerformanceSection {
247        position_count: positions.len(),
248        wins,
249        losses,
250        breakeven,
251        total_outcome: MetricValue::available(total),
252        mean_outcome: MetricValue::available(total / positions.len() as f64),
253        median_outcome: MetricValue::available(
254            median(&outcomes).expect("non-empty outcomes checked above"),
255        ),
256        win_rate: MetricValue::available(wins as f64 / outcomes.len() as f64),
257        win_rate_confidence: wilson_interval(wins, outcomes.len(), bootstrap.confidence_level),
258        gross_positive: MetricValue::available(gross_positive),
259        gross_negative: MetricValue::available(gross_negative),
260        profit_factor: if gross_negative > 0.0 {
261            MetricValue::available(gross_positive / gross_negative)
262        } else {
263            MetricValue::not_applicable("profit factor requires at least one losing position")
264        },
265        payoff_ratio: match (average_win, average_loss) {
266            (Some(win), Some(loss)) => MetricValue::available(win / loss),
267            _ => MetricValue::not_applicable(
268                "payoff ratio requires both winning and losing positions",
269            ),
270        },
271        best_outcome: MetricValue::available(
272            outcomes
273                .iter()
274                .copied()
275                .max_by(f64::total_cmp)
276                .expect("non-empty outcomes checked above"),
277        ),
278        worst_outcome: MetricValue::available(
279            outcomes
280                .iter()
281                .copied()
282                .min_by(f64::total_cmp)
283                .expect("non-empty outcomes checked above"),
284        ),
285        mean_outcome_confidence: bootstrap_mean_confidence(&outcomes, bootstrap),
286    }
287}
288
289fn no_outcomes<T>() -> MetricValue<T> {
290    MetricValue::insufficient_data("at least one finite outcome is required")
291}
292
293fn r_metrics(positions: &[&PositionOutcome], bootstrap: BootstrapConfig) -> RMetricsSection {
294    let mut observed: Vec<(&PositionOutcome, f64)> = positions
295        .iter()
296        .filter_map(|position| {
297            position
298                .r_multiple
299                .filter(|value| value.is_finite())
300                .map(|value| (*position, value))
301        })
302        .collect();
303    observed.sort_by(|(left, left_r), (right, right_r)| {
304        left.ordinal
305            .cmp(&right.ordinal)
306            .then_with(|| left.id.cmp(&right.id))
307            .then_with(|| left_r.total_cmp(right_r))
308    });
309    let values: Vec<f64> = observed.iter().map(|(_, value)| *value).collect();
310    let missing_or_invalid_count = positions.len() - values.len();
311
312    if values.is_empty() {
313        return RMetricsSection {
314            observed_count: 0,
315            missing_or_invalid_count,
316            total_r: no_r(),
317            mean_r: no_r(),
318            median_r: no_r(),
319            standard_deviation_r: no_r(),
320            positive_r_rate: no_r(),
321            positive_r_rate_confidence: no_r(),
322            mean_r_confidence: bootstrap_mean_confidence(&values, bootstrap),
323            profit_factor: no_r(),
324            average_winner_r: no_r(),
325            average_loser_r: no_r(),
326            best_r: no_r(),
327            worst_r: no_r(),
328            quantiles: no_r(),
329            cumulative_r_curve: no_r(),
330            max_realized_r_drawdown: no_r(),
331        };
332    }
333
334    let positive_values: Vec<f64> = values
335        .iter()
336        .copied()
337        .filter(|value| *value > 0.0)
338        .collect();
339    let negative_values: Vec<f64> = values
340        .iter()
341        .copied()
342        .filter(|value| *value < 0.0)
343        .collect();
344    let gross_positive = positive_values.iter().sum::<f64>();
345    let gross_negative = negative_values.iter().map(|value| value.abs()).sum::<f64>();
346    let (cumulative_r_curve, max_realized_r_drawdown) = cumulative_r_metrics(&observed);
347
348    RMetricsSection {
349        observed_count: values.len(),
350        missing_or_invalid_count,
351        total_r: finite_r_metric(values.iter().sum(), "total R"),
352        mean_r: finite_r_metric(
353            mean(&values).expect("non-empty R values checked above"),
354            "mean R",
355        ),
356        median_r: finite_r_metric(
357            median(&values).expect("non-empty R values checked above"),
358            "median R",
359        ),
360        standard_deviation_r: sample_standard_deviation(&values).map_or_else(
361            || MetricValue::insufficient_data("at least two R observations are required"),
362            |value| finite_r_metric(value, "R standard deviation"),
363        ),
364        positive_r_rate: MetricValue::available(positive_values.len() as f64 / values.len() as f64),
365        positive_r_rate_confidence: wilson_interval(
366            positive_values.len(),
367            values.len(),
368            bootstrap.confidence_level,
369        ),
370        mean_r_confidence: bootstrap_mean_confidence(&values, bootstrap),
371        profit_factor: if !gross_positive.is_finite() || !gross_negative.is_finite() {
372            MetricValue::invalid_input("R profit-factor totals exceed the finite f64 range")
373        } else if gross_negative > 0.0 {
374            finite_r_metric(gross_positive / gross_negative, "R profit factor")
375        } else {
376            MetricValue::not_applicable(
377                "R profit factor requires at least one negative R observation",
378            )
379        },
380        average_winner_r: observed_r_average(&positive_values, "positive"),
381        average_loser_r: observed_r_average(&negative_values, "negative"),
382        best_r: finite_r_metric(
383            values
384                .iter()
385                .copied()
386                .max_by(f64::total_cmp)
387                .expect("non-empty R values checked above"),
388            "best R",
389        ),
390        worst_r: finite_r_metric(
391            values
392                .iter()
393                .copied()
394                .min_by(f64::total_cmp)
395                .expect("non-empty R values checked above"),
396            "worst R",
397        ),
398        quantiles: r_quantiles(&values),
399        cumulative_r_curve,
400        max_realized_r_drawdown,
401    }
402}
403
404fn no_r<T>() -> MetricValue<T> {
405    MetricValue::insufficient_data("at least one finite R observation is required")
406}
407
408fn finite_r_metric(value: f64, name: &str) -> MetricValue<f64> {
409    if value.is_finite() {
410        MetricValue::available(value)
411    } else {
412        MetricValue::invalid_input(format!("{name} exceeds the finite f64 range"))
413    }
414}
415
416fn observed_r_average(values: &[f64], sign: &str) -> MetricValue<f64> {
417    if values.is_empty() {
418        MetricValue::not_applicable(format!(
419            "average {sign} R requires at least one {sign} R observation"
420        ))
421    } else {
422        finite_r_metric(
423            mean(values).expect("non-empty R values checked above"),
424            &format!("average {sign} R"),
425        )
426    }
427}
428
429fn r_quantiles(values: &[f64]) -> MetricValue<RQuantiles> {
430    let mut sorted = values.to_vec();
431    sorted.sort_by(f64::total_cmp);
432    let quantiles = RQuantiles {
433        p05: quantile_sorted(&sorted, 0.05),
434        p10: quantile_sorted(&sorted, 0.10),
435        p25: quantile_sorted(&sorted, 0.25),
436        p50: quantile_sorted(&sorted, 0.50),
437        p75: quantile_sorted(&sorted, 0.75),
438        p90: quantile_sorted(&sorted, 0.90),
439        p95: quantile_sorted(&sorted, 0.95),
440    };
441    let values = [
442        quantiles.p05,
443        quantiles.p10,
444        quantiles.p25,
445        quantiles.p50,
446        quantiles.p75,
447        quantiles.p90,
448        quantiles.p95,
449    ];
450    if values.into_iter().all(f64::is_finite) {
451        MetricValue::available(quantiles)
452    } else {
453        MetricValue::invalid_input("R quantiles exceed the finite f64 range")
454    }
455}
456
457fn cumulative_r_metrics(
458    observed: &[(&PositionOutcome, f64)],
459) -> (MetricValue<Vec<CumulativeRPoint>>, MetricValue<f64>) {
460    let mut cumulative_r = 0.0_f64;
461    let mut peak_r = 0.0_f64;
462    let mut max_drawdown_r = 0.0_f64;
463    let mut drawdown_overflowed = false;
464    let mut curve = Vec::with_capacity(observed.len());
465
466    for (position, realized_r) in observed {
467        cumulative_r += realized_r;
468        if !cumulative_r.is_finite() {
469            let reason = "cumulative realized R exceeds the finite f64 range";
470            return (
471                MetricValue::invalid_input(reason),
472                MetricValue::invalid_input(reason),
473            );
474        }
475        peak_r = peak_r.max(cumulative_r);
476        let drawdown_r = peak_r - cumulative_r;
477        if drawdown_r.is_finite() {
478            max_drawdown_r = max_drawdown_r.max(drawdown_r);
479        } else {
480            drawdown_overflowed = true;
481        }
482        curve.push(CumulativeRPoint {
483            position_id: position.id.clone(),
484            ordinal: position.ordinal,
485            realized_r: *realized_r,
486            cumulative_r,
487        });
488    }
489
490    let drawdown = if drawdown_overflowed {
491        MetricValue::invalid_input("realized-R drawdown exceeds the finite f64 range")
492    } else {
493        MetricValue::available(max_drawdown_r)
494    };
495    (MetricValue::available(curve), drawdown)
496}
497
498fn excursion_metrics(positions: &[&PositionOutcome]) -> ExcursionMetricsSection {
499    let favorable: Vec<f64> = positions
500        .iter()
501        .filter_map(|position| {
502            position
503                .excursions
504                .and_then(|value| value.favorable_r)
505                .filter(|value| value.is_finite())
506        })
507        .collect();
508    let adverse: Vec<f64> = positions
509        .iter()
510        .filter_map(|position| {
511            position
512                .excursions
513                .and_then(|value| value.adverse_r)
514                .filter(|value| value.is_finite())
515        })
516        .collect();
517
518    ExcursionMetricsSection {
519        favorable_observed_count: favorable.len(),
520        adverse_observed_count: adverse.len(),
521        mean_favorable_r: observed_mean(&favorable, "favorable excursion"),
522        median_favorable_r: observed_median(&favorable, "favorable excursion"),
523        mean_adverse_r: observed_mean(&adverse, "adverse excursion"),
524        median_adverse_r: observed_median(&adverse, "adverse excursion"),
525    }
526}
527
528fn execution_metrics(positions: &[&PositionOutcome]) -> ExecutionDiagnosticsSection {
529    let positions_with_diagnostics = positions
530        .iter()
531        .filter(|position| position.execution.is_some())
532        .count();
533    let slippage: Vec<f64> = positions
534        .iter()
535        .filter_map(|position| {
536            position
537                .execution
538                .and_then(|value| value.slippage_bps)
539                .filter(|value| value.is_finite())
540        })
541        .collect();
542    let latency: Vec<f64> = positions
543        .iter()
544        .filter_map(|position| {
545            position
546                .execution
547                .and_then(|value| value.latency_ms)
548                .filter(|value| value.is_finite())
549        })
550        .collect();
551    let fill_ratio: Vec<f64> = positions
552        .iter()
553        .filter_map(|position| {
554            position
555                .execution
556                .and_then(|value| value.fill_ratio)
557                .filter(|value| value.is_finite())
558        })
559        .collect();
560    let adverse_slippage = slippage.iter().filter(|value| **value > 0.0).count();
561
562    ExecutionDiagnosticsSection {
563        positions_with_diagnostics,
564        slippage_observed_count: slippage.len(),
565        latency_observed_count: latency.len(),
566        fill_ratio_observed_count: fill_ratio.len(),
567        mean_slippage_bps: observed_mean(&slippage, "slippage"),
568        median_slippage_bps: observed_median(&slippage, "slippage"),
569        adverse_slippage_rate: if slippage.is_empty() {
570            MetricValue::insufficient_data("at least one finite slippage observation is required")
571        } else {
572            MetricValue::available(adverse_slippage as f64 / slippage.len() as f64)
573        },
574        mean_latency_ms: observed_mean(&latency, "latency"),
575        median_latency_ms: observed_median(&latency, "latency"),
576        mean_fill_ratio: observed_mean(&fill_ratio, "fill ratio"),
577    }
578}
579
580fn observed_mean(values: &[f64], name: &str) -> MetricValue<f64> {
581    mean(values).map_or_else(
582        || {
583            MetricValue::insufficient_data(format!(
584                "at least one finite {name} observation is required"
585            ))
586        },
587        MetricValue::available,
588    )
589}
590
591fn observed_median(values: &[f64], name: &str) -> MetricValue<f64> {
592    median(values).map_or_else(
593        || {
594            MetricValue::insufficient_data(format!(
595                "at least one finite {name} observation is required"
596            ))
597        },
598        MetricValue::available,
599    )
600}
601
602fn robustness(positions: &[&PositionOutcome], window_size: usize) -> IntrinsicRobustnessSection {
603    let finite_positions: Vec<&PositionOutcome> = positions
604        .iter()
605        .copied()
606        .filter(|position| position.outcome.is_finite())
607        .collect();
608    let outcomes: Vec<f64> = finite_positions
609        .iter()
610        .map(|position| position.outcome)
611        .collect();
612    let removal_count = five_percent_count(outcomes.len());
613
614    let top_one = positive_concentration(&finite_positions, 1);
615    let pnl_concentration = PnlConcentrationSection {
616        top_1: top_one.clone(),
617        top_3: positive_concentration(&finite_positions, 3),
618        top_5: positive_concentration(&finite_positions, 5),
619        top_10: positive_concentration(&finite_positions, 10),
620    };
621
622    IntrinsicRobustnessSection {
623        best_one_removed: removal_impact(&outcomes, 1),
624        best_five_percent_removed: removal_impact(&outcomes, removal_count),
625        best_one_positive_concentration: top_one,
626        best_five_percent_positive_concentration: positive_concentration(
627            &finite_positions,
628            removal_count,
629        ),
630        pnl_concentration,
631        rolling_outcomes: rolling_outcomes(positions, window_size),
632    }
633}
634
635fn five_percent_count(position_count: usize) -> usize {
636    if position_count == 0 {
637        0
638    } else {
639        position_count.div_ceil(20)
640    }
641}
642
643fn removal_impact(outcomes: &[f64], remove_count: usize) -> MetricValue<RemovalImpact> {
644    if outcomes.len() < 2 || remove_count == 0 || remove_count >= outcomes.len() {
645        return MetricValue::insufficient_data(
646            "at least two finite outcomes with a non-empty remainder are required",
647        );
648    }
649
650    let mut sorted = outcomes.to_vec();
651    sorted.sort_by(|left, right| right.total_cmp(left));
652    let original_total = sorted.iter().sum::<f64>();
653    let removed_total = sorted[..remove_count].iter().sum::<f64>();
654    let remaining_total = original_total - removed_total;
655
656    MetricValue::available(RemovalImpact {
657        removed_count: remove_count,
658        original_total,
659        removed_total,
660        remaining_total,
661        remaining_mean: remaining_total / (outcomes.len() - remove_count) as f64,
662    })
663}
664
665fn positive_concentration(positions: &[&PositionOutcome], take_count: usize) -> MetricValue<f64> {
666    if positions.is_empty() || take_count == 0 {
667        return MetricValue::insufficient_data("at least one finite outcome is required");
668    }
669
670    let mut positives: Vec<f64> = positions
671        .iter()
672        .filter(|position| position.classification() == OutcomeClassification::Win)
673        .map(|position| position.outcome.abs())
674        .collect();
675    if positives.is_empty() {
676        return MetricValue::not_applicable("positive concentration requires a winning position");
677    }
678
679    positives.sort_by(|left, right| right.total_cmp(left));
680    let gross_positive = positives.iter().sum::<f64>();
681    let concentrated = positives.iter().take(take_count).sum::<f64>();
682    if !gross_positive.is_finite() || !concentrated.is_finite() {
683        MetricValue::invalid_input("positive P&L concentration exceeds the finite f64 range")
684    } else if gross_positive > 0.0 {
685        MetricValue::available(concentrated / gross_positive)
686    } else {
687        MetricValue::not_applicable("positive concentration requires positive gross P&L")
688    }
689}
690
691fn rolling_outcomes(positions: &[&PositionOutcome], window_size: usize) -> RollingOutcomes {
692    if window_size == 0 {
693        return RollingOutcomes {
694            window_size,
695            windows: Vec::new(),
696            worst_window_mean: MetricValue::invalid_input(
697                "rolling_window must be greater than zero",
698            ),
699            best_window_mean: MetricValue::invalid_input(
700                "rolling_window must be greater than zero",
701            ),
702            positive_window_rate: MetricValue::invalid_input(
703                "rolling_window must be greater than zero",
704            ),
705        };
706    }
707
708    let mut ordered: Vec<&PositionOutcome> = positions
709        .iter()
710        .copied()
711        .filter(|position| position.outcome.is_finite())
712        .collect();
713    ordered.sort_by(|left, right| {
714        left.ordinal
715            .cmp(&right.ordinal)
716            .then_with(|| left.id.cmp(&right.id))
717            .then_with(|| left.outcome.total_cmp(&right.outcome))
718    });
719
720    if ordered.len() < window_size {
721        let metric = || {
722            MetricValue::insufficient_data(format!(
723                "at least {window_size} finite outcomes are required"
724            ))
725        };
726        return RollingOutcomes {
727            window_size,
728            windows: Vec::new(),
729            worst_window_mean: metric(),
730            best_window_mean: metric(),
731            positive_window_rate: metric(),
732        };
733    }
734
735    let windows: Vec<RollingOutcome> = ordered
736        .windows(window_size)
737        .map(|window| {
738            let total_outcome = window.iter().map(|position| position.outcome).sum::<f64>();
739            RollingOutcome {
740                start_ordinal: window.first().expect("window is non-empty").ordinal,
741                end_ordinal: window.last().expect("window is non-empty").ordinal,
742                position_count: window_size,
743                total_outcome,
744                mean_outcome: total_outcome / window_size as f64,
745            }
746        })
747        .collect();
748    let positive_windows = windows
749        .iter()
750        .filter(|window| window.total_outcome > 0.0)
751        .count();
752    let worst = windows
753        .iter()
754        .map(|window| window.mean_outcome)
755        .min_by(f64::total_cmp)
756        .expect("at least one rolling window exists");
757    let best = windows
758        .iter()
759        .map(|window| window.mean_outcome)
760        .max_by(f64::total_cmp)
761        .expect("at least one rolling window exists");
762
763    RollingOutcomes {
764        window_size,
765        positive_window_rate: MetricValue::available(
766            positive_windows as f64 / windows.len() as f64,
767        ),
768        worst_window_mean: MetricValue::available(worst),
769        best_window_mean: MetricValue::available(best),
770        windows,
771    }
772}
773
774fn breakdowns(
775    request: &EvaluationRequest,
776    positions: &[&PositionOutcome],
777) -> (Vec<EvaluationBreakdown>, BreakdownRowSummary) {
778    let dimensions: BTreeSet<BreakdownDimension> = request.breakdowns.iter().cloned().collect();
779    let minimum_count = request.minimum_breakdown_bucket_count;
780    let maximum_rows = request.maximum_breakdown_rows.unwrap_or(usize::MAX);
781    let mut available_rows = 0;
782    let mut included_rows = 0;
783    let mut breakdowns = Vec::with_capacity(dimensions.len());
784
785    for dimension in dimensions {
786        let mut grouped: BTreeMap<BreakdownValue, Vec<&PositionOutcome>> = BTreeMap::new();
787        for position in positions {
788            for value in breakdown_values(position, &dimension) {
789                grouped.entry(value).or_default().push(position);
790            }
791        }
792
793        let eligible: Vec<_> = grouped
794            .into_iter()
795            .filter(|(_, bucket_positions)| bucket_positions.len() >= minimum_count)
796            .collect();
797        available_rows += eligible.len();
798        let remaining = maximum_rows.saturating_sub(included_rows);
799        let buckets = eligible
800            .into_iter()
801            .take(remaining)
802            .map(|(value, bucket_positions)| BreakdownBucket {
803                value,
804                performance: performance(&bucket_positions, request.bootstrap),
805                r_metrics: r_metrics(&bucket_positions, request.bootstrap),
806            })
807            .collect::<Vec<_>>();
808        included_rows += buckets.len();
809        breakdowns.push(EvaluationBreakdown { dimension, buckets });
810    }
811
812    (
813        breakdowns,
814        BreakdownRowSummary {
815            available_rows,
816            included_rows,
817            truncated: included_rows < available_rows,
818        },
819    )
820}
821
822fn breakdown_values(
823    position: &PositionOutcome,
824    dimension: &BreakdownDimension,
825) -> Vec<BreakdownValue> {
826    match dimension {
827        BreakdownDimension::Symbol => {
828            vec![BreakdownValue::Text(position.dimensions.symbol.clone())]
829        }
830        BreakdownDimension::Side => vec![BreakdownValue::Side(position.dimensions.side)],
831        BreakdownDimension::Group => vec![
832            position
833                .dimensions
834                .group
835                .clone()
836                .map_or(BreakdownValue::Missing, BreakdownValue::Text),
837        ],
838        BreakdownDimension::CloseReason => {
839            let values: BTreeSet<BreakdownValue> = position
840                .dimensions
841                .close_reasons
842                .iter()
843                .cloned()
844                .map(BreakdownValue::Text)
845                .collect();
846            if values.is_empty() {
847                vec![BreakdownValue::Missing]
848            } else {
849                values.into_iter().collect()
850            }
851        }
852        BreakdownDimension::Tag(key) => vec![
853            position
854                .dimensions
855                .tags
856                .get(key)
857                .cloned()
858                .map_or(BreakdownValue::Missing, BreakdownValue::Text),
859        ],
860    }
861}