kestrel_chartkit/evaluation/
mod.rs1#[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#[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#[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 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#[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, pub profit_factor: f64,
62 pub average_r_multiple: f64,
63 pub expectancy_r: f64, pub max_drawdown_r: f64,
65}
66
67impl TradeStats {
68 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 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 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 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 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, 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 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#[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 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};