Team Ai
Apppublic

javitechjkd/backtestingv2

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
validators.py472 linesDownload Raw Back to utils
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