kestrel_chartkit/evaluation/
excursion.rs1#[cfg(feature = "serde")]
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy)]
9pub struct TimedPriceObservation {
10 pub ts: i64,
11 pub high: f64,
12 pub low: f64,
13 pub close: f64,
14}
15
16#[derive(Debug, Clone, Copy)]
19pub struct SignalRef {
20 pub ts: i64,
21 pub direction: i8,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25#[cfg_attr(feature = "serde", derive(Serialize))]
26pub struct HourBucket {
27 pub hour: u8,
29 pub count: i64,
30 pub avg_mfe_pct: f64,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34#[cfg_attr(feature = "serde", derive(Serialize))]
35pub struct OutcomeSummary {
36 pub count: i64,
38 pub avg_mfe_pct: f64,
39 pub avg_mae_pct: f64,
40 pub win_rate: f64,
44 pub horizon: usize,
46 pub by_hour: Vec<HourBucket>,
47}
48
49pub fn evaluate_outcomes(
54 signals: &[SignalRef],
55 bars: &[TimedPriceObservation],
56 horizon: usize,
57) -> OutcomeSummary {
58 let mut count = 0_i64;
59 let mut sum_mfe = 0.0;
60 let mut sum_mae = 0.0;
61 let mut wins = 0_i64;
62 let mut hours: [(i64, f64); 24] = [(0, 0.0); 24];
64
65 for sig in signals {
66 let Some(idx) = entry_index(bars, sig.ts) else {
67 continue;
68 };
69 if idx + horizon >= bars.len() {
71 continue;
72 }
73 let entry = bars[idx].close;
74 if entry <= 0.0 {
75 continue;
76 }
77 let forward = &bars[idx + 1..=idx + horizon];
78 let samples: Vec<_> = forward
79 .iter()
80 .map(|b| super::price::PriceObservation {
81 high: b.high,
82 low: b.low,
83 close: b.close,
84 })
85 .collect();
86 let direction = if sig.direction >= 0 {
87 super::price::PriceDirection::Long
88 } else {
89 super::price::PriceDirection::Short
90 };
91 let outcome =
92 super::price::ForwardPriceOutcome::compute(direction, entry, &samples, horizon);
93 let mfe_pct = 100.0 * outcome.mfe / entry;
94 let mae_pct = 100.0 * outcome.mae / entry;
95
96 count += 1;
97 sum_mfe += mfe_pct;
98 sum_mae += mae_pct;
99 if mfe_pct >= mae_pct {
100 wins += 1;
101 }
102 let hour = hour_of(sig.ts);
103 hours[hour as usize].0 += 1;
104 hours[hour as usize].1 += mfe_pct;
105 }
106
107 let by_hour = hours
108 .iter()
109 .enumerate()
110 .filter(|(_, (c, _))| *c > 0)
111 .map(|(hour, (c, sum))| HourBucket {
112 hour: hour as u8,
113 count: *c,
114 avg_mfe_pct: sum / *c as f64,
115 })
116 .collect();
117
118 OutcomeSummary {
119 count,
120 avg_mfe_pct: if count > 0 {
121 sum_mfe / count as f64
122 } else {
123 0.0
124 },
125 avg_mae_pct: if count > 0 {
126 sum_mae / count as f64
127 } else {
128 0.0
129 },
130 win_rate: if count > 0 {
131 wins as f64 / count as f64
132 } else {
133 0.0
134 },
135 horizon,
136 by_hour,
137 }
138}
139
140fn entry_index(bars: &[TimedPriceObservation], ts: i64) -> Option<usize> {
142 let pos = bars.partition_point(|b| b.ts < ts);
143 (pos < bars.len()).then_some(pos)
144}
145
146fn hour_of(ts_ms: i64) -> u8 {
147 let secs = ts_ms.div_euclid(1000);
148 (secs.div_euclid(3600).rem_euclid(24)) as u8
149}