diegobeyl/backtesting
2
1"""2Módulo de validación y control de límites3Previene bloqueos y solicitudes excesivas4"""5 6from datetime import datetime, timedelta7from typing import Dict, Tuple, Optional8import logging9 10logger = logging.getLogger(__name__)11 12# Límites de seguridad por timeframe13# Formato: 'timeframe': (max_candles, max_years, description)14# Basado en límites reales de Yahoo Finance15SAFETY_LIMITS = {16 '1m': (10080, 0.02, "máx 7 días"), # 7 días * 1440 min/día = 10,080 velas17 '5m': (17280, 0.16, "máx 60 días"), # 60 días * 288 velas/día = 17,280 velas18 '15m': (5760, 0.16, "máx 60 días"), # 60 días * 96 velas/día = 5,760 velas19 '30m': (2880, 0.16, "máx 60 días"), # 60 días * 48 velas/día = 2,880 velas20 '1h': (17520, 2, "máx 730 días"), # 730 días * 24 horas/día = 17,520 velas21 '4h': (4380, 2, "máx 730 días"), # 730 días * 6 velas/día = 4,380 velas22 '1d': (7300, 20, "máx 20 años"), # 20 años * 365 días = 7,300 velas23 '1wk': (5200, 100, "máx ~100 años"), # 100 años * 52 semanas = 5,200 velas24 '1mo': (1200, 100, "máx ~100 años"), # 100 años * 12 meses = 1,200 velas25}26 27# Límites extendidos cuando se usa Twelve Data API28# Twelve Data permite hasta 5000 velas por request29SAFETY_LIMITS_WITH_API = {30 '1m': (10080, 0.02, "máx 7 días"), # Sin cambio (limitado por data source)31 '5m': (17280, 0.16, "máx 60 días"), # Sin cambio32 '15m': (5760, 0.16, "máx 60 días"), # Sin cambio33 '30m': (2880, 0.16, "máx 60 días"), # Sin cambio34 '1h': (87600, 10, "máx 10 años"), # 3650 días * 24 horas = 87,600 velas (Twelve Data)35 '4h': (21900, 10, "máx 10 años"), # 3650 días * 6 velas/día = 21,900 velas36 '1d': (7300, 20, "máx 20 años"), # Sin cambio37 '1wk': (5200, 100, "máx ~100 años"), # Sin cambio38 '1mo': (1200, 100, "máx ~100 años"), # Sin cambio39}40 41# Descripción de límites para el usuario42LIMIT_DESCRIPTIONS = {43 '1m': {44 'friendly_name': '1 minuto',45 'max_days': 7,46 'reason': 'Máximo 7 días. Límite de Yahoo Finance para datos intradiarios.'47 },48 '5m': {49 'friendly_name': '5 minutos',50 'max_days': 60,51 'reason': 'Máximo 60 días. Límite de Yahoo Finance para datos intradiarios.'52 },53 '15m': {54 'friendly_name': '15 minutos',55 'max_days': 60,56 'reason': 'Máximo 60 días. Límite de Yahoo Finance para datos intradiarios.'57 },58 '30m': {59 'friendly_name': '30 minutos',60 'max_days': 60,61 'reason': 'Máximo 60 días. Límite de Yahoo Finance para datos intradiarios.'62 },63 '1h': {64 'friendly_name': '1 hora',65 'max_days': 730,66 'max_days_with_api': 3650,67 'reason': 'Máximo 730 días (~2 años) con Yahoo Finance. Hasta 10 años con Twelve Data API.'68 },69 '4h': {70 'friendly_name': '4 horas',71 'max_days': 730,72 'max_days_with_api': 3650,73 'reason': 'Máximo 730 días (~2 años) con Yahoo Finance. Hasta 10 años con Twelve Data API.'74 },75 '1d': {76 'friendly_name': '1 día',77 'max_days': 7300,78 'reason': 'Máximo 20 años. Historial completo disponible.'79 },80 '1wk': {81 'friendly_name': '1 semana',82 'max_days': 36500,83 'reason': 'Máximo ~100 años. Historial completo disponible.'84 },85 '1mo': {86 'friendly_name': '1 mes',87 'max_days': 36500,88 'reason': 'Máximo ~100 años. Historial completo disponible.'89 },90}91 92 93class ValidationError(Exception):94 """Excepción personalizada para errores de validación"""95 pass96 97 98class DataValidator:99 """Validador de parámetros de descarga de datos"""100 101 @staticmethod102 def validate_timeframe(timeframe: str) -> None:103 """Valida que el timeframe sea válido"""104 valid_timeframes = list(SAFETY_LIMITS.keys())105 if timeframe not in valid_timeframes:106 raise ValidationError(107 f"Timeframe inválido: '{timeframe}'. "108 f"Opciones válidas: {', '.join(valid_timeframes)}"109 )110 111 @staticmethod112 def validate_symbol(symbol: str) -> None:113 """Valida que el símbolo tenga formato correcto"""114 if not symbol:115 raise ValidationError(116 f"Símbolo inválido: '{symbol}'. "117 f"El símbolo no puede estar vacío."118 )119 # Aceptar múltiples formatos: BTC-USD (crypto), AAPL (acciones), EURUSD=X (forex), ANA.MC (internacional)120 # Permitir alfanumérico, guiones, iguales, puntos y circunflejo (para índices como ^GSPC)121 if not all(c.isalnum() or c in '-=.^' for c in symbol):122 raise ValidationError(123 f"Símbolo inválido: '{symbol}'. "124 f"Debe contener solo letras, números, guiones, puntos e iguales."125 )126 127 @staticmethod128 def validate_date_range(start_date: datetime, end_date: datetime) -> None:129 """Valida que el rango de fechas sea válido"""130 if start_date >= end_date:131 raise ValidationError(132 f"Rango de fechas inválido: inicio ({start_date.date()}) "133 f"debe ser antes que fin ({end_date.date()})"134 )135 136 @staticmethod137 def calculate_candles(start_date: datetime, end_date: datetime, timeframe: str) -> int:138 """Calcula cuántas velas se descargarán"""139 days = (end_date - start_date).days140 141 candles_per_day = {142 '1m': 1440, # 24 * 60143 '5m': 288, # 1440 / 5144 '15m': 96, # 1440 / 15145 '30m': 48, # 1440 / 30146 '1h': 24, # 24147 '4h': 6, # 24 / 4148 '1d': 1,149 '1wk': 1 / 7,150 '1mo': 1 / 30,151 }152 153 return int(days * candles_per_day.get(timeframe, 1))154 155 @staticmethod156 def validate_request(157 symbol: str,158 timeframe: str,159 start_date: datetime,160 end_date: datetime,161 source: str = 'yahoo',162 has_api_key: bool = False163 ) -> Tuple[bool, Optional[str]]:164 """165 Valida una solicitud completa de descarga.166 167 Args:168 symbol: Símbolo del activo169 timeframe: Timeframe de las velas170 start_date: Fecha de inicio171 end_date: Fecha de fin172 source: Fuente de datos (yahoo, etc.)173 has_api_key: Si el usuario proporcionó API key de Twelve Data174 175 Retorna: (is_valid, error_message)176 """177 try:178 # Validar parámetros básicos179 DataValidator.validate_symbol(symbol)180 DataValidator.validate_timeframe(timeframe)181 DataValidator.validate_date_range(start_date, end_date)182 183 # Calcular velas necesarias184 candles = DataValidator.calculate_candles(start_date, end_date, timeframe)185 days = (end_date - start_date).days186 years = days / 365.25187 188 # Obtener límites para este timeframe189 # Si tiene API key de Twelve Data, usar límites extendidos190 if has_api_key and timeframe in ['1h', '4h', '1d']:191 max_candles, max_years, description = SAFETY_LIMITS_WITH_API[timeframe]192 else:193 max_candles, max_years, description = SAFETY_LIMITS[timeframe]194 195 # Validar contra límites196 if candles > max_candles:197 friendly = LIMIT_DESCRIPTIONS.get(timeframe, {}).get('friendly_name', timeframe)198 reason = LIMIT_DESCRIPTIONS.get(timeframe, {}).get('reason', 'Demasiados datos')199 200 # Mensaje personalizado dependiendo si tiene API key201 if not has_api_key and timeframe in ['1h', '4h']:202 api_suggestion = (203 f"\n 4️⃣ Usar Twelve Data API (hasta 10 años en {friendly})\n"204 f" 👉 Ingresa tu API key en el sidebar\n"205 f" 👉 Gratis en https://twelvedata.com/pricing"206 )207 else:208 api_suggestion = ""209 210 return False, (211 f"⚠️ PERÍODO DE TIEMPO DEMASIADO LARGO\n\n"212 f"📊 Solicitud Actual:\n"213 f" • Símbolo: {symbol}\n"214 f" • Timeframe: {friendly}\n"215 f" • Período: {days} días (~{years:.1f} años)\n"216 f" • Velas resultantes: {candles:,}\n\n"217 f"❌ Límite Excedido:\n"218 f" • Máximo permitido: {max_candles:,} velas\n"219 f" • Máximo período: {max_years} años\n"220 f" • Razón: {reason}\n\n"221 f"✅ Soluciones:\n"222 f" 1️⃣ Reducir el período temporal (máx {max_years} años)\n"223 f" 2️⃣ Usar timeframe más grande (ej: 4h, 1d, 1wk)\n"224 f" 3️⃣ Usar el selector de mes para datos específicos"225 f"{api_suggestion}\n\n"226 f"💡 Ejemplo: Para {symbol} en {friendly}, puedes obtener hasta {max_years} años"227 )228 229 return True, None230 231 except ValidationError as e:232 return False, str(e)233 except Exception as e:234 return False, f"Error de validación: {str(e)}"235 236 @staticmethod237 def get_download_info(238 symbol: str,239 timeframe: str,240 start_date: datetime,241 end_date: datetime242 ) -> Dict:243 """Retorna información sobre la descarga propuesta"""244 candles = DataValidator.calculate_candles(start_date, end_date, timeframe)245 days = (end_date - start_date).days246 years = days / 365.25247 248 friendly_name = LIMIT_DESCRIPTIONS.get(timeframe, {}).get('friendly_name', timeframe)249 max_candles, _, _ = SAFETY_LIMITS.get(timeframe, (0, 0, ''))250 251 return {252 'symbol': symbol,253 'timeframe': timeframe,254 'friendly_timeframe': friendly_name,255 'start_date': start_date.strftime('%Y-%m-%d'),256 'end_date': end_date.strftime('%Y-%m-%d'),257 'days': days,258 'years': round(years, 2),259 'candles': candles,260 'max_candles': max_candles,261 'usage_percent': round((candles / max_candles) * 100, 1),262 'estimated_mb': round(candles * 0.005, 2), # ~5KB por vela263 }264 