"""Tests de estacionariedad de una serie temporal: ADF + KPSS (grupo eda). Funcion pura y determinista que combina dos contrastes de estacionariedad con hipotesis nulas opuestas y emite un veredicto de consenso. Motivada por la necesidad (Hyndman "Forecasting", Hamilton "Time Series Analysis") de saber si una serie es estacionaria ANTES de correlacionarla o modelarla: correlacionar niveles no estacionarios produce correlacion espuria (Granger-Newbold 1974). """ from __future__ import annotations import math import warnings from statsmodels.tsa.stattools import adfuller, kpss def _clean(values: list) -> list[float]: """Conserva solo valores numericos finitos, descartando None/NaN/no-numericos. Los booleanos se excluyen explicitamente: en Python ``bool`` es subclase de ``int``, pero tratar True/False como numeros en una serie temporal es casi siempre un error de tipado. """ out: list[float] = [] for v in values: if v is None or isinstance(v, bool): continue if not isinstance(v, (int, float)): continue x = float(v) if math.isnan(x) or math.isinf(x): continue out.append(x) return out def adf_kpss_stationarity(values: list, alpha: float = 0.05) -> dict: """Evalua la estacionariedad de una serie combinando ADF y KPSS. Aplica dos contrastes con hipotesis nulas opuestas: - **ADF** (Augmented Dickey-Fuller): H0 = "la serie tiene raiz unitaria" (es NO estacionaria). Si ``p < alpha`` se rechaza H0 -> evidencia de estacionariedad. - **KPSS** (Kwiatkowski-Phillips-Schmidt-Shin): H0 = "la serie es estacionaria (en torno a una tendencia)". Si ``p < alpha`` se rechaza H0 -> evidencia de NO estacionariedad. Combinar ambos da mas robustez que cualquiera por separado, porque sus hipotesis nulas son contrarias. El veredicto de consenso sigue la interpretacion estandar (Hyndman, "Forecasting: Principles and Practice"): - ADF rechaza H0 **y** KPSS no rechaza H0 -> ``"stationary"``. - ADF no rechaza H0 **y** KPSS rechaza H0 -> ``"non_stationary"``. - Ambos coinciden en lo contrario o se contradicen -> ``"inconclusive"`` (a menudo indica serie diferenciable o estacionaria en tendencia). Funcion pura y determinista: no hace I/O, no muta los inputs. Args: values: serie temporal de valores numericos en orden cronologico. None/NaN/infinitos/no-numericos se descartan antes del test. alpha: nivel de significancia para ambos contrastes (default 0.05). Returns: Con menos de 8 puntos validos (muestra insuficiente para un test de raiz unitaria fiable) devuelve ``{"n": n, "note": "datos insuficientes", "verdict": None}``. En otro caso un dict con:: { "n": int, "alpha": float, "adf": {"stat": float, "p_value": float, "lags": int, "stationary": bool, # rechaza H0 de raiz unitaria "conclusion": str}, "kpss": {"stat": float, "p_value": float, "lags": int, "stationary": bool, # NO rechaza H0 de estacionariedad "conclusion": str, "p_value_clipped": bool}, # p en limite de tabla KPSS "verdict": "stationary" | "non_stationary" | "inconclusive", "warning": str | None, # aviso de correlacion espuria si procede } ``warning`` se rellena cuando el veredicto NO es ``"stationary"`` para recordar que correlacionar/regresionar niveles no estacionarios produce relaciones espurias; conviene pasar a retornos o diferencias. """ clean = _clean(values) n = len(clean) if n < 8: return {"n": n, "note": "datos insuficientes", "verdict": None} # ADF: H0 = raiz unitaria (no estacionaria). p < alpha => estacionaria. adf_stat, adf_p, adf_lags, _adf_nobs, _adf_crit, _adf_icbest = adfuller( clean, autolag="AIC" ) adf_stationary = bool(adf_p < alpha) adf = { "stat": float(adf_stat), "p_value": float(adf_p), "lags": int(adf_lags), "stationary": adf_stationary, "conclusion": ( "rechaza H0 de raiz unitaria: evidencia de estacionariedad" if adf_stationary else "no rechaza H0 de raiz unitaria: posible no estacionaria" ), } # KPSS: H0 = estacionaria en torno a tendencia. p < alpha => NO estacionaria. # statsmodels emite InterpolationWarning cuando el p-valor cae fuera de la # tabla [0.01, 0.10]; lo capturamos para saber si quedo recortado. with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") kpss_stat, kpss_p, kpss_lags, _kpss_crit = kpss( clean, regression="c", nlags="auto" ) p_clipped = any("InterpolationWarning" in str(w.category) for w in caught) or any( "p-value" in str(w.message).lower() for w in caught ) kpss_stationary = bool(kpss_p >= alpha) # NO rechaza H0 => estacionaria kpss_result = { "stat": float(kpss_stat), "p_value": float(kpss_p), "lags": int(kpss_lags), "stationary": kpss_stationary, "conclusion": ( "no rechaza H0 de estacionariedad: evidencia de estacionariedad" if kpss_stationary else "rechaza H0 de estacionariedad: posible no estacionaria" ), "p_value_clipped": bool(p_clipped), } # Consenso de los dos contrastes. if adf_stationary and kpss_stationary: verdict = "stationary" elif (not adf_stationary) and (not kpss_stationary): verdict = "non_stationary" else: verdict = "inconclusive" warning: str | None = None if verdict != "stationary": warning = ( "serie no claramente estacionaria: correlacionar o regresionar sus " "niveles puede dar relaciones espurias (Granger-Newbold). Considera " "trabajar sobre retornos o diferencias (ver to_returns)." ) return { "n": n, "alpha": float(alpha), "adf": adf, "kpss": kpss_result, "verdict": verdict, "warning": warning, }