\[ % MathJax has no bm package; redefine \bm in terms of \boldsymbol, which it supports natively \newcommand{\bm}[1]{\boldsymbol{#1}} % --- Operators ----------------------------------------------------------- % Expectation operator \DeclareMathOperator{\E}{E} % Variance operator \DeclareMathOperator{\Var}{Var} % Covariance operator \DeclareMathOperator{\Cov}{Cov} % Correlation operator \DeclareMathOperator{\Corr}{Corr} % Rank operator (Spearman rank correlation) \DeclareMathOperator{\Rank}{Rank} % Skewness operator \DeclareMathOperator{\Skewness}{Skewness} % Kurtosis operator \DeclareMathOperator{\Kurtosis}{Kurtosis} % Difference/differential operator (upright d per ISO 80000-2) \newcommand{\Diff}{\mathrm{d}} % --- Risk Measures ------------------------------------------------------- % Value at Risk \DeclareMathOperator{\VaR}{VaR} % Expected Shortfall \DeclareMathOperator{\ES}{ES} % Marginal Value at Risk \DeclareMathOperator{\MVaR}{MVaR} % Component Value at Risk \DeclareMathOperator{\CompVaR}{CVaR} % Incremental Value at Risk \DeclareMathOperator{\IVaR}{IVaR} % Component Expected Shortfall \DeclareMathOperator{\CompES}{CES} % Weighted sensitivity for risk factor in FRTB SBM bucket \DeclareMathOperator{\WS}{WS} % Hedge-benefit ratio for FRTB DRC bucket \DeclareMathOperator{\HBR}{HBR} % Stress scenario risk measure for FRTB NMRF capital add-on \DeclareMathOperator{\SES}{SES} % Jump-to-default exposure for obligor (FRTB DRC) \DeclareMathOperator{\JTD}{JTD} % Loss given default for obligor (FRTB DRC) \DeclareMathOperator{\LGD}{LGD} % Default risk weight for obligor (FRTB DRC) \DeclareMathOperator{\RW}{RW} % ES ratio function \newcommand{\ESratio}{\lambda} % Arbitrary risk measure function (coherence axioms) \newcommand{\RiskMeasure}{\varphi} % Asset position (coherence axioms) \newcommand{\Asset}{A} % First named asset in examples \newcommand{\AssetA}{A} % Second named asset in examples \newcommand{\AssetB}{B} % Third named asset in examples \newcommand{\AssetC}{C} % Constant (risk measure axioms) \newcommand{\Constant}{c} % Bucket-level aggregate sensitivity in FRTB SBM \newcommand{\BucketAgg}{S} % Cross-bucket correlation in FRTB SBM \newcommand{\CrossBucketCorr}{\gamma} % Notional amount for an instrument (FRTB DRC and RRAO) \newcommand{\Notional}{\text{Notional}} % --- Distributions ------------------------------------------------------- % Binomial distribution \DeclareMathOperator{\Binomial}{Binomial} % Uniform distribution \DeclareMathOperator{\Uniform}{Uniform} % Normal distribution \newcommand{\NormalDist}{\mathcal{N}} % Student-t CDF \newcommand{\StudentCDF}{t} % Student-t PDF \newcommand{\StudentPDF}{f} % --- Returns ------------------------------------------------------------- % Price \newcommand{\Price}{P} % Simple (arithmetic) return \newcommand{\SimpleReturns}{R} % Log (compound) return \newcommand{\CompoundReturns}{y} % Matrix of compound returns \newcommand{\ReturnMatrix}{\bm{y}} % Dividend payment \newcommand{\dividend}{d} % --- Volatility ---------------------------------------------------------- % Realised variance \DeclareMathOperator{\RealisedVar}{RV} % Realised volatility (square root of realised variance) \DeclareMathOperator{\RealisedVol}{RVol} % Volatility \newcommand{\Vol}{\sigma} % GARCH intercept parameter \newcommand{\GARCHconst}{\omega} % ARCH coefficient (weight on past squared returns) \newcommand{\ARCHcoeff}{\alpha} % GARCH coefficient (weight on past variance) \newcommand{\GARCHcoeff}{\beta} % EWMA decay factor (typically 0.94) \newcommand{\EWMAdecay}{\lambda} % Standardized residual / error term \newcommand{\StdNormal}{\epsilon} % Leverage parameter in apARCH \newcommand{\APARCHleverage}{\zeta} % Power parameter in apARCH \newcommand{\APARCHpower}{\delta} % Degrees of freedom (Student-t) \newcommand{\DOF}{\nu} % GJR-GARCH leverage parameter \newcommand{\GJRleverage}{\gamma} % GARCH-X external regressor coefficient \newcommand{\RegressorCoeff}{\xi} % Mean (first moment) \newcommand{\Mean}{\mu} % Lag order in volatility models \newcommand{\Lag}{L} % Dummy variable (indicator in regressions) \newcommand{\DummyVar}{D} % --- Portfolio ----------------------------------------------------------- % Portfolio weight (scalar) \newcommand{\weight}{w} % Portfolio weight vector \newcommand{\weights}{\bm{w}} % Covariance matrix \newcommand{\CovMatrix}{\bm{\Sigma}} % Correlation matrix \newcommand{\CorrMatrix}{\bm{C}} % Correlation coefficient \newcommand{\correlation}{\rho} % Number of assets in portfolio \newcommand{\NumberAssets}{K} % Portfolio value \newcommand{\PortfolioValue}{\vartheta} % Asset beta (CAPM) \newcommand{\AssetBeta}{\beta} % Ledoit-Wolf shrinkage intensity \newcommand{\ShrinkageIntensity}{\delta} % Sample covariance matrix \newcommand{\SampleCov}{\bm{S}} % --- Time Series --------------------------------------------------------- % Sample size \newcommand{\SampleSize}{T} % Generic count (number of lags, payments, etc.) \newcommand{\Count}{N} % Estimation window length \newcommand{\EstWindow}{W_E} % Testing window length \newcommand{\TestWindow}{W_T} % Stress window length \newcommand{\StressWindow}{W_S} % AR coefficient \newcommand{\ARcoeff}{\phi} % MA coefficient \newcommand{\MAcoeff}{\psi} % --- Probability --------------------------------------------------------- % Probability level (e.g. 0.01 for 1% VaR) \newcommand{\probability}{p} % Cumulative sorted scenario weight \newcommand{\CumScenarioWeight}{\Omega} % p-quantile of the P and L distribution \newcommand{\Quantile}{q} % Quantile function \newcommand{\QuantileFunction}{Q} % Probability density function \newcommand{\PDF}{f} % Cumulative distribution function \newcommand{\CDF}{F} % Standard normal CDF \newcommand{\NormalCDF}{\Phi} % Inverse standard normal (quantile function) \newcommand{\NormalQuantile}{\Phi^{-1}} % Standard normal density function \newcommand{\NormalPDF}{\phi} % Likelihood function \newcommand{\lik}{\mathcal{L}} % Log-likelihood \newcommand{\LogLikelihood}{\ell} % Parameter vector (MLE estimation) \newcommand{\ParamSet}{\theta} % Parameter space \newcommand{\ParamSpace}{\Theta} % Fisher information matrix \newcommand{\FisherInfo}{\mathcal{I}} % Statistical power (Type II error rate) \newcommand{\StatPower}{\beta} % Significance level (CI coverage complement) \newcommand{\SignifLevel}{\gamma} % --- Options ------------------------------------------------------------- % Call option label \newcommand{\CallOption}{\text{call}} % Put option label \newcommand{\PutOption}{\text{put}} % Strike price \newcommand{\Strike}{X} % Risk-free interest rate \newcommand{\RiskFree}{r_f} % Option maturity (time to expiration) \newcommand{\OptionMaturity}{\tau} % Option delta \newcommand{\OptionDelta}{\Delta} % Option gamma \newcommand{\OptionGamma}{\Gamma} % Option vega \newcommand{\Vega}{\mathcal{V}} % Holding period \newcommand{\HoldingPeriod}{H} % Simulation profit/loss (signed; negative is a loss) \newcommand{\ProfitLoss}{\Pi} % Futures price \newcommand{\Futures}{F} % Hedge ratio \newcommand{\HedgeRatio}{h} % Units of basic asset held \newcommand{\StockHolding}{x^b} % Units of options held \newcommand{\OptionHolding}{x^o} % --- Interest Rates ------------------------------------------------------ % Bond convexity \newcommand{\Convexity}{C} % Interest rate / yield \newcommand{\Yields}{r} % Key rate change, basis points (scalar tenor component); used as \KeyRateChange_k or \KeyRateChange_{t,k}. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \Yields is defined earlier in generated output (same interest_rates category, TOML order), so expansion order is safe; verify this still holds if either entry is reordered. \newcommand{\KeyRateChange}{\Delta \Yields^{\mathrm{bp}}} % Key rate changes, basis points (vector); used bare or as \KeyRateChanges_t. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \bm{\Yields} depends on \bm/\boldsymbol correctly expanding a macro argument rather than a bare letter --- confirm this renders correctly the first time either consumer compiles it. \newcommand{\KeyRateChanges}{\Delta \bm{\Yields}^{\mathrm{bp}}} % Dollar value of a basis point \newcommand{\DV}{\text{DV01}} % Mean reversion speed (interest rate models) \newcommand{\MeanReversion}{\varkappa} % Modified duration \newcommand{\ModDur}{D} % Macaulay duration \newcommand{\MacDur}{D_{\text{Mac}}} % Effective duration \newcommand{\EffDur}{D_{\text{eff}}} % DV01 vector \newcommand{\DVvec}{\bm{d}} % Long-run mean rate (Vasicek/CIR/Hull-White) \newcommand{\LongRunRate}{\theta} % Hull-White time-varying drift \newcommand{\HullWhiteDrift}{\varphi} % Cash flow (coupon/principal payment) \newcommand{\CashFlow}{c} % --- Extreme Value ------------------------------------------------------- % Tail index (EVT, Pareto-type tails) \newcommand{\TailIndex}{\iota} % Shape parameter (xi = 1/iota) \newcommand{\ShapeParam}{\varsigma} % Threshold value (EVT) \newcommand{\Threshold}{u} % Extremal index (Leadbetter 1983) \newcommand{\ExtremalIndex}{\theta} % Multivariate extremal coefficient, theta in [1,d]. theta=1 complete tail dependence; theta=d asymptotic independence. Glyph collides with ExtremalIndex and ParamSet; disambiguated at source by macro name. \newcommand{\ExtremalCoef}{\theta} % GEV distribution function \newcommand{\GEV}{\mathcal{H}} % GPD distribution function \newcommand{\GPD}{\mathcal{G}} % GPD scale parameter \newcommand{\GPDscale}{\beta} % GPD scale parameter at a threshold u, distinct from the generic GPD scale \newcommand{\GPDscaleAtThreshold}{\beta_u} % Number of threshold exceedances (EVT) \newcommand{\TailCount}{C} % Sample maximum (block maxima, EVT) \newcommand{\SampleMax}{M} % Kurtosis value (realized, e.g. sample kurtosis) \newcommand{\KurtosisVal}{\kappa} % Pareto scaling constant (EVT) \newcommand{\ParetoConst}{\mathcal{A}} % Generic constant (EVT asymptotics) \newcommand{\RemainderConst}{\mathcal{C}} % Little-o asymptotic notation \newcommand{\LittleO}{o} % Moment order (EVT) \newcommand{\MomentOrder}{\mathscr{m}} % Doubled sample size (EVT block maxima) \newcommand{\DoubleSample}{D} % GEV normalizing location constant \newcommand{\GEVloc}{a} % GEV normalizing scale constant \newcommand{\GEVscale}{b} % Negative return (loss, EVT context) \newcommand{\NegativeReturn}{L} % --- Copulas ------------------------------------------------------------- % Copula function \newcommand{\Copula}{\mathcal{C}} % Copula density \newcommand{\CopulaDensity}{c} % Generator function (Archimedean copulas) \newcommand{\generator}{\varphi} % Copula dependence parameter \newcommand{\CopulaParam}{\theta} % Copula marginal parameter set \newcommand{\MarginalParamSet}{\eta} % Lower tail dependence coefficient \newcommand{\LowerTailDep}{\lambda_L} % Upper tail dependence coefficient \newcommand{\UpperTailDep}{\lambda_U} % Marginal CDF (second variable) \newcommand{\MarginalCDF}{G} % Joint density function \newcommand{\JointDensity}{h} % Marginal density function \newcommand{\MarginalDensity}{g} % Joint distribution function \newcommand{\JointCDF}{H} % Gaussian copula correlation parameter \newcommand{\CopulaCorr}{\rho} % --- Multivariate -------------------------------------------------------- % Diagonal matrix of conditional volatilities \newcommand{\DiagVolD}{\bm{D}} % Idiosyncratic variance matrix (diagonal, factor models) \newcommand{\IdioVarMatrix}{\bm{\Psi}} % Covariance matrix entry (two indices) \newcommand{\CovElement}{\sigma} % Auxiliary matrix in DCC dynamics \newcommand{\DCCauxQ}{\bm{Q}} % BEKK constant matrix \newcommand{\BEKKconst}{\bm{\Omega}} % BEKK ARCH parameter matrix \newcommand{\BEKKarch}{\bm{A}} % BEKK GARCH parameter matrix \newcommand{\BEKKgarch}{\bm{B}} % DCC rescaling diagonal matrix \newcommand{\DCCrescaleZ}{\bm{Z}} % DCC auxiliary matrix element \newcommand{\DCCelement}{q} % Factor loadings matrix (loadings/eigenvectors of the covariance matrix) \newcommand{\FactorLoadings}{\bm{\Lambda}} % Factor subscript label \newcommand{\factor}{\text{factor}} % PCA eigenvalue \newcommand{\Eigenvalue}{\lambda} % Number of factors in factor model \newcommand{\NumberFactors}{m} % Factor return vector \newcommand{\FactorReturn}{\bm{g}} % PCA factor score (scalar; not an eigenvector) \newcommand{\PCAFactor}{F} % BEKK cross-covariance coefficient \newcommand{\BEKKcross}{\delta} % Factor loading element \newcommand{\FactorLoading}{\Lambda} % DCC correlation persistence parameter \newcommand{\DCCxi}{\xi} % DCC news coefficient (weight on recent shocks) \newcommand{\DCCzeta}{\zeta} % --- Simulation ---------------------------------------------------------- % Number of simulation paths \newcommand{\NumberSims}{B} % Cholesky factor of covariance matrix \newcommand{\Cholesky}{\bm{L}} % Uniform random number \newcommand{\UniformDraw}{\tilde{u}} % --- Backtesting --------------------------------------------------------- % Violation ratio (observed/expected violations) \DeclareMathOperator{\ViolRatio}{VR} % Stressed Value at Risk \DeclareMathOperator{\StressedVaR}{SVaR} % QLIKE loss function for variance forecasts \DeclareMathOperator{\QLIKE}{QLIKE} % Mean squared error (forecast loss function) \DeclareMathOperator{\MSE}{MSE} % Mean absolute error (forecast loss function) \DeclareMathOperator{\MAE}{MAE} % Markov transition probability \newcommand{\TransProb}{\pi} % Exception indicator: 1 if y_t < -VaR_t \newcommand{\ExceptionInd}{\eta} % Exception count \newcommand{\ExceptionCount}{\upsilon} % Probability Integral Transform \newcommand{\ProbIntegralTransform}{\hat{u}} % Quantile score (pinball loss function) \newcommand{\QuantileScore}{\mathcal{S}} % Kolmogorov-Smirnov test statistic \newcommand{\KSstat}{D} % Test statistic (generic base letter) \newcommand{\TestStat}{J} % --- General ------------------------------------------------------------- % Regression intercept \newcommand{\RegressionIntercept}{a} % Regression slope \newcommand{\RegressionSlope}{\beta} % Risk factor \newcommand{\RiskFactor}{x} % Forecast horizon \newcommand{\ForecastHorizon}{h} % Block length (bootstrap) \newcommand{\BlockLength}{\ell} % Capital tau variant \newcommand{\DeliveryTime}{\Upsilon} % Today's calendar time in years (option pricing input; distinct from trading-date index t) \newcommand{\CalendarTime}{t^*} % Probability measure \newcommand{\ProbMeasure}{\mathbb{P}} % Risk-neutral measure \newcommand{\RiskNeutral}{\mathbb{Q}} % Pricing function (Black-Scholes, bond pricing, etc.) \newcommand{\PricingFn}{V} % Indicator function \newcommand{\Indicator}{\bm{1}} % Vector of ones \newcommand{\OnesVector}{\bm{1}} % Risk factor sensitivity (first-order P&L) \newcommand{\Sensitivity}{\delta} % Second-order sensitivity (convexity/gamma) \newcommand{\ConvexitySens}{\gamma} % Output floor percentage (Basel) \newcommand{\OutputFloor}{\alpha} % --- Subscript Labels ---------------------------------------------------- % Subscript label: annualized \newcommand{\Annual}{a} % Subscript label: implied \newcommand{\Implied}{I} % Subscript label: portfolio \newcommand{\Portfolio}{\pi} % --- Text Abbreviations -------------------------------------------- % S&P 500 index \newcommand{\SP}{\text{S\&P-500}} % Student-t distribution (text) \newcommand{\St}{\text{Student-t}} % Geopolitical Risk index (Caldara and Iacoviello) \newcommand{\GPR}{\text{GPR}} \]

21  Manual estimation

Specialised libraries made estimation easy in Chapter 18. They also hid the machinery. This chapter opens that box.

Two pieces come out of it, the optimisation algorithm and the likelihood function. Knowing how each works is what lets us diagnose an estimation that fails and adapt a model the packages do not cover, which in quantitative finance is most of them.

21.1 Data and libraries

An optimiser in each language, since we are no longer using the packages’ own estimation routines.

source("common/functions.r", chdir = TRUE)
library(nloptr)
import numpy as np
from scipy.optimize import minimize
from scipy.special import gammaln
import sys
sys.path.insert(0, 'common')
from functions import ProcessRawData
using CSV, DataFrames, Dates, Statistics
using Optim
using SpecialFunctions
using Printf
include("common/functions.jl");
data = ProcessRawData()
y_vec = na.omit(data$sp500$y)
y_vec = y_vec - mean(y_vec)
data = ProcessRawData()
y = data['sp500']['y'].dropna().values
y = y - np.mean(y)
data_jl = ProcessRawData();
y_jl = collect(skipmissing(data_jl["sp500"].y));
y_jl = y_jl .- mean(y_jl);

21.2 Optimisers

Maximum likelihood estimation searches for the parameter values that maximise the likelihood function. The algorithm that conducts that search is called an optimiser.

21.2.2 Optimisers minimise

Optimisers minimise functions by default. Since we need to maximise the likelihood, we return the negative log-likelihood.

21.2.3 Ensuring positive parameters

The parameters that go into the likelihood function must stay non-negative, with \(\GARCHconst\) strictly positive so that the variance recursion cannot collapse to zero (the degrees of freedom \(\DOF\) carries its own constraint, \(\DOF > 2\), once the GARCH-t likelihood arrives). There are at least two ways to ensure non-negativity:

  1. Take the absolute value of the parameters inside the likelihood function
  2. Impose constraints when calling the optimising function (bounds)

Only R’s implementation sets explicit bounds, through nloptr. Python and Julia use unbounded Nelder-Mead and rely on the absolute-value reflection inside the likelihood, together with a large penalty value whenever the computed \(\Vol_t^2 \leq 0\).

The reflection has a cost. abs() puts a kink in the objective at zero and gives every sign-flipped parameter vector the same likelihood, so the optimiser searches a space of mirrored duplicate optima. That is harmless for a derivative-free method such as Nelder-Mead. R keeps the reflection alongside its bounds as a defensive redundancy, though bounded Nelder-Mead never proposes a point outside lb = c(0, 0, 0), so abs() never has anything to correct there.

R provides the built-in optim() function, which uses Nelder-Mead by default. For more advanced optimisation, the nloptr package provides access to the NLopt library with many algorithms.

# Built-in optim with Nelder-Mead
res = optim(c(0.000001, 0.1, 0.85), fn = likelihood_function)

# NLopt with bounds
opts = list("algorithm" = "NLOPT_LN_NELDERMEAD", "xtol_rel" = 1.0e-8, maxeval = 1e4)
res = nloptr(x0 = c(0.000001, 0.1, 0.85),
             eval_f = likelihood_function,
             lb = c(0, 0, 0),
             ub = c(1, 1, 1),
             opts = opts)

Python’s scipy.optimize module provides various optimisation algorithms. The minimize function is the main interface.

# Nelder-Mead (no bounds)
res = minimize(likelihood_function, x0=[0.000001, 0.1, 0.85], method='Nelder-Mead')

# L-BFGS-B (with bounds)
res = minimize(likelihood_function, x0=[0.000001, 0.1, 0.85], method='L-BFGS-B',
               bounds=[(1e-10, 1), (1e-10, 1), (1e-10, 1)])

Julia’s Optim.jl package provides optimisation algorithms. The optimize function is the main interface.

# Nelder-Mead (no bounds)
res = optimize(likelihood_function, [0.000001, 0.1, 0.85], NelderMead())

# L-BFGS with box constraints
res = optimize(likelihood_function, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
               [0.000001, 0.1, 0.85], Fminbox(LBFGS()))

21.3 Coding the likelihood

Coding the likelihood by hand is what lets us change the model — a custom distribution or an extra term in the variance equation needs only a new likelihood function.

21.3.1 Likelihood issues

  • Value of \(\Vol_1^2\): We need to assign a value to \(\Vol_1^2\), the initial value of the conditional variance. In general, we choose the unconditional variance of the data for this.
  • Initial values of the parameters: Optimisation works iteratively. We assign initial values for the parameters, making sure they satisfy the function’s restrictions and the parameter bounds. This will have a small effect on the parameter values but can have a large effect on computing time.

We also need to pick an optimisation method and algorithm.

21.3.2 Normal GARCH likelihood

The log-likelihood function for a normal GARCH(1,1) model is:

\[ \begin{split} \log\lik &= \underbrace{-\frac{\SampleSize-1}{2}\log(2\pi)}_{\text{Constant}} \\ &\quad - \frac{1}{2}\sum_{t=2}^{\SampleSize}\left(\log(\GARCHconst + \ARCHcoeff \CompoundReturns^2_{t-1} + \GARCHcoeff \Vol^2_{t-1}) + \frac{\CompoundReturns^2_t}{\GARCHconst + \ARCHcoeff \CompoundReturns^2_{t-1} + \GARCHcoeff \Vol^2_{t-1}}\right) \end{split} \]

21.3.3 GARCH-t likelihood

The Student-t density is given by \[ \frac{\Gamma\left(\frac{\DOF+1}{2}\right)}{\sqrt{\DOF\pi}\Gamma\left(\frac{\DOF}{2}\right)} \left(1+\frac{x^2}{\DOF}\right)^{-\frac{\DOF+1}{2}} \]

Start with the Student-t density. Then standardise it and express it with time-varying volatility:

\[ \frac{1}{\Vol_t} \frac{\Gamma\left(\frac{\DOF+1}{2}\right)}{\sqrt{(\DOF-2)\pi}\Gamma\left(\frac{\DOF}{2}\right)} \left(1+\frac{\CompoundReturns_t^2}{\Vol_t^2(\DOF-2)}\right)^{-\frac{\DOF+1}{2}} \]

Taking logs, the constant part is: \[ \log\Gamma\left(\frac{\DOF+1}{2}\right) - \frac{1}{2}\log((\DOF-2)\pi) - \log\Gamma\left(\frac{\DOF}{2}\right) \]

The time-varying part is: \[ -\log\Vol_t - \frac{\DOF+1}{2}\left(\log\left(\Vol_t^2(\DOF-2)+\CompoundReturns_t^2\right) - \log\left(\Vol_t^2(\DOF-2)\right)\right) \]

Summing over \(t = 2, \ldots, \SampleSize\) gives the full log-likelihood: \[ \log\lik = \underbrace{(\SampleSize-1)\left(\log\Gamma\left(\frac{\DOF+1}{2}\right) - \frac{1}{2}\log((\DOF-2)\pi) - \log\Gamma\left(\frac{\DOF}{2}\right)\right)}_{\text{Constant}} + \sum_{t=2}^{\SampleSize}\left(-\log\Vol_t - \frac{\DOF+1}{2}\left(\log\left(\Vol_t^2(\DOF-2)+\CompoundReturns_t^2\right) - \log\left(\Vol_t^2(\DOF-2)\right)\right)\right) \]

21.4 Implementation

This is where the formulas become an estimator. We combine the likelihood with the optimiser and deal with the parts that usually cause trouble.

21.4.1 Practical considerations

One practical issue arises before estimation starts. The optimiser calls the likelihood function many times, so we pre-calculate squared returns, sample size and variance outside the loop.

Parameter positivity is handled as described in Section 21.2.3.

21.4.2 Normal GARCH estimation

y2 = y_vec^2
sigma2_init = var(y_vec)
T_len = length(y2)

LL_GARCH = function(par) {
  par = abs(par)
  omega = par[1]
  alpha = par[2]
  beta = par[3]
  sigma2 = sigma2_init
  loglikelihood = 0
  for (i in 2:T_len) {
    sigma2 = omega + alpha * y2[i-1] + beta * sigma2
    if (sigma2 <= 0) return(1e10)
    loglikelihood = loglikelihood + log(sigma2) + y2[i] / sigma2
  }
  loglikelihood = -0.5 * loglikelihood - (T_len - 1) / 2 * log(2 * pi)
  return(-loglikelihood)
}

opts = list("algorithm" = "NLOPT_LN_NELDERMEAD", "xtol_rel" = 1.0e-8, maxeval = 1e5)
res_garch = nloptr(x0 = c(0.000001, 0.1, 0.85),
                   eval_f = LL_GARCH,
                   lb = c(0, 0, 0),
                   ub = c(1, 1, 1),
                   opts = opts)
if (res_garch$status < 0) warning("Normal GARCH nloptr did not converge: status ", res_garch$status)

cat("Normal GARCH(1,1) Parameters:\n")
cat("Omega:", sprintf("%.6f", res_garch$solution[1]), "\n")
cat("Alpha:", sprintf("%.6f", res_garch$solution[2]), "\n")
cat("Beta: ", sprintf("%.6f", res_garch$solution[3]), "\n")
cat("Log-likelihood:", sprintf("%.2f", -res_garch$objective), "\n")

r_loglik_garch = -res_garch$objective
Normal GARCH(1,1) Parameters:
Omega: 0.000002 
Alpha: 0.105267 
Beta:  0.879347 
Log-likelihood: 29234.41
y2 = y**2
sigma2_init = np.var(y, ddof=1)
T_len = len(y2)

def LL_GARCH(par):
    par = np.abs(par)
    omega, alpha, beta = par
    sigma2 = sigma2_init
    loglikelihood = 0
    for i in range(1, T_len):
        sigma2 = omega + alpha * y2[i-1] + beta * sigma2
        if sigma2 <= 0:
            return 1e10
        loglikelihood += np.log(sigma2) + y2[i] / sigma2
    loglikelihood = -0.5 * loglikelihood - (T_len - 1) / 2 * np.log(2 * np.pi)
    return -loglikelihood

res_garch = minimize(LL_GARCH, x0=[0.000001, 0.1, 0.85], method='Nelder-Mead',
                     options={'xatol': 1e-8, 'fatol': 1e-8, 'maxiter': 10000})
if not res_garch.success:
    print(f"Warning: Normal GARCH did not converge: {res_garch.message}")

print("Normal GARCH(1,1) Parameters:")
print(f"Omega: {np.abs(res_garch.x[0]):.6f}")
print(f"Alpha: {np.abs(res_garch.x[1]):.6f}")
print(f"Beta:  {np.abs(res_garch.x[2]):.6f}")
print(f"Log-likelihood: {-res_garch.fun:.2f}")

py_loglik_garch = -res_garch.fun
Normal GARCH(1,1) Parameters:
Omega: 0.000002
Alpha: 0.105267
Beta:  0.879347
Log-likelihood: 29234.41
using Statistics, Optim, Printf
y2_jl = y_jl.^2;
h_jl = var(y_jl);
N_jl = length(y2_jl);

function gLikelihoodA(theta, y2, h)
    o = abs(theta[1])
    a = abs(theta[2])
    b = abs(theta[3])
    N = length(y2)
    lik = 0.0
    u = h
    for i in 2:N
        u = o + a * y2[i-1] + b * u
        if u <= 0 || !isfinite(u)
            return 1e10
        end
        lik += log(u) + y2[i] / u
    end
    nll = -(-0.5 * lik - (N - 1) * 0.5 * log(2 * pi))
    return isfinite(nll) ? nll : 1e10
end

lg(b) = gLikelihoodA(b, y2_jl, h_jl);
res_garch_jl = optimize(lg, [0.000001, 0.1, 0.85], NelderMead(),
                        Optim.Options(iterations = 100000));
if !Optim.converged(res_garch_jl)
    println("Warning: Normal GARCH did not converge")
end
params_garch = abs.(Optim.minimizer(res_garch_jl));
jl_loglik_garch = -Optim.minimum(res_garch_jl);

println("Normal GARCH(1,1) Parameters:")
@printf("Omega: %.6f\n", params_garch[1])
@printf("Alpha: %.6f\n", params_garch[2])
@printf("Beta:  %.6f\n", params_garch[3])
@printf("Log-likelihood: %.2f\n", jl_loglik_garch)
Normal GARCH(1,1) Parameters:
Omega: 0.000002
Alpha: 0.105267
Beta:  0.879347
Log-likelihood: 29234.41

21.4.3 GARCH-t estimation

LL_tGARCH = function(par) {
  par = abs(par)
  omega = par[1]
  alpha = par[2]
  beta = par[3]
  nu = par[4]
  n1 = nu + 1.0
  n2 = nu - 2.0
  sigma2 = sigma2_init
  loglikelihood = 0

  for (i in 2:T_len) {
    sigma2 = omega + alpha * y2[i-1] + beta * sigma2
    if (sigma2 <= 0) return(1e10)
    loglikelihood = loglikelihood + log(sigma2) +
                    n1 * (log(n2 * sigma2 + y2[i]) - log(n2 * sigma2))
  }
  loglikelihood = -loglikelihood / 2.0
  loglikelihood = loglikelihood + (T_len - 1) * (lgamma(n1/2) -
                                                  lgamma(nu/2) - 0.5 * log(pi * n2))
  return(-loglikelihood)
}

opts = list("algorithm" = "NLOPT_LN_NELDERMEAD", "xtol_rel" = 1.0e-8, maxeval = 1e5)
res_tgarch = nloptr(x0 = c(0.000001, 0.1, 0.85, 6),
                    eval_f = LL_tGARCH,
                    lb = c(0, 0, 0, 2.1),
                    ub = c(1, 1, 1, 100),
                    opts = opts)
if (res_tgarch$status < 0) warning("tGARCH nloptr did not converge: status ", res_tgarch$status)

cat("GARCH-t(1,1) Parameters:\n")
cat("Omega:", sprintf("%.6f", res_tgarch$solution[1]), "\n")
cat("Alpha:", sprintf("%.6f", res_tgarch$solution[2]), "\n")
cat("Beta: ", sprintf("%.6f", res_tgarch$solution[3]), "\n")
cat("Nu:   ", sprintf("%.6f", res_tgarch$solution[4]), "\n")
cat("Log-likelihood:", sprintf("%.2f", -res_tgarch$objective), "\n")

r_loglik_tgarch = -res_tgarch$objective
GARCH-t(1,1) Parameters:
Omega: 0.000001 
Alpha: 0.099790 
Beta:  0.894967 
Nu:    6.304794 
Log-likelihood: 29443.61
def LL_tGARCH(par):
    par = np.abs(par)
    omega, alpha, beta, nu = par
    if nu < 2.1 or nu > 100:
        return 1e10
    n1 = nu + 1.0
    n2 = nu - 2.0
    sigma2 = sigma2_init
    loglikelihood = 0

    for i in range(1, T_len):
        sigma2 = omega + alpha * y2[i-1] + beta * sigma2
        if sigma2 <= 0 or not np.isfinite(sigma2):
            return 1e10
        loglikelihood += np.log(sigma2) + n1 * (np.log(n2 * sigma2 + y2[i]) - np.log(n2 * sigma2))

    loglikelihood = -loglikelihood / 2.0
    loglikelihood += (T_len - 1) * (gammaln(n1/2) - gammaln(nu/2) - 0.5 * np.log(np.pi * n2))
    nll = -loglikelihood
    return nll if np.isfinite(nll) else 1e10

res_tgarch = minimize(LL_tGARCH, x0=[0.000001, 0.1, 0.85, 6], method='Nelder-Mead',
                      options={'xatol': 1e-8, 'fatol': 1e-8, 'maxiter': 10000})
if not res_tgarch.success:
    print(f"Warning: tGARCH did not converge: {res_tgarch.message}")

print("GARCH-t(1,1) Parameters:")
print(f"Omega: {np.abs(res_tgarch.x[0]):.6f}")
print(f"Alpha: {np.abs(res_tgarch.x[1]):.6f}")
print(f"Beta:  {np.abs(res_tgarch.x[2]):.6f}")
print(f"Nu:    {np.abs(res_tgarch.x[3]):.6f}")
print(f"Log-likelihood: {-res_tgarch.fun:.2f}")

py_loglik_tgarch = -res_tgarch.fun
GARCH-t(1,1) Parameters:
Omega: 0.000001
Alpha: 0.099790
Beta:  0.894967
Nu:    6.304796
Log-likelihood: 29443.61
using Optim, SpecialFunctions, Printf
function LL_tGARCH_jl(theta, y2, h, N)
    o = abs(theta[1])
    a = abs(theta[2])
    b = abs(theta[3])
    nu = abs(theta[4])
    # Constraint: nu in [2.1, 100], matching R's nloptr bounds
    if nu < 2.1 || nu > 100
        return 1e10
    end
    n1 = nu + 1.0
    n2 = nu - 2.0
    u = h
    loglik = 0.0
    for i in 2:N
        u = o + a * y2[i-1] + b * u
        if u <= 0 || !isfinite(u)
            return 1e10
        end
        loglik += log(u) + n1 * (log(n2 * u + y2[i]) - log(n2 * u))
    end
    loglik = -loglik / 2.0
    loglik += (N - 1) * (loggamma((nu + 1) / 2) - loggamma(nu / 2) - 0.5 * log(pi * (nu - 2)))
    nll = -loglik  # Negative log-likelihood for minimisation
    return isfinite(nll) ? nll : 1e10
end

# Use NelderMead
ll(b) = LL_tGARCH_jl(b, y2_jl, h_jl, N_jl);
res_tgarch_jl = optimize(ll, [0.000001, 0.1, 0.85, 6.0], NelderMead(),
                         Optim.Options(iterations = 100000));
if !Optim.converged(res_tgarch_jl)
    println("Warning: tGARCH did not converge")
end
params_tgarch = abs.(Optim.minimizer(res_tgarch_jl));
jl_loglik_tgarch = -Optim.minimum(res_tgarch_jl);

println("GARCH-t(1,1) Parameters:")
@printf("Omega: %.6f\n", params_tgarch[1])
@printf("Alpha: %.6f\n", params_tgarch[2])
@printf("Beta:  %.6f\n", params_tgarch[3])
@printf("Nu:    %.6f\n", params_tgarch[4])
@printf("Log-likelihood: %.2f\n", jl_loglik_tgarch)
GARCH-t(1,1) Parameters:
Omega: 0.000001
Alpha: 0.099788
Beta:  0.894968
Nu:    6.304814
Log-likelihood: 29443.61

21.5 Comparison

The table below compares the log-likelihood values across the three implementations. R uses box-bounded optimisation, so every parameter stays inside explicit lower and upper limits. Python and Julia instead use unbounded Nelder-Mead with absolute-value reflection and penalty terms in the likelihood. This keeps parameters non-negative and the degrees of freedom within range.

None of the three enforces covariance stationarity, \(\ARCHcoeff + \GARCHcoeff < 1\). R’s bounds constrain each parameter separately, not their sum, so the two coefficients can still sum above one. R, Python and Julia each report successful convergence for this sample, and the log-likelihoods agree to within rounding in the table below.

|Model             |        R|   Python|    Julia|
|:-----------------|--------:|--------:|--------:|
|Normal GARCH(1,1) | 29234.41| 29234.41| 29234.41|
|GARCH-t(1,1)      | 29443.61| 29443.61| 29443.61|

The Student-t model achieves a higher log-likelihood than the normal model. A more flexible model always fits at least as well as one it nests, and the normal GARCH is the \(\DOF \to \infty\) limit of the GARCH-t, so part of the gain is mechanical. The improvement here is large enough to point to fat tails. Financial returns produce extreme outcomes to which the normal distribution assigns too little probability. A likelihood ratio test or an information criterion, not the log-likelihood alone, is what would settle the question formally.

21.6 Exercise

Implement a manual GJR-GARCH(1,1) estimation. Modify the likelihood function to include the leverage parameter \(\GJRleverage\), and compare your results across the three languages. Discuss the additional optimisation challenges introduced by the asymmetric term.

The GJR-GARCH model is: \[ \Vol_t^2 = \GARCHconst + \ARCHcoeff \CompoundReturns_{t-1}^2 + \GJRleverage \CompoundReturns_{t-1}^2 \Indicator_{\CompoundReturns_{t-1}<0} + \GARCHcoeff \Vol_{t-1}^2 \]

where \(\Indicator_{\CompoundReturns_{t-1}<0}\) is an indicator function that equals 1 when returns are negative.