Skip to main content

kestrel_chartkit/evaluation/
mod.rs

1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::signal::TriggerAction;
5
6pub mod calibration;
7pub mod exporter;
8pub mod recorder;
9
10pub use calibration::{
11    cohort_aggregate, compute_calibration, CalibrationBucket, CalibrationReport, Cohort,
12};
13pub use exporter::{FeatureExporter, FeatureRecord};
14pub use recorder::{
15    ActiveSetup, IntrabarFillPolicy, OutcomeExcursion, OutcomeRecorder, RecordSetupError,
16};
17
18/// Historic execution result of a triggered setup.
19#[derive(Debug, Clone, Copy, PartialEq)]
20#[cfg_attr(
21    feature = "serde",
22    derive(Serialize, Deserialize),
23    serde(rename_all = "snake_case")
24)]
25pub enum TradeOutcome {
26    Win,
27    Loss,
28    BreakEven,
29    Expired,
30}
31
32/// Recorded evaluation entry of a signal execution.
33#[derive(Debug, Clone, PartialEq)]
34#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
35pub struct SignalEvaluationRecord {
36    pub timestamp: i64,
37    pub trigger: TriggerAction,
38    pub score: f64,
39    /// 0..1 — wie einig sich die Eingangsgrößen waren, als das Signal
40    /// entstand. **Keine behauptete Wahrscheinlichkeit.**
41    ///
42    /// Hieß bis 2026-09-08 `confidence`. Genau das war der Denkfehler: die
43    /// Kalibrierung prüft, ob ein Wert Ausgänge vorhersagt — sie darf ihn
44    /// nicht schon im Namen als Vorhersage führen. Die Frage lautet „sagt
45    /// Einigkeit etwas über den Ausgang?", und die Antwort steht in
46    /// `calibration`, nicht im Feldnamen.
47    pub agreement: f64,
48    pub entry_price: f64,
49    pub exit_price: f64,
50    pub realized_r_multiple: f64,
51    pub duration_bars: u32,
52    pub outcome: TradeOutcome,
53}
54
55/// Aggregated statistical metrics over a series of evaluations.
56#[derive(Debug, Clone, Copy, PartialEq)]
57#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
58pub struct TradeStats {
59    pub total_trades: usize,
60    pub winrate: f64, // 0.0 .. 1.0
61    pub profit_factor: f64,
62    pub average_r_multiple: f64,
63    pub expectancy_r: f64, // EV in R
64    pub max_drawdown_r: f64,
65}
66
67impl TradeStats {
68    /// Computes realized statistics after normalizing outcome/R inconsistencies:
69    /// wins are positive, losses are negative, break-even records are zero, and expired records
70    /// retain their finite realized R value. Non-finite R values are treated as zero.
71    pub fn compute(records: &[SignalEvaluationRecord]) -> Self {
72        if records.is_empty() {
73            return Self {
74                total_trades: 0,
75                winrate: 0.0,
76                profit_factor: 0.0,
77                average_r_multiple: 0.0,
78                expectancy_r: 0.0,
79                max_drawdown_r: 0.0,
80            };
81        }
82
83        let total = records.len();
84        let wins = records
85            .iter()
86            .filter(|r| r.outcome == TradeOutcome::Win)
87            .count();
88        // Winrate: Ratio of winning trades to total trades
89        let winrate = wins as f64 / total as f64;
90
91        let normalized_r = |record: &SignalEvaluationRecord| {
92            let realized = if record.realized_r_multiple.is_finite() {
93                record.realized_r_multiple
94            } else {
95                0.0
96            };
97            match record.outcome {
98                TradeOutcome::Win => realized.abs(),
99                TradeOutcome::Loss => -realized.abs(),
100                TradeOutcome::BreakEven => 0.0,
101                TradeOutcome::Expired => realized,
102            }
103        };
104
105        let mut total_gain = 0.0f64;
106        let mut total_loss = 0.0f64;
107        let mut sum_r = 0.0f64;
108
109        for r in records {
110            let r_val = normalized_r(r);
111            sum_r += r_val;
112            if r_val > 0.0 {
113                total_gain += r_val;
114            } else if r_val < 0.0 {
115                total_loss += r_val.abs();
116            }
117        }
118
119        let profit_factor = if total_loss > 0.0 {
120            total_gain / total_loss
121        } else if total_gain > 0.0 {
122            f64::INFINITY
123        } else {
124            0.0
125        };
126
127        let average_r_multiple = sum_r / total as f64;
128        let expectancy_r = average_r_multiple;
129
130        let mut equity = 0.0f64;
131        let mut peak = 0.0f64;
132        let mut max_dd = 0.0f64;
133
134        for r in records {
135            let r_val = normalized_r(r);
136            equity += r_val;
137            if equity > peak {
138                peak = equity;
139            }
140            let dd = peak - equity;
141            if dd > max_dd {
142                max_dd = dd;
143            }
144        }
145
146        Self {
147            total_trades: total,
148            winrate,
149            profit_factor,
150            average_r_multiple,
151            expectancy_r,
152            max_drawdown_r: max_dd,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_trade_stats_edge_cases() {
163        // 1. All wins
164        let wins_only = vec![SignalEvaluationRecord {
165            timestamp: 1000,
166            trigger: TriggerAction::Buy,
167            score: 0.8,
168            agreement: 0.9,
169            entry_price: 100.0,
170            exit_price: 105.0,
171            realized_r_multiple: 2.0,
172            duration_bars: 5,
173            outcome: TradeOutcome::Win,
174        }];
175        let stats_wins = TradeStats::compute(&wins_only);
176        assert_eq!(stats_wins.winrate, 1.0);
177        assert_eq!(stats_wins.profit_factor, f64::INFINITY);
178        assert_eq!(stats_wins.average_r_multiple, 2.0);
179
180        // 2. All losses
181        let losses_only = vec![SignalEvaluationRecord {
182            timestamp: 1000,
183            trigger: TriggerAction::Sell,
184            score: 0.8,
185            agreement: 0.9,
186            entry_price: 100.0,
187            exit_price: 105.0,
188            realized_r_multiple: -1.0,
189            duration_bars: 5,
190            outcome: TradeOutcome::Loss,
191        }];
192        let stats_losses = TradeStats::compute(&losses_only);
193        assert_eq!(stats_losses.winrate, 0.0);
194        assert_eq!(stats_losses.profit_factor, 0.0);
195        assert_eq!(stats_losses.average_r_multiple, -1.0);
196
197        // 3. BreakEven & Expired
198        let breakeven_and_expired = vec![
199            SignalEvaluationRecord {
200                timestamp: 1000,
201                trigger: TriggerAction::Buy,
202                score: 0.8,
203                agreement: 0.9,
204                entry_price: 100.0,
205                exit_price: 100.0,
206                realized_r_multiple: 0.5, // Should be sanitized to 0.0
207                duration_bars: 5,
208                outcome: TradeOutcome::BreakEven,
209            },
210            SignalEvaluationRecord {
211                timestamp: 2000,
212                trigger: TriggerAction::Buy,
213                score: 0.8,
214                agreement: 0.9,
215                entry_price: 100.0,
216                exit_price: 100.2,
217                realized_r_multiple: 0.1,
218                duration_bars: 20,
219                outcome: TradeOutcome::Expired,
220            },
221        ];
222        let stats_be = TradeStats::compute(&breakeven_and_expired);
223        assert_eq!(stats_be.winrate, 0.0);
224        assert_eq!(stats_be.average_r_multiple, 0.05);
225
226        // 4. Outcome is authoritative when the realized R sign is inconsistent
227        let inconsistent = vec![
228            SignalEvaluationRecord {
229                timestamp: 3000,
230                trigger: TriggerAction::Buy,
231                score: 0.8,
232                agreement: 0.9,
233                entry_price: 100.0,
234                exit_price: 90.0,
235                realized_r_multiple: -2.0,
236                duration_bars: 5,
237                outcome: TradeOutcome::Win,
238            },
239            SignalEvaluationRecord {
240                timestamp: 4000,
241                trigger: TriggerAction::Sell,
242                score: -0.8,
243                agreement: 0.9,
244                entry_price: 100.0,
245                exit_price: 90.0,
246                realized_r_multiple: 1.0,
247                duration_bars: 5,
248                outcome: TradeOutcome::Loss,
249            },
250        ];
251        let stats_inconsistent = TradeStats::compute(&inconsistent);
252        assert_eq!(stats_inconsistent.average_r_multiple, 0.5);
253        assert_eq!(stats_inconsistent.expectancy_r, 0.5);
254        assert_eq!(stats_inconsistent.profit_factor, 2.0);
255    }
256}
257
258/// Parameter optimization feedback hook for adjusting strategy parameters based on performance.
259#[derive(Debug, Clone, PartialEq)]
260#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
261pub struct ParameterOptimizationHook {
262    pub indicator_weights: std::collections::HashMap<String, f64>,
263    pub min_confidence_threshold: f64,
264    pub min_rr_threshold: f64,
265}
266
267impl ParameterOptimizationHook {
268    pub fn default_preset() -> Self {
269        Self {
270            indicator_weights: std::collections::HashMap::new(),
271            min_confidence_threshold: 0.50,
272            min_rr_threshold: 1.5,
273        }
274    }
275
276    /// Recommends weight adjustments based on trade statistics.
277    pub fn optimize_from_stats(&mut self, stats: &TradeStats) {
278        if stats.winrate < 0.40 {
279            self.min_confidence_threshold = (self.min_confidence_threshold + 0.05).min(0.80);
280        } else if stats.winrate > 0.65 {
281            self.min_confidence_threshold = (self.min_confidence_threshold - 0.05).max(0.40);
282        }
283
284        if stats.average_r_multiple < 1.0 {
285            self.min_rr_threshold = (self.min_rr_threshold + 0.2).min(3.0);
286        }
287    }
288}
289
290pub mod excursion;
291pub mod price;
292pub mod probability;
293pub mod split;
294
295pub use probability::{
296    block_bootstrap_brier, compute_calibration_metrics, CalibratedProbability, CalibrationMetrics,
297    IsotonicCalibrator, ValidationExperimentManifest,
298};
299pub use split::{
300    split_trades_purged, PurgedSplitConfig, PurgedTrainTestSplit, SplitError, TradeSpan,
301};