Skip to main content

kestrel_chartkit/evaluation/
price.rs

1//! Price-unit paper accounting. No contract multiplier, FX, or intrabar fill assumptions.
2
3/// Direction of a position or directional signal.
4#[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    /// Extend non-negative running excursions with one completed bar.
42    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/// OHLC range relevant to price-unit evaluation; timestamps/order are the caller's responsibility.
57#[derive(Debug, Clone, Copy)]
58pub struct PriceObservation {
59    pub high: f64,
60    pub low: f64,
61    pub close: f64,
62}
63
64/// Recomputed from bars strictly after entry. Excursion times are one-based;
65/// ties select the first observation, even when every raw excursion is negative.
66#[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/// Summary of price deltas within ONE instrument (or another explicitly common unit).
108#[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    /// `closed_count` counts every value, `None` included; `win_rate = wins / closed_count`, a win
117    /// being a value `> 0`; `total_pnl` sums the present values and `avg_pnl = total_pnl / present
118    /// count`. NULL outcomes thus count as closed (and not won), but do not enter the mean,
119    /// matching stored legacy rows. All zero for no values.
120    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    /// Merge disjoint summaries in the same price unit: counts and totals add up, `win_rate` is
144    /// weighted by `closed_count`, and `avg_pnl = total_pnl / closed_count`. The present count is
145    /// not kept in a summary, so where the inputs held `None` values this mean differs from
146    /// [`PriceStats::compute`] over the combined values, which divides by the present count.
147    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/// A completed directional outcome, normalized only during aggregation.
167#[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    /// Entries must be positive; caller selects completed outcomes and cohorts.
188    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}