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
14pub 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(§ion);
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}