1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum MetricStatus {
9 Available,
10 InsufficientData,
11 NotApplicable,
12 InvalidInput,
13}
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct MetricValue<T> {
22 pub status: MetricStatus,
23 pub value: Option<T>,
24 pub reason: Option<String>,
25}
26
27impl<T> Default for MetricValue<T> {
28 fn default() -> Self {
29 Self::insufficient_data("metric was not present in serialized input")
30 }
31}
32
33impl<T> MetricValue<T> {
34 pub fn available(value: T) -> Self {
35 Self {
36 status: MetricStatus::Available,
37 value: Some(value),
38 reason: None,
39 }
40 }
41
42 pub fn insufficient_data(reason: impl Into<String>) -> Self {
43 Self::unavailable(MetricStatus::InsufficientData, reason)
44 }
45
46 pub fn not_applicable(reason: impl Into<String>) -> Self {
47 Self::unavailable(MetricStatus::NotApplicable, reason)
48 }
49
50 pub fn invalid_input(reason: impl Into<String>) -> Self {
51 Self::unavailable(MetricStatus::InvalidInput, reason)
52 }
53
54 fn unavailable(status: MetricStatus, reason: impl Into<String>) -> Self {
55 Self {
56 status,
57 value: None,
58 reason: Some(reason.into()),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum PositionSide {
68 Long,
69 Short,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct PositionDimensions {
75 pub symbol: String,
76 pub side: PositionSide,
77 #[serde(default)]
78 pub group: Option<String>,
79 #[serde(default)]
81 pub close_reasons: Vec<String>,
82 #[serde(default)]
84 pub tags: BTreeMap<String, String>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
89pub struct ExcursionInput {
90 #[serde(default)]
91 pub favorable_r: Option<f64>,
92 #[serde(default)]
93 pub adverse_r: Option<f64>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
98pub struct PositionCostsInput {
99 pub commission: f64,
101 pub swap: f64,
103}
104
105impl PositionCostsInput {
106 pub fn total(self) -> f64 {
107 self.commission + self.swap
108 }
109
110 pub fn is_charged(self) -> bool {
111 self.commission != 0.0 || self.swap != 0.0
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
117pub struct ExecutionDiagnosticsInput {
118 #[serde(default)]
120 pub slippage_bps: Option<f64>,
121 #[serde(default)]
122 pub latency_ms: Option<f64>,
123 #[serde(default)]
125 pub fill_ratio: Option<f64>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum OutcomeClassification {
132 Win,
133 Loss,
134 Breakeven,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct PositionOutcome {
144 pub id: String,
145 #[serde(default)]
147 pub trade_id: Option<String>,
148 pub ordinal: i64,
149 pub dimensions: PositionDimensions,
150 pub outcome: f64,
151 #[serde(default)]
155 pub outcome_classification: Option<OutcomeClassification>,
156 #[serde(default)]
157 pub r_multiple: Option<f64>,
158 #[serde(default)]
159 pub excursions: Option<ExcursionInput>,
160 #[serde(default)]
161 pub execution: Option<ExecutionDiagnosticsInput>,
162 #[serde(default)]
164 pub costs: Option<PositionCostsInput>,
165}
166
167impl PositionOutcome {
168 pub fn classification(&self) -> OutcomeClassification {
169 self.outcome_classification.unwrap_or({
170 if self.outcome > 0.0 {
171 OutcomeClassification::Win
172 } else if self.outcome < 0.0 {
173 OutcomeClassification::Loss
174 } else {
175 OutcomeClassification::Breakeven
176 }
177 })
178 }
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(default)]
187pub struct LifecycleCounts {
188 pub candidates: u64,
189 pub accepted: u64,
190 pub opened: u64,
191 pub completed: u64,
192 pub rejected: u64,
193 pub filled: u64,
195 pub cancelled: u64,
197 pub unfilled_at_end: u64,
199 pub open_at_end: u64,
200}
201
202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct SourceCoverageCounts {
209 pub raw_messages: u64,
210 pub parsed_messages: u64,
211 pub skipped_messages: u64,
212 pub failed_messages: u64,
213 pub emitted_signals: u64,
214 pub emitted_entry_signals: u64,
215}
216
217impl SourceCoverageCounts {
218 pub fn validation_error(self) -> Option<String> {
219 let Some(classified) = self
220 .parsed_messages
221 .checked_add(self.skipped_messages)
222 .and_then(|count| count.checked_add(self.failed_messages))
223 else {
224 return Some("parsed/skipped/failed message counts overflow u64".into());
225 };
226 if classified != self.raw_messages {
227 return Some(format!(
228 "raw_messages ({}) must equal parsed_messages + skipped_messages + failed_messages ({classified})",
229 self.raw_messages
230 ));
231 }
232 if self.emitted_signals < self.parsed_messages {
233 return Some(format!(
234 "emitted_signals ({}) cannot be less than parsed_messages ({})",
235 self.emitted_signals, self.parsed_messages
236 ));
237 }
238 if self.emitted_entry_signals > self.emitted_signals {
239 return Some(format!(
240 "emitted_entry_signals ({}) cannot exceed emitted_signals ({})",
241 self.emitted_entry_signals, self.emitted_signals
242 ));
243 }
244 None
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case")]
251pub enum GroupFilter {
252 Named(String),
253 Ungrouped,
254}
255
256#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261pub struct PositionFilter {
262 #[serde(default)]
263 pub symbols: Vec<String>,
264 #[serde(default)]
265 pub sides: Vec<PositionSide>,
266 #[serde(default)]
267 pub groups: Vec<GroupFilter>,
268 #[serde(default)]
269 pub close_reasons: Vec<String>,
270 #[serde(default)]
272 pub tags: BTreeMap<String, Vec<String>>,
273}
274
275impl PositionFilter {
276 pub fn matches(&self, position: &PositionOutcome) -> bool {
277 let dimensions = &position.dimensions;
278
279 let symbol_matches = self.symbols.is_empty()
280 || self
281 .symbols
282 .iter()
283 .any(|symbol| symbol == &dimensions.symbol);
284 let side_matches = self.sides.is_empty() || self.sides.contains(&dimensions.side);
285 let group_matches = self.groups.is_empty()
286 || self.groups.iter().any(|group| match group {
287 GroupFilter::Named(name) => dimensions.group.as_ref() == Some(name),
288 GroupFilter::Ungrouped => dimensions.group.is_none(),
289 });
290 let close_reason_matches = self.close_reasons.is_empty()
291 || self.close_reasons.iter().any(|expected| {
292 dimensions
293 .close_reasons
294 .iter()
295 .any(|actual| actual == expected)
296 });
297 let tags_match = self.tags.iter().all(|(key, accepted_values)| {
298 accepted_values.is_empty()
299 || dimensions
300 .tags
301 .get(key)
302 .is_some_and(|actual| accepted_values.iter().any(|value| value == actual))
303 });
304
305 symbol_matches && side_matches && group_matches && close_reason_matches && tags_match
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum BreakdownDimension {
313 Symbol,
314 Side,
315 Group,
316 CloseReason,
317 Tag(String),
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum BreakdownValue {
324 Text(String),
325 Side(PositionSide),
326 Missing,
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
331#[serde(default)]
332pub struct BootstrapConfig {
333 pub samples: usize,
334 pub confidence_level: f64,
335 pub seed: u64,
336 pub minimum_sample_size: usize,
337}
338
339impl Default for BootstrapConfig {
340 fn default() -> Self {
341 Self {
342 samples: 2_000,
343 confidence_level: 0.95,
344 seed: 0xA076_1D64_78BD_642F,
345 minimum_sample_size: 5,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(default, deny_unknown_fields)]
354pub struct EvaluationContext {
355 pub provider_id: Option<String>,
356 pub source_id: Option<String>,
357}
358
359impl EvaluationContext {
360 pub fn is_empty(&self) -> bool {
361 self.provider_id.is_none() && self.source_id.is_none()
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum EvaluationSection {
369 Coverage,
370 PositionPerformance,
371 RMetrics,
372 Excursions,
373 Execution,
374 Costs,
375 Robustness,
376 Breakdowns,
377}
378
379impl EvaluationSection {
380 pub const ALL: [Self; 8] = [
381 Self::Coverage,
382 Self::PositionPerformance,
383 Self::RMetrics,
384 Self::Excursions,
385 Self::Execution,
386 Self::Costs,
387 Self::Robustness,
388 Self::Breakdowns,
389 ];
390
391 pub fn all() -> BTreeSet<Self> {
392 Self::ALL.into_iter().collect()
393 }
394}
395
396fn default_evaluation_sections() -> BTreeSet<EvaluationSection> {
397 EvaluationSection::all()
398}
399
400const fn default_rolling_window() -> usize {
401 20
402}
403
404const fn default_minimum_breakdown_bucket_count() -> usize {
405 1
406}
407
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411#[serde(default)]
412pub struct EvaluationOptions {
413 pub context: EvaluationContext,
414 pub source_coverage: Option<SourceCoverageCounts>,
416 #[serde(default = "default_evaluation_sections")]
418 pub sections: BTreeSet<EvaluationSection>,
419 pub filter: PositionFilter,
420 pub breakdowns: Vec<BreakdownDimension>,
421 pub bootstrap: BootstrapConfig,
422 pub rolling_window: usize,
424 pub minimum_breakdown_bucket_count: usize,
426 pub maximum_breakdown_rows: Option<usize>,
428 pub include_position_rows: bool,
430 pub maximum_position_rows: Option<usize>,
432}
433
434impl Default for EvaluationOptions {
435 fn default() -> Self {
436 Self {
437 context: EvaluationContext::default(),
438 source_coverage: None,
439 sections: EvaluationSection::all(),
440 filter: PositionFilter::default(),
441 breakdowns: Vec::new(),
442 bootstrap: BootstrapConfig::default(),
443 rolling_window: default_rolling_window(),
444 minimum_breakdown_bucket_count: default_minimum_breakdown_bucket_count(),
445 maximum_breakdown_rows: None,
446 include_position_rows: false,
447 maximum_position_rows: None,
448 }
449 }
450}
451
452#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
455pub struct EvaluationRequest {
456 pub positions: Vec<PositionOutcome>,
457 #[serde(default)]
458 pub lifecycle: Option<LifecycleCounts>,
459 #[serde(flatten, default)]
460 pub options: EvaluationOptions,
461}
462
463impl std::ops::Deref for EvaluationRequest {
464 type Target = EvaluationOptions;
465
466 fn deref(&self) -> &Self::Target {
467 &self.options
468 }
469}
470
471impl std::ops::DerefMut for EvaluationRequest {
472 fn deref_mut(&mut self) -> &mut Self::Target {
473 &mut self.options
474 }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
478pub struct ConfidenceInterval {
479 pub estimate: f64,
480 pub lower: f64,
481 pub upper: f64,
482 pub confidence_level: f64,
483}
484
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486pub struct CoverageSection {
487 pub provided_positions: usize,
488 pub selected_positions: usize,
489 pub filtered_out_positions: usize,
490 pub valid_outcomes: usize,
491 pub invalid_outcomes: usize,
492 pub source: Option<SourceCoverageCounts>,
494 pub lifecycle: Option<LifecycleCounts>,
495 pub acceptance_rate: MetricValue<f64>,
496 pub open_rate: MetricValue<f64>,
497 pub completion_rate: MetricValue<f64>,
498 pub r_coverage: MetricValue<f64>,
499 pub excursion_coverage: MetricValue<f64>,
500 pub execution_coverage: MetricValue<f64>,
501}
502
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504pub struct PositionPerformanceSection {
505 pub position_count: usize,
506 pub wins: usize,
507 pub losses: usize,
508 pub breakeven: usize,
509 pub total_outcome: MetricValue<f64>,
510 pub mean_outcome: MetricValue<f64>,
511 pub median_outcome: MetricValue<f64>,
512 pub win_rate: MetricValue<f64>,
513 pub win_rate_confidence: MetricValue<ConfidenceInterval>,
514 pub gross_positive: MetricValue<f64>,
515 pub gross_negative: MetricValue<f64>,
516 pub profit_factor: MetricValue<f64>,
517 pub payoff_ratio: MetricValue<f64>,
518 pub best_outcome: MetricValue<f64>,
519 pub worst_outcome: MetricValue<f64>,
520 pub mean_outcome_confidence: MetricValue<ConfidenceInterval>,
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
525pub struct RQuantiles {
526 pub p05: f64,
527 pub p10: f64,
528 pub p25: f64,
529 pub p50: f64,
530 pub p75: f64,
531 pub p90: f64,
532 pub p95: f64,
533}
534
535#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
537pub struct CumulativeRPoint {
538 pub position_id: String,
539 pub ordinal: i64,
540 pub realized_r: f64,
541 pub cumulative_r: f64,
542}
543
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
545pub struct RMetricsSection {
546 pub observed_count: usize,
547 pub missing_or_invalid_count: usize,
548 pub total_r: MetricValue<f64>,
549 pub mean_r: MetricValue<f64>,
550 pub median_r: MetricValue<f64>,
551 pub standard_deviation_r: MetricValue<f64>,
552 pub positive_r_rate: MetricValue<f64>,
553 pub positive_r_rate_confidence: MetricValue<ConfidenceInterval>,
554 pub mean_r_confidence: MetricValue<ConfidenceInterval>,
555 #[serde(default)]
557 pub profit_factor: MetricValue<f64>,
558 #[serde(default)]
559 pub average_winner_r: MetricValue<f64>,
560 #[serde(default)]
562 pub average_loser_r: MetricValue<f64>,
563 #[serde(default)]
564 pub best_r: MetricValue<f64>,
565 #[serde(default)]
566 pub worst_r: MetricValue<f64>,
567 #[serde(default)]
568 pub quantiles: MetricValue<RQuantiles>,
569 #[serde(default)]
571 pub cumulative_r_curve: MetricValue<Vec<CumulativeRPoint>>,
572 #[serde(default)]
574 pub max_realized_r_drawdown: MetricValue<f64>,
575}
576
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578pub struct ExcursionMetricsSection {
579 pub favorable_observed_count: usize,
580 pub adverse_observed_count: usize,
581 pub mean_favorable_r: MetricValue<f64>,
582 pub median_favorable_r: MetricValue<f64>,
583 pub mean_adverse_r: MetricValue<f64>,
584 pub median_adverse_r: MetricValue<f64>,
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
591pub struct CostMetricsSection {
592 pub positions_with_costs: usize,
593 pub total_commission: f64,
594 pub total_swap: f64,
595 pub total_cost: f64,
596 pub gross_outcome: f64,
598 pub net_outcome: f64,
600 pub cost_share_of_gross: MetricValue<f64>,
602 pub mean_cost_per_position: MetricValue<f64>,
603}
604
605#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
606pub struct ExecutionDiagnosticsSection {
607 pub positions_with_diagnostics: usize,
608 pub slippage_observed_count: usize,
609 pub latency_observed_count: usize,
610 pub fill_ratio_observed_count: usize,
611 pub mean_slippage_bps: MetricValue<f64>,
612 pub median_slippage_bps: MetricValue<f64>,
613 pub adverse_slippage_rate: MetricValue<f64>,
614 pub mean_latency_ms: MetricValue<f64>,
615 pub median_latency_ms: MetricValue<f64>,
616 pub mean_fill_ratio: MetricValue<f64>,
617}
618
619#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
620pub struct RemovalImpact {
621 pub removed_count: usize,
622 pub original_total: f64,
623 pub removed_total: f64,
624 pub remaining_total: f64,
625 pub remaining_mean: f64,
626}
627
628#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
629pub struct RollingOutcome {
630 pub start_ordinal: i64,
631 pub end_ordinal: i64,
632 pub position_count: usize,
633 pub total_outcome: f64,
634 pub mean_outcome: f64,
635}
636
637#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
638pub struct RollingOutcomes {
639 pub window_size: usize,
640 pub windows: Vec<RollingOutcome>,
641 pub worst_window_mean: MetricValue<f64>,
642 pub best_window_mean: MetricValue<f64>,
643 pub positive_window_rate: MetricValue<f64>,
644}
645
646#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
648#[serde(default)]
649pub struct PnlConcentrationSection {
650 pub top_1: MetricValue<f64>,
651 pub top_3: MetricValue<f64>,
652 pub top_5: MetricValue<f64>,
653 pub top_10: MetricValue<f64>,
654}
655
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657pub struct IntrinsicRobustnessSection {
658 pub best_one_removed: MetricValue<RemovalImpact>,
659 pub best_five_percent_removed: MetricValue<RemovalImpact>,
660 pub best_one_positive_concentration: MetricValue<f64>,
662 pub best_five_percent_positive_concentration: MetricValue<f64>,
664 #[serde(default)]
666 pub pnl_concentration: PnlConcentrationSection,
667 pub rolling_outcomes: RollingOutcomes,
668}
669
670#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
671pub struct BreakdownBucket {
672 pub value: BreakdownValue,
673 pub performance: PositionPerformanceSection,
674 pub r_metrics: RMetricsSection,
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678pub struct EvaluationBreakdown {
679 pub dimension: BreakdownDimension,
680 pub buckets: Vec<BreakdownBucket>,
683}
684
685#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
687#[serde(default)]
688pub struct BreakdownRowSummary {
689 pub available_rows: usize,
690 pub included_rows: usize,
691 pub truncated: bool,
692}
693
694impl BreakdownRowSummary {
695 pub fn is_empty(&self) -> bool {
696 self.available_rows == 0 && self.included_rows == 0 && !self.truncated
697 }
698}
699
700#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
702#[serde(default)]
703pub struct EvaluationPositionRows {
704 pub available_rows: usize,
705 pub included_rows: usize,
706 pub truncated: bool,
707 pub rows: Vec<PositionOutcome>,
708}
709
710fn requested_sections_default() -> BTreeSet<EvaluationSection> {
711 EvaluationSection::all()
712}
713
714fn requested_all_sections(sections: &BTreeSet<EvaluationSection>) -> bool {
715 *sections == EvaluationSection::all()
716}
717
718#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
724pub struct EvaluationReport {
725 #[serde(default, skip_serializing_if = "EvaluationContext::is_empty")]
726 pub context: EvaluationContext,
727 #[serde(
728 default = "requested_sections_default",
729 skip_serializing_if = "requested_all_sections"
730 )]
731 pub requested_sections: BTreeSet<EvaluationSection>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub coverage: Option<CoverageSection>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub position_performance: Option<PositionPerformanceSection>,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub r_metrics: Option<RMetricsSection>,
738 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub excursions: Option<ExcursionMetricsSection>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub execution: Option<ExecutionDiagnosticsSection>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub costs: Option<CostMetricsSection>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub robustness: Option<IntrinsicRobustnessSection>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub breakdowns: Option<Vec<EvaluationBreakdown>>,
748 #[serde(default, skip_serializing_if = "BreakdownRowSummary::is_empty")]
749 pub breakdown_rows: BreakdownRowSummary,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub position_rows: Option<EvaluationPositionRows>,
752}