import threading import numpy as np import pandas as pd from models.base_model import BaseForecastModel, causal_exogenous, validate_history_and_horizon from models.dependencies import fit_arima, import_arima, quiet_fit from features.feature_pipeline import validate_features class ArimaModel(BaseForecastModel): name = 'ARIMA' def __init__(self, max_p=3, max_d=2, max_q=3, order=None, refit_order_every=None): self.max_p = max_p self.max_d = max_d self.max_q = max_q self._cached_order = order self.refit_order_every = refit_order_every self._calls_since_search = 0 self._lock = threading.Lock() self.last_diagnostics = {} def _best_order(self, series: pd.Series, exog: pd.DataFrame=None): arima = import_arima(self.name) best_aic = np.inf best_order = None failures = [] # AIC values from different integration orders have different likelihood # bases. Select d on the training sample, then compare only p/q at that d. from models.auto_arima_model import AutoArimaModel selected_d = AutoArimaModel(max_d=self.max_d)._find_d(series) with quiet_fit(): candidates = [(p, d, q) for d in [selected_d] for p in range(self.max_p + 1) for q in range(self.max_q + 1) if p + q <= 2] for order in candidates: try: fit = fit_arima(arima(series, exog=exog, order=order)) except Exception as error: failures.append({'order': order, 'error': f'{type(error).__name__}: {error}'}) continue if fit.aic < best_aic: best_aic = fit.aic best_order = order if best_order is None: self.last_diagnostics = {'converged': False, 'candidate_errors': failures} raise ValueError(f'No ARIMA candidate converged on this training window: {failures}') return best_order def _fit_forecast(self, arima, series, order, horizon, exog, future_exog): """Execute the selected ARIMA order; a failed fit is a failed evaluation.""" with quiet_fit(): fit = fit_arima(arima(series, exog=exog, order=order)) forecast = fit.forecast(steps=horizon, exog=future_exog) if exog is not None else fit.forecast(steps=horizon) values = np.exp(np.asarray(forecast, dtype=float)) if values.shape != (horizon,) or not np.isfinite(values).all() or (values <= 0).any(): raise ValueError(f'{self.name} returned an invalid forecast for selected order {order}.') self.last_diagnostics = {'converged': True, 'selected_order': order, 'executed_order': order, 'order_fallback': False, 'feature_policy': 'lagged one candle; unknown future covariates persist'} return values def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list: with self._lock: self.last_diagnostics = {} validate_history_and_horizon(history, horizon, self.name) series = pd.Series(history).astype(float).reset_index(drop=True) validate_history_and_horizon(series, horizon, self.name) if len(series) < 20: raise ValueError('ARIMA needs at least 20 candles of history.') arima = import_arima(self.name) exog = future_exog = None if features is not None: validate_features(features, expected_len=len(series), model_name='ARIMA') exog, future_exog = causal_exogenous(features, horizon, self.name) if exog is not None: series = series.iloc[1:].reset_index(drop=True) if (series <= 0).any(): raise ValueError('ARIMA needs strictly positive prices for its log-price model.') log_series = np.log(series) need_search = self._cached_order is None if self.refit_order_every and self._calls_since_search >= self.refit_order_every: need_search = True if need_search: self._cached_order = self._best_order(log_series, exog=exog) self._calls_since_search = 0 self._calls_since_search += 1 forecast = self._fit_forecast(arima, log_series, self._cached_order, horizon, exog, future_exog) return [float(x) for x in forecast]