diegobeyl/backtesting
2
1"""
2Tests for Input Validators
3Tests the InputValidator class to ensure robust validation
4"""
5
6import pytest
7import pandas as pd
8import numpy as np
9from datetime import datetime, timedelta
10
11from utils.validators import InputValidator
12from utils.exceptions import ValidationException
13
14
15class TestDataFrameValidation:
16 """Tests for DataFrame validation"""
17
18 def test_valid_dataframe(self):
19 """Test with valid DataFrame"""
20 df = pd.DataFrame({
21 'open': [100.0] * 20,
22 'high': [101.0] * 20,
23 'low': [99.0] * 20,
24 'close': [100.5] * 20,
25 'volume': [1000000] * 20
26 })
27
28 is_valid, error = InputValidator.validate_dataframe(df, "BTCUSD")
29 assert is_valid is True
30 assert error is None
31
32 def test_empty_dataframe(self):
33 """Test with empty DataFrame"""
34 df = pd.DataFrame()
35
36 with pytest.raises(ValidationException) as exc_info:
37 InputValidator.validate_dataframe(df, "BTCUSD")
38
39 assert exc_info.value.error_code == "VALIDATION_ERROR"
40 assert "empty" in exc_info.value.message.lower()
41
42 def test_insufficient_rows(self):
43 """Test with too few rows"""
44 df = pd.DataFrame({
45 'open': [100.0] * 5,
46 'high': [101.0] * 5,
47 'low': [99.0] * 5,
48 'close': [100.5] * 5,
49 'volume': [1000000] * 5
50 })
51
52 with pytest.raises(ValidationException) as exc_info:
53 InputValidator.validate_dataframe(df, "BTCUSD")
54
55 assert "Insufficient data" in exc_info.value.message
56
57 def test_missing_columns(self):
58 """Test with missing required columns"""
59 df = pd.DataFrame({
60 'open': [100.0] * 20,
61 'close': [100.5] * 20,
62 'volume': [1000000] * 20
63 })
64
65 with pytest.raises(ValidationException) as exc_info:
66 InputValidator.validate_dataframe(df, "BTCUSD")
67
68 assert "Missing required columns" in exc_info.value.message
69
70 def test_nan_values(self):
71 """Test with NaN values"""
72 df = pd.DataFrame({
73 'open': [100.0, np.nan] + [100.0] * 18,
74 'high': [101.0] * 20,
75 'low': [99.0] * 20,
76 'close': [100.5] * 20,
77 'volume': [1000000] * 20
78 })
79
80 with pytest.raises(ValidationException) as exc_info:
81 InputValidator.validate_dataframe(df, "BTCUSD")
82
83 assert "NaN" in exc_info.value.message
84
85 def test_negative_prices(self):
86 """Test with negative prices"""
87 df = pd.DataFrame({
88 'open': [-100.0] + [100.0] * 19,
89 'high': [101.0] * 20,
90 'low': [99.0] * 20,
91 'close': [100.5] * 20,
92 'volume': [1000000] * 20
93 })
94
95 with pytest.raises(ValidationException) as exc_info:
96 InputValidator.validate_dataframe(df, "BTCUSD")
97
98 assert "negative" in exc_info.value.message.lower()
99
100 def test_invalid_ohlc_logic(self):
101 """Test with invalid OHLC logic (High < Low)"""
102 df = pd.DataFrame({
103 'open': [100.0] * 20,
104 'high': [99.0] * 20, # Invalid: high < low
105 'low': [101.0] * 20,
106 'close': [100.5] * 20,
107 'volume': [1000000] * 20
108 })
109
110 with pytest.raises(ValidationException) as exc_info:
111 InputValidator.validate_dataframe(df, "BTCUSD")
112
113 assert "OHLC logic" in exc_info.value.message
114
115
116class TestBacktestParamValidation:
117 """Tests for backtest parameter validation"""
118
119 def test_valid_params(self):
120 """Test with valid parameters"""
121 is_valid, error = InputValidator.validate_backtest_params(
122 initial_capital=10000,
123 risk_percent=2.0,
124 symbol="BTCUSD",
125 bars=500
126 )
127 assert is_valid is True
128 assert error is None
129
130 def test_capital_too_small(self):
131 """Test with capital below minimum"""
132 with pytest.raises(ValidationException) as exc_info:
133 InputValidator.validate_backtest_params(
134 initial_capital=50,
135 risk_percent=2.0,
136 symbol="BTCUSD"
137 )
138
139 assert "initial_capital too small" in exc_info.value.message
140
141 def test_capital_too_large(self):
142 """Test with capital above maximum"""
143 with pytest.raises(ValidationException) as exc_info:
144 InputValidator.validate_backtest_params(
145 initial_capital=2e9, # 2 billion
146 risk_percent=2.0,
147 symbol="BTCUSD"
148 )
149
150 assert "initial_capital too large" in exc_info.value.message
151
152 def test_risk_percent_too_low(self):
153 """Test with risk percent below minimum"""
154 with pytest.raises(ValidationException) as exc_info:
155 InputValidator.validate_backtest_params(
156 initial_capital=10000,
157 risk_percent=0.05,
158 symbol="BTCUSD"
159 )
160
161 assert "risk_percent too low" in exc_info.value.message
162
163 def test_risk_percent_too_high(self):
164 """Test with risk percent above maximum"""
165 with pytest.raises(ValidationException) as exc_info:
166 InputValidator.validate_backtest_params(
167 initial_capital=10000,
168 risk_percent=60.0,
169 symbol="BTCUSD"
170 )
171
172 assert "risk_percent too high" in exc_info.value.message
173
174 def test_invalid_symbol(self):
175 """Test with invalid symbol"""
176 with pytest.raises(ValidationException) as exc_info:
177 InputValidator.validate_backtest_params(
178 initial_capital=10000,
179 risk_percent=2.0,
180 symbol="INVALID@SYMBOL!"
181 )
182
183 assert "invalid characters" in exc_info.value.message.lower()
184
185
186class TestSymbolValidation:
187 """Tests for symbol validation"""
188
189 def test_valid_symbols(self):
190 """Test with valid symbols"""
191 valid_symbols = ["BTCUSD", "AAPL", "BTC-USD", "ES", "GLD"]
192 for symbol in valid_symbols:
193 assert InputValidator.validate_symbol(symbol) is True
194
195 def test_empty_symbol(self):
196 """Test with empty symbol"""
197 with pytest.raises(ValidationException) as exc_info:
198 InputValidator.validate_symbol("")
199
200 assert "empty" in exc_info.value.message.lower()
201
202 def test_invalid_characters(self):
203 """Test with invalid characters"""
204 invalid_symbols = ["BTC@USD", "AAPL!", "BTC USD"]
205 for symbol in invalid_symbols:
206 with pytest.raises(ValidationException):
207 InputValidator.validate_symbol(symbol)
208
209 def test_symbol_too_long(self):
210 """Test with symbol longer than 20 characters"""
211 with pytest.raises(ValidationException) as exc_info:
212 InputValidator.validate_symbol("A" * 25)
213
214 assert "too long" in exc_info.value.message.lower()
215
216
217class TestDateRangeValidation:
218 """Tests for date range validation"""
219
220 def test_valid_date_range(self):
221 """Test with valid date range"""
222 is_valid, error = InputValidator.validate_date_range(
223 "2023-01-01",
224 "2023-12-31"
225 )
226 assert is_valid is True
227 assert error is None
228
229 def test_none_dates(self):
230 """Test with None dates"""
231 is_valid, error = InputValidator.validate_date_range(None, None)
232 assert is_valid is True
233 assert error is None
234
235 def test_invalid_date_format(self):
236 """Test with invalid date format"""
237 with pytest.raises(ValidationException) as exc_info:
238 InputValidator.validate_date_range(
239 "01-01-2023", # Wrong format
240 "2023-12-31"
241 )
242
243 assert "format" in exc_info.value.message.lower()
244
245 def test_start_after_end(self):
246 """Test with start date after end date"""
247 with pytest.raises(ValidationException) as exc_info:
248 InputValidator.validate_date_range(
249 "2023-12-31",
250 "2023-01-01"
251 )
252
253 assert "before" in exc_info.value.message.lower()
254
255
256class TestGenericValidation:
257 """Tests for generic input validation"""
258
259 def test_valid_type(self):
260 """Test with correct type"""
261 result = InputValidator.validate_input(100, int, "test_field")
262 assert result == 100
263
264 def test_invalid_type(self):
265 """Test with incorrect type"""
266 with pytest.raises(ValidationException) as exc_info:
267 InputValidator.validate_input("100", int, "test_field")
268
269 assert "must be int" in exc_info.value.message
270
271 def test_none_not_allowed(self):
272 """Test with None when not allowed"""
273 with pytest.raises(ValidationException) as exc_info:
274 InputValidator.validate_input(None, int, "test_field", allow_none=False)
275
276 assert "cannot be None" in exc_info.value.message
277
278 def test_none_allowed(self):
279 """Test with None when allowed"""
280 result = InputValidator.validate_input(None, int, "test_field", allow_none=True)
281 assert result is None
282
283
284if __name__ == "__main__":
285 pytest.main([__file__, "-v"])
286 