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 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#[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, pub profit_factor: f64,
54 pub average_r_multiple: f64,
55 pub expectancy_r: f64, pub max_drawdown_r: f64,
57}
58
59impl TradeStats {
60 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 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 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 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 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, 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 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#[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 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}