javitechjkd/backtestingv2
0
1"""
2Input validation framework for Backtesting Application V2
3Provides comprehensive validation for DataFrames, parameters, and symbols
4"""
5
6import pandas as pd
7import numpy as np
8from typing import Any, Dict, List, Optional, Tuple
9from datetime import datetime
10
11from utils.exceptions import ValidationException
12
13
14class InputValidator:
15 """
16 Validates inputs for backtesting operations
17 Provides specific error messages for debugging
18 """
19
20 # Constants for validation
21 MIN_DATAFRAME_ROWS = 10
22 MAX_DATAFRAME_ROWS = 100000
23 REQUIRED_COLUMNS = ['open', 'high', 'low', 'close', 'volume']
24 VALID_SYMBOLS_PATTERN = r'^[A-Z0-9\-\.=\^]{1,20}$'
25 MIN_CAPITAL = 100
26 MAX_CAPITAL = 1000000000 # 1 billion
27 MIN_RISK_PERCENT = 0.1
28 MAX_RISK_PERCENT = 50.0
29
30 @staticmethod
31 def validate_dataframe(df: pd.DataFrame, symbol: str = "UNKNOWN") -> Tuple[bool, Optional[str]]:
32 """
33 Validates a OHLCV DataFrame
34
35 Args:
36 df: DataFrame to validate
37 symbol: Symbol name for error messages
38
39 Returns:
40 Tuple of (is_valid, error_message)
41 """
42 try:
43 # Check if it's actually a DataFrame
44 if not isinstance(df, pd.DataFrame):
45 raise ValidationException(
46 f"Expected DataFrame, got {type(df).__name__}",
47 field="dataframe",
48 expected="pandas.DataFrame",
49 actual=type(df).__name__,
50 details={"symbol": symbol}
51 )
52
53 # Check if empty
54 if df.empty:
55 raise ValidationException(
56 f"DataFrame is empty for symbol {symbol}",
57 field="dataframe",
58 details={"symbol": symbol, "rows": 0}
59 )
60
61 # Check row count
62 if len(df) < InputValidator.MIN_DATAFRAME_ROWS:
63 raise ValidationException(
64 f"Insufficient data: {len(df)} rows, need at least {InputValidator.MIN_DATAFRAME_ROWS}",
65 field="dataframe_rows",
66 expected=f">= {InputValidator.MIN_DATAFRAME_ROWS}",
67 actual=str(len(df)),
68 details={"symbol": symbol}
69 )
70
71 if len(df) > InputValidator.MAX_DATAFRAME_ROWS:
72 raise ValidationException(
73 f"Too much data: {len(df)} rows, max is {InputValidator.MAX_DATAFRAME_ROWS}",
74 field="dataframe_rows",
75 expected=f"<= {InputValidator.MAX_DATAFRAME_ROWS}",
76 actual=str(len(df)),
77 details={"symbol": symbol}
78 )
79
80 # Check required columns (normalize to lowercase)
81 df_columns = {col.lower() for col in df.columns}
82 missing_columns = set(InputValidator.REQUIRED_COLUMNS) - df_columns
83
84 if missing_columns:
85 raise ValidationException(
86 f"Missing required columns: {', '.join(missing_columns)}",
87 field="dataframe_columns",
88 expected=f"Contains all of: {', '.join(InputValidator.REQUIRED_COLUMNS)}",
89 actual=f"Found: {', '.join(df.columns)}",
90 details={"symbol": symbol, "missing": list(missing_columns)}
91 )
92
93 # Rename columns to lowercase for consistency
94 df.columns = [col.lower() for col in df.columns]
95
96 # Check for NaN values in critical columns
97 nan_counts = {}
98 for col in InputValidator.REQUIRED_COLUMNS:
99 if col in df.columns:
100 nan_count = df[col].isna().sum()
101 if nan_count > 0:
102 nan_counts[col] = int(nan_count)
103
104 if nan_counts:
105 total_nans = sum(nan_counts.values())
106 raise ValidationException(
107 f"Found {total_nans} NaN values in critical columns: {nan_counts}",
108 field="dataframe_values",
109 details={"symbol": symbol, "nan_counts": nan_counts}
110 )
111
112 # Check for infinite values
113 inf_counts = {}
114 for col in InputValidator.REQUIRED_COLUMNS:
115 if col in df.columns:
116 inf_count = np.isinf(df[col]).sum()
117 if inf_count > 0:
118 inf_counts[col] = int(inf_count)
119
120 if inf_counts:
121 total_infs = sum(inf_counts.values())
122 raise ValidationException(
123 f"Found {total_infs} infinite values in critical columns: {inf_counts}",
124 field="dataframe_values",
125 details={"symbol": symbol, "inf_counts": inf_counts}
126 )
127
128 # Check for negative prices and filter them out (common in international market data)
129 # Instead of failing, we clean the data automatically
130 negative_mask = pd.Series(False, index=df.index)
131 for col in ['open', 'high', 'low', 'close']:
132 if col in df.columns:
133 negative_mask |= (df[col] < 0)
134
135 if negative_mask.sum() > 0:
136 # Filter out rows with negative prices
137 original_len = len(df)
138 df.drop(df[negative_mask].index, inplace=True)
139 filtered_count = original_len - len(df)
140
141 # If we filtered too many rows, fail
142 if len(df) < InputValidator.MIN_DATAFRAME_ROWS:
143 raise ValidationException(
144 f"Too many bad data rows filtered: {filtered_count} rows with negative prices removed, only {len(df)} remaining",
145 field="dataframe_values",
146 details={"symbol": symbol, "filtered_rows": filtered_count, "remaining_rows": len(df)}
147 )
148
149 # Check for negative volume
150 if 'volume' in df.columns:
151 negative_volume = (df['volume'] < 0).sum()
152 if negative_volume > 0:
153 raise ValidationException(
154 f"Found {negative_volume} negative volume values",
155 field="dataframe_values",
156 details={"symbol": symbol, "negative_volume": int(negative_volume)}
157 )
158
159 # Check OHLC logic and filter invalid rows (common in international market data)
160 invalid_ohlc_mask = (
161 (df['high'] < df['low']) |
162 (df['high'] < df['open']) |
163 (df['high'] < df['close']) |
164 (df['low'] > df['open']) |
165 (df['low'] > df['close'])
166 )
167
168 if invalid_ohlc_mask.sum() > 0:
169 # Filter out rows with invalid OHLC logic
170 original_len = len(df)
171 df.drop(df[invalid_ohlc_mask].index, inplace=True)
172 filtered_count = original_len - len(df)
173
174 # If we filtered too many rows, fail
175 if len(df) < InputValidator.MIN_DATAFRAME_ROWS:
176 raise ValidationException(
177 f"Too many bad data rows filtered: {filtered_count} rows with invalid OHLC removed, only {len(df)} remaining",
178 field="dataframe_values",
179 details={"symbol": symbol, "filtered_rows": filtered_count, "remaining_rows": len(df)}
180 )
181
182 return True, None
183
184 except ValidationException:
185 raise
186 except Exception as e:
187 # Include original exception name and message for better debugging
188 orig_error = f"{type(e).__name__}: {str(e)}"
189 raise ValidationException(
190 f"Unexpected error validating DataFrame: {orig_error}",
191 field="dataframe",
192 details={"symbol": symbol, "error": orig_error}
193 )
194
195 @staticmethod
196 def validate_backtest_params(
197 initial_capital: float,
198 risk_percent: float,
199 symbol: str,
200 bars: int = None
201 ) -> Tuple[bool, Optional[str]]:
202 """
203 Validates backtest parameters
204
205 Args:
206 initial_capital: Starting capital in USD
207 risk_percent: Risk percentage per trade (0.1 - 50)
208 symbol: Trading symbol
209 bars: Number of bars to backtest
210
211 Returns:
212 Tuple of (is_valid, error_message)
213 """
214 try:
215 # Validate initial_capital
216 if not isinstance(initial_capital, (int, float)):
217 raise ValidationException(
218 f"initial_capital must be numeric, got {type(initial_capital).__name__}",
219 field="initial_capital",
220 expected="int or float",
221 actual=type(initial_capital).__name__
222 )
223
224 if initial_capital < InputValidator.MIN_CAPITAL:
225 raise ValidationException(
226 f"initial_capital too small: {initial_capital}, minimum is {InputValidator.MIN_CAPITAL}",
227 field="initial_capital",
228 expected=f">= {InputValidator.MIN_CAPITAL}",
229 actual=str(initial_capital)
230 )
231
232 if initial_capital > InputValidator.MAX_CAPITAL:
233 raise ValidationException(
234 f"initial_capital too large: {initial_capital}, maximum is {InputValidator.MAX_CAPITAL}",
235 field="initial_capital",
236 expected=f"<= {InputValidator.MAX_CAPITAL}",
237 actual=str(initial_capital)
238 )
239
240 if np.isnan(initial_capital) or np.isinf(initial_capital):
241 raise ValidationException(
242 f"initial_capital is NaN or infinite",
243 field="initial_capital",
244 actual=str(initial_capital)
245 )
246
247 # Validate risk_percent
248 if not isinstance(risk_percent, (int, float)):
249 raise ValidationException(
250 f"risk_percent must be numeric, got {type(risk_percent).__name__}",
251 field="risk_percent",
252 expected="int or float",
253 actual=type(risk_percent).__name__
254 )
255
256 if risk_percent < InputValidator.MIN_RISK_PERCENT:
257 raise ValidationException(
258 f"risk_percent too low: {risk_percent}, minimum is {InputValidator.MIN_RISK_PERCENT}%",
259 field="risk_percent",
260 expected=f">= {InputValidator.MIN_RISK_PERCENT}",
261 actual=str(risk_percent)
262 )
263
264 if risk_percent > InputValidator.MAX_RISK_PERCENT:
265 raise ValidationException(
266 f"risk_percent too high: {risk_percent}, maximum is {InputValidator.MAX_RISK_PERCENT}%",
267 field="risk_percent",
268 expected=f"<= {InputValidator.MAX_RISK_PERCENT}",
269 actual=str(risk_percent)
270 )
271
272 if np.isnan(risk_percent) or np.isinf(risk_percent):
273 raise ValidationException(
274 f"risk_percent is NaN or infinite",
275 field="risk_percent",
276 actual=str(risk_percent)
277 )
278
279 # Validate symbol
280 InputValidator.validate_symbol(symbol)
281
282 # Validate bars if provided
283 if bars is not None:
284 if not isinstance(bars, int):
285 raise ValidationException(
286 f"bars must be int, got {type(bars).__name__}",
287 field="bars",
288 expected="int",
289 actual=type(bars).__name__
290 )
291
292 if bars < InputValidator.MIN_DATAFRAME_ROWS:
293 raise ValidationException(
294 f"bars too small: {bars}, minimum is {InputValidator.MIN_DATAFRAME_ROWS}",
295 field="bars",
296 expected=f">= {InputValidator.MIN_DATAFRAME_ROWS}",
297 actual=str(bars)
298 )
299
300 if bars > InputValidator.MAX_DATAFRAME_ROWS:
301 raise ValidationException(
302 f"bars too large: {bars}, maximum is {InputValidator.MAX_DATAFRAME_ROWS}",
303 field="bars",
304 expected=f"<= {InputValidator.MAX_DATAFRAME_ROWS}",
305 actual=str(bars)
306 )
307
308 return True, None
309
310 except ValidationException:
311 raise
312 except Exception as e:
313 raise ValidationException(
314 f"Unexpected error validating backtest parameters: {str(e)}",
315 field="backtest_params",
316 details={"error": str(e)}
317 )
318
319 @staticmethod
320 def validate_symbol(symbol: str) -> bool:
321 """
322 Validates a trading symbol
323
324 Args:
325 symbol: Symbol string (e.g., 'BTCUSD', 'AAPL')
326
327 Returns:
328 True if valid
329
330 Raises:
331 ValidationException if invalid
332 """
333 import re
334
335 if not isinstance(symbol, str):
336 raise ValidationException(
337 f"Symbol must be string, got {type(symbol).__name__}",
338 field="symbol",
339 expected="str",
340 actual=type(symbol).__name__
341 )
342
343 if len(symbol) == 0:
344 raise ValidationException(
345 "Symbol cannot be empty",
346 field="symbol"
347 )
348
349 if len(symbol) > 20:
350 raise ValidationException(
351 f"Symbol too long: {len(symbol)} characters, maximum is 20",
352 field="symbol",
353 expected="<= 20 characters",
354 actual=str(len(symbol))
355 )
356
357 if not re.match(InputValidator.VALID_SYMBOLS_PATTERN, symbol):
358 raise ValidationException(
359 f"Symbol '{symbol}' contains invalid characters",
360 field="symbol",
361 expected="Alphanumeric, dashes, dots, equals and caret only",
362 actual=symbol
363 )
364
365 return True
366
367 @staticmethod
368 def validate_date_range(
369 start_date: Optional[str],
370 end_date: Optional[str]
371 ) -> Tuple[bool, Optional[str]]:
372 """
373 Validates date range for backtesting
374
375 Args:
376 start_date: Start date (YYYY-MM-DD)
377 end_date: End date (YYYY-MM-DD)
378
379 Returns:
380 Tuple of (is_valid, error_message)
381 """
382 try:
383 if start_date is None and end_date is None:
384 return True, None
385
386 parsed_dates = {}
387
388 for date_str, date_name in [(start_date, "start_date"), (end_date, "end_date")]:
389 if date_str is None:
390 continue
391
392 if not isinstance(date_str, str):
393 raise ValidationException(
394 f"{date_name} must be string (YYYY-MM-DD), got {type(date_str).__name__}",
395 field=date_name,
396 expected="str in YYYY-MM-DD format",
397 actual=type(date_str).__name__
398 )
399
400 try:
401 parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
402 parsed_dates[date_name] = parsed_date
403 except ValueError as e:
404 raise ValidationException(
405 f"Invalid {date_name} format: {date_str} (expected YYYY-MM-DD)",
406 field=date_name,
407 expected="YYYY-MM-DD format",
408 actual=date_str,
409 details={"error": str(e)}
410 )
411
412 # Check that start_date is before end_date
413 if len(parsed_dates) == 2:
414 if parsed_dates["start_date"] >= parsed_dates["end_date"]:
415 raise ValidationException(
416 f"start_date must be before end_date: {start_date} >= {end_date}",
417 field="date_range",
418 details={
419 "start_date": start_date,
420 "end_date": end_date
421 }
422 )
423
424 return True, None
425
426 except ValidationException:
427 raise
428 except Exception as e:
429 raise ValidationException(
430 f"Unexpected error validating date range: {str(e)}",
431 field="date_range",
432 details={"error": str(e)}
433 )
434
435 @staticmethod
436 def validate_input(value: Any, expected_type: type, field_name: str, allow_none: bool = False) -> Any:
437 """
438 Generic input validation with type checking
439
440 Args:
441 value: Value to validate
442 expected_type: Expected Python type
443 field_name: Field name for error messages
444 allow_none: Whether None is acceptable
445
446 Returns:
447 The validated value
448
449 Raises:
450 ValidationException if invalid
451 """
452 if value is None and allow_none:
453 return None
454
455 if value is None and not allow_none:
456 raise ValidationException(
457 f"{field_name} cannot be None",
458 field=field_name,
459 expected=expected_type.__name__,
460 actual="None"
461 )
462
463 if not isinstance(value, expected_type):
464 raise ValidationException(
465 f"{field_name} must be {expected_type.__name__}, got {type(value).__name__}",
466 field=field_name,
467 expected=expected_type.__name__,
468 actual=type(value).__name__
469 )
470
471 return value
472 