Source code for fynance.models.econometric_models

#!/usr/bin/env python3
# coding: utf-8
# @Author: ArthurBernard
# @Email: arthur.bernard.92@gmail.com
# @Date: 2019-02-20 10:39:56
# @Last modified by: ArthurBernard
# @Last modified time: 2019-05-23 17:31:40

""" Classical econometric time-series models.

Pure-NumPy implementations of moving-average, autoregressive and
GARCH-family processes used to simulate or fit return series. Parameter
estimation is delegated to the Numba routines in
:mod:`fynance.estimator` via :func:`get_parameters`, a single
maximum-likelihood entry point for all four model types.

Main entry points
-----------------
- :func:`MA` — Moving Average process.
- :func:`ARMA` — AutoRegressive Moving Average.
- :func:`ARMA_GARCH` — ARMA with GARCH(p, q) conditional variance.
- :func:`ARMAX_GARCH` — ARMA-GARCH with exogenous regressors.
- :func:`get_parameters` — fit the parameters of any of the above.
- :func:`loglik_garch` — GARCH-family (GARCH / GJR / EGARCH) log-likelihood
  under normal or standardized Student-t innovations, the objective for a
  maximum-likelihood fit driver.

"""

# Built-in packages
import math

# External packages
import numpy as np
import polars as pl

# Local packages
from numba import njit


@njit(cache=True)
def _ma(y, theta, c, q):
    """ MA(q) residual recursion (numba kernel). """
    T = y.size
    u = np.zeros(T)
    for t in range(T):
        s = 0.0
        for i in range(min(t, q)):
            s += u[t - i - 1] * theta[i]
        if s > 1e12 or s < -1e12:
            return 1e6 * np.ones(T)
        u[t] = y[t] - c - s
    return u


@njit(cache=True)
def _arma(y, phi, theta, c, p, q):
    """ ARMA(p, q) residual recursion (numba kernel). """
    T = y.size
    u = np.zeros(T)
    for t in range(T):
        s = 0.0
        for i in range(min(t, max(q, p))):
            if i < q:
                s += u[t - i - 1] * theta[i]
            if i < p:
                s += y[t - i - 1] * phi[i]
            if s > 1e12 or s < -1e12:
                return 1e6 * np.ones(T)
        u[t] = y[t] - c - s
    return u


@njit(cache=True)
def _arma_garch(y, phi, theta, alpha, beta, c, omega, p, q, Q, P):
    """ ARMA(p, q)-GARCH(Q, P) residual/volatility recursion (numba kernel). """
    T = y.size
    u = np.zeros(T)
    h = np.zeros(T)
    for t in range(T):
        arma = 0.0
        arch = 0.0
        for i in range(min(t, max(max(q, p), max(Q, P)))):
            if i < p:
                arma += y[t - i - 1] * phi[i]
            if i < q:
                arma += u[t - i - 1] * theta[i]
            if i < Q:
                arch += u[t - i - 1] ** 2 * alpha[i]
            if i < P:
                arch += h[t - i - 1] ** 2 * beta[i]
            if arch < 0.0:
                return 1e8 * np.ones(T), np.ones(T)
            if arch > 1e12 or arma > 1e12 or arma < -1e12:
                return 1e6 * np.ones(T), np.ones(T)
        u[t] = y[t] - c - arma
        h[t] = np.sqrt(omega + arch)
    return u, h


@njit(cache=True)
def _armax_garch(y, x, phi, psi, theta, alpha, beta, c, omega, p, q, Q, P):
    """ ARMAX(p, q)-GARCH(Q, P) residual/volatility recursion (numba kernel). """
    T = y.size
    u = np.zeros(T)
    h = np.zeros(T)
    for t in range(T):
        armax = 0.0
        for k in range(x.shape[1]):
            armax += x[t, k] * psi[k]
        arch = 0.0
        for i in range(min(t, max(max(q, p), max(Q, P)))):
            if i < p:
                armax += y[t - i - 1] * phi[i]
            if i < q:
                armax += u[t - i - 1] * theta[i]
            if i < Q:
                arch += u[t - i - 1] ** 2 * alpha[i]
            if i < P:
                arch += h[t - i - 1] ** 2 * beta[i]
            if arch < 0.0:
                return 1e8 * np.ones(T), np.ones(T)
            if arch > 1e12 or armax > 1e12 or armax < -1e12:
                return 1e6 * np.ones(T), np.ones(T)
        u[t] = y[t] - c - armax
        h[t] = np.sqrt(omega + arch)
    return u, h


