kestrel_chartkit/evaluation/
split.rs1use std::fmt;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
11pub struct TradeSpan {
12 pub id: usize,
13 pub entry_bar: usize,
15 pub exit_bar: usize,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22pub struct PurgedSplitConfig {
23 pub train_start: usize,
25 pub train_end: usize,
27 pub test_start: usize,
29 pub test_end: usize,
31 pub embargo_bars: usize,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum SplitError {
38 InvalidRange(&'static str),
39 EmbargoViolation {
40 actual_gap: usize,
41 required_embargo: usize,
42 },
43}
44
45impl fmt::Display for SplitError {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::InvalidRange(msg) => write!(f, "invalid range: {msg}"),
49 Self::EmbargoViolation {
50 actual_gap,
51 required_embargo,
52 } => write!(
53 f,
54 "embargo violation: actual gap {actual_gap} bars < required {required_embargo} bars"
55 ),
56 }
57 }
58}
59
60impl std::error::Error for SplitError {}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
65pub struct PurgedTrainTestSplit {
66 pub train_trade_ids: Vec<usize>,
68 pub test_trade_ids: Vec<usize>,
70 pub purged_trade_ids: Vec<usize>,
72}
73
74pub fn split_trades_purged(
77 trades: &[TradeSpan],
78 config: &PurgedSplitConfig,
79) -> Result<PurgedTrainTestSplit, SplitError> {
80 if config.train_end <= config.train_start {
81 return Err(SplitError::InvalidRange(
82 "train_end must be strictly greater than train_start",
83 ));
84 }
85 if config.test_end <= config.test_start {
86 return Err(SplitError::InvalidRange(
87 "test_end must be strictly greater than test_start",
88 ));
89 }
90 if config.test_start < config.train_end {
91 return Err(SplitError::InvalidRange(
92 "test_start must be >= train_end chronologically",
93 ));
94 }
95
96 let actual_gap = config.test_start - config.train_end;
97 if actual_gap < config.embargo_bars {
98 return Err(SplitError::EmbargoViolation {
99 actual_gap,
100 required_embargo: config.embargo_bars,
101 });
102 }
103
104 let mut train_trade_ids = Vec::new();
105 let mut test_trade_ids = Vec::new();
106 let mut purged_trade_ids = Vec::new();
107
108 for trade in trades {
109 if trade.exit_bar < trade.entry_bar {
110 return Err(SplitError::InvalidRange(
111 "trade exit_bar must be >= entry_bar",
112 ));
113 }
114
115 if trade.entry_bar >= config.train_start && trade.entry_bar < config.train_end {
117 if trade.exit_bar >= config.test_start {
120 purged_trade_ids.push(trade.id);
121 } else {
122 train_trade_ids.push(trade.id);
123 }
124 } else if trade.entry_bar >= config.test_start && trade.entry_bar < config.test_end {
125 test_trade_ids.push(trade.id);
127 }
128 }
129
130 Ok(PurgedTrainTestSplit {
131 train_trade_ids,
132 test_trade_ids,
133 purged_trade_ids,
134 })
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn test_purging_trade_overlapping_test_start() {
143 let config = PurgedSplitConfig {
144 train_start: 0,
145 train_end: 100,
146 test_start: 110,
147 test_end: 200,
148 embargo_bars: 10,
149 };
150
151 let trades = vec![
152 TradeSpan {
154 id: 1,
155 entry_bar: 20,
156 exit_bar: 35,
157 },
158 TradeSpan {
160 id: 2,
161 entry_bar: 95,
162 exit_bar: 115,
163 },
164 TradeSpan {
166 id: 3,
167 entry_bar: 115,
168 exit_bar: 130,
169 },
170 ];
171
172 let split = split_trades_purged(&trades, &config).unwrap();
173 assert_eq!(split.train_trade_ids, vec![1]);
174 assert_eq!(split.purged_trade_ids, vec![2]);
175 assert_eq!(split.test_trade_ids, vec![3]);
176 }
177
178 #[test]
179 fn test_embargo_violation_detection() {
180 let config = PurgedSplitConfig {
181 train_start: 0,
182 train_end: 100,
183 test_start: 105, test_end: 200,
185 embargo_bars: 10, };
187 let res = split_trades_purged(&[], &config);
188 assert!(matches!(res, Err(SplitError::EmbargoViolation { .. })));
189 }
190}