Skip to main content

kestrel_chartkit/evaluation/
excursion.rs

1//! Forward excursion summaries and an explicitly uncalibrated MFE >= MAE proxy.
2//! Input timestamps are epoch milliseconds, preserving subsecond alignment.
3
4#[cfg(feature = "serde")]
5use serde::Serialize;
6
7/// A bar with an explicit epoch-millisecond close timestamp.
8#[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/// A fired signal reduced to what the outcome math needs: when it fired and
17/// which way it pointed (`+1` bull / long, `-1` bear / short).
18#[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    /// Hour of day (0..23, UTC) the signal fired in.
28    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    /// Signals with enough forward bars to be evaluated (`horizon` bars).
37    pub count: i64,
38    pub avg_mfe_pct: f64,
39    pub avg_mae_pct: f64,
40    /// Proxy win rate: fraction of settled signals whose favorable excursion
41    /// exceeded its adverse one (mfe ≥ mae). Not a calibrated success metric
42    /// — see the module documentation.
43    pub win_rate: f64,
44    /// How many forward bars each signal was measured over.
45    pub horizon: usize,
46    pub by_hour: Vec<HourBucket>,
47}
48
49/// Evaluates every `signal` against `bars` (oldest first, same timeframe),
50/// looking `horizon` bars forward from the first bar at or after the signal.
51/// Entry is that bar's close; excursions are measured on the following bars'
52/// highs/lows, expressed as a percentage of entry.
53pub 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    // hour -> (count, sum_mfe_pct)
63    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        // Need `horizon` bars strictly after the entry bar to settle.
70        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
140/// First bar index whose `ts` is ≥ the signal's `ts` (bars sorted ascending).
141fn 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}