File size: 7,286 Bytes
c40c447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
"""
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__)


@dataclass
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
        }


@dataclass
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
        )