Spaces:
Build error
Build error
| """ | |
| Servicio de dominio para backtesting. | |
| Este servicio encapsula la l贸gica de validaci贸n de modelos, | |
| cumpliendo con SRP y DIP. | |
| """ | |
| import numpy as np | |
| from dataclasses import dataclass | |
| from typing import List | |
| from app.domain.interfaces.forecast_model import IForecastModel | |
| from app.domain.interfaces.data_transformer import IDataTransformer | |
| from app.domain.models.time_series import TimeSeries | |
| from app.domain.models.forecast_config import ForecastConfig | |
| from app.utils.logger import setup_logger | |
| logger = setup_logger(__name__) | |
| class BacktestMetrics: | |
| """ | |
| M茅tricas de evaluaci贸n de un backtest. | |
| Attributes: | |
| mae: Mean Absolute Error | |
| mape: Mean Absolute Percentage Error (%) | |
| rmse: Root Mean Squared Error | |
| wql: Weighted Quantile Loss (para cuantil 0.5) | |
| """ | |
| mae: float | |
| mape: float | |
| rmse: float | |
| wql: float | |
| def to_dict(self) -> dict: | |
| """Serializa las m茅tricas""" | |
| return { | |
| "mae": self.mae, | |
| "mape": self.mape, | |
| "rmse": self.rmse, | |
| "wql": self.wql | |
| } | |
| class BacktestResult: | |
| """ | |
| Resultado completo de un backtest. | |
| Attributes: | |
| metrics: M茅tricas de evaluaci贸n | |
| forecast: Valores pronosticados | |
| actuals: Valores reales | |
| timestamps: Timestamps del per铆odo de prueba | |
| """ | |
| metrics: BacktestMetrics | |
| forecast: List[float] | |
| actuals: List[float] | |
| timestamps: List[str] | |
| def to_dict(self) -> dict: | |
| """Serializa el resultado""" | |
| return { | |
| "metrics": self.metrics.to_dict(), | |
| "forecast": self.forecast, | |
| "actuals": self.actuals, | |
| "timestamps": self.timestamps | |
| } | |
| class BacktestService: | |
| """ | |
| Servicio de dominio para backtesting de modelos. | |
| Realiza validaci贸n de modelos separando la serie en train/test | |
| y comparando pron贸sticos con valores reales. | |
| Attributes: | |
| model: Modelo de forecasting | |
| transformer: Transformador de datos | |
| Example: | |
| >>> service = BacktestService(model, transformer) | |
| >>> series = TimeSeries(values=[100, 102, 105, 103, 108, 112, 115]) | |
| >>> result = service.simple_backtest(series, test_length=3) | |
| >>> result.metrics.mae < 5 # Buen modelo | |
| True | |
| """ | |
| def __init__( | |
| self, | |
| model: IForecastModel, | |
| transformer: IDataTransformer | |
| ): | |
| """ | |
| Inicializa el servicio. | |
| Args: | |
| model: Implementaci贸n de IForecastModel | |
| transformer: Implementaci贸n de IDataTransformer | |
| """ | |
| self.model = model | |
| self.transformer = transformer | |
| logger.info("BacktestService initialized") | |
| def simple_backtest( | |
| self, | |
| series: TimeSeries, | |
| test_length: int, | |
| config: ForecastConfig = None | |
| ) -> BacktestResult: | |
| """ | |
| Realiza un backtest simple: train/test split. | |
| Separa la serie en train (hist贸rico) y test (validaci贸n), | |
| genera pron贸stico para el per铆odo test y calcula m茅tricas. | |
| Args: | |
| series: Serie temporal completa | |
| test_length: N煤mero de puntos para test (final de la serie) | |
| config: Configuraci贸n del forecast (opcional) | |
| Returns: | |
| BacktestResult: Resultado con m茅tricas y pron贸sticos | |
| Raises: | |
| ValueError: Si test_length >= longitud de la serie | |
| Example: | |
| >>> series = TimeSeries(values=[100, 102, 105, 103, 108]) | |
| >>> result = service.simple_backtest(series, test_length=2) | |
| >>> len(result.forecast) | |
| 2 | |
| """ | |
| logger.info( | |
| f"Running simple backtest for series '{series.series_id}' " | |
| f"(total_length={series.length}, test_length={test_length})" | |
| ) | |
| # Validar | |
| if test_length >= series.length: | |
| raise ValueError( | |
| f"test_length ({test_length}) debe ser menor que " | |
| f"la longitud de la serie ({series.length})" | |
| ) | |
| if test_length < 1: | |
| raise ValueError(f"test_length debe ser >= 1, recibido: {test_length}") | |
| # Configuraci贸n por defecto si no se proporciona | |
| if config is None: | |
| config = ForecastConfig( | |
| prediction_length=test_length, | |
| quantile_levels=[0.5], # Solo mediana para backtest | |
| freq=series.freq | |
| ) | |
| else: | |
| # Ajustar prediction_length | |
| config.prediction_length = test_length | |
| # Separar train/test | |
| train_length = series.length - test_length | |
| train_series = series.get_subset(0, train_length) | |
| test_values = series.values[train_length:] | |
| logger.debug(f"Train length: {train_length}, Test length: {test_length}") | |
| # Construir DataFrame de train | |
| context_df = self.transformer.build_context_df( | |
| values=train_series.values, | |
| timestamps=train_series.timestamps, | |
| series_id=series.series_id, | |
| freq=config.freq | |
| ) | |
| # Predecir | |
| pred_df = self.model.predict( | |
| context_df=context_df, | |
| prediction_length=test_length, | |
| quantile_levels=[0.5] | |
| ) | |
| # Parsear resultado | |
| result = self.transformer.parse_prediction_result( | |
| pred_df=pred_df, | |
| quantile_levels=[0.5] | |
| ) | |
| forecast = np.array(result["median"], dtype=float) | |
| actuals = np.array(test_values, dtype=float) | |
| # Calcular m茅tricas | |
| metrics = self._calculate_metrics(forecast, actuals) | |
| logger.info( | |
| f"Backtest completed: MAE={metrics.mae:.2f}, " | |
| f"MAPE={metrics.mape:.2f}%, RMSE={metrics.rmse:.2f}" | |
| ) | |
| return BacktestResult( | |
| metrics=metrics, | |
| forecast=forecast.tolist(), | |
| actuals=actuals.tolist(), | |
| timestamps=result["timestamps"] | |
| ) | |
| def _calculate_metrics( | |
| self, | |
| forecast: np.ndarray, | |
| actuals: np.ndarray | |
| ) -> BacktestMetrics: | |
| """ | |
| Calcula m茅tricas de evaluaci贸n. | |
| Args: | |
| forecast: Valores pronosticados | |
| actuals: Valores reales | |
| Returns: | |
| BacktestMetrics: M茅tricas calculadas | |
| """ | |
| # MAE: Mean Absolute Error | |
| mae = float(np.mean(np.abs(actuals - forecast))) | |
| # MAPE: Mean Absolute Percentage Error | |
| eps = 1e-8 | |
| mape = float(np.mean(np.abs((actuals - forecast) / (actuals + eps)))) * 100.0 | |
| # RMSE: Root Mean Squared Error | |
| rmse = float(np.sqrt(np.mean((actuals - forecast) ** 2))) | |
| # WQL: Weighted Quantile Loss (para cuantil 0.5 = MAE/2) | |
| tau = 0.5 | |
| diff = actuals - forecast | |
| wql = float(np.mean(np.maximum(tau * diff, (tau - 1) * diff))) | |
| return BacktestMetrics( | |
| mae=mae, | |
| mape=mape, | |
| rmse=rmse, | |
| wql=wql | |
| ) | |