""" Caso de uso para Backtesting. Implementa la lógica de aplicación para evaluar la precisión de pronósticos usando datos históricos. """ import math from typing import List from app.domain.services.forecast_service import ForecastService from app.domain.models.time_series import TimeSeries from app.domain.models.forecast_config import ForecastConfig from app.application.dtos.backtest_dtos import ( BacktestInputDTO, BacktestOutputDTO, BacktestMetricsDTO ) from app.utils.logger import setup_logger logger = setup_logger(__name__) class BacktestUseCase: """ Caso de uso: Backtesting. Responsabilidad: Evaluar precisión del modelo usando datos históricos. Divide los datos en train/test y calcula métricas de error. """ def __init__(self, forecast_service: ForecastService): """ Inicializa el caso de uso. Args: forecast_service: Servicio de dominio para forecasting """ self.forecast_service = forecast_service logger.info("BacktestUseCase initialized") def execute(self, input_dto: BacktestInputDTO) -> BacktestOutputDTO: """ Ejecuta el caso de uso de backtesting. Args: input_dto: Datos de entrada con serie completa y tamaño de test Returns: BacktestOutputDTO: Resultados del backtest con métricas Raises: ValueError: Si los datos son inválidos RuntimeError: Si falla el backtest """ logger.info( f"Executing backtest: {len(input_dto.values)} total points, " f"{input_dto.test_size} test points" ) # Validar entrada input_dto.validate() # Dividir en train/test train_values = input_dto.values[:-input_dto.test_size] test_values = input_dto.values[-input_dto.test_size:] train_timestamps = None test_timestamps = None if input_dto.timestamps: train_timestamps = input_dto.timestamps[:-input_dto.test_size] test_timestamps = input_dto.timestamps[-input_dto.test_size:] logger.info(f"Train size: {len(train_values)}, Test size: {len(test_values)}") # Crear modelos de dominio para train train_series = TimeSeries( values=train_values, timestamps=train_timestamps, freq=input_dto.freq ) config = ForecastConfig( prediction_length=input_dto.test_size, quantile_levels=input_dto.quantile_levels, freq=input_dto.freq ) # Ejecutar pronóstico sobre datos de train try: result = self.forecast_service.forecast_univariate(train_series, config) logger.info(f"Forecast completed: {len(result.median)} predictions") except Exception as e: logger.error(f"Backtest forecast failed: {e}", exc_info=True) raise RuntimeError(f"Backtest forecast failed: {str(e)}") from e # Comparar con valores reales forecast_values = result.median actual_values = test_values # Calcular errores errors = [ actual - forecast for actual, forecast in zip(actual_values, forecast_values) ] # Calcular métricas metrics = self._calculate_metrics(actual_values, forecast_values) logger.info( f"Backtest metrics - MAE: {metrics.mae:.2f}, " f"MAPE: {metrics.mape:.2f}%, RMSE: {metrics.rmse:.2f}" ) # Preparar timestamps de salida if test_timestamps: output_timestamps = test_timestamps else: output_timestamps = result.timestamps # Crear DTO de salida output_dto = BacktestOutputDTO( forecast_values=forecast_values, actual_values=actual_values, errors=errors, metrics=metrics, timestamps=output_timestamps, quantiles=result.quantiles if result.quantiles else None ) return output_dto def _calculate_metrics( self, actual: List[float], forecast: List[float] ) -> BacktestMetricsDTO: """ Calcula métricas de error para el backtest. Args: actual: Valores reales forecast: Valores pronosticados Returns: BacktestMetricsDTO: Métricas calculadas """ n = len(actual) # Mean Absolute Error mae = sum(abs(a - f) for a, f in zip(actual, forecast)) / n # Mean Absolute Percentage Error mape_values = [] for a, f in zip(actual, forecast): if a != 0: mape_values.append(abs((a - f) / a)) mape = (sum(mape_values) / len(mape_values) * 100) if mape_values else 0.0 # Mean Squared Error mse = sum((a - f) ** 2 for a, f in zip(actual, forecast)) / n # Root Mean Squared Error rmse = math.sqrt(mse) return BacktestMetricsDTO( mae=mae, mape=mape, rmse=rmse, mse=mse )