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#[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#[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 #[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 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 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); 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}