Skip to content

Volatility Tracking Kalman/GARCH (cantaro86)

Background

cantaro86_volatility_tracking.py Volatility Tracking: Kalman Filter vs GARCH vs Rolling Variance

Credits

Based on notebook "5.3 Volatility tracking" from: cantaro86, "Financial Models Numerical Methods" (FMNM) https://github.com/cantaro86/Financial-Models-Numerical-Methods

Adapted as a SELF-CONTAINED educational module for the "Quant Finance with Python" course (Chapter 17 -- Calibration).

Topics covered

  1. Heston stochastic volatility path simulation (Euler scheme).
  2. GARCH(1,1) model: parameter estimation and variance forecasting.
  3. Rolling variance estimation.
  4. Kalman filter for log-variance tracking using Taylor's (1986) linearisation: log(R^2) ≈ h + log(chi^2_1), where h is the latent log-variance.
  5. Rauch-Tung-Striebel (RTS) smoother for smoothed volatility.
  6. MSE comparison of all methods.

Key insight: The Kalman filter for volatility tracking uses a DIFFERENT model from the Kalman regression (see cantaro86_kalman_filter.md). Here the hidden state is log-variance, and observations are log-squared-returns.

References: [1] Taylor, S.J. (1986) "Modelling Financial Time Series", Wiley. [2] Harvey, Ruiz, Shephard (1994) "Multivariate SV models", Review of Economic Studies. [3] Bollerslev (1986) "Generalized autoregressive conditional heteroskedasticity", J. Econometrics.


Code

