#[cfg(feature = "serde")]
use serde::Serialize;
#[derive(Debug, Clone, Copy)]
pub struct TimedPriceObservation {
pub ts: i64,
pub high: f64,
pub low: f64,
pub close: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct SignalRef {
pub ts: i64,
pub direction: i8,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct HourBucket {
pub hour: u8,
pub count: i64,
pub avg_mfe_pct: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct OutcomeSummary {
pub count: i64,
pub avg_mfe_pct: f64,
pub avg_mae_pct: f64,
pub win_rate: f64,
pub horizon: usize,
pub by_hour: Vec<HourBucket>,
}
pub fn evaluate_outcomes(
signals: &[SignalRef],
bars: &[TimedPriceObservation],
horizon: usize,
) -> OutcomeSummary {
let mut count = 0_i64;
let mut sum_mfe = 0.0;
let mut sum_mae = 0.0;
let mut wins = 0_i64;
let mut hours: [(i64, f64); 24] = [(0, 0.0); 24];
for sig in signals {
let Some(idx) = entry_index(bars, sig.ts) else {
continue;
};
if idx + horizon >= bars.len() {
continue;
}
let entry = bars[idx].close;
if entry <= 0.0 {
continue;
}
let forward = &bars[idx + 1..=idx + horizon];
let samples: Vec<_> = forward
.iter()
.map(|b| super::price::PriceObservation {
high: b.high,
low: b.low,
close: b.close,
})
.collect();
let direction = if sig.direction >= 0 {
super::price::PriceDirection::Long
} else {
super::price::PriceDirection::Short
};
let outcome =
super::price::ForwardPriceOutcome::compute(direction, entry, &samples, horizon);
let mfe_pct = 100.0 * outcome.mfe / entry;
let mae_pct = 100.0 * outcome.mae / entry;
count += 1;
sum_mfe += mfe_pct;
sum_mae += mae_pct;
if mfe_pct >= mae_pct {
wins += 1;
}
let hour = hour_of(sig.ts);
hours[hour as usize].0 += 1;
hours[hour as usize].1 += mfe_pct;
}
let by_hour = hours
.iter()
.enumerate()
.filter(|(_, (c, _))| *c > 0)
.map(|(hour, (c, sum))| HourBucket {
hour: hour as u8,
count: *c,
avg_mfe_pct: sum / *c as f64,
})
.collect();
OutcomeSummary {
count,
avg_mfe_pct: if count > 0 {
sum_mfe / count as f64
} else {
0.0
},
avg_mae_pct: if count > 0 {
sum_mae / count as f64
} else {
0.0
},
win_rate: if count > 0 {
wins as f64 / count as f64
} else {
0.0
},
horizon,
by_hour,
}
}
fn entry_index(bars: &[TimedPriceObservation], ts: i64) -> Option<usize> {
let pos = bars.partition_point(|b| b.ts < ts);
(pos < bars.len()).then_some(pos)
}
fn hour_of(ts_ms: i64) -> u8 {
let secs = ts_ms.div_euclid(1000);
(secs.div_euclid(3600).rem_euclid(24)) as u8
}