@njit(cache=True)
def _gjr_garch(y, omega, alpha, gamma, beta):
    """ GJR-GARCH(1, 1) conditional std recursion (numba kernel).

    Variance recursion
    ``sigma_t^2 = omega + (alpha + gamma * 1[y_{t-1} < 0]) * y_{t-1}^2
    + beta * sigma_{t-1}^2`` with ``sigma_0^2 = omega`` (only the constant
    survives at t=0, matching the `_arma_garch` convention). ``y`` is used
    as the mean-zero innovation series. Returns the conditional standard
    deviation ``h_t = sigma_t``. With ``gamma == 0`` the ``h`` path is
    identical to the vanilla `_arma_garch` filter.
    """
    T = y.size
    h = np.zeros(T)
    h[0] = np.sqrt(omega)
    for t in range(1, T):
        ind = 1.0 if y[t - 1] < 0.0 else 0.0
        arch = (alpha + gamma * ind) * y[t - 1] ** 2 + beta * h[t - 1] ** 2
        h[t] = np.sqrt(omega + arch)
    return h


@njit(cache=True)
def _egarch(y, omega, alpha, gamma, beta, mean_abs_z):
    """ EGARCH(1, 1) conditional std recursion (numba kernel).

    Log-variance recursion
    ``ln sigma_t^2 = omega + beta * ln sigma_{t-1}^2
    + alpha * (|z_{t-1}| - mean_abs_z) + gamma * z_{t-1}`` with
    ``z_t = y_t / sigma_t`` and ``ln sigma_0^2 = omega`` (only the constant
    survives at t=0, matching the `_arma_garch` convention). ``mean_abs_z``
    is E|z| for the innovation distribution, supplied by the caller
    (``sqrt(2 / pi)`` for the normal, :func:`_mean_abs_standardized_t` for a
    standardized Student-t). Returns the conditional standard deviation
    ``h_t = sigma_t``.
    """
    T = y.size
    h = np.zeros(T)
    log_var = omega
    h[0] = np.exp(0.5 * log_var)
    for t in range(1, T):
        z = y[t - 1] / h[t - 1]
        log_var = (omega + beta * log_var
                   + alpha * (abs(z) - mean_abs_z) + gamma * z)
        h[t] = np.exp(0.5 * log_var)
    return h


@njit(cache=True)
def _mean_abs_standardized_t(nu):
    """ E|z| for a unit-variance Student-t (nu > 2) innovation (numba kernel).

    ``E|z| = 2 * sqrt(nu - 2) * Gamma((nu + 1) / 2)
    / ((nu - 1) * sqrt(pi) * Gamma(nu / 2))``.
    """
    ratio = math.exp(math.lgamma((nu + 1.0) / 2.0) - math.lgamma(nu / 2.0))
    return 2.0 * math.sqrt(nu - 2.0) * ratio / ((nu - 1.0) * math.sqrt(math.pi))


@njit(cache=True)
def _loglik_normal(y, h):
    """ Gaussian log-likelihood given conditional std ``h`` (numba kernel). """
    T = y.size
    const = -0.5 * math.log(2.0 * math.pi)
    ll = 0.0
    for t in range(T):
        ll += const - math.log(h[t]) - 0.5 * (y[t] / h[t]) ** 2
    return ll


@njit(cache=True)
def _loglik_t(y, h, nu):
    """ Standardized Student-t (nu > 2) log-likelihood given ``h`` (numba). """
    T = y.size
    const = (math.lgamma((nu + 1.0) / 2.0) - math.lgamma(nu / 2.0)
             - 0.5 * math.log(math.pi * (nu - 2.0)))
    ll = 0.0
    for t in range(T):
        z2 = (y[t] / h[t]) ** 2
        ll += (const - math.log(h[t])
               - 0.5 * (nu + 1.0) * math.log(1.0 + z2 / (nu - 2.0)))
    return ll


__all__ = [
    'get_parameters', 'MA', 'ARMA', 'ARMA_GARCH', 'ARMAX_GARCH'
]

# =========================================================================== #
#                             PARAMETERS FUNCTION                             #
# =========================================================================== #


