kestrel_chartkit/evaluation/
price.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum PriceDirection {
6 Long,
7 Short,
8}
9impl PriceDirection {
10 pub fn parse(value: &str) -> Option<Self> {
11 match value {
12 "long" | "bull" => Some(Self::Long),
13 "short" | "bear" => Some(Self::Short),
14 _ => None,
15 }
16 }
17 pub fn as_str(self) -> &'static str {
18 match self {
19 Self::Long => "long",
20 Self::Short => "short",
21 }
22 }
23 pub fn pnl(self, entry: f64, exit: f64) -> f64 {
24 match self {
25 Self::Long => exit - entry,
26 Self::Short => entry - exit,
27 }
28 }
29 pub fn favorable(self, entry: f64, high: f64, low: f64) -> f64 {
30 match self {
31 Self::Long => high - entry,
32 Self::Short => entry - low,
33 }
34 }
35 pub fn adverse(self, entry: f64, high: f64, low: f64) -> f64 {
36 match self {
37 Self::Long => entry - low,
38 Self::Short => high - entry,
39 }
40 }
41 pub fn update_excursions(
43 self,
44 entry: f64,
45 bar: PriceObservation,
46 mfe: f64,
47 mae: f64,
48 ) -> (f64, f64) {
49 (
50 mfe.max(self.favorable(entry, bar.high, bar.low).max(0.0)),
51 mae.max(self.adverse(entry, bar.high, bar.low).max(0.0)),
52 )
53 }
54}
55
56#[derive(Debug, Clone, Copy)]
58pub struct PriceObservation {
59 pub high: f64,
60 pub low: f64,
61 pub close: f64,
62}
63
64#[derive(Debug, Clone, Default)]
67pub struct ForwardPriceOutcome {
68 pub returns: Vec<f64>,
69 pub mfe: f64,
70 pub mae: f64,
71 pub bars_to_mfe: usize,
72 pub bars_to_mae: usize,
73}
74impl ForwardPriceOutcome {
75 pub fn compute(
76 direction: PriceDirection,
77 entry: f64,
78 bars: &[PriceObservation],
79 horizon: usize,
80 ) -> Self {
81 let mut out = Self::default();
82 let (mut best, mut worst) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
83 for (i, b) in bars.iter().take(horizon).enumerate() {
84 out.returns.push(direction.pnl(entry, b.close));
85 let fav = direction.favorable(entry, b.high, b.low);
86 let adv = direction.adverse(entry, b.high, b.low);
87 if fav > best {
88 best = fav;
89 out.bars_to_mfe = i + 1;
90 }
91 if adv > worst {
92 worst = adv;
93 out.bars_to_mae = i + 1;
94 }
95 }
96 out.mfe = best.max(0.0);
97 out.mae = worst.max(0.0);
98 out
99 }
100 pub fn return_at(&self, horizon: usize) -> Option<f64> {
101 horizon
102 .checked_sub(1)
103 .and_then(|i| self.returns.get(i).copied())
104 }
105}
106
107#[derive(Debug, Clone, Copy, Default)]
109pub struct PriceStats {
110 pub closed_count: i64,
111 pub win_rate: f64,
112 pub avg_pnl: f64,
113 pub total_pnl: f64,
114}
115impl PriceStats {
116 pub fn compute(values: impl IntoIterator<Item = Option<f64>>) -> Self {
121 let (mut n, mut present, mut wins, mut total) = (0, 0, 0, 0.0);
122 for p in values {
123 n += 1;
124 if let Some(p) = p {
125 present += 1;
126 total += p;
127 if p > 0.0 {
128 wins += 1;
129 }
130 }
131 }
132 Self {
133 closed_count: n,
134 win_rate: if n > 0 { wins as f64 / n as f64 } else { 0.0 },
135 avg_pnl: if present > 0 {
136 total / present as f64
137 } else {
138 0.0
139 },
140 total_pnl: total,
141 }
142 }
143 pub fn merge(values: impl IntoIterator<Item = Self>) -> Self {
148 let (mut n, mut wins, mut total) = (0, 0.0, 0.0);
149 for v in values {
150 n += v.closed_count;
151 wins += v.win_rate * v.closed_count as f64;
152 total += v.total_pnl;
153 }
154 if n == 0 {
155 return Self::default();
156 }
157 Self {
158 closed_count: n,
159 win_rate: wins / n as f64,
160 avg_pnl: total / n as f64,
161 total_pnl: total,
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy)]
168pub struct PriceOutcomeSample {
169 pub entry: f64,
170 pub return_value: Option<f64>,
171 pub atr: Option<f64>,
172 pub mfe: f64,
173 pub mae: f64,
174 pub strength: f64,
175}
176#[derive(Debug, Clone, Default)]
177pub struct PriceOutcomeStats {
178 pub n: i64,
179 pub hit_rate: f64,
180 pub avg_ret_pct: Option<f64>,
181 pub avg_ret_atr: Option<f64>,
182 pub avg_mfe_pct: f64,
183 pub avg_mae_pct: f64,
184 pub avg_strength: f64,
185}
186impl PriceOutcomeStats {
187 pub fn compute(values: &[PriceOutcomeSample]) -> Self {
189 if values.is_empty() {
190 return Self::default();
191 }
192 let n = values.len() as f64;
193 let returns: Vec<_> = values
194 .iter()
195 .filter_map(|v| v.return_value.map(|r| r / v.entry * 100.0))
196 .collect();
197 let atr: Vec<_> = values
198 .iter()
199 .filter_map(|v| {
200 v.return_value
201 .zip(v.atr.filter(|a| *a > 0.0))
202 .map(|(r, a)| r / a)
203 })
204 .collect();
205 Self {
206 n: values.len() as i64,
207 hit_rate: values
208 .iter()
209 .filter(|v| v.return_value.is_some_and(|r| r > 0.0))
210 .count() as f64
211 / n,
212 avg_ret_pct: (!returns.is_empty())
213 .then(|| returns.iter().sum::<f64>() / returns.len() as f64),
214 avg_ret_atr: (!atr.is_empty()).then(|| atr.iter().sum::<f64>() / atr.len() as f64),
215 avg_mfe_pct: values.iter().map(|v| v.mfe / v.entry * 100.0).sum::<f64>() / n,
216 avg_mae_pct: values.iter().map(|v| v.mae / v.entry * 100.0).sum::<f64>() / n,
217 avg_strength: values.iter().map(|v| v.strength).sum::<f64>() / n,
218 }
219 }
220}