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.
@@ -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
@@ -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)