# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""GARCH(p, o, q) volatility models, following the arch package's
``arch_model(..., vol='GARCH', dist='normal')``. Each series is fitted on the
GPU."""
from ._optional_numpy import require_numpy
np = require_numpy('_x_sequence_garch')
from . import _backend
from ._buffer import _native
# Fitted on the GPU one series per thread (`sequence/garch.mojo`): arch's
# variance recursion, backcast, variance bounds, starting-value grid and
# Gaussian likelihood, maximised by statsforecast's Nelder-Mead inside arch's
# bounds.
[docs]
class GARCH:
"""GARCH(p, o, q) conditional volatility model with normal errors.
Fits a constant- or zero-mean GARCH model by Gaussian maximum
likelihood, following the arch package's
``arch_model(y, mean='Constant'|'Zero', vol='GARCH', p=p, o=o, q=q,
dist='normal')``: arch's variance recursion, backcast, variance bounds
and starting values. ``o > 0`` adds asymmetric (GJR) terms. ``fit``
takes one series of shape ``(n_obs,)`` or a batch of shape
``(batch_size, n_obs)``.
Parameters
----------
p : int, default 1
Order of the lagged squared innovations (ARCH terms).
o : int, default 0
Order of the asymmetric innovations; needs ``p > 0``.
q : int, default 1
Order of the lagged conditional variance (GARCH terms).
mean : {"Constant", "Zero"}, default "Constant"
Mean model. Other arch mean models (AR, HAR, LS) raise
NotImplementedError.
dist : {"normal"}, default "normal"
Error distribution; only ``"normal"`` is supported.
power : float, default 2.0
Only 2.0 is supported (TARCH/APARCH powers raise
NotImplementedError).
numeric_mode : str or None, default None
Numeric mode for this model (``'identical'``, ``'deterministic'``
or ``'fast'``); None uses the process default (see
:func:`mojolearn.set_numeric_mode`).
Attributes
----------
params_ : ndarray of shape (2 + p + o + q,) or (batch_size, 2 + p + o + q), float32
``mu, omega, alpha[1..p], gamma[1..o], beta[1..q]``.
loglikelihood_ : float32 or ndarray of shape (batch_size,)
Maximized Gaussian log-likelihood.
n_iter_ : int64 or ndarray of shape (batch_size,)
Nelder-Mead iterations used.
conditional_volatility_ : ndarray of shape (n_obs,) or (batch_size, n_obs), float32
Fitted conditional volatility.
Raises
------
ValueError
If p, o or q is negative, all are zero, or ``o > 0`` with ``p == 0``.
Notes
-----
Input must be float32; float64 raises TypeError. Rescaling is not
supported: scale the data yourself if needed.
Examples
--------
>>> import numpy as np, mojolearn
>>> r = np.random.default_rng(0).standard_normal(500).astype(np.float32)
>>> model = mojolearn.GARCH(p=1, q=1).fit(r)
>>> model.forecast(5).shape
(5,)
"""
[docs]
def __init__(self, p=1, o=0, q=1, mean="Constant", dist="normal", power=2.0, numeric_mode=None):
if mean not in ("Constant", "Zero"):
raise NotImplementedError(f"GARCH: mean={mean!r} is not implemented ('Constant', 'Zero')")
if dist != "normal":
raise NotImplementedError(f"GARCH: dist={dist!r} is not implemented ('normal')")
if power != 2.0:
raise NotImplementedError("GARCH: power != 2 is not implemented")
self.p, self.o, self.q = int(p), int(o), int(q)
if min(self.p, self.o, self.q) < 0 or self.p + self.o + self.q < 1:
raise ValueError("GARCH: p, o, q >= 0 with at least one term")
if self.o > 0 and self.p == 0:
raise ValueError("GARCH: o > 0 needs p > 0 (arch refuses it too)")
self.mean, self.dist, self.power = mean, dist, power
self.numeric_mode = numeric_mode
[docs]
def fit(self, y, horizon=1):
"""Fit the model to one series or a batch of series.
Parameters
----------
y : array-like of shape (n_obs,) or (batch_size, n_obs)
Returns (or other zero-centered data), float32, finite, at least
10 observations per series.
horizon : int, default 1
Variance forecast horizon computed alongside the fit (at least 1).
Returns
-------
self
Raises
------
TypeError
If ``y`` is float64.
ValueError
If a series has fewer than 10 observations or ``y`` holds a
non-finite value.
"""
y = np.asarray(y)
if y.dtype == np.float64:
raise TypeError("GARCH: float64 is refused; pass float32")
self._one = y.ndim == 1
Y = np.ascontiguousarray(y, dtype=np.float32).reshape(1, -1) if self._one else \
np.ascontiguousarray(y, dtype=np.float32)
if Y.ndim != 2 or Y.shape[1] < 10:
raise ValueError("GARCH: each series needs at least 10 observations")
# the finiteness scan in Mojo (the base binding's `all_finite_f32`)
if not int(_native("all_finite_f32")(Y.ctypes.data, Y.size)):
raise ValueError("GARCH: y holds a non-finite value")
B, n = Y.shape
h = max(1, int(horizon))
k = 1 + self.p + self.o + self.q
params = np.zeros((B, 1 + k), dtype=np.float32)
info = np.zeros((B, 4), dtype=np.float32)
sigma = np.zeros((B, n), dtype=np.float32)
fc = np.zeros((B, h), dtype=np.float32)
ip = [B, n, h, self.p, self.o, self.q, int(self.mean == "Constant")]
stall = getattr(self, "_fast_stall", None) # (iterations, relative drop): the FAST stop's
if stall is not None: # quality sweep (tools/sequence_quality.py)
ip = ip + [int(stall[0]), int(round(float(stall[1]) * 1e9))]
_backend.binding("_mojolearn_x_sequence", self.numeric_mode).garch(
[Y.ctypes.data, params.ctypes.data, info.ctypes.data, sigma.ctypes.data, fc.ctypes.data], ip)
self._y = Y
pick = (lambda a: a[0]) if self._one else (lambda a: a)
self.params_ = pick(params)
self.loglikelihood_ = pick(info[:, 0])
self.n_iter_ = pick(info[:, 1].astype(np.int64)) # Nelder-Mead iterations, both runs
self.conditional_volatility_ = pick(sigma)
self._fc = fc
return self
[docs]
def forecast(self, horizon=1):
"""Analytic conditional variance forecasts.
Parameters
----------
horizon : int, default 1
Number of steps ahead. A horizon longer than the one computed at
fit refits the stored series.
Returns
-------
ndarray of shape (horizon,) or (batch_size, horizon), float32
Forecast conditional variances.
"""
h = int(horizon)
if h > self._fc.shape[1]:
y = self._y[0] if self._one else self._y
self.fit(y, horizon=h)
f = self._fc[:, :h]
return f[0] if self._one else f