[docs] def get_parameters(params, p=0, q=0, Q=0, P=0, cons=True): """ Get parameters for ARMA-GARCH models. Helper that splits the flat ``params`` vector returned by maximum-likelihood estimation into the structured groups expected by the model evaluation routines: AR coefficients ``phi``, MA coefficients ``theta``, GARCH ARCH/GARCH coefficients ``alpha``/``beta``, and constants ``c`` and ``omega``. Pass the same ``p, q, Q, P, cons`` configuration that was used at the estimation step (see :mod:`fynance.estimator`). Parameters ---------- params : np.ndarray[np.float64, ndim=1] Array of model parameters. p, q, Q, P : int, optional Order of model, default is 0. cons : bool, optional True if model contains constant, default is True. Returns ------- phi : np.ndarray[np.float64, ndim=1] AR parameters. theta : np.ndarray[np.float64, ndim=1] MA parameters. alpha : np.ndarray[np.float64, ndim=1] First part GARCH parameters. beta : np.ndarray[np.float64, ndim=1] Last part GARCH parameters. c : float Constant of ARMA part. omega : float Constants of GARCH part. See Also -------- ARMAX_GARCH, ARMA_GARCH, ARMA, MA. """ i = 0 if cons: c = params[i] i += 1 else: c = 0. if p > 0: phi = params[i: p + i] i += p else: phi = np.array([0.], dtype=np.float64) if q > 0: theta = params[i: q + i] i += q else: theta = np.array([0.], dtype=np.float64) if Q > 0 or P > 0: omega = params[i] i += 1 if Q > 0: alpha = params[i: Q + i] i += Q else: alpha = np.array([0.], dtype=np.float64) if P > 0: beta = params[i: P + i] i += P else: beta = np.array([0.], dtype=np.float64) else: omega = 0. alpha = np.array([0.], dtype=np.float64) beta = np.array([0.], dtype=np.float64) return phi, theta, alpha, beta, c, omega
# =========================================================================== # # MODELs # # =========================================================================== #
[docs] def MA(y, theta, c, q): r""" Moving Average model of order `q` s.t: .. math:: y_t = c + \theta_1 * u_{t-1} + ... + \theta_q * u_{t-q} + u_t Parameters ---------- y : np.ndarray[np.float64, ndim=1] Time series. theta : np.ndarray[np.float64, ndim=1] Coefficients of model. c : np.float64 Constant of the model. q : int Order of MA(q) model. Returns ------- u : np.ndarray[ndim=1, dtype=np.float64] Residual of the model. Examples -------- >>> y = np.array([3, 4, 6, 8, 5, 3]) >>> MA(y=y, theta=np.array([0.8]), c=3, q=1) array([ 0. , 1. , 2.2 , 3.24 , -0.592 , 0.4736]) See Also -------- ARMA_GARCH, ARMA, ARMAX_GARCH """ # Set type of variables if isinstance(y, (pl.DataFrame, pl.Series)): y = y.to_numpy() elif isinstance(y, list): y = np.asarray(y) y = y.astype(np.float64).reshape([y.size]) if isinstance(theta, list): theta = np.asarray(theta) theta = theta.astype(np.float64).reshape([theta.size]) # Compute residuals u = _ma(y, theta, float(c), int(q)) return u
[docs] def ARMA(y, phi, theta, c, p, q): r""" AutoRegressive Moving Average model of order `q` and `p` s.t: .. math:: y_t = c + \phi_1 * y_{t-1} + ... + \phi_p * y_{t-p} + \theta_1 * u_{t-1} + ... + \theta_q * u_{t-q} + u_t Parameters ---------- y : np.ndarray[np.float64, ndim=1] Time series. phi : np.ndarray[np.float64, ndim=1] Coefficients of AR model. theta : np.ndarray[np.float64, ndim=1] Coefficients of MA model. c : np.float64 Constant of the model. p : int Order of AR(p) model. q : int Order of MA(q) model. Returns ------- u : np.ndarray[np.float64, ndim=1] Residual of the model. See Also -------- ARMA_GARCH, ARMAX_GARCH, MA. """ # Set type variables and parameters y = np.asarray(y, dtype=np.float64) y = y.reshape([y.size]) theta = np.asarray(theta, dtype=np.float64) phi = np.asarray(phi, dtype=np.float64) # Compute residuals u = _arma(y, phi, theta, float(c), int(p), int(q)) return u
[docs] def ARMA_GARCH(y, phi, theta, alpha, beta, c, omega, p, q, Q, P): r""" AutoRegressive Moving Average model of order q and p, such that: .. math:: y_t = c + \phi_1 * y_{t-1} + ... + \phi_p * y_{t-p} + \theta_1 * u_{t-1} + ... + \theta_q * u_{t-q} + u_t With Generalized AutoRegressive Conditional Heteroskedasticity volatility model of order `Q` and `P`, such that: .. math:: u_t = z_t * h_t h_t^2 = \omega + \alpha_1 * u^2_{t-1} + ... + \alpha_Q * u^2_{t-Q} + \beta_1 * h^2_{t-1} + ... + \beta_P * h^2_{t-P} Parameters ---------- y : np.ndarray[np.float64, ndim=1] Time series. phi : np.ndarray[np.float64, ndim=1] Coefficients of AR model. theta : np.ndarray[np.float64, ndim=1] Coefficients of MA model. alpha : np.ndarray[np.float64, ndim=1] Coefficients of MA part of GARCH. beta : np.ndarray[np.float64, ndim=1] Coefficients of AR part of GARCH. c : np.float64 Constant of ARMA model. omega : np.float64 Constant of GARCH model. p : int Order of AR(p) model. q : int Order of MA(q) model. Q : int Order of MA part of GARCH. P : int Order of AR part of GARCH. Returns ------- u : np.ndarray[np.float64, ndim=1] Residual of the model. h : np.ndarray[np.float64, ndim=1] Conditional volatility of the model. See Also -------- ARMAX_GARCH, ARMA, MA. """ y = np.asarray(y, dtype=np.float64) y = y.reshape([y.size]) theta = np.asarray(theta, dtype=np.float64) phi = np.asarray(phi, dtype=np.float64) alpha = np.asarray(alpha, dtype=np.float64) beta = np.asarray(beta, dtype=np.float64) u, h = _arma_garch( y, phi, theta, alpha, beta, float(c), float(omega), int(p), int(q), int(Q), int(P) ) return u, h
[docs] def ARMAX_GARCH(y, x, phi, psi, theta, alpha, beta, c, omega, p, q, Q, P): r""" AutoRegressive Moving Average model of order q and p, such that: .. math:: y_t = c + \phi_1 * y_{t-1} + ... + \phi_p * y_{t-p} + \sum_k \psi_k * x_{t,k} + \theta_1 * u_{t-1} + ... + \theta_q * u_{t-q} + u_t With Generalized AutoRegressive Conditional Heteroskedasticity volatility model of order `Q` and `P`, such that: .. math:: u_t = z_t * h_t h_t^2 = \omega + \alpha_1 * u^2_{t-1} + ... + \alpha_Q * u^2_{t-Q} + \beta_1 * h^2_{t-1} + ... + \beta_P * h^2_{t-P} Parameters ---------- y : np.ndarray[np.float64, ndim=1] Time series. x : np.ndarray[np.float64, ndim=2] Time series of external features. phi : np.ndarray[np.float64, ndim=1] Coefficients of AR model. psi : np.ndarray[np.float64, ndim=1] Coefficients of external features. theta : np.ndarray[np.float64, ndim=1] Coefficients of MA model. alpha : np.ndarray[np.float64, ndim=1] Coefficients of MA part of GARCH. beta : np.ndarray[np.float64, ndim=1] Coefficients of AR part of GARCH. c : np.float64 Constant of ARMA model. omega : np.float64 Constant of GARCH model. p : int Order of AR(p) model. q : int Order of MA(q) model. Q : int Order of MA part of GARCH. P : int Order of AR part of GARCH. Returns ------- u : np.ndarray[np.float64, ndim=1] Residual of the model. h : np.ndarray[np.float64, ndim=1] Conditional volatility of the model. See Also -------- ARMA_GARCH, ARMA, MA. """ # Set array variables y = np.asarray(y, dtype=np.float64) y = y.reshape([y.size]) x = np.asarray(x, dtype=np.float64) theta = np.asarray(theta, dtype=np.float64) phi = np.asarray(phi, dtype=np.float64) psi = np.asarray(psi, dtype=np.float64) alpha = np.asarray(alpha, dtype=np.float64) beta = np.asarray(beta, dtype=np.float64) # Compute residuals and volatility u, h = _armax_garch( y, x, phi, psi, theta, alpha, beta, float(c), float(omega), int(p), int(q), int(Q), int(P) ) return u, h
# =========================================================================== # # GARCH-FAMILY LOG-LIKELIHOODS # # =========================================================================== # _SQRT_2_OVER_PI = math.sqrt(2.0 / math.pi) def loglik_garch( params: np.ndarray, y: np.ndarray, model: str = 'garch', dist: str = 'normal', ) -> float: r""" Log-likelihood of a GARCH-family volatility model. Runs the conditional-variance recursion of the selected ``model`` over the mean-zero series ``y`` and returns the total log-likelihood of the innovations under the chosen ``dist``. The value is a *log-likelihood* (higher is better, to be **maximised**); invalid parameter regions map to ``-np.inf`` so a maximiser (or a minimiser of its negative) is repelled from them without exceptions being raised. Intended as the objective for a scipy maximum-likelihood driver (built in a later step). Parameters ---------- params : np.ndarray[np.float64, ndim=1] Flat parameter vector, laid out per ``model`` (all variance parameters first, ``nu`` appended last when ``dist='t'``): - ``model='garch'`` : ``(omega, alpha, beta)`` - ``model='gjr'`` : ``(omega, alpha, gamma, beta)`` - ``model='egarch'``: ``(omega, alpha, gamma, beta)`` With ``dist='t'`` the degrees of freedom ``nu`` (``> 2``) is appended as the final element. y : np.ndarray[np.float64, ndim=1] Mean-zero innovation (return) series. model : {'garch', 'gjr', 'egarch'}, optional Conditional-variance specification. ``'garch'`` is the vanilla GARCH(1, 1); ``'gjr'`` adds a leverage term ``gamma * 1[y_{t-1} < 0] * y_{t-1}^2``; ``'egarch'`` models the log-variance. Default is ``'garch'``. dist : {'normal', 't'}, optional Innovation density: Gaussian, or standardized (unit-variance) Student-t with ``nu > 2`` degrees of freedom. Default is ``'normal'``. Returns ------- float Total log-likelihood (higher is better), or ``-np.inf`` when the parameters leave the admissible region (see Notes). Notes ----- Admissible regions (outside them the return is ``-np.inf``): - ``garch`` : ``omega > 0``, ``alpha >= 0``, ``beta >= 0``, ``alpha + beta < 1`` (stationarity). - ``gjr`` : ``omega > 0``, ``alpha >= 0``, ``beta >= 0``, ``alpha + gamma >= 0`` (variance non-negativity), ``alpha + beta + gamma / 2 < 1`` (stationarity, symmetric innovations). - ``egarch`` : ``|beta| < 1`` only (the log-variance form needs no non-negativity constraint). - ``dist='t'`` : ``nu > 2`` (finite variance). The conditional variance is initialised at ``sigma_0^2 = omega`` (GARCH, GJR) or ``ln sigma_0^2 = omega`` (EGARCH): only the constant survives at ``t = 0``, matching the :func:`_arma_garch` convention. The EGARCH ``E|z|`` term is ``sqrt(2 / pi)`` for the normal and ``2 * sqrt(nu - 2) * Gamma((nu + 1) / 2) / ((nu - 1) * sqrt(pi) * Gamma(nu / 2))`` for the standardized Student-t. Examples -------- >>> import numpy as np >>> rng = np.random.default_rng(0) >>> y = rng.standard_normal(500) >>> ll = loglik_garch([0.1, 0.05, 0.9], y, model='garch', dist='normal') >>> bool(ll < 0.0) True >>> loglik_garch([-1.0, 0.05, 0.9], y, model='garch') # omega <= 0 -inf """ y = np.asarray(y, dtype=np.float64).reshape(-1) params = np.asarray(params, dtype=np.float64).reshape(-1) model = model.lower() dist = dist.lower() # Split off the Student-t degrees of freedom, if any. if dist == 't': nu = float(params[-1]) core = params[:-1] if nu <= 2.0: return -np.inf elif dist == 'normal': nu = 0.0 core = params else: raise ValueError(f"Unknown dist: {dist!r}") # Parse per model, validate the admissible region, run the filter. if model == 'garch': omega, alpha, beta = core[0], core[1], core[2] if omega <= 0.0 or alpha < 0.0 or beta < 0.0 or alpha + beta >= 1.0: return -np.inf h = _gjr_garch(y, omega, alpha, 0.0, beta) elif model == 'gjr': omega, alpha, gamma, beta = core[0], core[1], core[2], core[3] if (omega <= 0.0 or alpha < 0.0 or beta < 0.0 or alpha + gamma < 0.0 or alpha + beta + 0.5 * gamma >= 1.0): return -np.inf h = _gjr_garch(y, omega, alpha, gamma, beta) elif model == 'egarch': omega, alpha, gamma, beta = core[0], core[1], core[2], core[3] if abs(beta) >= 1.0: return -np.inf if dist == 't': mean_abs_z = _mean_abs_standardized_t(nu) else: mean_abs_z = _SQRT_2_OVER_PI h = _egarch(y, omega, alpha, gamma, beta, mean_abs_z) else: raise ValueError(f"Unknown model: {model!r}") # Innovation log-density. if dist == 't': ll = _loglik_t(y, h, nu) else: ll = _loglik_normal(y, h) if not np.isfinite(ll): return -np.inf return float(ll)