use super::{SignalEvaluationRecord, TradeOutcome};
use crate::model::Bar;
use crate::signal::TriggerAction;
use std::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ActiveSetup {
pub id: u64,
pub timestamp: i64,
pub trigger: TriggerAction,
pub score: f64,
pub agreement: f64,
pub entry_price: f64,
pub target_price: f64,
pub stop_price: f64,
pub max_favorable_excursion: f64,
pub max_adverse_excursion: f64,
pub duration_bars: usize,
pub max_duration_bars: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IntrabarFillPolicy {
#[default]
StopFirst,
TargetFirst,
NearestToOpen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordSetupError {
UnsupportedTrigger,
NonFiniteValue,
InvalidPriceGeometry,
ZeroDuration,
}
impl fmt::Display for RecordSetupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::UnsupportedTrigger => "only buy and sell setups can be recorded",
Self::NonFiniteValue => "setup values must be finite",
Self::InvalidPriceGeometry => "target, entry, and stop are not ordered for the trigger",
Self::ZeroDuration => "maximum duration must be greater than zero",
})
}
}
impl std::error::Error for RecordSetupError {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OutcomeExcursion {
pub setup_id: u64,
pub max_favorable_excursion: f64,
pub max_adverse_excursion: f64,
}
#[derive(Debug, Clone, Default)]
pub struct OutcomeRecorder {
next_id: u64,
active_setups: Vec<ActiveSetup>,
completed_records: Vec<SignalEvaluationRecord>,
completed_excursions: Vec<OutcomeExcursion>,
intrabar_fill_policy: IntrabarFillPolicy,
}
impl OutcomeRecorder {
pub fn new() -> Self {
Self {
next_id: 1,
active_setups: Vec::new(),
completed_records: Vec::new(),
completed_excursions: Vec::new(),
intrabar_fill_policy: IntrabarFillPolicy::StopFirst,
}
}
pub fn with_intrabar_fill_policy(policy: IntrabarFillPolicy) -> Self {
Self {
intrabar_fill_policy: policy,
..Self::new()
}
}
#[allow(clippy::too_many_arguments)]
pub fn record_setup(
&mut self,
timestamp: i64,
trigger: TriggerAction,
score: f64,
agreement: f64,
entry_price: f64,
target_price: f64,
stop_price: f64,
max_duration_bars: usize,
) -> Result<u64, RecordSetupError> {
if !matches!(trigger, TriggerAction::Buy | TriggerAction::Sell) {
return Err(RecordSetupError::UnsupportedTrigger);
}
if ![score, agreement, entry_price, target_price, stop_price]
.into_iter()
.all(f64::is_finite)
{
return Err(RecordSetupError::NonFiniteValue);
}
let valid_geometry = match trigger {
TriggerAction::Buy => stop_price < entry_price && entry_price < target_price,
TriggerAction::Sell => target_price < entry_price && entry_price < stop_price,
_ => false,
};
if !valid_geometry {
return Err(RecordSetupError::InvalidPriceGeometry);
}
if max_duration_bars == 0 {
return Err(RecordSetupError::ZeroDuration);
}
let id = self.next_id;
self.next_id += 1;
self.active_setups.push(ActiveSetup {
id,
timestamp,
trigger,
score,
agreement,
entry_price,
target_price,
stop_price,
max_favorable_excursion: 0.0,
max_adverse_excursion: 0.0,
duration_bars: 0,
max_duration_bars,
});
Ok(id)
}
pub fn on_bar(&mut self, bar: &Bar) {
let mut unresolved = Vec::new();
for mut setup in self.active_setups.drain(..) {
setup.duration_bars += 1;
let (favorable, adverse) = match setup.trigger {
TriggerAction::Buy => (bar.high - setup.entry_price, setup.entry_price - bar.low),
TriggerAction::Sell => (setup.entry_price - bar.low, bar.high - setup.entry_price),
_ => (0.0, 0.0),
};
setup.max_favorable_excursion = setup.max_favorable_excursion.max(favorable);
setup.max_adverse_excursion = setup.max_adverse_excursion.max(adverse);
let initial_risk = (setup.entry_price - setup.stop_price).abs().max(1e-8);
let hit_target = match setup.trigger {
TriggerAction::Buy => bar.high >= setup.target_price,
TriggerAction::Sell => bar.low <= setup.target_price,
_ => false,
};
let hit_stop = match setup.trigger {
TriggerAction::Buy => bar.low <= setup.stop_price,
TriggerAction::Sell => bar.high >= setup.stop_price,
_ => false,
};
if hit_target || hit_stop || setup.duration_bars >= setup.max_duration_bars {
let target_wins = if hit_target && hit_stop {
match self.intrabar_fill_policy {
IntrabarFillPolicy::StopFirst => false,
IntrabarFillPolicy::TargetFirst => true,
IntrabarFillPolicy::NearestToOpen => {
(bar.open - setup.target_price).abs()
< (bar.open - setup.stop_price).abs()
}
}
} else {
hit_target
};
let outcome = if target_wins {
TradeOutcome::Win
} else if hit_stop {
TradeOutcome::Loss
} else {
TradeOutcome::Expired
};
let exit_price = if target_wins {
setup.target_price
} else if hit_stop {
setup.stop_price
} else {
bar.close
};
let realized_r = match setup.trigger {
TriggerAction::Buy => (exit_price - setup.entry_price) / initial_risk,
TriggerAction::Sell => (setup.entry_price - exit_price) / initial_risk,
_ => 0.0,
};
self.completed_records.push(SignalEvaluationRecord {
timestamp: setup.timestamp,
trigger: setup.trigger,
score: setup.score,
agreement: setup.agreement,
entry_price: setup.entry_price,
exit_price,
realized_r_multiple: realized_r,
duration_bars: setup.duration_bars as u32,
outcome,
});
self.completed_excursions.push(OutcomeExcursion {
setup_id: setup.id,
max_favorable_excursion: setup.max_favorable_excursion,
max_adverse_excursion: setup.max_adverse_excursion,
});
} else {
unresolved.push(setup);
}
}
self.active_setups = unresolved;
}
pub fn completed_records(&self) -> &[SignalEvaluationRecord] {
&self.completed_records
}
pub fn active_setups(&self) -> &[ActiveSetup] {
&self.active_setups
}
pub fn completed_excursions(&self) -> &[OutcomeExcursion] {
&self.completed_excursions
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_outcome_recorder_win() {
let mut recorder = OutcomeRecorder::new();
let id = recorder
.record_setup(1000, TriggerAction::Buy, 0.8, 0.9, 100.0, 105.0, 95.0, 10)
.unwrap();
assert_eq!(id, 1);
let bar = Bar::new(1000, 100.0, 106.0, 99.0, 105.0, 1000.0);
recorder.on_bar(&bar);
assert!(recorder.active_setups().is_empty());
assert_eq!(recorder.completed_records().len(), 1);
let rec = &recorder.completed_records()[0];
assert_eq!(rec.outcome, TradeOutcome::Win);
assert_eq!(rec.realized_r_multiple, 1.0); assert_eq!(
recorder.completed_excursions()[0].max_favorable_excursion,
6.0
);
}
#[test]
fn same_bar_stop_and_target_use_explicit_policy() {
let bar = Bar::new(1, 100.0, 106.0, 94.0, 100.0, 1.0);
let mut conservative = OutcomeRecorder::new();
conservative
.record_setup(0, TriggerAction::Buy, 1.0, 1.0, 100.0, 105.0, 95.0, 10)
.unwrap();
conservative.on_bar(&bar);
assert_eq!(
conservative.completed_records()[0].outcome,
TradeOutcome::Loss
);
let mut optimistic =
OutcomeRecorder::with_intrabar_fill_policy(IntrabarFillPolicy::TargetFirst);
optimistic
.record_setup(0, TriggerAction::Buy, 1.0, 1.0, 100.0, 105.0, 95.0, 10)
.unwrap();
optimistic.on_bar(&bar);
assert_eq!(optimistic.completed_records()[0].outcome, TradeOutcome::Win);
}
#[test]
fn rejects_non_directional_and_invalid_setups() {
let mut recorder = OutcomeRecorder::new();
assert_eq!(
recorder.record_setup(0, TriggerAction::Hold, 0.0, 0.0, 100.0, 105.0, 95.0, 10),
Err(RecordSetupError::UnsupportedTrigger)
);
assert_eq!(
recorder.record_setup(0, TriggerAction::Buy, 0.0, 0.0, 100.0, 95.0, 105.0, 10),
Err(RecordSetupError::InvalidPriceGeometry)
);
}
}