Skip to main content

kestrel_chartkit/evaluation/
split.rs

1//! Out-of-sample data splits with purging and embargo to eliminate lookahead bias and label overlap.
2
3use std::fmt;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8/// Representation of a trade's temporal lifespan for purging calculations.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
11pub struct TradeSpan {
12    pub id: usize,
13    /// Bar index when the trade setup was entered / signal evaluated.
14    pub entry_bar: usize,
15    /// Bar index when the trade reached its target, stop, or horizon expiry.
16    pub exit_bar: usize,
17}
18
19/// Configuration for a train/test chronological split with embargo.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22pub struct PurgedSplitConfig {
23    /// Beginning of training window (inclusive).
24    pub train_start: usize,
25    /// End of training window (exclusive).
26    pub train_end: usize,
27    /// Beginning of testing window (inclusive). Must be >= `train_end + embargo_bars`.
28    pub test_start: usize,
29    /// End of testing window (exclusive).
30    pub test_end: usize,
31    /// Minimum separation buffer between training end and test start.
32    pub embargo_bars: usize,
33}
34
35/// Error during split configuration or purging.
36#[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/// The result of a purged train/test split.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
65pub struct PurgedTrainTestSplit {
66    /// Trade IDs retained in the training set.
67    pub train_trade_ids: Vec<usize>,
68    /// Trade IDs retained in the testing set.
69    pub test_trade_ids: Vec<usize>,
70    /// Trade IDs purged from training because their evaluation window overlaps the test period.
71    pub purged_trade_ids: Vec<usize>,
72}
73
74/// Partitions trades into train and test sets, purging any training trade whose exit bar
75/// reaches into or beyond the test set start.
76pub 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        // Training candidate: entered during the training interval
116        if trade.entry_bar >= config.train_start && trade.entry_bar < config.train_end {
117            // Purge condition: if trade's outcome is not fully realized before test starts,
118            // it carries lookahead information across the split boundary.
119            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            // Testing candidate
126            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            // Trade 1: entry 20, exit 35 -> safe train
153            TradeSpan {
154                id: 1,
155                entry_bar: 20,
156                exit_bar: 35,
157            },
158            // Trade 2: entry 95, exit 115 -> overlaps test_start 110! MUST be purged!
159            TradeSpan {
160                id: 2,
161                entry_bar: 95,
162                exit_bar: 115,
163            },
164            // Trade 3: entry 115, exit 130 -> test set
165            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, // gap = 5
184            test_end: 200,
185            embargo_bars: 10, // required = 10
186        };
187        let res = split_trades_purged(&[], &config);
188        assert!(matches!(res, Err(SplitError::EmbargoViolation { .. })));
189    }
190}