```python

!/usr/bin/env python3

-- coding: utf-8 --

""" cantaro86_volatility_tracking.py Volatility Tracking: Kalman Filter vs GARCH vs Rolling Variance

Credits

Based on notebook "5.3 Volatility tracking" from: cantaro86, "Financial Models Numerical Methods" (FMNM) https://github.com/cantaro86/Financial-Models-Numerical-Methods

Adapted as a SELF-CONTAINED educational module for the "Quant Finance with Python" course (Chapter 17 -- Calibration).

Topics covered

  1. Heston stochastic volatility path simulation (Euler scheme).
  2. GARCH(1,1) model: parameter estimation and variance forecasting.
  3. Rolling variance estimation.
  4. Kalman filter for log-variance tracking using Taylor's (1986) linearisation: log(R^2) ≈ h + log(chi^2_1), where h is the latent log-variance.
  5. Rauch-Tung-Striebel (RTS) smoother for smoothed volatility.
  6. MSE comparison of all methods.

Key insight: The Kalman filter for volatility tracking uses a DIFFERENT model from the Kalman regression (see cantaro86_kalman_filter.md). Here the hidden state is log-variance, and observations are log-squared-returns.

References: [1] Taylor, S.J. (1986) "Modelling Financial Time Series", Wiley. [2] Harvey, Ruiz, Shephard (1994) "Multivariate SV models", Review of Economic Studies. [3] Bollerslev (1986) "Generalized autoregressive conditional heteroskedasticity", J. Econometrics. """

import numpy as np import scipy.stats as ss from scipy.integrate import quad from scipy.optimize import minimize import matplotlib.pyplot as plt

============================================================================

1. HESTON PROCESS PATH SIMULATION

============================================================================

def heston_path(S0, v0, mu, kappa, theta, sigma, rho, N, T): """ Simulate one path of the Heston stochastic volatility model using the Euler scheme with full truncation.

    dS_t = mu * S_t * dt + sqrt(v_t) * S_t * dW_1
    dv_t = kappa * (theta - v_t) * dt + sigma * sqrt(v_t) * dW_2
    corr(dW_1, dW_2) = rho

Parameters
----------
S0 : float     Initial stock price.
v0 : float     Initial variance.
mu : float     Drift.
kappa : float  Mean reversion speed.
theta : float  Long-run variance.
sigma : float  Vol-of-vol.
rho : float    Correlation.
N : int        Number of time points.
T : float      Time horizon (years).

Returns
-------
S : ndarray    Stock price path (length N).
V : ndarray    Variance path (length N).
dt : float     Time step.
"""
dt = T / (N - 1)
S = np.zeros(N)
V = np.zeros(N)
S[0] = S0
V[0] = v0

for i in range(N - 1):
    Z1 = np.random.randn()
    Z2 = rho * Z1 + np.sqrt(1 - rho**2) * np.random.randn()
    v_pos = max(V[i], 0)
    sqrt_v = np.sqrt(v_pos)

    S[i + 1] = S[i] * np.exp(
        (mu - 0.5 * v_pos) * dt + sqrt_v * np.sqrt(dt) * Z1
    )
    V[i + 1] = abs(
        V[i] + kappa * (theta - v_pos) * dt
        + sigma * sqrt_v * np.sqrt(dt) * Z2
    )

return S, V, dt

============================================================================

2. GARCH(1,1) MODEL

============================================================================

class GARCH: """ GARCH(1,1) model for variance forecasting.

The conditional variance follows:
    sigma^2_t = omega + alpha * R^2_{t-1} + beta * sigma^2_{t-1}

Reparametrised with:
    gamma = 1 - alpha - beta
    omega = gamma * V_L       (V_L = long-run variance)

So the model becomes:
    sigma^2_t = gamma * V_L + alpha * R^2_{t-1} + beta * sigma^2_{t-1}

Parameters
----------
VL : float     Long-run variance.
alpha : float  Weight on lagged squared return.
beta : float   Weight on lagged variance.
"""

def __init__(self, VL=None, alpha=None, beta=None):
    self.VL = VL
    self.alpha = alpha
    self.beta = beta
    self.gamma = None
    if alpha is not None and beta is not None:
        self.gamma = 1 - alpha - beta

def generate_var(self, R, R0=None, var0=None):
    """
    Generate conditional variance series from return data.

    Parameters
    ----------
    R : ndarray    Return series.
    R0 : float     Previous return (for initialisation).
    var0 : float   Initial variance.

    Returns
    -------
    var_series : ndarray  Conditional variance at each step.
    """
    N = len(R)
    var_series = np.zeros(N)

    if var0 is None:
        var0 = self.VL
    if R0 is None:
        R0 = 0.0

    omega = self.gamma * self.VL
    var_series[0] = omega + self.alpha * R0**2 + self.beta * var0

    for t in range(1, N):
        var_series[t] = omega + self.alpha * R[t - 1]**2 + self.beta * var_series[t - 1]

    return var_series

def log_likelihood(self, R, last_var=False):
    """
    Compute the log-likelihood of the return series under GARCH(1,1).

    Assumes R_t | F_{t-1} ~ N(0, sigma^2_t).

    Parameters
    ----------
    R : ndarray    Return series.
    last_var : bool  If True, also return the last variance.

    Returns
    -------
    ll : float     Log-likelihood value.
    last_v : float (optional)  Last conditional variance.
    """
    N = len(R)
    omega = self.gamma * self.VL

    var_t = R[0]**2  # initialise with squared first return
    ll = -0.5 * (np.log(2 * np.pi) + np.log(var_t) + R[0]**2 / var_t)

    for t in range(1, N):
        var_t = omega + self.alpha * R[t - 1]**2 + self.beta * var_t
        if var_t <= 0:
            var_t = 1e-12
        ll += -0.5 * (np.log(2 * np.pi) + np.log(var_t) + R[t]**2 / var_t)

    if last_var:
        return ll, var_t
    return ll

def fit_from_data(self, R, disp=False):
    """
    Estimate GARCH(1,1) parameters by MLE.

    Optimises over (VL, alpha, beta) subject to alpha + beta < 1
    and all parameters positive.

    Parameters
    ----------
    R : ndarray   Return series.
    disp : bool   Print parameter estimates.
    """
    def neg_ll(x):
        self.VL = x[0]
        self.alpha = x[1]
        self.beta = x[2]
        self.gamma = 1 - x[1] - x[2]
        if self.gamma <= 0:
            return 1e10
        ll = self.log_likelihood(R)
        return -ll

    sample_var = np.var(R)
    x0 = [sample_var, 0.1, 0.85]
    bounds = [(1e-15, None), (1e-15, 0.999), (1e-15, 0.999)]

    result = minimize(neg_ll, x0=x0, method="L-BFGS-B",
                      bounds=bounds, tol=1e-8)

    self.VL = result.x[0]
    self.alpha = result.x[1]
    self.beta = result.x[2]
    self.gamma = 1 - self.alpha - self.beta

    if disp:
        print(f"  GARCH(1,1) MLE estimates:")
        print(f"    V_L = {self.VL:.8f}")
        print(f"    alpha = {self.alpha:.4f}")
        print(f"    beta = {self.beta:.4f}")
        print(f"    gamma = {self.gamma:.4f}")
        print(f"    alpha + beta = {self.alpha + self.beta:.4f}")

============================================================================

3. ROLLING VARIANCE

============================================================================

def rolling_variance(returns, window, dt=1.0): """ Compute rolling MLE variance estimate.

Parameters
----------
returns : ndarray  Return series.
window : int       Rolling window size.
dt : float         Time step (for annualisation).

Returns
-------
roll_var : ndarray  Rolling variance (NaN for initial window).
"""
N = len(returns)
roll_var = np.full(N, np.nan)
for i in range(window - 1, N):
    chunk = returns[i - window + 1: i + 1]
    roll_var[i] = np.var(chunk, ddof=0) / dt
return roll_var

============================================================================

4. KALMAN FILTER FOR LOG-VARIANCE TRACKING

============================================================================

Taylor's (1986) model for volatility tracking:

Observation model:

R_t = sqrt(v_t) * e_t, e_t ~ N(0, 1)

Taking logs of squared returns:

log(R_t^2) = log(v_t) + log(e_t^2)

= h_t + log(chi^2_1)

where h_t = log(v_t) is the latent log-variance.

The log-chi-squared(1) random variable has:

E[log(chi^2_1)] ≈ -1.27

Var[log(chi^2_1)] ≈ pi^2/2

After centering: Y_t = log(R_t^2) + 1.27 ≈ h_t + N(0, pi^2/2)

State equation (AR(1) for log-variance):

h_t = phi * h_{t-1} + eta_t, eta_t ~ N(0, var_eta)

This is a linear Gaussian state-space model solvable by Kalman filter.

def log_chi2_density(x): """ Probability density of log(chi^2_1).

If Z ~ chi^2(1), then X = log(Z) has density:
    f(x) = (1/sqrt(2*pi)) * exp(x/2 - exp(x)/2)

Returns
-------
float  Density at x.
"""
return 1.0 / np.sqrt(2 * np.pi) * np.exp(x / 2 - np.exp(x) / 2)

def kalman_volatility(data, h0, P0, phi, var_eta, scale): """ 1-D Kalman filter for log-variance tracking.

Observation model:
    Y_k = h_k + epsilon_k,   epsilon_k ~ N(0, pi^2/2)
where Y_k = log(data_k^2) + 1.27 - log(scale^2).

State model:
    h_k = phi * h_{k-1} + eta_k,   eta_k ~ N(0, var_eta)

The parameter 'scale' absorbs any scaling mismatch between the
observed returns and the model variance.

Parameters
----------
data : ndarray   Return series (demeaned).
h0 : float       Initial log-variance estimate.
P0 : float       Initial uncertainty (variance of h0).
phi : float      AR(1) coefficient for log-variance.
var_eta : float  Process noise variance.
scale : float    Scaling parameter for returns.

Returns
-------
hs : ndarray     Filtered log-variance estimates.
Ps : ndarray     Filtered covariance estimates.
loglik : float   Log-likelihood of observations.
"""
Y = np.log(data**2)
N = len(Y)
hs = np.zeros(N)
Ps = np.zeros(N)

# Redefine Y with centering and scaling
Y = Y + 1.27 - np.log(scale**2)

h = h0
P = P0
log_2pi = np.log(2 * np.pi)
var_obs = 0.5 * np.pi**2  # variance of log(chi^2_1)
loglikelihood = 0.0

for k in range(N):
    # Prediction step
    h_p = phi * h
    P_p = phi**2 * P + var_eta

    # Innovation
    r = Y[k] - h_p
    S = P_p + var_obs
    KG = P_p / S  # Kalman gain

    # Update step
    h = h_p + KG * r
    P = P_p * (1 - KG)

    # Log-likelihood
    loglikelihood += -0.5 * (log_2pi + np.log(S) + r**2 / S)

    hs[k] = h
    Ps[k] = P

return hs, Ps, loglikelihood

def calibrate_kalman_volatility(train_data, h0, P0): """ Calibrate (phi, var_eta, scale) by Maximum Likelihood Estimation.

Parameters
----------
train_data : ndarray  Training return series.
h0 : float            Initial log-variance.
P0 : float            Initial uncertainty.

Returns
-------
params : tuple  (phi, var_eta, scale).
"""
def neg_loglik(c):
    _, _, ll = kalman_volatility(train_data, h0, P0, c[0], c[1], c[2])
    return -ll

result = minimize(
    neg_loglik, x0=[0.1, 1.0, 1.0],
    method="L-BFGS-B",
    bounds=[(-1, 1), (1e-15, None), (1e-15, None)],
    tol=1e-8,
)

return tuple(result.x)

def rts_smoother_1d(hs, Ps, phi, var_eta): """ Rauch-Tung-Striebel smoother for 1-D state (log-variance).

Parameters
----------
hs : ndarray     Filtered log-variance estimates.
Ps : ndarray     Filtered covariance estimates.
phi : float      AR(1) coefficient.
var_eta : float  Process noise variance.

Returns
-------
hs_smooth : ndarray  Smoothed log-variance estimates.
Ps_smooth : ndarray  Smoothed covariance estimates.
"""
N = len(hs)
hs_smooth = np.zeros(N)
Ps_smooth = np.zeros(N)
hs_smooth[-1] = hs[-1]
Ps_smooth[-1] = Ps[-1]

for k in range(N - 2, -1, -1):
    P_pred = phi**2 * Ps[k] + var_eta
    C = phi * Ps[k] / P_pred
    hs_smooth[k] = hs[k] + C * (hs_smooth[k + 1] - phi * hs[k])
    Ps_smooth[k] = Ps[k] + C**2 * (Ps_smooth[k + 1] - P_pred)

return hs_smooth, Ps_smooth

============================================================================

5. LOG-CHI-SQUARED APPROXIMATION ANALYSIS

============================================================================

def log_chi2_analysis(): """ Compute mean and variance of log(chi^2_1) and compare with Normal.

The key approximation: log(chi^2_1) ≈ N(-1.27, pi^2/2).
This is a reasonable approximation despite the asymmetry of
the log-chi-squared distribution.

Returns
-------
dict with mean_chi2, var_chi2.
"""
mean_val = quad(lambda x: x * log_chi2_density(x), -30, 10)[0]
var_val = quad(lambda x: (x - mean_val)**2 * log_chi2_density(x), -50, 10)[0]

print(f"  Log-Chi-Squared(1) distribution:")
print(f"    E[log(chi^2_1)]   = {mean_val:.4f}  (≈ -1.27)")
print(f"    Var[log(chi^2_1)] = {var_val:.4f}  (≈ pi^2/2 = {0.5 * np.pi**2:.4f})")

return {"mean_chi2": mean_val, "var_chi2": var_val}

============================================================================

COMPREHENSIVE DEMO

============================================================================

def demo_all(): """Run all volatility tracking demonstrations.""" np.random.seed(42)

# ---- Heston model parameters ----
S0, v0 = 100, 0.04
mu = 0.1
rho = -0.1
kappa = 5
theta = 0.04
sigma_v = 0.6
N = 2500
T = 10

# ---- 1. Simulate Heston path ----
print("=" * 60)
print("1. Heston Process Simulation")
print("=" * 60)

# Feller condition check
feller = 2 * kappa * theta - sigma_v**2
print(f"  Feller condition: 2*kappa*theta - sigma^2 = {feller:.4f}"
      f"  {'SATISFIED' if feller > 0 else 'VIOLATED'}")

S, V, dt = heston_path(S0, v0, mu, kappa, theta, sigma_v, rho, N, T)
T_vec = np.linspace(0, T, N)

# Asymptotic standard deviation of CIR process
std_asy = np.sqrt(theta * sigma_v**2 / (2 * kappa))
print(f"  Long-run variance theta = {theta}")
print(f"  Asymptotic std dev = {std_asy:.4f}")

# Plot
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 4))
ax1.plot(T_vec, S)
ax1.set_title("Heston Model: Stock Price")
ax1.set_xlabel("Time (years)")
ax1.set_ylabel("S")
ax1.grid(True, alpha=0.3)

ax2.plot(T_vec, V, label="Variance process")
ax2.axhline(theta, color="k", ls="--", label=f"theta = {theta}")
ax2.axhline(theta + std_asy, color="grey", ls=":", label=f"± 1 asy. std")
ax2.axhline(theta - std_asy, color="grey", ls=":")
ax2.set_title("Heston Model: Variance Process")
ax2.set_xlabel("Time (years)")
ax2.set_ylabel("v")
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# ---- 2. Log-return analysis ----
print("\n" + "=" * 60)
print("2. Log-Return Analysis")
print("=" * 60)

ret_log = np.log(S[1:] / S[:-1])
mean_log = ret_log.mean()
std_log = ret_log.std()
print(f"  Normalised mean = {mean_log / dt:.4f}")
print(f"  Normalised std  = {std_log / np.sqrt(dt):.4f}")
print(f"  Ito-corrected mean ≈ {mean_log / dt + 0.5 * theta:.4f}"
      f"  (expected mu = {mu})")

# Student-t fit
t_params = ss.t.fit(ret_log)
print(f"  Student-t fit: df = {t_params[0]:.2f}")

# ---- 3. Log-chi-squared approximation ----
print("\n" + "=" * 60)
print("3. Log-Chi-Squared Approximation")
print("=" * 60)
chi2_result = log_chi2_analysis()

x_plot = np.linspace(-15, 5, 200)
mean_c = chi2_result["mean_chi2"]
var_c = chi2_result["var_chi2"]

plt.figure(figsize=(10, 4))
plt.plot(x_plot, ss.norm.pdf(x_plot, loc=mean_c, scale=np.sqrt(var_c)),
         "b-", lw=2, label="Normal approximation")
plt.plot(x_plot, log_chi2_density(x_plot),
         "r-", lw=2, label="Log-Chi-Squared(1)")
plt.title("Normal Approximation to Log-Chi-Squared(1)")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# ---- 4. GARCH(1,1) ----
print("\n" + "=" * 60)
print("4. GARCH(1,1) Variance Tracking")
print("=" * 60)

train_size = 1500
train = ret_log[:train_size]
train = train - train.mean()  # detrend
test = ret_log[train_size:]

garch = GARCH()
garch.fit_from_data(train, disp=True)

ll_val = garch.log_likelihood(train)
print(f"  Log-likelihood = {ll_val:.2f}")

# Generate variance forecasts
var_train = garch.generate_var(R=train[1:], R0=train[0], var0=train[0]**2)
last_var = garch.generate_var(R=train[1:], R0=train[0], var0=train[0]**2)[-1]
var_test = garch.generate_var(R=test, R0=train[-1], var0=last_var)

# ---- 5. Rolling variance ----
print("\n" + "=" * 60)
print("5. Rolling Variance")
print("=" * 60)
window = 20
roll_var = rolling_variance(ret_log, window, dt)
print(f"  Rolling window = {window}")
print(f"  Mean rolling variance (test) = "
      f"{np.nanmean(roll_var[train_size:]):.6f}")

# ---- 6. Kalman filter for volatility ----
print("\n" + "=" * 60)
print("6. Kalman Filter for Log-Variance Tracking")
print("=" * 60)

h0 = np.log(train.var())  # initial log-variance from training data
P0 = 100  # large initial uncertainty

# Calibrate parameters
print("  Calibrating (phi, var_eta, scale) via MLE...")
phi, var_eta, scale = calibrate_kalman_volatility(train, h0, P0)
print(f"    phi = {phi:.4f}")
print(f"    var_eta = {var_eta:.6f}")
print(f"    scale = {scale:.4f}")

# Run Kalman filter on training set, then test set
hs_train, Ps_train, ll_train = kalman_volatility(
    train, h0, P0, phi, var_eta, scale)
hs_test, Ps_test, ll_test = kalman_volatility(
    test, hs_train[-1], Ps_train[-1], phi, var_eta, scale)

# Transform back to variance
V_kalm = np.exp(hs_test) / dt * scale**2
V_up = np.exp(hs_test + np.sqrt(Ps_test)) / dt * scale**2
V_down = np.exp(hs_test - np.sqrt(Ps_test)) / dt * scale**2

# RTS smoother
hs_smooth, Ps_smooth = rts_smoother_1d(hs_test, Ps_test, phi, var_eta)
V_smooth = np.exp(hs_smooth) / dt * scale**2

# ---- 7. Comparison ----
print("\n" + "=" * 60)
print("7. MSE Comparison (Out-of-Sample)")
print("=" * 60)

V_true_test = V[1 + train_size:]
min_len = min(len(V_true_test), len(V_kalm), len(var_test))
V_true_test = V_true_test[:min_len]
V_kalm_cmp = V_kalm[:min_len]
V_smooth_cmp = V_smooth[:min_len]
var_test_cmp = var_test[:min_len] / dt
roll_test = roll_var[train_size:train_size + min_len]

mse_kalman = np.mean((V_true_test - V_kalm_cmp)**2)
mse_smooth = np.mean((V_true_test - V_smooth_cmp)**2)
mse_garch = np.mean((V_true_test - var_test_cmp)**2)
valid_roll = ~np.isnan(roll_test)
mse_rolling = np.mean((V_true_test[valid_roll] - roll_test[valid_roll])**2)

print(f"  {'Method':<25s}  {'MSE':>12s}")
print(f"  {'-'*25}  {'-'*12}")
print(f"  {'Kalman Filter':<25s}  {mse_kalman:>12.6f}")
print(f"  {'RTS Smoother':<25s}  {mse_smooth:>12.6f}")
print(f"  {'GARCH(1,1)':<25s}  {mse_garch:>12.6f}")
print(f"  {'Rolling Variance (w=20)':<25s}  {mse_rolling:>12.6f}")

# Determine winner
results = {
    "Kalman": mse_kalman,
    "Smoother": mse_smooth,
    "GARCH": mse_garch,
    "Rolling": mse_rolling,
}
winner = min(results, key=results.get)
print(f"\n  Best method: {winner}")

# ---- Plots ----
# Plot 1: Kalman filter with confidence bands
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10))

ax1.plot(V_true_test, label="True Heston variance", linewidth=2,
         color="steelblue")
ax1.plot(V_kalm_cmp, label="Kalman filter", color="darksalmon",
         linewidth=1.5)
ax1.fill_between(range(min_len), V_up[:min_len], V_down[:min_len],
                 alpha=0.3, color="seagreen",
                 label=r"Kalman $\pm$ 1 std dev")
ax1.plot(V_smooth_cmp, label="RTS smoother", color="maroon",
         linewidth=1.5, ls="--")
ax1.set_title("Out-of-Sample: Kalman Filter & RTS Smoother")
ax1.set_xlabel("Time step")
ax1.set_ylabel("Variance")
ax1.legend()
ax1.grid(True, alpha=0.3)

# Plot 2: All methods comparison
ax2.plot(V_true_test, label="True Heston variance", linewidth=2,
         color="steelblue")
ax2.plot(V_kalm_cmp, label="Kalman filter", color="limegreen",
         linewidth=1.5)
ax2.plot(var_test_cmp, label="GARCH(1,1)", color="peru",
         linewidth=1.5, ls="--")
ax2.plot(roll_test, label=f"Rolling variance (w={window})",
         color="orchid", linewidth=1.5, ls="--", alpha=0.8)
ax2.set_title("Out-of-Sample: All Methods Comparison")
ax2.set_xlabel("Time step")
ax2.set_ylabel("Variance")
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# ---- Summary ----
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
print("  The Kalman filter for log-variance tracking (Taylor 1986):")
print("  - Uses the observation log(R^2) ≈ h + log(chi^2_1)")
print("  - Models h_t = phi * h_{t-1} + eta_t as an AR(1) state")
print("  - The log-chi^2(1) is approximated by N(-1.27, pi^2/2)")
print("  - Parameters (phi, var_eta, scale) calibrated by MLE")
print()
print("  Comparison with other methods:")
print("  - GARCH: parametric, fast, but can be sensitive to outliers")
print("  - Rolling: non-parametric but noisy and lagging")
print("  - Kalman: provides confidence bands and smooth estimates")
print("  - RTS smoother: best MSE (uses future data, not real-time)")

============================================================================

MAIN

============================================================================

if name == "main": demo_all() ```

