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
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 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}