Skip to main content

kestrel_chartkit/evaluation/
recorder.rs

1use super::{SignalEvaluationRecord, TradeOutcome};
2use crate::model::Bar;
3use crate::signal::TriggerAction;
4use std::fmt;
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9/// Active setup tracking record for lookahead-free forward evaluation.
10#[derive(Debug, Clone, PartialEq)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct ActiveSetup {
13    pub id: u64,
14    pub timestamp: i64,
15    pub trigger: TriggerAction,
16    pub score: f64,
17    pub agreement: f64,
18    pub entry_price: f64,
19    pub target_price: f64,
20    pub stop_price: f64,
21    pub max_favorable_excursion: f64,
22    pub max_adverse_excursion: f64,
23    pub duration_bars: usize,
24    pub max_duration_bars: usize,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum IntrabarFillPolicy {
29    #[default]
30    StopFirst,
31    TargetFirst,
32    NearestToOpen,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum RecordSetupError {
37    UnsupportedTrigger,
38    NonFiniteValue,
39    InvalidPriceGeometry,
40    ZeroDuration,
41}
42
43impl fmt::Display for RecordSetupError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(match self {
46            Self::UnsupportedTrigger => "only buy and sell setups can be recorded",
47            Self::NonFiniteValue => "setup values must be finite",
48            Self::InvalidPriceGeometry => "target, entry, and stop are not ordered for the trigger",
49            Self::ZeroDuration => "maximum duration must be greater than zero",
50        })
51    }
52}
53
54impl std::error::Error for RecordSetupError {}
55
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct OutcomeExcursion {
58    pub setup_id: u64,
59    pub max_favorable_excursion: f64,
60    pub max_adverse_excursion: f64,
61}
62
63/// Bar-by-bar lookahead-free evaluation recorder for active strategy setups.
64#[derive(Debug, Clone, Default)]
65pub struct OutcomeRecorder {
66    next_id: u64,
67    active_setups: Vec<ActiveSetup>,
68    completed_records: Vec<SignalEvaluationRecord>,
69    completed_excursions: Vec<OutcomeExcursion>,
70    intrabar_fill_policy: IntrabarFillPolicy,
71}
72
73impl OutcomeRecorder {
74    pub fn new() -> Self {
75        Self {
76            next_id: 1,
77            active_setups: Vec::new(),
78            completed_records: Vec::new(),
79            completed_excursions: Vec::new(),
80            intrabar_fill_policy: IntrabarFillPolicy::StopFirst,
81        }
82    }
83
84    pub fn with_intrabar_fill_policy(policy: IntrabarFillPolicy) -> Self {
85        Self {
86            intrabar_fill_policy: policy,
87            ..Self::new()
88        }
89    }
90
91    /// Registers a new active trade setup for forward evaluation.
92    #[allow(clippy::too_many_arguments)]
93    pub fn record_setup(
94        &mut self,
95        timestamp: i64,
96        trigger: TriggerAction,
97        score: f64,
98        agreement: f64,
99        entry_price: f64,
100        target_price: f64,
101        stop_price: f64,
102        max_duration_bars: usize,
103    ) -> Result<u64, RecordSetupError> {
104        if !matches!(trigger, TriggerAction::Buy | TriggerAction::Sell) {
105            return Err(RecordSetupError::UnsupportedTrigger);
106        }
107        if ![score, agreement, entry_price, target_price, stop_price]
108            .into_iter()
109            .all(f64::is_finite)
110        {
111            return Err(RecordSetupError::NonFiniteValue);
112        }
113        let valid_geometry = match trigger {
114            TriggerAction::Buy => stop_price < entry_price && entry_price < target_price,
115            TriggerAction::Sell => target_price < entry_price && entry_price < stop_price,
116            _ => false,
117        };
118        if !valid_geometry {
119            return Err(RecordSetupError::InvalidPriceGeometry);
120        }
121        if max_duration_bars == 0 {
122            return Err(RecordSetupError::ZeroDuration);
123        }
124        let id = self.next_id;
125        self.next_id += 1;
126
127        self.active_setups.push(ActiveSetup {
128            id,
129            timestamp,
130            trigger,
131            score,
132            agreement,
133            entry_price,
134            target_price,
135            stop_price,
136            max_favorable_excursion: 0.0,
137            max_adverse_excursion: 0.0,
138            duration_bars: 0,
139            max_duration_bars,
140        });
141
142        Ok(id)
143    }
144
145    /// Processes an incoming price bar, updating MFE/MAE and checking for target/stop hits.
146    pub fn on_bar(&mut self, bar: &Bar) {
147        let mut unresolved = Vec::new();
148
149        for mut setup in self.active_setups.drain(..) {
150            setup.duration_bars += 1;
151
152            let (favorable, adverse) = match setup.trigger {
153                TriggerAction::Buy => (bar.high - setup.entry_price, setup.entry_price - bar.low),
154                TriggerAction::Sell => (setup.entry_price - bar.low, bar.high - setup.entry_price),
155                _ => (0.0, 0.0),
156            };
157
158            setup.max_favorable_excursion = setup.max_favorable_excursion.max(favorable);
159            setup.max_adverse_excursion = setup.max_adverse_excursion.max(adverse);
160
161            let initial_risk = (setup.entry_price - setup.stop_price).abs().max(1e-8);
162
163            let hit_target = match setup.trigger {
164                TriggerAction::Buy => bar.high >= setup.target_price,
165                TriggerAction::Sell => bar.low <= setup.target_price,
166                _ => false,
167            };
168
169            let hit_stop = match setup.trigger {
170                TriggerAction::Buy => bar.low <= setup.stop_price,
171                TriggerAction::Sell => bar.high >= setup.stop_price,
172                _ => false,
173            };
174
175            if hit_target || hit_stop || setup.duration_bars >= setup.max_duration_bars {
176                let target_wins = if hit_target && hit_stop {
177                    match self.intrabar_fill_policy {
178                        IntrabarFillPolicy::StopFirst => false,
179                        IntrabarFillPolicy::TargetFirst => true,
180                        IntrabarFillPolicy::NearestToOpen => {
181                            (bar.open - setup.target_price).abs()
182                                < (bar.open - setup.stop_price).abs()
183                        }
184                    }
185                } else {
186                    hit_target
187                };
188                let outcome = if target_wins {
189                    TradeOutcome::Win
190                } else if hit_stop {
191                    TradeOutcome::Loss
192                } else {
193                    TradeOutcome::Expired
194                };
195
196                let exit_price = if target_wins {
197                    setup.target_price
198                } else if hit_stop {
199                    setup.stop_price
200                } else {
201                    bar.close
202                };
203
204                let realized_r = match setup.trigger {
205                    TriggerAction::Buy => (exit_price - setup.entry_price) / initial_risk,
206                    TriggerAction::Sell => (setup.entry_price - exit_price) / initial_risk,
207                    _ => 0.0,
208                };
209
210                self.completed_records.push(SignalEvaluationRecord {
211                    timestamp: setup.timestamp,
212                    trigger: setup.trigger,
213                    score: setup.score,
214                    agreement: setup.agreement,
215                    entry_price: setup.entry_price,
216                    exit_price,
217                    realized_r_multiple: realized_r,
218                    duration_bars: setup.duration_bars as u32,
219                    outcome,
220                });
221                self.completed_excursions.push(OutcomeExcursion {
222                    setup_id: setup.id,
223                    max_favorable_excursion: setup.max_favorable_excursion,
224                    max_adverse_excursion: setup.max_adverse_excursion,
225                });
226            } else {
227                unresolved.push(setup);
228            }
229        }
230
231        self.active_setups = unresolved;
232    }
233
234    pub fn completed_records(&self) -> &[SignalEvaluationRecord] {
235        &self.completed_records
236    }
237
238    pub fn active_setups(&self) -> &[ActiveSetup] {
239        &self.active_setups
240    }
241
242    pub fn completed_excursions(&self) -> &[OutcomeExcursion] {
243        &self.completed_excursions
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_outcome_recorder_win() {
253        let mut recorder = OutcomeRecorder::new();
254        let id = recorder
255            .record_setup(1000, TriggerAction::Buy, 0.8, 0.9, 100.0, 105.0, 95.0, 10)
256            .unwrap();
257        assert_eq!(id, 1);
258
259        // Bar 1 hits target 105.0
260        let bar = Bar::new(1000, 100.0, 106.0, 99.0, 105.0, 1000.0);
261        recorder.on_bar(&bar);
262
263        assert!(recorder.active_setups().is_empty());
264        assert_eq!(recorder.completed_records().len(), 1);
265        let rec = &recorder.completed_records()[0];
266        assert_eq!(rec.outcome, TradeOutcome::Win);
267        assert_eq!(rec.realized_r_multiple, 1.0); // (105 - 100) / (100 - 95) = 5 / 5 = 1.0
268        assert_eq!(
269            recorder.completed_excursions()[0].max_favorable_excursion,
270            6.0
271        );
272    }
273
274    #[test]
275    fn same_bar_stop_and_target_use_explicit_policy() {
276        let bar = Bar::new(1, 100.0, 106.0, 94.0, 100.0, 1.0);
277        let mut conservative = OutcomeRecorder::new();
278        conservative
279            .record_setup(0, TriggerAction::Buy, 1.0, 1.0, 100.0, 105.0, 95.0, 10)
280            .unwrap();
281        conservative.on_bar(&bar);
282        assert_eq!(
283            conservative.completed_records()[0].outcome,
284            TradeOutcome::Loss
285        );
286
287        let mut optimistic =
288            OutcomeRecorder::with_intrabar_fill_policy(IntrabarFillPolicy::TargetFirst);
289        optimistic
290            .record_setup(0, TriggerAction::Buy, 1.0, 1.0, 100.0, 105.0, 95.0, 10)
291            .unwrap();
292        optimistic.on_bar(&bar);
293        assert_eq!(optimistic.completed_records()[0].outcome, TradeOutcome::Win);
294    }
295
296    #[test]
297    fn rejects_non_directional_and_invalid_setups() {
298        let mut recorder = OutcomeRecorder::new();
299        assert_eq!(
300            recorder.record_setup(0, TriggerAction::Hold, 0.0, 0.0, 100.0, 105.0, 95.0, 10),
301            Err(RecordSetupError::UnsupportedTrigger)
302        );
303        assert_eq!(
304            recorder.record_setup(0, TriggerAction::Buy, 0.0, 0.0, 100.0, 95.0, 105.0, 10),
305            Err(RecordSetupError::InvalidPriceGeometry)
306        );
307    }
308}