Exercises

Exercise 1. Model calibration finds parameters that best fit market prices. Explain the difference between calibration to vanilla options and calibration to exotic options.

Solution to Exercise 1

Vanilla calibration fits to European calls/puts, which constrain the marginal distributions of \(S_T\) at each maturity. Exotic calibration must also match path-dependent features (barriers, autocallable triggers). A model calibrated to vanillas may misprice exotics if it has the wrong dynamics (e.g., local vol matches vanilla prices but mishandles forward smiles). Exotic calibration requires richer models (stochastic vol, jumps).


Exercise 2. Regularization adds a penalty term \(\lambda \|\Theta - \Theta_0\|^2\) to the calibration objective. Explain its purpose and how \(\lambda\) affects the result.

Solution to Exercise 2

Regularization prevents overfitting to noisy market data by penalizing large deviations from a prior \(\Theta_0\). Large \(\lambda\) pulls parameters toward \(\Theta_0\) (underfitting); small \(\lambda\) allows data-driven parameters (potential overfitting). Optimal \(\lambda\) balances fit quality with parameter stability. L-curve or cross-validation methods can select \(\lambda\).


Exercise 3. Calibration stability means small changes in market data produce small parameter changes. Explain why the Heston model can be unstable and how to improve stability.

Solution to Exercise 3

Instability arises from parameter correlations: \(\kappa\) and \(\theta\) are nearly interchangeable for short maturities (only \(\kappa\theta\) matters). Small data changes can cause large swings between \((\kappa, \theta)\) pairs with similar products. Remedies: (1) fix \(\kappa\) or \(\theta\) and calibrate the other; (2) regularize toward previous calibration; (3) use Tikhonov regularization; (4) calibrate to a well-conditioned reparametrization.


Exercise 4. Compare least-squares (price error), implied volatility error, and relative price error as calibration objectives. Which is most robust?

Solution to Exercise 4

Price error: simple but ATM-biased (large prices dominate). IV error: equal weighting across strikes but requires inverting BS for each model price (slow). Relative price error: normalizes by market price but unstable for small prices (OTM). Most robust: IV error with vega weighting, which balances all regions of the smile and has direct financial interpretation. The choice depends on the trading application.