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