Cox-Ingersoll-Ross Ordinary Least Squares Estimation (cantaro86)¶
Background¶
cantaro86_cir_estimation.py
CIR process simulation (4 truncation schemes), Bessel-function density, OLS parameter estimation, and log-transform simulation.
Based on the CIR material (cells 37-60) in cantaro86's notebook "1.2 SDE simulations and statistics" from the repository Financial-Models-Numerical-Methods (https://github.com/cantaro86/Financial-Models-Numerical-Methods).
Adapted as a self-contained educational module -- no local imports required.
Contents¶
- Euler-Maruyama CIR simulation with four truncation methods: reflection, positive-part, full truncation, partial truncation.
- CIR density via modified Bessel functions (CIR_pdf) and equivalence check with scipy.stats.ncx2.
- OLS regression estimator for (kappa, theta, sigma): closed-form formulas + scipy.optimize.minimize verification.
- Log-transform simulation: Ito-lemma change of variable Y = log(X), Euler-Maruyama on Y, then recover X = exp(Y).
- Histogram vs density comparison with original and estimated parameters.
Code¶
```python """ cantaro86_cir_estimation.py
CIR process simulation (4 truncation schemes), Bessel-function density, OLS parameter estimation, and log-transform simulation.
Based on the CIR material (cells 37-60) in cantaro86's notebook "1.2 SDE simulations and statistics" from the repository Financial-Models-Numerical-Methods (https://github.com/cantaro86/Financial-Models-Numerical-Methods).
Adapted as a self-contained educational module -- no local imports required.
Contents¶
- Euler-Maruyama CIR simulation with four truncation methods: reflection, positive-part, full truncation, partial truncation.
- CIR density via modified Bessel functions (CIR_pdf) and equivalence check with scipy.stats.ncx2.
- OLS regression estimator for (kappa, theta, sigma): closed-form formulas + scipy.optimize.minimize verification.
- Log-transform simulation: Ito-lemma change of variable Y = log(X), Euler-Maruyama on Y, then recover X = exp(Y).
- Histogram vs density comparison with original and estimated parameters. """
import numpy as np import scipy.stats as ss from scipy.special import iv # modified Bessel function, 1st kind from scipy.optimize import minimize import matplotlib.pyplot as plt
============================================================================¶
1. CIR process simulation -- Euler-Maruyama with 4 truncation methods¶
============================================================================¶
def simulate_cir_euler( kappa, theta, sigma, X0, T, N, paths, method="reflection", seed=42 ): """ Simulate the CIR process via Euler-Maruyama with a chosen truncation scheme to handle the square-root of potentially negative values.
The CIR SDE is:
dX_t = kappa * (theta - X_t) dt + sigma * sqrt(X_t) dW_t
Parameters
----------
kappa : float
Mean reversion speed.
theta : float
Long-run mean level.
sigma : float
Volatility coefficient.
X0 : float
Initial value of the process.
T : float
Terminal time.
N : int
Number of time steps (N-1 increments after the initial value).
paths : int
Number of independent Monte-Carlo paths.
method : str
Truncation method. One of:
'reflection' -- method 4: take abs of the whole update
'positive_part' -- method 1: use max(X, 0) under sqrt only
'full_truncation' -- method 2: use max(X, 0) in both drift
and diffusion
'partial_truncation'-- method 3: use abs(X) under sqrt only
seed : int or None
Random seed for reproducibility.
Returns
-------
T_vec : ndarray, shape (N,)
Time grid.
X : ndarray, shape (N, paths)
Simulated paths (rows = time steps, columns = paths).
"""
if seed is not None:
np.random.seed(seed)
T_vec, dt = np.linspace(0, T, N, retstep=True)
W = ss.norm.rvs(loc=0, scale=np.sqrt(dt), size=(N - 1, paths))
X = np.zeros((N, paths))
X[0, :] = X0
for t in range(N - 1):
Xt = X[t, :]
if method == "reflection":
# Method 4: X_{i+1} = | X_i + drift + diffusion |
X[t + 1, :] = np.abs(
Xt + kappa * (theta - Xt) * dt + sigma * np.sqrt(Xt) * W[t, :]
)
elif method == "positive_part":
# Method 1: sqrt uses X^+ only; result may still be negative
Xt_pos = np.maximum(Xt, 0.0)
X[t + 1, :] = (
Xt + kappa * (theta - Xt) * dt
+ sigma * np.sqrt(Xt_pos) * W[t, :]
)
elif method == "full_truncation":
# Method 2: both drift and diffusion use X^+
Xt_pos = np.maximum(Xt, 0.0)
X[t + 1, :] = (
Xt + kappa * (theta - Xt_pos) * dt
+ sigma * np.sqrt(Xt_pos) * W[t, :]
)
elif method == "partial_truncation":
# Method 3: sqrt uses |X|; drift uses original X
X[t + 1, :] = (
Xt + kappa * (theta - Xt) * dt
+ sigma * np.sqrt(np.abs(Xt)) * W[t, :]
)
else:
raise ValueError(
f"Unknown method '{method}'. Choose from: reflection, "
"positive_part, full_truncation, partial_truncation."
)
return T_vec, X
============================================================================¶
2. CIR density via modified Bessel functions¶
============================================================================¶
def CIR_pdf(x, x0, T, k, t, s): """ Probability density of the CIR process X_T | X_0 = x0.
Uses the modified Bessel function of the first kind I_q from
scipy.special.iv.
The density is:
f(x | x0) = c * exp(-u - v) * (v/u)^{q/2} * I_q(2*sqrt(u*v))
with:
c = 2*kappa / ((1 - exp(-kappa*T)) * sigma^2)
q = 2*kappa*theta / sigma^2 - 1
u = c * x0 * exp(-kappa*T)
v = c * x
Parameters
----------
x : array_like
Points at which to evaluate the density.
x0 : float
Initial value.
T : float
Time horizon.
k, t, s : float
kappa, theta, sigma of the CIR process.
Returns
-------
ndarray
Density values at each x.
Reference
---------
Cox, J.C., Ingersoll, J.E., and Ross, S.A.
"A Theory of the Term Structure of Interest Rates."
Econometrica, Vol. 53, No. 2 (March, 1985).
"""
c = 2 * k / ((1 - np.exp(-k * T)) * s ** 2)
q = 2 * k * t / s ** 2 - 1
u = c * x0 * np.exp(-k * T)
v = c * x
return c * np.exp(-u - v) * (v / u) ** (q / 2) * iv(q, 2 * np.sqrt(u * v))
def CIR_ncx2_pdf(x, x0, T, kappa, theta, sigma): """ Same density as CIR_pdf but computed through scipy.stats.ncx2.
The CIR transition density is a scaled non-central chi-squared:
X_T ~ Y / (2c) with Y ~ ncx2(df=K, nc=lambda, scale=1)
Parameterisation (scipy convention):
c = 2*kappa / ((1 - exp(-kappa*T)) * sigma^2)
K = 4*kappa*theta / sigma^2 (degrees of freedom)
lambda = 2*c*x0*exp(-kappa*T) (non-centrality)
scale = 1 / (2*c)
Parameters
----------
x : array_like
Points at which to evaluate the density.
x0 : float
Initial value.
T : float
Time horizon.
kappa, theta, sigma : float
CIR parameters.
Returns
-------
ndarray
Density values at each x.
"""
c = 2 * kappa / ((1 - np.exp(-kappa * T)) * sigma ** 2)
df = 4 * kappa * theta / sigma ** 2 # degrees of freedom K
nc = 2 * c * x0 * np.exp(-kappa * T) # non-centrality lambda
return ss.ncx2.pdf(x, df, nc, scale=1 / (2 * c))
============================================================================¶
3. OLS parameter estimation for CIR¶
============================================================================¶
def estimate_cir_ols(X_path, dt): """ Estimate CIR parameters (kappa, theta, sigma) from a single observed path using the OLS (Ordinary Least Squares) regression method.
Derivation
----------
Divide the discrete CIR equation by sqrt(X_i):
(X_{i+1} - X_i) / sqrt(X_i) = kappa*theta*dt / sqrt(X_i)
- kappa*sqrt(X_i)*dt
+ sigma * DeltaW_i
Define:
Y = (X_{i+1} - X_i) / sqrt(X_i) -- response
X1 = dt / sqrt(X_i) -- regressor 1
X2 = dt * sqrt(X_i) -- regressor 2
Then:
Y = a * (1/sqrt(X_i)) + b * sqrt(X_i) + noise
with a = kappa*theta*dt, b = -kappa*dt.
Minimising the sum of squared residuals yields closed-form OLS
formulas for kappa and theta (implemented below).
sigma is estimated from the standard deviation of the residuals:
sigma_hat = std(residuals, ddof=2) / sqrt(dt)
The choice ddof=2 is conventional in linear regression because
two parameters (a and b) have already been estimated.
Parameters
----------
X_path : ndarray, shape (N,)
A single realised CIR path.
dt : float
Time step between consecutive observations.
Returns
-------
kappa_hat : float
Estimated mean-reversion speed.
theta_hat : float
Estimated long-run mean.
sigma_hat : float
Estimated volatility.
"""
N = len(X_path)
# Closed-form OLS formulas for kappa and theta
# (derived by solving the 2x2 normal equations of the linear regression)
X_curr = X_path[:-1] # X_0, ..., X_{N-2}
X_next = X_path[1:] # X_1, ..., X_{N-1}
n = N - 1 # number of increments
sum_Xnext = np.sum(X_next)
sum_Xcurr = np.sum(X_curr)
sum_inv_Xcurr = np.sum(1.0 / X_curr)
sum_ratio = np.sum(X_next / X_curr)
num_kappa = (
n ** 2
+ sum_Xnext * sum_inv_Xcurr
- sum_Xcurr * sum_inv_Xcurr
- n * sum_ratio
)
den_kappa = (n ** 2 - sum_Xcurr * sum_inv_Xcurr) * dt
kappa_hat = num_kappa / den_kappa
theta_hat = (
(n * sum_Xnext - sum_ratio * sum_Xcurr) / num_kappa
)
# Residuals and sigma estimation
YY = (X_next - X_curr) / np.sqrt(X_curr) # response
XX1 = 1.0 / np.sqrt(X_curr) # regressor 1
XX2 = np.sqrt(X_curr) # regressor 2
residuals = YY - theta_hat * kappa_hat * dt * XX1 + dt * kappa_hat * XX2
sigma_hat = np.std(residuals, ddof=2) / np.sqrt(dt)
return kappa_hat, theta_hat, sigma_hat
def estimate_cir_ols_minimize(X_path, dt): """ Verify the OLS estimates by numerically minimising the sum of squared residuals with scipy.optimize.minimize (Nelder-Mead).
This should reproduce the closed-form OLS result from
estimate_cir_ols() up to optimiser tolerance.
Parameters
----------
X_path : ndarray, shape (N,)
A single realised CIR path.
dt : float
Time step between consecutive observations.
Returns
-------
kappa_opt : float
Numerically estimated kappa.
theta_opt : float
Numerically estimated theta.
result : scipy.optimize.OptimizeResult
Full optimiser result object.
"""
X_curr = X_path[:-1]
X_next = X_path[1:]
YY = (X_next - X_curr) / np.sqrt(X_curr)
XX1 = 1.0 / np.sqrt(X_curr)
XX2 = np.sqrt(X_curr)
def sum_of_squares(c):
"""Objective: sum of squared residuals."""
kappa_trial, theta_trial = c
predicted = theta_trial * kappa_trial * dt * XX1 - kappa_trial * dt * XX2
return np.sum((YY - predicted) ** 2)
result = minimize(
sum_of_squares,
x0=[1.0, 1.0],
method="Nelder-Mead",
tol=1e-8,
)
kappa_opt, theta_opt = result.x
return kappa_opt, theta_opt, result
============================================================================¶
4. Log-transform simulation¶
============================================================================¶
def simulate_cir_log_transform(kappa, theta, sigma, X0, T, N, paths, seed=42): """ Simulate the CIR process via a log-variable change of variable.
Let Y_t = log(X_t). Applying Ito's lemma to f(x) = log(x):
dY_t = exp(-Y_t) * [ kappa*(theta - exp(Y_t)) - sigma^2/2 ] dt
+ sigma * exp(-Y_t/2) dW_t
Euler-Maruyama on Y:
Y_{i+1} = Y_i
+ exp(-Y_i) * [ kappa*(theta - exp(Y_i)) - 0.5*sigma^2 ] * dt
+ sigma * exp(-Y_i/2) * DeltaW_i
The original process is recovered as X_t = exp(Y_t).
Advantages
----------
- Y can be negative, so no square-root-of-negative issue arises.
- Under the same random seed the path matches the direct simulation
(reflection method) when dt is small.
Caveats
-------
- For coarse grids (small N) or large sigma, exp(Y) can overflow.
- Not universally more accurate than direct truncation methods.
Parameters
----------
kappa, theta, sigma, X0 : float
CIR parameters and initial value.
T : float
Terminal time.
N : int
Number of time-grid points.
paths : int
Number of Monte-Carlo paths.
seed : int or None
Random seed.
Returns
-------
T_vec : ndarray, shape (N,)
Time grid.
X : ndarray, shape (N, paths)
Simulated CIR paths (exp of the log-variable).
Y : ndarray, shape (N, paths)
Simulated log-paths (before exponentiation).
"""
if seed is not None:
np.random.seed(seed)
T_vec, dt = np.linspace(0, T, N, retstep=True)
W = ss.norm.rvs(loc=0, scale=np.sqrt(dt), size=(N - 1, paths))
Y0 = np.log(X0)
Y = np.zeros((N, paths))
Y[0, :] = Y0
for t in range(N - 1):
Yt = Y[t, :]
exp_neg_Y = np.exp(-Yt)
Y[t + 1, :] = (
Yt
+ exp_neg_Y * (kappa * (theta - np.exp(Yt)) - 0.5 * sigma ** 2) * dt
+ sigma * np.exp(-Yt / 2) * W[t, :]
)
X = np.exp(Y)
return T_vec, X, Y
============================================================================¶
Main demonstration¶
============================================================================¶
if name == "main": # ------------------------------------------------------------------ # True CIR parameters (cantaro86 defaults for the interest-rate case) # ------------------------------------------------------------------ kappa = 3.0 theta = 0.04 sigma = 0.25 X0 = 0.05 T = 3.0 N = 200_001 # fine grid for accurate simulation n_paths = 2000
feller_satisfied = 2 * kappa * theta > sigma ** 2
std_asy = np.sqrt(theta * sigma ** 2 / (2 * kappa))
print("=" * 70)
print("CIR ESTIMATION AND LOG-TRANSFORM SIMULATION")
print(" Based on cantaro86, 'SDE simulations and statistics'")
print("=" * 70)
print()
print("True parameters")
print(f" kappa = {kappa}")
print(f" theta = {theta}")
print(f" sigma = {sigma}")
print(f" X0 = {X0}")
print(f" T = {T}")
print(f" Feller condition (2*kappa*theta > sigma^2): {feller_satisfied}")
print(f" Asymptotic std dev = {std_asy:.6f}")
print()
# ==================================================================
# Part 1 -- Direct simulation (reflection method)
# ==================================================================
print("-" * 70)
print("PART 1: Direct Euler-Maruyama simulation (reflection method)")
print("-" * 70)
T_vec, X = simulate_cir_euler(
kappa, theta, sigma, X0, T, N, n_paths,
method="reflection", seed=42,
)
dt = T_vec[1] - T_vec[0]
X_T = X[-1, :] # terminal values across all paths
X_1 = X[:, 0] # single representative path
print(f" Simulated {n_paths} paths with {N} time steps (dt = {dt:.2e})")
print(f" E[X_T] = {X_T.mean():.6f} (asymptotic: {theta})")
print(f" Std[X_T]= {X_T.std():.6f} (asymptotic: {std_asy:.6f})")
print()
# ==================================================================
# Part 2 -- CIR density via Bessel vs ncx2
# ==================================================================
print("-" * 70)
print("PART 2: CIR density -- Bessel function vs scipy.stats.ncx2")
print("-" * 70)
x_grid = np.linspace(0.001, 0.12, 200)
pdf_bessel = CIR_pdf(x_grid, X0, T, kappa, theta, sigma)
pdf_ncx2 = CIR_ncx2_pdf(x_grid, X0, T, kappa, theta, sigma)
max_diff = np.max(np.abs(pdf_bessel - pdf_ncx2))
print(f" Max |CIR_pdf - ncx2_pdf| = {max_diff:.2e} (should be ~0)")
print()
# ==================================================================
# Part 3 -- OLS parameter estimation
# ==================================================================
print("-" * 70)
print("PART 3: OLS parameter estimation from a single path")
print("-" * 70)
kappa_ols, theta_ols, sigma_ols = estimate_cir_ols(X_1, dt)
print(" Closed-form OLS estimates:")
print(f" kappa_hat = {kappa_ols:.6f} (true: {kappa})")
print(f" theta_hat = {theta_ols:.6f} (true: {theta})")
print(f" sigma_hat = {sigma_ols:.6f} (true: {sigma})")
print()
# Verify with scipy.optimize.minimize
kappa_opt, theta_opt, opt_result = estimate_cir_ols_minimize(X_1, dt)
print(" Numerical verification (Nelder-Mead minimiser):")
print(f" kappa_opt = {kappa_opt:.6f}")
print(f" theta_opt = {theta_opt:.6f}")
print(f" Optimiser success: {opt_result.success}")
print()
print(" Parameter comparison:")
print(f" {'Param':<8} {'True':>10} {'OLS':>10} {'Minimise':>10}")
print(f" {'-'*8} {'-'*10} {'-'*10} {'-'*10}")
print(f" {'kappa':<8} {kappa:>10.4f} {kappa_ols:>10.4f} {kappa_opt:>10.4f}")
print(f" {'theta':<8} {theta:>10.4f} {theta_ols:>10.4f} {theta_opt:>10.4f}")
print(f" {'sigma':<8} {sigma:>10.4f} {sigma_ols:>10.4f} {'--':>10}")
print()
print(" NOTE: kappa is the most volatile parameter to estimate.")
print(" Changing the random seed will show that theta and sigma are")
print(" quite stable, whereas kappa can vary considerably.")
print()
# ==================================================================
# Part 4 -- Log-transform simulation
# ==================================================================
print("-" * 70)
print("PART 4: Log-transform simulation (Y = log X, Euler on Y)")
print("-" * 70)
T_vec_log, X_log, Y_log = simulate_cir_log_transform(
kappa, theta, sigma, X0, T, N, n_paths, seed=42,
)
# Compare first path of direct vs log-transform simulation
X_1_log = X_log[:, 0]
max_path_diff = np.max(np.abs(X_1 - X_1_log))
print(f" Max |X_direct - exp(Y)| on path 0 = {max_path_diff:.6e}")
print(" (Same seed -> paths should match closely for fine grids)")
print()
print(" Caveat: for coarse grids or large sigma, exp(Y) can")
print(" overflow to Inf / NaN. The log-transform is the best")
print(" theoretical approach, but the exponential creates practical")
print(" difficulties. (See cantaro86, cell 60.)")
print()
# ==================================================================
# Part 5 -- Histogram vs density with original & estimated params
# ==================================================================
print("-" * 70)
print("PART 5: Histogram vs CIR density (original & estimated params)")
print("-" * 70)
print(" Generating comparison plot ...")
x_hist = np.linspace(max(X_T.min(), 1e-4), X_T.max(), 200)
fig, ax = plt.subplots(figsize=(12, 6))
ax.hist(
X_T, bins=70, density=True,
facecolor="LightBlue", edgecolor="gray", alpha=0.7,
label="Simulated $X_T$ histogram",
)
ax.plot(
x_hist, CIR_pdf(x_hist, X0, T, kappa, theta, sigma),
"r-", linewidth=2.5, label="CIR density (true params)",
)
ax.plot(
x_hist, CIR_pdf(x_hist, X0, T, kappa_ols, theta_ols, sigma_ols),
"g--", linewidth=2.5, label="CIR density (OLS params)",
)
ax.set_xlabel("$X_T$", fontsize=12)
ax.set_ylabel("Density", fontsize=12)
ax.set_title(
"CIR Terminal Distribution: Histogram vs Analytical Density",
fontsize=13, fontweight="bold",
)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
# Parameter annotation
param_text = (
f"True: $\\kappa$={kappa}, $\\theta$={theta}, $\\sigma$={sigma}\n"
f"OLS: $\\hat{{\\kappa}}$={kappa_ols:.3f}, "
f"$\\hat{{\\theta}}$={theta_ols:.4f}, "
f"$\\hat{{\\sigma}}$={sigma_ols:.4f}"
)
ax.text(
0.98, 0.95, param_text,
transform=ax.transAxes, fontsize=10,
verticalalignment="top", horizontalalignment="right",
bbox=dict(boxstyle="round,pad=0.4", facecolor="wheat", alpha=0.8),
)
plt.tight_layout()
plt.savefig("/tmp/cantaro86_cir_estimation.png", dpi=150)
print(" Figure saved: /tmp/cantaro86_cir_estimation.png")
plt.close()
# ------------------------------------------------------------------
# Single path comparison: direct vs log-transform
# ------------------------------------------------------------------
fig2, ax2 = plt.subplots(figsize=(12, 5))
ax2.plot(T_vec, X_1, linewidth=1.0, alpha=0.9, label="Direct (reflection)")
ax2.plot(
T_vec_log, X_1_log,
linewidth=1.0, alpha=0.9, linestyle="--",
label="Log-transform exp(Y)",
)
ax2.axhline(theta, color="k", linestyle=":", linewidth=1, label=f"Long-run mean $\\theta$={theta}")
ax2.axhline(
theta + std_asy, color="gray", linestyle=":", linewidth=0.8,
label=f"$\\theta \\pm$ asymptotic std",
)
ax2.axhline(theta - std_asy, color="gray", linestyle=":", linewidth=0.8)
ax2.set_xlabel("Time", fontsize=11)
ax2.set_ylabel("$X_t$", fontsize=12)
ax2.set_title(
"CIR Path: Direct Euler vs Log-Transform (same seed)",
fontsize=13, fontweight="bold",
)
ax2.legend(fontsize=10, loc="upper right")
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("/tmp/cantaro86_cir_log_transform.png", dpi=150)
print(" Figure saved: /tmp/cantaro86_cir_log_transform.png")
plt.close()
# ------------------------------------------------------------------
# Truncation method comparison
# ------------------------------------------------------------------
methods = ["reflection", "positive_part", "full_truncation", "partial_truncation"]
fig3, axes = plt.subplots(2, 2, figsize=(14, 10))
for ax_i, method in zip(axes.ravel(), methods):
_, X_m = simulate_cir_euler(
kappa, theta, sigma, X0, T, N, 1,
method=method, seed=42,
)
ax_i.plot(T_vec, X_m[:, 0], linewidth=0.8)
ax_i.axhline(theta, color="r", linestyle="--", linewidth=1, alpha=0.6)
ax_i.set_title(f"Method: {method}", fontsize=11, fontweight="bold")
ax_i.set_xlabel("Time")
ax_i.set_ylabel("$X_t$")
ax_i.grid(True, alpha=0.3)
plt.suptitle(
"CIR Euler-Maruyama: 4 Truncation Methods (single path, same seed)",
fontsize=14, fontweight="bold",
)
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.savefig("/tmp/cantaro86_cir_truncation_methods.png", dpi=150)
print(" Figure saved: /tmp/cantaro86_cir_truncation_methods.png")
plt.close()
print()
print("=" * 70)
print("Done.")
print("=" * 70)
```
Exercises¶
Exercise 1. The CIR model \(dr = \kappa(\theta - r)\,dt + \sigma\sqrt{r}\,dW\) can be estimated from time series of short rates. Write the discrete OLS regression form \(r_{t+1} - r_t = a + b \cdot r_t + \epsilon_t\).
Solution to Exercise 1
Discretizing: \(r_{t+1} - r_t \approx \kappa\theta\Delta t - \kappa r_t \Delta t + \sigma\sqrt{r_t}\Delta W_t\). Setting \(a = \kappa\theta\Delta t\) and \(b = -\kappa\Delta t\): \(\Delta r_t = a + b r_t + \epsilon_t\) where \(\epsilon_t \sim N(0, \sigma^2 r_t \Delta t)\). OLS gives estimates \(\hat{a}, \hat{b}\), from which \(\hat{\kappa} = -\hat{b}/\Delta t\) and \(\hat{\theta} = \hat{a}/(\hat{\kappa}\Delta t)\).
Exercise 2. OLS assumes homoscedastic errors, but CIR has \(\text{Var}(\epsilon_t) = \sigma^2 r_t \Delta t\) (heteroscedastic). Explain how WLS (weighted least squares) corrects this.
Solution to Exercise 2
WLS uses weights \(w_t = 1/(\sigma^2 r_t \Delta t) \propto 1/r_t\). This downweights observations with high variance (high \(r_t\)) and upweights low-variance observations. The WLS estimator is BLUE (best linear unbiased estimator) under the CIR heteroscedasticity structure, while OLS is unbiased but inefficient.
Exercise 3. From daily Federal Funds rate data, you estimate \(\hat{\kappa} = 0.15\), \(\hat{\theta} = 0.03\), \(\hat{\sigma} = 0.05\). Compute the half-life of mean reversion and check the Feller condition.
Solution to Exercise 3
Half-life \(= \ln(2)/\kappa = 0.693/0.15 = 4.62\) years. Feller: \(2\kappa\theta = 2(0.15)(0.03) = 0.009\) vs \(\sigma^2 = 0.0025\). Since \(0.009 > 0.0025\), the Feller condition is satisfied.
Exercise 4. Explain why maximum likelihood estimation (MLE) is preferred over OLS for CIR parameter estimation.
Solution to Exercise 4
MLE exploits the known transition density of the CIR process (non-central chi-squared), properly accounting for heteroscedasticity and the positivity constraint. OLS ignores these features, potentially producing biased estimates when rates are near zero. MLE also provides standard errors and confidence intervals via the Fisher information matrix.