stokestrel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kestrel/__init__.py +32 -0
- kestrel/base.py +77 -0
- kestrel/diffusion/__init__.py +12 -0
- kestrel/diffusion/brownian.py +404 -0
- kestrel/diffusion/cir.py +332 -0
- kestrel/diffusion/ou.py +362 -0
- kestrel/jump_diffusion/__init__.py +5 -0
- kestrel/jump_diffusion/merton.py +375 -0
- kestrel/utils/__init__.py +5 -0
- kestrel/utils/kestrel_result.py +67 -0
- stokestrel-0.1.0.dist-info/METADATA +234 -0
- stokestrel-0.1.0.dist-info/RECORD +15 -0
- stokestrel-0.1.0.dist-info/WHEEL +5 -0
- stokestrel-0.1.0.dist-info/licenses/LICENSE +21 -0
- stokestrel-0.1.0.dist-info/top_level.txt +1 -0
kestrel/diffusion/cir.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# kestrel/diffusion/cir.py
|
|
2
|
+
"""Cox-Ingersoll-Ross process implementation."""
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from scipy.optimize import minimize
|
|
7
|
+
from kestrel.base import StochasticProcess
|
|
8
|
+
from kestrel.utils.kestrel_result import KestrelResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CIRProcess(StochasticProcess):
|
|
12
|
+
"""
|
|
13
|
+
Cox-Ingersoll-Ross (CIR) Process.
|
|
14
|
+
|
|
15
|
+
Models mean-reverting dynamics with state-dependent volatility.
|
|
16
|
+
SDE: dX_t = kappa * (theta - X_t) dt + sigma * sqrt(X_t) dW_t
|
|
17
|
+
|
|
18
|
+
Feller condition (2*kappa*theta > sigma^2) ensures strict positivity.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
kappa : float, optional
|
|
23
|
+
Rate of mean reversion.
|
|
24
|
+
theta : float, optional
|
|
25
|
+
Long-term mean level.
|
|
26
|
+
sigma : float, optional
|
|
27
|
+
Volatility coefficient.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, kappa: float = None, theta: float = None, sigma: float = None):
|
|
31
|
+
super().__init__()
|
|
32
|
+
self.kappa = kappa
|
|
33
|
+
self.theta = theta
|
|
34
|
+
self.sigma = sigma
|
|
35
|
+
|
|
36
|
+
def fit(self, data: pd.Series, dt: float = None, method: str = 'mle'):
|
|
37
|
+
"""
|
|
38
|
+
Estimates (kappa, theta, sigma) from time-series data.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
data : pd.Series
|
|
43
|
+
Observed time-series (must be positive for CIR).
|
|
44
|
+
dt : float, optional
|
|
45
|
+
Time step between observations. Inferred from DatetimeIndex if None.
|
|
46
|
+
method : str
|
|
47
|
+
Estimation method: 'mle' or 'lsq'.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
ValueError
|
|
52
|
+
If data not pandas Series, contains non-positive values, or unknown method.
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(data, pd.Series):
|
|
55
|
+
raise ValueError("Input data must be a pandas Series.")
|
|
56
|
+
|
|
57
|
+
# CIR requires strictly positive data
|
|
58
|
+
if (data <= 0).any():
|
|
59
|
+
raise ValueError("CIR process requires strictly positive data.")
|
|
60
|
+
|
|
61
|
+
if dt is None:
|
|
62
|
+
dt = self._infer_dt(data)
|
|
63
|
+
|
|
64
|
+
self.dt_ = dt
|
|
65
|
+
|
|
66
|
+
if method == 'mle':
|
|
67
|
+
param_ses = self._fit_mle(data, dt)
|
|
68
|
+
elif method == 'lsq':
|
|
69
|
+
param_ses = self._fit_lsq(data, dt)
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"Unknown estimation method: {method}. Choose 'mle' or 'lsq'.")
|
|
72
|
+
|
|
73
|
+
self._set_params(
|
|
74
|
+
last_data_point=data.iloc[-1],
|
|
75
|
+
kappa=self.kappa,
|
|
76
|
+
theta=self.theta,
|
|
77
|
+
sigma=self.sigma,
|
|
78
|
+
dt=self.dt_,
|
|
79
|
+
param_ses=param_ses
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _infer_dt(self, data: pd.Series) -> float:
|
|
83
|
+
"""Infers dt from DatetimeIndex or defaults to 1.0."""
|
|
84
|
+
if isinstance(data.index, pd.DatetimeIndex):
|
|
85
|
+
if len(data.index) < 2:
|
|
86
|
+
return 1.0
|
|
87
|
+
|
|
88
|
+
inferred_timedelta = data.index[1] - data.index[0]
|
|
89
|
+
current_freq = pd.infer_freq(data.index)
|
|
90
|
+
if current_freq is None:
|
|
91
|
+
current_freq = 'B'
|
|
92
|
+
|
|
93
|
+
if current_freq in ['B', 'C', 'D']:
|
|
94
|
+
dt = inferred_timedelta / pd.Timedelta(days=252.0)
|
|
95
|
+
elif current_freq.startswith('W'):
|
|
96
|
+
dt = inferred_timedelta / pd.Timedelta(weeks=52)
|
|
97
|
+
elif current_freq in ['M', 'MS', 'BM', 'BMS']:
|
|
98
|
+
dt = inferred_timedelta / pd.Timedelta(days=365 / 12)
|
|
99
|
+
elif current_freq in ['Q', 'QS', 'BQ', 'BQS']:
|
|
100
|
+
dt = inferred_timedelta / pd.Timedelta(days=365 / 4)
|
|
101
|
+
elif current_freq in ['A', 'AS', 'BA', 'BAS', 'Y', 'YS', 'BY', 'BYS']:
|
|
102
|
+
dt = inferred_timedelta / pd.Timedelta(days=365)
|
|
103
|
+
else:
|
|
104
|
+
dt = inferred_timedelta.total_seconds() / (365 * 24 * 3600)
|
|
105
|
+
|
|
106
|
+
return max(dt, 1e-10)
|
|
107
|
+
return 1.0
|
|
108
|
+
|
|
109
|
+
def _fit_mle(self, data: pd.Series, dt: float) -> dict:
|
|
110
|
+
"""
|
|
111
|
+
Estimates CIR parameters using Maximum Likelihood.
|
|
112
|
+
|
|
113
|
+
Uses Gaussian approximation to conditional distribution for tractability.
|
|
114
|
+
"""
|
|
115
|
+
if len(data) < 2:
|
|
116
|
+
raise ValueError("MLE estimation requires at least 2 data points.")
|
|
117
|
+
|
|
118
|
+
x = data.values
|
|
119
|
+
n = len(x)
|
|
120
|
+
|
|
121
|
+
# Initial estimates via LSQ for starting values
|
|
122
|
+
x_t = x[:-1]
|
|
123
|
+
x_next = x[1:]
|
|
124
|
+
dx = x_next - x_t
|
|
125
|
+
|
|
126
|
+
# Regression: dx/sqrt(x_t) = (kappa*theta)/sqrt(x_t) - kappa*sqrt(x_t) + noise
|
|
127
|
+
# Rewrite as: dx = kappa*theta*dt - kappa*x_t*dt + sigma*sqrt(x_t)*dW
|
|
128
|
+
# Simple moment-based initial guess
|
|
129
|
+
mean_x = np.mean(x)
|
|
130
|
+
var_dx = np.var(dx)
|
|
131
|
+
|
|
132
|
+
kappa_0 = max(0.1, -np.cov(dx, x_t)[0, 1] / (np.var(x_t) * dt))
|
|
133
|
+
theta_0 = mean_x
|
|
134
|
+
sigma_0 = max(0.01, np.sqrt(var_dx / (mean_x * dt)))
|
|
135
|
+
|
|
136
|
+
initial_params = [kappa_0, theta_0, sigma_0]
|
|
137
|
+
bounds = [(1e-6, None), (1e-6, None), (1e-6, None)]
|
|
138
|
+
|
|
139
|
+
result = minimize(
|
|
140
|
+
self._neg_log_likelihood,
|
|
141
|
+
initial_params,
|
|
142
|
+
args=(x, dt),
|
|
143
|
+
bounds=bounds,
|
|
144
|
+
method='L-BFGS-B'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if result.success:
|
|
148
|
+
self.kappa, self.theta, self.sigma = result.x
|
|
149
|
+
else:
|
|
150
|
+
# Fallback to LSQ estimates
|
|
151
|
+
self._fit_lsq_internal(data, dt)
|
|
152
|
+
|
|
153
|
+
# Compute standard errors from inverse Hessian
|
|
154
|
+
param_ses = self._compute_standard_errors(result, ['kappa', 'theta', 'sigma'])
|
|
155
|
+
return param_ses
|
|
156
|
+
|
|
157
|
+
def _neg_log_likelihood(self, params, x, dt):
|
|
158
|
+
"""
|
|
159
|
+
Negative log-likelihood for CIR using Gaussian approximation.
|
|
160
|
+
|
|
161
|
+
Conditional distribution X_{t+dt} | X_t approximated as Gaussian
|
|
162
|
+
with mean and variance from Euler discretisation.
|
|
163
|
+
"""
|
|
164
|
+
kappa, theta, sigma = params
|
|
165
|
+
|
|
166
|
+
if kappa <= 0 or theta <= 0 or sigma <= 0:
|
|
167
|
+
return np.inf
|
|
168
|
+
|
|
169
|
+
n = len(x)
|
|
170
|
+
ll = 0.0
|
|
171
|
+
|
|
172
|
+
for i in range(1, n):
|
|
173
|
+
x_prev = x[i - 1]
|
|
174
|
+
x_curr = x[i]
|
|
175
|
+
|
|
176
|
+
# Conditional mean: E[X_{t+dt} | X_t] = X_t * exp(-kappa*dt) + theta*(1 - exp(-kappa*dt))
|
|
177
|
+
exp_kdt = np.exp(-kappa * dt)
|
|
178
|
+
mean_cond = x_prev * exp_kdt + theta * (1 - exp_kdt)
|
|
179
|
+
|
|
180
|
+
# Conditional variance (Euler approximation)
|
|
181
|
+
var_cond = (sigma ** 2 * x_prev * (1 - exp_kdt ** 2)) / (2 * kappa)
|
|
182
|
+
|
|
183
|
+
if var_cond <= 1e-12:
|
|
184
|
+
return np.inf
|
|
185
|
+
|
|
186
|
+
# Gaussian log-likelihood contribution
|
|
187
|
+
ll += -0.5 * np.log(2 * np.pi * var_cond)
|
|
188
|
+
ll += -0.5 * ((x_curr - mean_cond) ** 2 / var_cond)
|
|
189
|
+
|
|
190
|
+
return -ll
|
|
191
|
+
|
|
192
|
+
def _fit_lsq(self, data: pd.Series, dt: float) -> dict:
|
|
193
|
+
"""
|
|
194
|
+
Estimates CIR parameters using Least Squares regression.
|
|
195
|
+
|
|
196
|
+
Transforms CIR dynamics for linear regression.
|
|
197
|
+
"""
|
|
198
|
+
if len(data) < 2:
|
|
199
|
+
raise ValueError("LSQ estimation requires at least 2 data points.")
|
|
200
|
+
|
|
201
|
+
param_ses = self._fit_lsq_internal(data, dt)
|
|
202
|
+
return param_ses
|
|
203
|
+
|
|
204
|
+
def _fit_lsq_internal(self, data: pd.Series, dt: float) -> dict:
|
|
205
|
+
"""Internal LSQ implementation."""
|
|
206
|
+
x = data.values
|
|
207
|
+
n = len(x) - 1
|
|
208
|
+
|
|
209
|
+
x_t = x[:-1]
|
|
210
|
+
x_next = x[1:]
|
|
211
|
+
dx = x_next - x_t
|
|
212
|
+
|
|
213
|
+
# Regression: dx = alpha + beta * x_t + epsilon
|
|
214
|
+
# where alpha = kappa * theta * dt, beta = -kappa * dt
|
|
215
|
+
X_reg = np.vstack([np.ones(n), x_t]).T
|
|
216
|
+
beta_hat, residuals, rank, s = np.linalg.lstsq(X_reg, dx, rcond=None)
|
|
217
|
+
|
|
218
|
+
alpha, beta = beta_hat[0], beta_hat[1]
|
|
219
|
+
|
|
220
|
+
# Map to CIR parameters
|
|
221
|
+
self.kappa = max(1e-6, -beta / dt)
|
|
222
|
+
self.theta = alpha / (self.kappa * dt) if self.kappa > 1e-6 else np.mean(x)
|
|
223
|
+
|
|
224
|
+
# Estimate sigma from residual variance
|
|
225
|
+
epsilon = dx - (alpha + beta * x_t)
|
|
226
|
+
sigma_sq_eps = np.var(epsilon)
|
|
227
|
+
|
|
228
|
+
# For CIR: Var(epsilon) approx sigma^2 * E[X_t] * dt
|
|
229
|
+
mean_x = np.mean(x_t)
|
|
230
|
+
sigma_sq = sigma_sq_eps / (mean_x * dt) if mean_x > 0 else sigma_sq_eps / dt
|
|
231
|
+
self.sigma = max(1e-6, np.sqrt(sigma_sq))
|
|
232
|
+
|
|
233
|
+
# Compute standard errors
|
|
234
|
+
try:
|
|
235
|
+
cov_beta = np.linalg.inv(X_reg.T @ X_reg) * sigma_sq_eps
|
|
236
|
+
se_alpha = np.sqrt(cov_beta[0, 0])
|
|
237
|
+
se_beta = np.sqrt(cov_beta[1, 1])
|
|
238
|
+
|
|
239
|
+
# Delta method for kappa and theta
|
|
240
|
+
se_kappa = np.abs(-1 / dt) * se_beta
|
|
241
|
+
se_theta = se_alpha / (self.kappa * dt) if self.kappa > 1e-6 else np.nan
|
|
242
|
+
se_sigma = self.sigma / np.sqrt(2 * n)
|
|
243
|
+
except np.linalg.LinAlgError:
|
|
244
|
+
se_kappa, se_theta, se_sigma = np.nan, np.nan, np.nan
|
|
245
|
+
|
|
246
|
+
return {'kappa': se_kappa, 'theta': se_theta, 'sigma': se_sigma}
|
|
247
|
+
|
|
248
|
+
def _compute_standard_errors(self, result, param_names: list) -> dict:
|
|
249
|
+
"""Computes standard errors from optimisation result."""
|
|
250
|
+
param_ses = {}
|
|
251
|
+
if hasattr(result, 'hess_inv') and result.hess_inv is not None:
|
|
252
|
+
if callable(getattr(result.hess_inv, 'todense', None)):
|
|
253
|
+
cov_matrix = result.hess_inv.todense()
|
|
254
|
+
else:
|
|
255
|
+
cov_matrix = result.hess_inv
|
|
256
|
+
|
|
257
|
+
if cov_matrix.shape == (len(param_names), len(param_names)):
|
|
258
|
+
for i, name in enumerate(param_names):
|
|
259
|
+
param_ses[name] = np.sqrt(max(0, cov_matrix[i, i]))
|
|
260
|
+
else:
|
|
261
|
+
for name in param_names:
|
|
262
|
+
param_ses[name] = np.nan
|
|
263
|
+
else:
|
|
264
|
+
for name in param_names:
|
|
265
|
+
param_ses[name] = np.nan
|
|
266
|
+
|
|
267
|
+
return param_ses
|
|
268
|
+
|
|
269
|
+
def sample(self, n_paths: int = 1, horizon: int = 1, dt: float = None) -> KestrelResult:
|
|
270
|
+
"""
|
|
271
|
+
Simulates future paths using Euler-Maruyama method.
|
|
272
|
+
|
|
273
|
+
Uses reflection at zero to maintain positivity.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
n_paths : int
|
|
278
|
+
Number of simulation paths.
|
|
279
|
+
horizon : int
|
|
280
|
+
Number of time steps to simulate.
|
|
281
|
+
dt : float, optional
|
|
282
|
+
Simulation time step. Uses fitted dt if None.
|
|
283
|
+
|
|
284
|
+
Returns
|
|
285
|
+
-------
|
|
286
|
+
KestrelResult
|
|
287
|
+
Simulation results (all paths non-negative).
|
|
288
|
+
"""
|
|
289
|
+
if not self.is_fitted and any(p is None for p in [self.kappa, self.theta, self.sigma]):
|
|
290
|
+
raise RuntimeError("Model must be fitted or initialised with parameters before sampling.")
|
|
291
|
+
|
|
292
|
+
if dt is None:
|
|
293
|
+
dt = self._dt_ if self.is_fitted and hasattr(self, '_dt_') else 1.0
|
|
294
|
+
|
|
295
|
+
kappa = self.kappa_ if self.is_fitted else self.kappa
|
|
296
|
+
theta = self.theta_ if self.is_fitted else self.theta
|
|
297
|
+
sigma = self.sigma_ if self.is_fitted else self.sigma
|
|
298
|
+
|
|
299
|
+
if any(p is None for p in [kappa, theta, sigma]):
|
|
300
|
+
raise RuntimeError("CIR parameters must be set or estimated to sample.")
|
|
301
|
+
|
|
302
|
+
paths = np.zeros((horizon + 1, n_paths))
|
|
303
|
+
if self.is_fitted and hasattr(self, '_last_data_point'):
|
|
304
|
+
initial_val = self._last_data_point
|
|
305
|
+
else:
|
|
306
|
+
initial_val = theta
|
|
307
|
+
|
|
308
|
+
paths[0, :] = initial_val
|
|
309
|
+
|
|
310
|
+
for t in range(horizon):
|
|
311
|
+
dW = np.random.normal(loc=0.0, scale=np.sqrt(dt), size=n_paths)
|
|
312
|
+
sqrt_Xt = np.sqrt(np.maximum(0, paths[t, :]))
|
|
313
|
+
paths[t + 1, :] = paths[t, :] + kappa * (theta - paths[t, :]) * dt + sigma * sqrt_Xt * dW
|
|
314
|
+
# Reflection scheme for positivity
|
|
315
|
+
paths[t + 1, :] = np.abs(paths[t + 1, :])
|
|
316
|
+
|
|
317
|
+
return KestrelResult(pd.DataFrame(paths), initial_value=initial_val)
|
|
318
|
+
|
|
319
|
+
def feller_condition_satisfied(self) -> bool:
|
|
320
|
+
"""
|
|
321
|
+
Checks if Feller condition (2*kappa*theta > sigma^2) is satisfied.
|
|
322
|
+
|
|
323
|
+
Returns True if process is guaranteed to stay strictly positive.
|
|
324
|
+
"""
|
|
325
|
+
kappa = self.kappa_ if self.is_fitted else self.kappa
|
|
326
|
+
theta = self.theta_ if self.is_fitted else self.theta
|
|
327
|
+
sigma = self.sigma_ if self.is_fitted else self.sigma
|
|
328
|
+
|
|
329
|
+
if any(p is None for p in [kappa, theta, sigma]):
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
return 2 * kappa * theta > sigma ** 2
|
kestrel/diffusion/ou.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# kestrel/diffusion/ou.py
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
from scipy.optimize import minimize
|
|
5
|
+
from kestrel.base import StochasticProcess
|
|
6
|
+
from kestrel.utils.kestrel_result import KestrelResult
|
|
7
|
+
|
|
8
|
+
class OUProcess(StochasticProcess):
|
|
9
|
+
"""
|
|
10
|
+
Ornstein-Uhlenbeck (OU) Process.
|
|
11
|
+
|
|
12
|
+
Models mean-reverting stochastic dynamics via SDE:
|
|
13
|
+
dX_t = theta * (mu - X_t) dt + sigma dW_t
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
theta (float, optional): Mean reversion speed.
|
|
17
|
+
mu (float, optional): Long-term mean.
|
|
18
|
+
sigma (float, optional): Volatility.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, theta: float = None, mu: float = None, sigma: float = None):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.theta = theta
|
|
23
|
+
self.mu = mu
|
|
24
|
+
self.sigma = sigma
|
|
25
|
+
|
|
26
|
+
def fit(self, data: pd.Series, dt: float = None, method: str = 'mle', freq: str = None):
|
|
27
|
+
"""
|
|
28
|
+
Estimates (theta, mu, sigma) parameters from time-series data.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
data (pd.Series): Time-series data for fitting.
|
|
32
|
+
dt (float, optional): Time step between observations.
|
|
33
|
+
If None, inferred from data; else defaults to 1.0.
|
|
34
|
+
method (str): Estimation method: 'mle' (Exact Maximum Likelihood) or
|
|
35
|
+
'ar1' (AR(1) regression).
|
|
36
|
+
freq (str, optional): Data frequency if `data` has `DatetimeIndex`.
|
|
37
|
+
e.g., 'B' (business day), 'D' (calendar day).
|
|
38
|
+
Converts `dt` to annual basis. Inferred if None.
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: Input data not pandas Series or unknown estimation method.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(data, pd.Series):
|
|
43
|
+
raise ValueError("Input data must be a pandas Series.")
|
|
44
|
+
|
|
45
|
+
if dt is None:
|
|
46
|
+
if isinstance(data.index, pd.DatetimeIndex):
|
|
47
|
+
# Need at least two points for time difference from index
|
|
48
|
+
if len(data.index) < 2:
|
|
49
|
+
dt = 1.0
|
|
50
|
+
print("dt not provided, DatetimeIndex has less than 2 points. Defaulting to 1.0.")
|
|
51
|
+
else:
|
|
52
|
+
inferred_timedelta = data.index[1] - data.index[0]
|
|
53
|
+
# Determine frequency to convert timedelta to annual dt
|
|
54
|
+
current_freq = freq # Use provided freq
|
|
55
|
+
if current_freq is None:
|
|
56
|
+
if data.index.freq:
|
|
57
|
+
current_freq = data.index.freq
|
|
58
|
+
print(f"Using frequency from DatetimeIndex.freq: {current_freq}")
|
|
59
|
+
else:
|
|
60
|
+
inferred_freq = pd.infer_freq(data.index)
|
|
61
|
+
if inferred_freq:
|
|
62
|
+
current_freq = inferred_freq
|
|
63
|
+
print(f"Inferred frequency from DatetimeIndex: {current_freq}")
|
|
64
|
+
else:
|
|
65
|
+
print("Could not infer frequency from DatetimeIndex. Defaulting to business day ('B') for dt conversion.")
|
|
66
|
+
current_freq = 'B' # Default if inference fails
|
|
67
|
+
|
|
68
|
+
# Convert timedelta to numerical dt based on current_freq
|
|
69
|
+
if current_freq in ['B', 'C', 'D']: # Business day, Custom Business Day, Calendar Day
|
|
70
|
+
dt = inferred_timedelta / pd.Timedelta(days=252.0)
|
|
71
|
+
elif current_freq in ['W', 'W-SUN', 'W-MON', 'W-TUE', 'W-WED', 'W-THU', 'W-FRI', 'W-SAT']: # Weekly
|
|
72
|
+
dt = inferred_timedelta / pd.Timedelta(weeks=52)
|
|
73
|
+
elif current_freq in ['M', 'MS', 'BM', 'BMS']: # Monthly
|
|
74
|
+
dt = inferred_timedelta / pd.Timedelta(days=365/12)
|
|
75
|
+
elif current_freq in ['Q', 'QS', 'BQ', 'BQS']: # Quarterly
|
|
76
|
+
dt = inferred_timedelta / pd.Timedelta(days=365/4)
|
|
77
|
+
elif current_freq in ['A', 'AS', 'BA', 'BAS', 'Y', 'YS', 'BY', 'BYS']: # Annual
|
|
78
|
+
dt = inferred_timedelta / pd.Timedelta(days=365)
|
|
79
|
+
else: # Fallback for other frequencies, or if conversion ambiguous
|
|
80
|
+
dt = inferred_timedelta.total_seconds() / (365 * 24 * 3600)
|
|
81
|
+
print(f"Using total seconds for dt conversion for frequency '{current_freq}'. Consider explicit dt.")
|
|
82
|
+
|
|
83
|
+
if dt == 0: # Handle zero time_diffs
|
|
84
|
+
dt = 1.0
|
|
85
|
+
print(f"Inferred dt from DatetimeIndex: {dt} (annualized based on '{current_freq}' frequency)")
|
|
86
|
+
else: # Not DatetimeIndex
|
|
87
|
+
dt = 1.0
|
|
88
|
+
print("dt not provided; cannot infer from index. Defaulting to 1.0.")
|
|
89
|
+
|
|
90
|
+
self.dt_ = dt # Store dt used for fitting
|
|
91
|
+
|
|
92
|
+
if method == 'mle':
|
|
93
|
+
self._fit_mle(data, dt, freq)
|
|
94
|
+
elif method == 'ar1':
|
|
95
|
+
self._fit_ar1(data, dt, freq)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"Unknown estimation method: {method}. Choose 'mle' or 'ar1'.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fit_mle(self, data: pd.Series, dt: float, freq: str = None):
|
|
101
|
+
"""
|
|
102
|
+
Estimates OU parameters using Exact Maximum Likelihood Estimation.
|
|
103
|
+
Method based on transition density.
|
|
104
|
+
"""
|
|
105
|
+
if len(data) < 2:
|
|
106
|
+
raise ValueError("MLE estimation requires at least 2 data points.")
|
|
107
|
+
x = data.values
|
|
108
|
+
n = len(x)
|
|
109
|
+
|
|
110
|
+
# Initial parameter guess
|
|
111
|
+
# Based on approximate moment matching / AR(1) regression for start values
|
|
112
|
+
dx = np.diff(x)
|
|
113
|
+
|
|
114
|
+
# Approximate AR(1) parameters for initial guess
|
|
115
|
+
# Regression: dx = a + b*x + epsilon
|
|
116
|
+
X_reg = np.vstack([np.ones(n - 1), x[:-1]]).T
|
|
117
|
+
beta_hat = np.linalg.lstsq(X_reg, dx, rcond=None)[0]
|
|
118
|
+
a, b = beta_hat[0], beta_hat[1]
|
|
119
|
+
|
|
120
|
+
# Map AR(1) to OU for initial guess
|
|
121
|
+
theta_0 = -b / dt
|
|
122
|
+
mu_0 = -a / b if b != 0 else np.mean(x) # Fallback if b near zero
|
|
123
|
+
|
|
124
|
+
# Estimate sigma from residuals
|
|
125
|
+
residuals = dx - (a + b * x[:-1])
|
|
126
|
+
sigma_sq_0 = np.var(residuals) / dt # Rough initial sigma^2
|
|
127
|
+
sigma_0 = np.sqrt(sigma_sq_0) if sigma_sq_0 > 0 else 0.1
|
|
128
|
+
|
|
129
|
+
# Ensure theta_0 positive
|
|
130
|
+
theta_0 = max(0.01, theta_0)
|
|
131
|
+
|
|
132
|
+
initial_params = [theta_0, mu_0, sigma_0]
|
|
133
|
+
|
|
134
|
+
# Bounds for parameters: theta > 0, sigma > 0
|
|
135
|
+
bounds = [(1e-6, None), (None, None), (1e-6, None)]
|
|
136
|
+
|
|
137
|
+
# Use L-BFGS-B method as it supports bounds and can return approximation of inverse Hessian
|
|
138
|
+
result = minimize(self._log_likelihood_ou, initial_params, args=(x, dt), bounds=bounds, method='L-BFGS-B')
|
|
139
|
+
|
|
140
|
+
if result.success:
|
|
141
|
+
self.theta, self.mu, self.sigma = result.x
|
|
142
|
+
|
|
143
|
+
param_ses = {}
|
|
144
|
+
# Calculate standard errors from the inverse Hessian (covariance matrix approximation)
|
|
145
|
+
# The inverse Hessian is returned as result.hess_inv for 'L-BFGS-B'
|
|
146
|
+
if hasattr(result, 'hess_inv') and result.hess_inv is not None:
|
|
147
|
+
# Convert hess_inv (LinearOperator) to a dense matrix if it's not already
|
|
148
|
+
# hess_inv can be either a dense array or a LinearOperator
|
|
149
|
+
if callable(getattr(result.hess_inv, 'todense', None)):
|
|
150
|
+
cov_matrix = result.hess_inv.todense()
|
|
151
|
+
else: # Assume it's already a dense matrix or array
|
|
152
|
+
cov_matrix = result.hess_inv
|
|
153
|
+
|
|
154
|
+
# Check if cov_matrix is a square matrix of expected size
|
|
155
|
+
if cov_matrix.shape == (len(initial_params), len(initial_params)):
|
|
156
|
+
param_ses = {
|
|
157
|
+
'theta': np.sqrt(cov_matrix[0, 0]),
|
|
158
|
+
'mu': np.sqrt(cov_matrix[1, 1]),
|
|
159
|
+
'sigma': np.sqrt(cov_matrix[2, 2]),
|
|
160
|
+
}
|
|
161
|
+
else:
|
|
162
|
+
print("Warning: Hessian inverse shape mismatch, cannot calculate standard errors.")
|
|
163
|
+
else:
|
|
164
|
+
print("Warning: Could not retrieve Hessian inverse for standard error calculation.")
|
|
165
|
+
else:
|
|
166
|
+
raise RuntimeError(f"MLE optimization failed: {result.message}")
|
|
167
|
+
|
|
168
|
+
# Store parameters and standard errors
|
|
169
|
+
self._set_params(last_data_point=data.iloc[-1], theta=self.theta, mu=self.mu, sigma=self.sigma,
|
|
170
|
+
dt=self.dt_, freq=freq, param_ses=param_ses)
|
|
171
|
+
|
|
172
|
+
def _log_likelihood_ou(self, params, x, dt):
|
|
173
|
+
"""
|
|
174
|
+
Negative log-likelihood function for OU process (Exact MLE).
|
|
175
|
+
"""
|
|
176
|
+
theta, mu, sigma = params
|
|
177
|
+
n = len(x)
|
|
178
|
+
|
|
179
|
+
if theta <= 0 or sigma <= 0:
|
|
180
|
+
return np.inf # Penalise invalid parameters
|
|
181
|
+
|
|
182
|
+
# Pre-calculate terms
|
|
183
|
+
exp_theta_dt = np.exp(-theta * dt)
|
|
184
|
+
one_minus_exp_theta_dt = 1 - exp_theta_dt
|
|
185
|
+
|
|
186
|
+
# Variance of conditional distribution X_t | X_{t-1}
|
|
187
|
+
variance_conditional = sigma**2 * (1 - exp_theta_dt**2) / (2 * theta)
|
|
188
|
+
|
|
189
|
+
# Numerical stability check for variance_conditional
|
|
190
|
+
if variance_conditional <= 1e-12: # Small positive threshold
|
|
191
|
+
return np.inf
|
|
192
|
+
|
|
193
|
+
log_variance_conditional = np.log(variance_conditional)
|
|
194
|
+
log_2pi = np.log(2 * np.pi)
|
|
195
|
+
|
|
196
|
+
ll = 0.0
|
|
197
|
+
for i in range(1, n):
|
|
198
|
+
mean_conditional = x[i-1] * exp_theta_dt + mu * one_minus_exp_theta_dt
|
|
199
|
+
|
|
200
|
+
# Normal PDF log-likelihood part
|
|
201
|
+
ll += -0.5 * (log_variance_conditional + log_2pi)
|
|
202
|
+
ll += -0.5 * ((x[i] - mean_conditional)**2 / variance_conditional)
|
|
203
|
+
|
|
204
|
+
return -ll # Minimise negative log-likelihood
|
|
205
|
+
|
|
206
|
+
def _fit_ar1(self, data: pd.Series, dt: float, freq: str = None):
|
|
207
|
+
"""
|
|
208
|
+
Estimates OU parameters using AR(1) regression.
|
|
209
|
+
Maps coefficients back to continuous-time parameters.
|
|
210
|
+
"""
|
|
211
|
+
if len(data) < 2:
|
|
212
|
+
raise ValueError("AR(1) regression requires at least 2 data points.")
|
|
213
|
+
x_t = data.iloc[:-1].values
|
|
214
|
+
x_t_plus_dt = data.iloc[1:].values
|
|
215
|
+
n = len(x_t)
|
|
216
|
+
|
|
217
|
+
# Linear regression: x_{t+dt} = c + phi * x_t + epsilon
|
|
218
|
+
X_reg = np.vstack([np.ones(n), x_t]).T
|
|
219
|
+
|
|
220
|
+
# Robust LSQ
|
|
221
|
+
beta_hat, ss_residuals, rank, s = np.linalg.lstsq(X_reg, x_t_plus_dt, rcond=None)
|
|
222
|
+
|
|
223
|
+
c, phi = beta_hat[0], beta_hat[1]
|
|
224
|
+
|
|
225
|
+
# Calculate standard errors for c and phi
|
|
226
|
+
# Residual variance: sigma_epsilon_sq
|
|
227
|
+
sigma_epsilon_sq = ss_residuals[0] / (n - rank) if (n - rank) > 0 else np.var(x_t_plus_dt)
|
|
228
|
+
|
|
229
|
+
# Covariance matrix for beta_hat (c, phi)
|
|
230
|
+
# (X_reg^T X_reg)^-1 * sigma_epsilon_sq
|
|
231
|
+
try:
|
|
232
|
+
cov_beta = np.linalg.inv(X_reg.T @ X_reg) * sigma_epsilon_sq
|
|
233
|
+
se_c = np.sqrt(cov_beta[0, 0])
|
|
234
|
+
se_phi = np.sqrt(cov_beta[1, 1])
|
|
235
|
+
cov_c_phi = cov_beta[0, 1]
|
|
236
|
+
except np.linalg.LinAlgError:
|
|
237
|
+
print("Warning: Could not invert (X_reg.T @ X_reg) for AR(1) standard errors.")
|
|
238
|
+
se_c, se_phi, cov_c_phi = np.nan, np.nan, np.nan
|
|
239
|
+
|
|
240
|
+
param_ses = {}
|
|
241
|
+
|
|
242
|
+
# Map AR(1) coefficients to OU parameters
|
|
243
|
+
if phi >= 1.0 - 1e-6: # Check for stationarity / near-unit root
|
|
244
|
+
print("Warning: AR(1) coefficient phi >= 1.0. OU process may not be stationary / mean-reverting. Setting theta small positive.")
|
|
245
|
+
self.theta = 1e-6 # Set small positive theta
|
|
246
|
+
self.mu = np.mean(data) # Long-term mean is data mean
|
|
247
|
+
self.sigma = 0.1 # Default sigma
|
|
248
|
+
# Assign NaNs for SE as parameters are defaulted
|
|
249
|
+
param_ses['theta'] = np.nan
|
|
250
|
+
param_ses['mu'] = np.nan
|
|
251
|
+
param_ses['sigma'] = np.nan
|
|
252
|
+
else:
|
|
253
|
+
# Ensure theta positive. If phi > 1, log(phi) > 0, theta negative.
|
|
254
|
+
# If phi negative, log(phi) complex. Assume phi between 0 and 1 for stationary OU.
|
|
255
|
+
if phi <= 0:
|
|
256
|
+
print("Warning: Inferred AR(1) coefficient phi non-positive. Setting theta small positive, mu to data mean.")
|
|
257
|
+
self.theta = 1e-6
|
|
258
|
+
self.mu = np.mean(data)
|
|
259
|
+
self.sigma = 0.1 # Default sigma
|
|
260
|
+
param_ses['theta'] = np.nan
|
|
261
|
+
param_ses['mu'] = np.nan
|
|
262
|
+
param_ses['sigma'] = np.nan
|
|
263
|
+
else:
|
|
264
|
+
self.theta = -np.log(phi) / dt
|
|
265
|
+
self.mu = c / (1 - phi)
|
|
266
|
+
|
|
267
|
+
# Estimate sigma from residuals (same as before)
|
|
268
|
+
epsilon_t = x_t_plus_dt - (c + phi * x_t)
|
|
269
|
+
sigma_epsilon_sq = np.var(epsilon_t)
|
|
270
|
+
|
|
271
|
+
if self.theta > 0 and (1 - np.exp(-2 * self.theta * dt)) > 0:
|
|
272
|
+
sigma_sq_ou = (sigma_epsilon_sq * 2 * self.theta) / (1 - np.exp(-2 * self.theta * dt))
|
|
273
|
+
self.sigma = np.sqrt(sigma_sq_ou)
|
|
274
|
+
else:
|
|
275
|
+
self.sigma = np.sqrt(sigma_epsilon_sq / dt) # Fallback if theta near zero
|
|
276
|
+
|
|
277
|
+
if self.sigma <= 0:
|
|
278
|
+
self.sigma = 0.1 # Ensure sigma positive
|
|
279
|
+
|
|
280
|
+
# Calculate standard errors for OU parameters using Delta Method approximations
|
|
281
|
+
# d(theta)/d(phi) = -1 / (phi * dt)
|
|
282
|
+
# d(mu)/d(c) = 1 / (1 - phi)
|
|
283
|
+
# d(mu)/d(phi) = c / (1 - phi)^2
|
|
284
|
+
|
|
285
|
+
d_theta_d_phi = -1 / (phi * dt)
|
|
286
|
+
se_theta = np.abs(d_theta_d_phi) * se_phi if not np.isnan(se_phi) else np.nan
|
|
287
|
+
|
|
288
|
+
d_mu_d_c = 1 / (1 - phi)
|
|
289
|
+
d_mu_d_phi = c / ((1 - phi)**2)
|
|
290
|
+
|
|
291
|
+
# Variance of mu: Var(mu) = (d(mu)/dc)^2 Var(c) + (d(mu)/dphi)^2 Var(phi) + 2 (d(mu)/dc)(d(mu)/dphi)Cov(c,phi)
|
|
292
|
+
# Assuming Var(c) = se_c^2, Var(phi) = se_phi^2
|
|
293
|
+
if not np.isnan(se_c) and not np.isnan(se_phi) and not np.isnan(cov_c_phi):
|
|
294
|
+
se_mu_sq = (d_mu_d_c**2 * se_c**2) + (d_mu_d_phi**2 * se_phi**2) + (2 * d_mu_d_c * d_mu_d_phi * cov_c_phi)
|
|
295
|
+
se_mu = np.sqrt(se_mu_sq) if se_mu_sq >= 0 else np.nan
|
|
296
|
+
else:
|
|
297
|
+
se_mu = np.nan
|
|
298
|
+
|
|
299
|
+
# A simple approximation for sigma SE (might need more rigorous derivation)
|
|
300
|
+
se_sigma = self.sigma * np.sqrt(sigma_epsilon_sq / (2 * n * dt * self.theta)) if self.theta > 0 else np.nan
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
param_ses = {
|
|
304
|
+
'theta': se_theta,
|
|
305
|
+
'mu': se_mu,
|
|
306
|
+
'sigma': se_sigma
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# Store parameters and standard errors
|
|
310
|
+
self._set_params(last_data_point=data.iloc[-1], theta=self.theta, mu=self.mu, sigma=self.sigma,
|
|
311
|
+
dt=self.dt_, freq=freq, param_ses=param_ses)
|
|
312
|
+
|
|
313
|
+
def sample(self, n_paths: int = 1, horizon: int = 1, dt: float = None) -> KestrelResult:
|
|
314
|
+
"""
|
|
315
|
+
Simulates future paths using Euler-Maruyama method.
|
|
316
|
+
|
|
317
|
+
Parameters
|
|
318
|
+
----------
|
|
319
|
+
n_paths : int
|
|
320
|
+
Number of simulation paths to generate.
|
|
321
|
+
horizon : int
|
|
322
|
+
Number of future time steps to simulate.
|
|
323
|
+
dt : float, optional
|
|
324
|
+
Simulation time step. Uses fitted dt if None.
|
|
325
|
+
|
|
326
|
+
Returns
|
|
327
|
+
-------
|
|
328
|
+
KestrelResult
|
|
329
|
+
Simulation results with plotting and analysis methods.
|
|
330
|
+
|
|
331
|
+
Raises
|
|
332
|
+
------
|
|
333
|
+
RuntimeError
|
|
334
|
+
If model not fitted and parameters not provided at initialisation.
|
|
335
|
+
"""
|
|
336
|
+
if not self.is_fitted and (self.theta is None or self.mu is None or self.sigma is None):
|
|
337
|
+
raise RuntimeError("Model must be fitted or initialised with parameters before sampling.")
|
|
338
|
+
|
|
339
|
+
if dt is None:
|
|
340
|
+
dt = self._dt_ if self.is_fitted and hasattr(self, '_dt_') else 1.0
|
|
341
|
+
|
|
342
|
+
theta = self.theta_ if self.is_fitted else self.theta
|
|
343
|
+
mu = self.mu_ if self.is_fitted else self.mu
|
|
344
|
+
sigma = self.sigma_ if self.is_fitted else self.sigma
|
|
345
|
+
|
|
346
|
+
if any(p is None for p in [theta, mu, sigma]):
|
|
347
|
+
raise RuntimeError("OU parameters (theta, mu, sigma) must be set or estimated to sample.")
|
|
348
|
+
|
|
349
|
+
paths = np.zeros((horizon + 1, n_paths))
|
|
350
|
+
if self.is_fitted and hasattr(self, '_last_data_point'):
|
|
351
|
+
initial_val = self._last_data_point
|
|
352
|
+
paths[0, :] = initial_val
|
|
353
|
+
else:
|
|
354
|
+
initial_val = mu # Start at long-term mean if not fitted
|
|
355
|
+
paths[0, :] = initial_val
|
|
356
|
+
|
|
357
|
+
# Euler-Maruyama simulation
|
|
358
|
+
for t in range(horizon):
|
|
359
|
+
dW = np.random.normal(loc=0.0, scale=np.sqrt(dt), size=n_paths)
|
|
360
|
+
paths[t + 1, :] = paths[t, :] + theta * (mu - paths[t, :]) * dt + sigma * dW
|
|
361
|
+
|
|
362
|
+
return KestrelResult(pd.DataFrame(paths), initial_value=initial_val)
|