diegobeyl/backtesting
2
1"""2Módulo de Timeouts y Límites de Ejecución - Fase 33Previene que backtests larguísimos bloqueen la aplicación4"""5 6import time7import threading8from typing import Optional, Callable, Any, Dict9from datetime import datetime, timedelta10import logging11 12logger = logging.getLogger(__name__)13 14 15class TimeoutException(Exception):16 """Excepción cuando se excede el timeout"""17 pass18 19 20class ExecutionLimiter:21 """22 Limita el tiempo de ejecución de operaciones23 Soporta:24 - Timeout global25 - Timeouts por operación26 - Estimación de tiempo restante27 - Cancelación de operaciones28 """29 30 # Timeouts por defecto (en segundos)31 DEFAULT_TIMEOUTS = {32 'download_data': 60, # 1 minuto para descargar33 'backtest': 300, # 5 minutos para backtest34 'analysis': 120, # 2 minutos para análisis35 'optimization': 600, # 10 minutos para optimización36 }37 38 # Límites de duración de ejecución39 EXECUTION_LIMITS = {40 'data_download': {41 'min_seconds': 1,42 'max_seconds': 120,43 'warning_seconds': 60,44 'description': 'Descarga de datos'45 },46 'backtest_single': {47 'min_seconds': 1,48 'max_seconds': 600,49 'warning_seconds': 300,50 'description': 'Backtest de un activo'51 },52 'backtest_multi': {53 'min_seconds': 1,54 'max_seconds': 1200,55 'warning_seconds': 600,56 'description': 'Backtest multi-activo'57 },58 }59 60 def __init__(self, operation: str = 'backtest'):61 """62 Inicializa el limitador63 64 Args:65 operation: Tipo de operación ('download_data', 'backtest', 'analysis', etc.)66 """67 self.operation = operation68 self.start_time = None69 self.timeout = self.DEFAULT_TIMEOUTS.get(operation, 300)70 self.elapsed = 071 self.is_running = False72 self.was_cancelled = False73 self._timer_thread = None74 75 def start(self) -> None:76 """Inicia el cronómetro"""77 self.start_time = time.time()78 self.is_running = True79 self.elapsed = 080 self.was_cancelled = False81 logger.info(f"⏱️ Iniciando {self.operation} con timeout de {self.timeout}s")82 83 def stop(self) -> float:84 """Detiene el cronómetro y retorna tiempo transcurrido"""85 if not self.is_running:86 return self.elapsed87 88 self.elapsed = time.time() - self.start_time89 self.is_running = False90 logger.info(f"⏱️ {self.operation} completado en {self.elapsed:.2f}s")91 return self.elapsed92 93 def get_elapsed(self) -> float:94 """Retorna tiempo transcurrido hasta ahora"""95 if not self.is_running or self.start_time is None:96 return self.elapsed97 return time.time() - self.start_time98 99 def check_timeout(self) -> None:100 """Verifica si se excedió el timeout"""101 if not self.is_running:102 return103 104 elapsed = self.get_elapsed()105 if elapsed > self.timeout:106 self.was_cancelled = True107 raise TimeoutException(108 f"Timeout excedido en {self.operation}\n"109 f"Límite: {self.timeout}s\n"110 f"Transcurrido: {elapsed:.2f}s"111 )112 113 def get_remaining_time(self) -> float:114 """Retorna segundos restantes"""115 elapsed = self.get_elapsed()116 remaining = max(0, self.timeout - elapsed)117 return remaining118 119 def get_progress_percent(self) -> float:120 """Retorna progreso como porcentaje"""121 elapsed = self.get_elapsed()122 percent = min(100, (elapsed / self.timeout) * 100)123 return percent124 125 def format_remaining(self) -> str:126 """Retorna tiempo restante formateado"""127 remaining = self.get_remaining_time()128 minutes = int(remaining // 60)129 seconds = int(remaining % 60)130 return f"{minutes}m {seconds}s"131 132 def format_elapsed(self) -> str:133 """Retorna tiempo transcurrido formateado"""134 elapsed = self.get_elapsed()135 minutes = int(elapsed // 60)136 seconds = int(elapsed % 60)137 return f"{minutes}m {seconds}s"138 139 def get_status_message(self) -> str:140 """Retorna mensaje de estado"""141 elapsed = self.get_elapsed()142 remaining = self.get_remaining_time()143 percent = self.get_progress_percent()144 145 return (146 f"⏱️ {self.operation}\n"147 f" Transcurrido: {self.format_elapsed()}\n"148 f" Restante: {self.format_remaining()}\n"149 f" Progreso: {percent:.0f}%"150 )151 152 153class OperationTracker:154 """155 Rastreia múltiples operaciones simultáneamente156 Útil para backtests multi-activo157 """158 159 def __init__(self):160 self.operations: Dict[str, ExecutionLimiter] = {}161 self.results: Dict[str, Any] = {}162 163 def start_operation(self, operation_id: str, operation_type: str = 'backtest') -> ExecutionLimiter:164 """Inicia una nueva operación"""165 limiter = ExecutionLimiter(operation_type)166 limiter.start()167 self.operations[operation_id] = limiter168 return limiter169 170 def complete_operation(self, operation_id: str, result: Any = None) -> float:171 """Completa una operación"""172 if operation_id not in self.operations:173 return 0174 175 elapsed = self.operations[operation_id].stop()176 if result is not None:177 self.results[operation_id] = result178 return elapsed179 180 def get_total_elapsed(self) -> float:181 """Retorna tiempo total de todas las operaciones"""182 total = 0183 for limiter in self.operations.values():184 total += limiter.get_elapsed()185 return total186 187 def get_status_summary(self) -> str:188 """Retorna resumen de estado"""189 summary = "📊 Estado de Operaciones:\n"190 for op_id, limiter in self.operations.items():191 status = "✅" if limiter.elapsed > 0 else "⏱️"192 summary += f" {status} {op_id}: {limiter.format_elapsed()}\n"193 return summary194 195 196class ProgressTracker:197 """198 Rastreia el progreso de operaciones largas199 Proporciona ETAs y estimaciones200 """201 202 def __init__(self, total_items: int, operation_name: str = 'Procesamiento'):203 self.total_items = total_items204 self.operation_name = operation_name205 self.processed = 0206 self.start_time = time.time()207 208 def update(self, items_processed: int = 1) -> None:209 """Actualiza contador"""210 self.processed += items_processed211 212 def get_progress_percent(self) -> float:213 """Retorna progreso como %"""214 if self.total_items == 0:215 return 100216 return (self.processed / self.total_items) * 100217 218 def get_elapsed_time(self) -> float:219 """Retorna tiempo transcurrido"""220 return time.time() - self.start_time221 222 def get_estimated_total_time(self) -> float:223 """Estima tiempo total basado en progreso"""224 elapsed = self.get_elapsed_time()225 if self.processed == 0:226 return 0227 228 rate = self.processed / elapsed229 if rate == 0:230 return 0231 232 return self.total_items / rate233 234 def get_eta(self) -> float:235 """Retorna ETA en segundos"""236 total = self.get_estimated_total_time()237 elapsed = self.get_elapsed_time()238 return max(0, total - elapsed)239 240 def format_eta(self) -> str:241 """Retorna ETA formateado"""242 eta_seconds = self.get_eta()243 if eta_seconds < 60:244 return f"{int(eta_seconds)}s"245 246 minutes = int(eta_seconds // 60)247 seconds = int(eta_seconds % 60)248 return f"{minutes}m {seconds}s"249 250 def format_progress_bar(self, width: int = 20) -> str:251 """Retorna barra de progreso ASCII"""252 percent = self.get_progress_percent()253 filled = int(width * percent / 100)254 bar = "█" * filled + "░" * (width - filled)255 return f"[{bar}] {percent:.0f}%"256 257 def get_status(self) -> str:258 """Retorna mensaje de estado completo"""259 return (260 f"📊 {self.operation_name}\n"261 f" Progreso: {self.processed}/{self.total_items} ({self.get_progress_percent():.0f}%)\n"262 f" {self.format_progress_bar()}\n"263 f" Transcurrido: {self.format_elapsed()}\n"264 f" ETA: {self.format_eta()}"265 )266 267 def format_elapsed(self) -> str:268 """Retorna tiempo transcurrido formateado"""269 elapsed = self.get_elapsed_time()270 if elapsed < 60:271 return f"{int(elapsed)}s"272 273 minutes = int(elapsed // 60)274 seconds = int(elapsed % 60)275 return f"{minutes}m {seconds}s"276 