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 ExecutionDiagnosticsInput {
99 #[serde(default)]
101 pub slippage_bps: Option<f64>,
102 #[serde(default)]
103 pub latency_ms: Option<f64>,
104 #[serde(default)]
106 pub fill_ratio: Option<f64>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum OutcomeClassification {
113 Win,
114 Loss,
115 Breakeven,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct PositionOutcome {
125 pub id: String,
126 #[serde(default)]
128 pub trade_id: Option<String>,
129 pub ordinal: i64,
130 pub dimensions: PositionDimensions,
131 pub outcome: f64,
132 #[serde(default)]
136 pub outcome_classification: Option<OutcomeClassification>,
137 #[serde(default)]
138 pub r_multiple: Option<f64>,
139 #[serde(default)]
140 pub excursions: Option<ExcursionInput>,
141 #[serde(default)]
142 pub execution: Option<ExecutionDiagnosticsInput>,
143}
144
145impl PositionOutcome {
146 pub fn classification(&self) -> OutcomeClassification {
147 self.outcome_classification.unwrap_or({
148 if self.outcome > 0.0 {
149 OutcomeClassification::Win
150 } else if self.outcome < 0.0 {
151 OutcomeClassification::Loss
152 } else {
153 OutcomeClassification::Breakeven
154 }
155 })
156 }
157}
158
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(default)]
165pub struct LifecycleCounts {
166 pub candidates: u64,
167 pub accepted: u64,
168 pub opened: u64,
169 pub completed: u64,
170 pub rejected: u64,
171 pub filled: u64,
173 pub cancelled: u64,
175 pub unfilled_at_end: u64,
177 pub open_at_end: u64,
178}
179
180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(default, deny_unknown_fields)]
186pub struct SourceCoverageCounts {
187 pub raw_messages: u64,
188 pub parsed_messages: u64,
189 pub skipped_messages: u64,
190 pub failed_messages: u64,
191 pub emitted_signals: u64,
192 pub emitted_entry_signals: u64,
193}
194
195impl SourceCoverageCounts {
196 pub fn validation_error(self) -> Option<String> {
197 let Some(classified) = self
198 .parsed_messages
199 .checked_add(self.skipped_messages)
200 .and_then(|count| count.checked_add(self.failed_messages))
201 else {
202 return Some("parsed/skipped/failed message counts overflow u64".into());
203 };
204 if classified != self.raw_messages {
205 return Some(format!(
206 "raw_messages ({}) must equal parsed_messages + skipped_messages + failed_messages ({classified})",
207 self.raw_messages
208 ));
209 }
210 if self.emitted_signals < self.parsed_messages {
211 return Some(format!(
212 "emitted_signals ({}) cannot be less than parsed_messages ({})",
213 self.emitted_signals, self.parsed_messages
214 ));
215 }
216 if self.emitted_entry_signals > self.emitted_signals {
217 return Some(format!(
218 "emitted_entry_signals ({}) cannot exceed emitted_signals ({})",
219 self.emitted_entry_signals, self.emitted_signals
220 ));
221 }
222 None
223 }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
228#[serde(rename_all = "snake_case")]
229pub enum GroupFilter {
230 Named(String),
231 Ungrouped,
232}
233
234#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct PositionFilter {
240 #[serde(default)]
241 pub symbols: Vec<String>,
242 #[serde(default)]
243 pub sides: Vec<PositionSide>,
244 #[serde(default)]
245 pub groups: Vec<GroupFilter>,
246 #[serde(default)]
247 pub close_reasons: Vec<String>,
248 #[serde(default)]
250 pub tags: BTreeMap<String, Vec<String>>,
251}
252
253impl PositionFilter {
254 pub fn matches(&self, position: &PositionOutcome) -> bool {
255 let dimensions = &position.dimensions;
256
257 let symbol_matches = self.symbols.is_empty()
258 || self
259 .symbols
260 .iter()
261 .any(|symbol| symbol == &dimensions.symbol);
262 let side_matches = self.sides.is_empty() || self.sides.contains(&dimensions.side);
263 let group_matches = self.groups.is_empty()
264 || self.groups.iter().any(|group| match group {
265 GroupFilter::Named(name) => dimensions.group.as_ref() == Some(name),
266 GroupFilter::Ungrouped => dimensions.group.is_none(),
267 });
268 let close_reason_matches = self.close_reasons.is_empty()
269 || self.close_reasons.iter().any(|expected| {
270 dimensions
271 .close_reasons
272 .iter()
273 .any(|actual| actual == expected)
274 });
275 let tags_match = self.tags.iter().all(|(key, accepted_values)| {
276 accepted_values.is_empty()
277 || dimensions
278 .tags
279 .get(key)
280 .is_some_and(|actual| accepted_values.iter().any(|value| value == actual))
281 });
282
283 symbol_matches && side_matches && group_matches && close_reason_matches && tags_match
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
289#[serde(rename_all = "snake_case")]
290pub enum BreakdownDimension {
291 Symbol,
292 Side,
293 Group,
294 CloseReason,
295 Tag(String),
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum BreakdownValue {
302 Text(String),
303 Side(PositionSide),
304 Missing,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
309#[serde(default)]
310pub struct BootstrapConfig {
311 pub samples: usize,
312 pub confidence_level: f64,
313 pub seed: u64,
314 pub minimum_sample_size: usize,
315}
316
317impl Default for BootstrapConfig {
318 fn default() -> Self {
319 Self {
320 samples: 2_000,
321 confidence_level: 0.95,
322 seed: 0xA076_1D64_78BD_642F,
323 minimum_sample_size: 5,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(default, deny_unknown_fields)]
332pub struct EvaluationContext {
333 pub provider_id: Option<String>,
334 pub source_id: Option<String>,
335}
336
337impl EvaluationContext {
338 pub fn is_empty(&self) -> bool {
339 self.provider_id.is_none() && self.source_id.is_none()
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum EvaluationSection {
347 Coverage,
348 PositionPerformance,
349 RMetrics,
350 Excursions,
351 Execution,
352 Robustness,
353 Breakdowns,
354}
355
356impl EvaluationSection {
357 pub const ALL: [Self; 7] = [
358 Self::Coverage,
359 Self::PositionPerformance,
360 Self::RMetrics,
361 Self::Excursions,
362 Self::Execution,
363 Self::Robustness,
364 Self::Breakdowns,
365 ];
366
367 pub fn all() -> BTreeSet<Self> {
368 Self::ALL.into_iter().collect()
369 }
370}
371
372fn default_evaluation_sections() -> BTreeSet<EvaluationSection> {
373 EvaluationSection::all()
374}
375
376const fn default_rolling_window() -> usize {
377 20
378}
379
380const fn default_minimum_breakdown_bucket_count() -> usize {
381 1
382}
383
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387#[serde(default)]
388pub struct EvaluationOptions {
389 pub context: EvaluationContext,
390 pub source_coverage: Option<SourceCoverageCounts>,
392 #[serde(default = "default_evaluation_sections")]
394 pub sections: BTreeSet<EvaluationSection>,
395 pub filter: PositionFilter,
396 pub breakdowns: Vec<BreakdownDimension>,
397 pub bootstrap: BootstrapConfig,
398 pub rolling_window: usize,
400 pub minimum_breakdown_bucket_count: usize,
402 pub maximum_breakdown_rows: Option<usize>,
404 pub include_position_rows: bool,
406 pub maximum_position_rows: Option<usize>,
408}
409
410impl Default for EvaluationOptions {
411 fn default() -> Self {
412 Self {
413 context: EvaluationContext::default(),
414 source_coverage: None,
415 sections: EvaluationSection::all(),
416 filter: PositionFilter::default(),
417 breakdowns: Vec::new(),
418 bootstrap: BootstrapConfig::default(),
419 rolling_window: default_rolling_window(),
420 minimum_breakdown_bucket_count: default_minimum_breakdown_bucket_count(),
421 maximum_breakdown_rows: None,
422 include_position_rows: false,
423 maximum_position_rows: None,
424 }
425 }
426}
427
428#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
431pub struct EvaluationRequest {
432 pub positions: Vec<PositionOutcome>,
433 #[serde(default)]
434 pub lifecycle: Option<LifecycleCounts>,
435 #[serde(flatten, default)]
436 pub options: EvaluationOptions,
437}
438
439impl std::ops::Deref for EvaluationRequest {
440 type Target = EvaluationOptions;
441
442 fn deref(&self) -> &Self::Target {
443 &self.options
444 }
445}
446
447impl std::ops::DerefMut for EvaluationRequest {
448 fn deref_mut(&mut self) -> &mut Self::Target {
449 &mut self.options
450 }
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
454pub struct ConfidenceInterval {
455 pub estimate: f64,
456 pub lower: f64,
457 pub upper: f64,
458 pub confidence_level: f64,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462pub struct CoverageSection {
463 pub provided_positions: usize,
464 pub selected_positions: usize,
465 pub filtered_out_positions: usize,
466 pub valid_outcomes: usize,
467 pub invalid_outcomes: usize,
468 pub source: Option<SourceCoverageCounts>,
470 pub lifecycle: Option<LifecycleCounts>,
471 pub acceptance_rate: MetricValue<f64>,
472 pub open_rate: MetricValue<f64>,
473 pub completion_rate: MetricValue<f64>,
474 pub r_coverage: MetricValue<f64>,
475 pub excursion_coverage: MetricValue<f64>,
476 pub execution_coverage: MetricValue<f64>,
477}
478
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
480pub struct PositionPerformanceSection {
481 pub position_count: usize,
482 pub wins: usize,
483 pub losses: usize,
484 pub breakeven: usize,
485 pub total_outcome: MetricValue<f64>,
486 pub mean_outcome: MetricValue<f64>,
487 pub median_outcome: MetricValue<f64>,
488 pub win_rate: MetricValue<f64>,
489 pub win_rate_confidence: MetricValue<ConfidenceInterval>,
490 pub gross_positive: MetricValue<f64>,
491 pub gross_negative: MetricValue<f64>,
492 pub profit_factor: MetricValue<f64>,
493 pub payoff_ratio: MetricValue<f64>,
494 pub best_outcome: MetricValue<f64>,
495 pub worst_outcome: MetricValue<f64>,
496 pub mean_outcome_confidence: MetricValue<ConfidenceInterval>,
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
501pub struct RQuantiles {
502 pub p05: f64,
503 pub p10: f64,
504 pub p25: f64,
505 pub p50: f64,
506 pub p75: f64,
507 pub p90: f64,
508 pub p95: f64,
509}
510
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
513pub struct CumulativeRPoint {
514 pub position_id: String,
515 pub ordinal: i64,
516 pub realized_r: f64,
517 pub cumulative_r: f64,
518}
519
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct RMetricsSection {
522 pub observed_count: usize,
523 pub missing_or_invalid_count: usize,
524 pub total_r: MetricValue<f64>,
525 pub mean_r: MetricValue<f64>,
526 pub median_r: MetricValue<f64>,
527 pub standard_deviation_r: MetricValue<f64>,
528 pub positive_r_rate: MetricValue<f64>,
529 pub positive_r_rate_confidence: MetricValue<ConfidenceInterval>,
530 pub mean_r_confidence: MetricValue<ConfidenceInterval>,
531 #[serde(default)]
533 pub profit_factor: MetricValue<f64>,
534 #[serde(default)]
535 pub average_winner_r: MetricValue<f64>,
536 #[serde(default)]
538 pub average_loser_r: MetricValue<f64>,
539 #[serde(default)]
540 pub best_r: MetricValue<f64>,
541 #[serde(default)]
542 pub worst_r: MetricValue<f64>,
543 #[serde(default)]
544 pub quantiles: MetricValue<RQuantiles>,
545 #[serde(default)]
547 pub cumulative_r_curve: MetricValue<Vec<CumulativeRPoint>>,
548 #[serde(default)]
550 pub max_realized_r_drawdown: MetricValue<f64>,
551}
552
553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
554pub struct ExcursionMetricsSection {
555 pub favorable_observed_count: usize,
556 pub adverse_observed_count: usize,
557 pub mean_favorable_r: MetricValue<f64>,
558 pub median_favorable_r: MetricValue<f64>,
559 pub mean_adverse_r: MetricValue<f64>,
560 pub median_adverse_r: MetricValue<f64>,
561}
562
563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
564pub struct ExecutionDiagnosticsSection {
565 pub positions_with_diagnostics: usize,
566 pub slippage_observed_count: usize,
567 pub latency_observed_count: usize,
568 pub fill_ratio_observed_count: usize,
569 pub mean_slippage_bps: MetricValue<f64>,
570 pub median_slippage_bps: MetricValue<f64>,
571 pub adverse_slippage_rate: MetricValue<f64>,
572 pub mean_latency_ms: MetricValue<f64>,
573 pub median_latency_ms: MetricValue<f64>,
574 pub mean_fill_ratio: MetricValue<f64>,
575}
576
577#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578pub struct RemovalImpact {
579 pub removed_count: usize,
580 pub original_total: f64,
581 pub removed_total: f64,
582 pub remaining_total: f64,
583 pub remaining_mean: f64,
584}
585
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587pub struct RollingOutcome {
588 pub start_ordinal: i64,
589 pub end_ordinal: i64,
590 pub position_count: usize,
591 pub total_outcome: f64,
592 pub mean_outcome: f64,
593}
594
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
596pub struct RollingOutcomes {
597 pub window_size: usize,
598 pub windows: Vec<RollingOutcome>,
599 pub worst_window_mean: MetricValue<f64>,
600 pub best_window_mean: MetricValue<f64>,
601 pub positive_window_rate: MetricValue<f64>,
602}
603
604#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
606#[serde(default)]
607pub struct PnlConcentrationSection {
608 pub top_1: MetricValue<f64>,
609 pub top_3: MetricValue<f64>,
610 pub top_5: MetricValue<f64>,
611 pub top_10: MetricValue<f64>,
612}
613
614#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615pub struct IntrinsicRobustnessSection {
616 pub best_one_removed: MetricValue<RemovalImpact>,
617 pub best_five_percent_removed: MetricValue<RemovalImpact>,
618 pub best_one_positive_concentration: MetricValue<f64>,
620 pub best_five_percent_positive_concentration: MetricValue<f64>,
622 #[serde(default)]
624 pub pnl_concentration: PnlConcentrationSection,
625 pub rolling_outcomes: RollingOutcomes,
626}
627
628#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
629pub struct BreakdownBucket {
630 pub value: BreakdownValue,
631 pub performance: PositionPerformanceSection,
632 pub r_metrics: RMetricsSection,
633}
634
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636pub struct EvaluationBreakdown {
637 pub dimension: BreakdownDimension,
638 pub buckets: Vec<BreakdownBucket>,
641}
642
643#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(default)]
646pub struct BreakdownRowSummary {
647 pub available_rows: usize,
648 pub included_rows: usize,
649 pub truncated: bool,
650}
651
652impl BreakdownRowSummary {
653 pub fn is_empty(&self) -> bool {
654 self.available_rows == 0 && self.included_rows == 0 && !self.truncated
655 }
656}
657
658#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
660#[serde(default)]
661pub struct EvaluationPositionRows {
662 pub available_rows: usize,
663 pub included_rows: usize,
664 pub truncated: bool,
665 pub rows: Vec<PositionOutcome>,
666}
667
668fn requested_sections_default() -> BTreeSet<EvaluationSection> {
669 EvaluationSection::all()
670}
671
672fn requested_all_sections(sections: &BTreeSet<EvaluationSection>) -> bool {
673 *sections == EvaluationSection::all()
674}
675
676#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct EvaluationReport {
683 #[serde(default, skip_serializing_if = "EvaluationContext::is_empty")]
684 pub context: EvaluationContext,
685 #[serde(
686 default = "requested_sections_default",
687 skip_serializing_if = "requested_all_sections"
688 )]
689 pub requested_sections: BTreeSet<EvaluationSection>,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub coverage: Option<CoverageSection>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub position_performance: Option<PositionPerformanceSection>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub r_metrics: Option<RMetricsSection>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
697 pub excursions: Option<ExcursionMetricsSection>,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub execution: Option<ExecutionDiagnosticsSection>,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub robustness: Option<IntrinsicRobustnessSection>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub breakdowns: Option<Vec<EvaluationBreakdown>>,
704 #[serde(default, skip_serializing_if = "BreakdownRowSummary::is_empty")]
705 pub breakdown_rows: BreakdownRowSummary,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub position_rows: Option<EvaluationPositionRows>,
708}