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    pub confidence: f64,
40    pub entry_price: f64,
41    pub exit_price: f64,
42    pub realized_r_multiple: f64,
43    pub duration_bars: u32,
44    pub outcome: TradeOutcome,
45}
46
47/// Aggregated statistical metrics over a series of evaluations.
48#[derive(Debug, Clone, Copy, PartialEq)]
49#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
50pub struct TradeStats {
51    pub total_trades: usize,
52    pub winrate: f64, // 0.0 .. 1.0
53    pub profit_factor: f64,
54    pub average_r_multiple: f64,
55    pub expectancy_r: f64, // EV in R
56    pub max_drawdown_r: f64,
57}
58
59impl TradeStats {
60    /// Computes realized statistics after normalizing outcome/R inconsistencies:
61    /// wins are positive, losses are negative, break-even records are zero, and expired records
62    /// retain their finite realized R value. Non-finite R values are treated as zero.
63    pub fn compute(records: &[SignalEvaluationRecord]) -> Self {
64        if records.is_empty() {
65            return Self {
66                total_trades: 0,
67                winrate: 0.0,
68                profit_factor: 0.0,
69                average_r_multiple: 0.0,
70                expectancy_r: 0.0,
71                max_drawdown_r: 0.0,
72            };
73        }
74
75        let total = records.len();
76        let wins = records
77            .iter()
78            .filter(|r| r.outcome == TradeOutcome::Win)
79            .count();
80        // Winrate: Ratio of winning trades to total trades
81        let winrate = wins as f64 / total as f64;
82
83        let normalized_r = |record: &SignalEvaluationRecord| {
84            let realized = if record.realized_r_multiple.is_finite() {
85                record.realized_r_multiple
86            } else {
87                0.0
88            };
89            match record.outcome {
90                TradeOutcome::Win => realized.abs(),
91                TradeOutcome::Loss => -realized.abs(),
92                TradeOutcome::BreakEven => 0.0,
93                TradeOutcome::Expired => realized,
94            }
95        };
96
97        let mut total_gain = 0.0f64;
98        let mut total_loss = 0.0f64;
99        let mut sum_r = 0.0f64;
100
101        for r in records {
102            let r_val = normalized_r(r);
103            sum_r += r_val;
104            if r_val > 0.0 {
105                total_gain += r_val;
106            } else if r_val < 0.0 {
107                total_loss += r_val.abs();
108            }
109        }
110
111        let profit_factor = if total_loss > 0.0 {
112            total_gain / total_loss
113        } else if total_gain > 0.0 {
114            f64::INFINITY
115        } else {
116            0.0
117        };
118
119        let average_r_multiple = sum_r / total as f64;
120        let expectancy_r = average_r_multiple;
121
122        let mut equity = 0.0f64;
123        let mut peak = 0.0f64;
124        let mut max_dd = 0.0f64;
125
126        for r in records {
127            let r_val = normalized_r(r);
128            equity += r_val;
129            if equity > peak {
130                peak = equity;
131            }
132            let dd = peak - equity;
133            if dd > max_dd {
134                max_dd = dd;
135            }
136        }
137
138        Self {
139            total_trades: total,
140            winrate,
141            profit_factor,
142            average_r_multiple,
143            expectancy_r,
144            max_drawdown_r: max_dd,
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_trade_stats_edge_cases() {
155        // 1. All wins
156        let wins_only = vec![SignalEvaluationRecord {
157            timestamp: 1000,
158            trigger: TriggerAction::Buy,
159            score: 0.8,
160            confidence: 0.9,
161            entry_price: 100.0,
162            exit_price: 105.0,
163            realized_r_multiple: 2.0,
164            duration_bars: 5,
165            outcome: TradeOutcome::Win,
166        }];
167        let stats_wins = TradeStats::compute(&wins_only);
168        assert_eq!(stats_wins.winrate, 1.0);
169        assert_eq!(stats_wins.profit_factor, f64::INFINITY);
170        assert_eq!(stats_wins.average_r_multiple, 2.0);
171
172        // 2. All losses
173        let losses_only = vec![SignalEvaluationRecord {
174            timestamp: 1000,
175            trigger: TriggerAction::Sell,
176            score: 0.8,
177            confidence: 0.9,
178            entry_price: 100.0,
179            exit_price: 105.0,
180            realized_r_multiple: -1.0,
181            duration_bars: 5,
182            outcome: TradeOutcome::Loss,
183        }];
184        let stats_losses = TradeStats::compute(&losses_only);
185        assert_eq!(stats_losses.winrate, 0.0);
186        assert_eq!(stats_losses.profit_factor, 0.0);
187        assert_eq!(stats_losses.average_r_multiple, -1.0);
188
189        // 3. BreakEven & Expired
190        let breakeven_and_expired = vec![
191            SignalEvaluationRecord {
192                timestamp: 1000,
193                trigger: TriggerAction::Buy,
194                score: 0.8,
195                confidence: 0.9,
196                entry_price: 100.0,
197                exit_price: 100.0,
198                realized_r_multiple: 0.5, // Should be sanitized to 0.0
199                duration_bars: 5,
200                outcome: TradeOutcome::BreakEven,
201            },
202            SignalEvaluationRecord {
203                timestamp: 2000,
204                trigger: TriggerAction::Buy,
205                score: 0.8,
206                confidence: 0.9,
207                entry_price: 100.0,
208                exit_price: 100.2,
209                realized_r_multiple: 0.1,
210                duration_bars: 20,
211                outcome: TradeOutcome::Expired,
212            },
213        ];
214        let stats_be = TradeStats::compute(&breakeven_and_expired);
215        assert_eq!(stats_be.winrate, 0.0);
216        assert_eq!(stats_be.average_r_multiple, 0.05);
217
218        // 4. Outcome is authoritative when the realized R sign is inconsistent
219        let inconsistent = vec![
220            SignalEvaluationRecord {
221                timestamp: 3000,
222                trigger: TriggerAction::Buy,
223                score: 0.8,
224                confidence: 0.9,
225                entry_price: 100.0,
226                exit_price: 90.0,
227                realized_r_multiple: -2.0,
228                duration_bars: 5,
229                outcome: TradeOutcome::Win,
230            },
231            SignalEvaluationRecord {
232                timestamp: 4000,
233                trigger: TriggerAction::Sell,
234                score: -0.8,
235                confidence: 0.9,
236                entry_price: 100.0,
237                exit_price: 90.0,
238                realized_r_multiple: 1.0,
239                duration_bars: 5,
240                outcome: TradeOutcome::Loss,
241            },
242        ];
243        let stats_inconsistent = TradeStats::compute(&inconsistent);
244        assert_eq!(stats_inconsistent.average_r_multiple, 0.5);
245        assert_eq!(stats_inconsistent.expectancy_r, 0.5);
246        assert_eq!(stats_inconsistent.profit_factor, 2.0);
247    }
248}
249
250/// Parameter optimization feedback hook for adjusting strategy parameters based on performance.
251#[derive(Debug, Clone, PartialEq)]
252#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
253pub struct ParameterOptimizationHook {
254    pub indicator_weights: std::collections::HashMap<String, f64>,
255    pub min_confidence_threshold: f64,
256    pub min_rr_threshold: f64,
257}
258
259impl ParameterOptimizationHook {
260    pub fn default_preset() -> Self {
261        Self {
262            indicator_weights: std::collections::HashMap::new(),
263            min_confidence_threshold: 0.50,
264            min_rr_threshold: 1.5,
265        }
266    }
267
268    /// Recommends weight adjustments based on trade statistics.
269    pub fn optimize_from_stats(&mut self, stats: &TradeStats) {
270        if stats.winrate < 0.40 {
271            self.min_confidence_threshold = (self.min_confidence_threshold + 0.05).min(0.80);
272        } else if stats.winrate > 0.65 {
273            self.min_confidence_threshold = (self.min_confidence_threshold - 0.05).max(0.40);
274        }
275
276        if stats.average_r_multiple < 1.0 {
277            self.min_rr_threshold = (self.min_rr_threshold + 0.2).min(3.0);
278        }
279    }
280}