garch 1.0.2 → 1.1.0
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.
- package/README.md +416 -121
- package/build/index.cjs +1329 -35
- package/build/index.mjs +1311 -36
- package/package.json +2 -1
- package/types.d.ts +346 -3
package/build/index.mjs
CHANGED
|
@@ -9,7 +9,7 @@ function nelderMead(fn, x0, options = {}) {
|
|
|
9
9
|
const simplex = [x0.slice()];
|
|
10
10
|
for (let i = 0; i < n; i++) {
|
|
11
11
|
const point = x0.slice();
|
|
12
|
-
const delta = point[i] === 0 ? 0.00025 : point[i] * 0.
|
|
12
|
+
const delta = point[i] === 0 ? 0.00025 : point[i] * 0.20;
|
|
13
13
|
point[i] += delta;
|
|
14
14
|
simplex.push(point);
|
|
15
15
|
}
|
|
@@ -103,6 +103,38 @@ function shrink(simplex, values, sigma, fn, n) {
|
|
|
103
103
|
values[i] = fn(simplex[i]);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Multi-start Nelder-Mead: runs NM from multiple deterministic starting
|
|
108
|
+
* points and returns the best result. Escapes local minima by exploring
|
|
109
|
+
* different basins of attraction.
|
|
110
|
+
*
|
|
111
|
+
* Perturbation uses golden-ratio quasi-random sequence for uniform
|
|
112
|
+
* coverage of the search space without clustering.
|
|
113
|
+
*/
|
|
114
|
+
const PHI = (1 + Math.sqrt(5)) / 2; // golden ratio
|
|
115
|
+
function nelderMeadMultiStart(fn, x0, options = {}) {
|
|
116
|
+
const { maxIter = 1000, tol = 1e-8, restarts = 3 } = options;
|
|
117
|
+
const n = x0.length;
|
|
118
|
+
// Run from original starting point
|
|
119
|
+
let best = nelderMead(fn, x0, { maxIter, tol });
|
|
120
|
+
// Run from perturbed starting points
|
|
121
|
+
for (let k = 1; k <= restarts; k++) {
|
|
122
|
+
const perturbed = new Array(n);
|
|
123
|
+
for (let i = 0; i < n; i++) {
|
|
124
|
+
// Quasi-random perturbation: golden-ratio sequence mapped to [-0.5, +0.5]
|
|
125
|
+
const frac = (k * (i + 1) * PHI) % 1;
|
|
126
|
+
const scale = frac - 0.5; // range [-0.5, +0.5]
|
|
127
|
+
perturbed[i] = x0[i] === 0
|
|
128
|
+
? 0.001 * scale
|
|
129
|
+
: x0[i] * (1 + scale);
|
|
130
|
+
}
|
|
131
|
+
const result = nelderMead(fn, perturbed, { maxIter, tol });
|
|
132
|
+
if (result.fx < best.fx) {
|
|
133
|
+
best = result;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return best;
|
|
137
|
+
}
|
|
106
138
|
|
|
107
139
|
/**
|
|
108
140
|
* Calculate log returns from candles
|
|
@@ -110,7 +142,7 @@ function shrink(simplex, values, sigma, fn, n) {
|
|
|
110
142
|
function calculateReturns(candles) {
|
|
111
143
|
const returns = [];
|
|
112
144
|
for (let i = 1; i < candles.length; i++) {
|
|
113
|
-
if (candles[i].close
|
|
145
|
+
if (!(candles[i].close > 0) || !(candles[i - 1].close > 0)) {
|
|
114
146
|
throw new Error(`Invalid close price at index ${i}`);
|
|
115
147
|
}
|
|
116
148
|
returns.push(Math.log(candles[i].close / candles[i - 1].close));
|
|
@@ -123,7 +155,7 @@ function calculateReturns(candles) {
|
|
|
123
155
|
function calculateReturnsFromPrices(prices) {
|
|
124
156
|
const returns = [];
|
|
125
157
|
for (let i = 1; i < prices.length; i++) {
|
|
126
|
-
if (prices[i]
|
|
158
|
+
if (!(prices[i] > 0 && Number.isFinite(prices[i])) || !(prices[i - 1] > 0 && Number.isFinite(prices[i - 1]))) {
|
|
127
159
|
throw new Error(`Invalid price at index ${i}`);
|
|
128
160
|
}
|
|
129
161
|
returns.push(Math.log(prices[i] / prices[i - 1]));
|
|
@@ -167,11 +199,225 @@ function checkLeverageEffect(returns) {
|
|
|
167
199
|
recommendation: ratio > 1.2 ? 'egarch' : 'garch',
|
|
168
200
|
};
|
|
169
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Garman-Klass (1980) variance estimator using OHLC data.
|
|
204
|
+
*
|
|
205
|
+
* σ²_GK = (1/n) Σ [ 0.5·(ln(H/L))² − (2ln2−1)·(ln(C/O))² ]
|
|
206
|
+
*
|
|
207
|
+
* ~5x more efficient than close-to-close variance.
|
|
208
|
+
*/
|
|
209
|
+
function garmanKlassVariance(candles) {
|
|
210
|
+
const n = candles.length;
|
|
211
|
+
const coeff = 2 * Math.LN2 - 1;
|
|
212
|
+
let sum = 0;
|
|
213
|
+
for (let i = 0; i < n; i++) {
|
|
214
|
+
const { open, high, low, close } = candles[i];
|
|
215
|
+
const hl = Math.log(high / low);
|
|
216
|
+
const co = Math.log(close / open);
|
|
217
|
+
sum += 0.5 * hl * hl - coeff * co * co;
|
|
218
|
+
}
|
|
219
|
+
return sum / n;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Yang-Zhang (2000) variance estimator using OHLC data.
|
|
223
|
+
*
|
|
224
|
+
* Combines overnight (open vs prev close), open-to-close,
|
|
225
|
+
* and Rogers-Satchell components. More efficient than Garman-Klass
|
|
226
|
+
* and handles overnight gaps (relevant for stocks).
|
|
227
|
+
*
|
|
228
|
+
* σ²_YZ = σ²_overnight + k·σ²_close + (1−k)·σ²_RS
|
|
229
|
+
*/
|
|
230
|
+
function yangZhangVariance(candles) {
|
|
231
|
+
const n = candles.length;
|
|
232
|
+
if (n < 2)
|
|
233
|
+
return garmanKlassVariance(candles);
|
|
234
|
+
const k = 0.34 / (1.34 + (n + 1) / (n - 1));
|
|
235
|
+
let overnightSum = 0;
|
|
236
|
+
let closeSum = 0;
|
|
237
|
+
let rsSum = 0;
|
|
238
|
+
let count = 0;
|
|
239
|
+
for (let i = 1; i < n; i++) {
|
|
240
|
+
const prevClose = candles[i - 1].close;
|
|
241
|
+
const { open, high, low, close } = candles[i];
|
|
242
|
+
const overnight = Math.log(open / prevClose);
|
|
243
|
+
const co = Math.log(close / open);
|
|
244
|
+
const hc = Math.log(high / close);
|
|
245
|
+
const ho = Math.log(high / open);
|
|
246
|
+
const lc = Math.log(low / close);
|
|
247
|
+
const lo = Math.log(low / open);
|
|
248
|
+
overnightSum += overnight * overnight;
|
|
249
|
+
closeSum += co * co;
|
|
250
|
+
rsSum += ho * hc + lo * lc;
|
|
251
|
+
count++;
|
|
252
|
+
}
|
|
253
|
+
const overnightVar = overnightSum / count;
|
|
254
|
+
const closeVar = closeSum / count;
|
|
255
|
+
const rsVar = rsSum / count;
|
|
256
|
+
return overnightVar + k * closeVar + (1 - k) * rsVar;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Per-candle Parkinson (1980) realized variance proxy.
|
|
260
|
+
*
|
|
261
|
+
* RV_i = (1/(4·ln2)) · ln(H/L)²
|
|
262
|
+
*
|
|
263
|
+
* ~5× more efficient than squared returns. Falls back to r² when H === L.
|
|
264
|
+
* rv[i] aligned with returns[i], using candles[i+1]'s OHLC.
|
|
265
|
+
*/
|
|
266
|
+
function perCandleParkinson(candles, returns) {
|
|
267
|
+
const coeff = 1 / (4 * Math.LN2);
|
|
268
|
+
const rv = [];
|
|
269
|
+
for (let i = 0; i < returns.length; i++) {
|
|
270
|
+
const c = candles[i + 1];
|
|
271
|
+
const hl = Math.log(c.high / c.low);
|
|
272
|
+
const parkinson = coeff * hl * hl;
|
|
273
|
+
// Fall back to r² if high === low (zero range)
|
|
274
|
+
rv.push(parkinson > 0 ? parkinson : returns[i] * returns[i]);
|
|
275
|
+
}
|
|
276
|
+
return rv;
|
|
277
|
+
}
|
|
170
278
|
/**
|
|
171
279
|
* Expected value of |Z| where Z ~ N(0,1)
|
|
172
280
|
* E[|Z|] = sqrt(2/π)
|
|
173
281
|
*/
|
|
174
282
|
const EXPECTED_ABS_NORMAL = Math.sqrt(2 / Math.PI);
|
|
283
|
+
/**
|
|
284
|
+
* Chi-squared survival function approximation (Wilson-Hilferty).
|
|
285
|
+
* P(X > x) where X ~ χ²(df)
|
|
286
|
+
*/
|
|
287
|
+
function chi2Survival(x, df) {
|
|
288
|
+
if (df <= 0 || x < 0)
|
|
289
|
+
return 1;
|
|
290
|
+
// Wilson-Hilferty normal approximation
|
|
291
|
+
const z = Math.cbrt(x / df) - (1 - 2 / (9 * df));
|
|
292
|
+
const denom = Math.sqrt(2 / (9 * df));
|
|
293
|
+
const normZ = z / denom;
|
|
294
|
+
// Standard normal CDF via error function approximation
|
|
295
|
+
return 1 - normalCDF(normZ);
|
|
296
|
+
}
|
|
297
|
+
function normalCDF(x) {
|
|
298
|
+
const t = 1 / (1 + 0.2316419 * Math.abs(x));
|
|
299
|
+
const d = 0.3989422804014327; // 1/sqrt(2π)
|
|
300
|
+
const p = d * Math.exp(-0.5 * x * x) *
|
|
301
|
+
(t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))));
|
|
302
|
+
return x >= 0 ? 1 - p : p;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Ljung-Box test for autocorrelation.
|
|
306
|
+
*
|
|
307
|
+
* Q = n(n+2) Σ(k=1..m) ρ²_k / (n−k)
|
|
308
|
+
*
|
|
309
|
+
* Under H₀ (no autocorrelation), Q ~ χ²(m).
|
|
310
|
+
* Use on squared standardized residuals to test GARCH adequacy.
|
|
311
|
+
*/
|
|
312
|
+
function ljungBox(data, maxLag) {
|
|
313
|
+
const n = data.length;
|
|
314
|
+
const mean = data.reduce((s, v) => s + v, 0) / n;
|
|
315
|
+
const gamma0 = data.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
|
|
316
|
+
if (gamma0 === 0)
|
|
317
|
+
return { statistic: 0, pValue: 1 };
|
|
318
|
+
let Q = 0;
|
|
319
|
+
for (let k = 1; k <= maxLag; k++) {
|
|
320
|
+
let gammaK = 0;
|
|
321
|
+
for (let t = k; t < n; t++) {
|
|
322
|
+
gammaK += (data[t] - mean) * (data[t - k] - mean);
|
|
323
|
+
}
|
|
324
|
+
gammaK /= n;
|
|
325
|
+
const rhoK = gammaK / gamma0;
|
|
326
|
+
Q += (rhoK * rhoK) / (n - k);
|
|
327
|
+
}
|
|
328
|
+
Q *= n * (n + 2);
|
|
329
|
+
return { statistic: Q, pValue: chi2Survival(Q, maxLag) };
|
|
330
|
+
}
|
|
331
|
+
// ── Student-t distribution helpers ─────────────────────────────
|
|
332
|
+
/**
|
|
333
|
+
* Log-Gamma function via Lanczos approximation (g=7, n=9).
|
|
334
|
+
* Accurate to ~15 digits for x > 0.
|
|
335
|
+
*/
|
|
336
|
+
function logGamma(x) {
|
|
337
|
+
if (x <= 0)
|
|
338
|
+
return Infinity;
|
|
339
|
+
const g = 7;
|
|
340
|
+
const c = [
|
|
341
|
+
0.99999999999980993,
|
|
342
|
+
676.5203681218851,
|
|
343
|
+
-1259.1392167224028,
|
|
344
|
+
771.32342877765313,
|
|
345
|
+
-176.6150291621406,
|
|
346
|
+
12.507343278686905,
|
|
347
|
+
-0.13857109526572012,
|
|
348
|
+
9.9843695780195716e-6,
|
|
349
|
+
1.5056327351493116e-7,
|
|
350
|
+
];
|
|
351
|
+
let sum = c[0];
|
|
352
|
+
for (let i = 1; i < g + 2; i++) {
|
|
353
|
+
sum += c[i] / (x - 1 + i);
|
|
354
|
+
}
|
|
355
|
+
const t = x - 1 + g + 0.5;
|
|
356
|
+
return 0.5 * Math.log(2 * Math.PI) + (x - 0.5) * Math.log(t) - t + Math.log(sum);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Per-observation Student-t negative log-likelihood contribution.
|
|
360
|
+
*
|
|
361
|
+
* For standardized t(df) with variance σ²_t:
|
|
362
|
+
* -LL_i = 0.5·ln(σ²_t) + ((df+1)/2)·ln(1 + r²_t / ((df-2)·σ²_t))
|
|
363
|
+
* - lnΓ((df+1)/2) + lnΓ(df/2) + 0.5·ln(π·(df-2))
|
|
364
|
+
*
|
|
365
|
+
* Returns the per-observation neg-LL (without the constant terms).
|
|
366
|
+
* Caller accumulates and adds the constant once.
|
|
367
|
+
*/
|
|
368
|
+
function studentTNegLL(returns, varianceSeries, df) {
|
|
369
|
+
const n = returns.length;
|
|
370
|
+
// Constant part (same for all observations)
|
|
371
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
372
|
+
const constant = n * (logGamma(df / 2) - logGamma(halfDfPlus1) + 0.5 * Math.log(Math.PI * (df - 2)));
|
|
373
|
+
let sum = 0;
|
|
374
|
+
for (let i = 0; i < n; i++) {
|
|
375
|
+
const v = varianceSeries[i];
|
|
376
|
+
if (v <= 1e-12 || !isFinite(v))
|
|
377
|
+
return 1e10;
|
|
378
|
+
sum += 0.5 * Math.log(v) + halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / ((df - 2) * v));
|
|
379
|
+
}
|
|
380
|
+
return sum + constant;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* E[|Z|] where Z follows a standardized Student-t(df) distribution (variance = 1).
|
|
384
|
+
*
|
|
385
|
+
* E[|Z|] = √((df-2)/π) · Γ((df-1)/2) / Γ(df/2)
|
|
386
|
+
*
|
|
387
|
+
* Converges to √(2/π) as df → ∞ (Gaussian limit).
|
|
388
|
+
*/
|
|
389
|
+
function expectedAbsStudentT(df) {
|
|
390
|
+
if (df <= 2)
|
|
391
|
+
return EXPECTED_ABS_NORMAL; // fallback
|
|
392
|
+
return Math.sqrt((df - 2) / Math.PI) * Math.exp(logGamma((df - 1) / 2) - logGamma(df / 2));
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* 1D grid search for optimal df that minimizes Student-t neg-LL.
|
|
396
|
+
* Used by HAR-RV and NoVaS where df is profiled after main optimization.
|
|
397
|
+
*/
|
|
398
|
+
function profileStudentTDf(returns, varianceSeries) {
|
|
399
|
+
let bestDf = 30;
|
|
400
|
+
let bestNLL = studentTNegLL(returns, varianceSeries, 30);
|
|
401
|
+
// Coarse grid: 2.5 to 50
|
|
402
|
+
for (let df = 2.5; df <= 50; df += 0.5) {
|
|
403
|
+
const nll = studentTNegLL(returns, varianceSeries, df);
|
|
404
|
+
if (nll < bestNLL) {
|
|
405
|
+
bestNLL = nll;
|
|
406
|
+
bestDf = df;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Fine grid around best
|
|
410
|
+
const lo = Math.max(2.1, bestDf - 1);
|
|
411
|
+
const hi = bestDf + 1;
|
|
412
|
+
for (let df = lo; df <= hi; df += 0.05) {
|
|
413
|
+
const nll = studentTNegLL(returns, varianceSeries, df);
|
|
414
|
+
if (nll < bestNLL) {
|
|
415
|
+
bestNLL = nll;
|
|
416
|
+
bestDf = df;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return bestDf;
|
|
420
|
+
}
|
|
175
421
|
/**
|
|
176
422
|
* Calculate AIC (Akaike Information Criterion)
|
|
177
423
|
*/
|
|
@@ -184,6 +430,28 @@ function calculateAIC(logLikelihood, numParams) {
|
|
|
184
430
|
function calculateBIC(logLikelihood, numParams, numObs) {
|
|
185
431
|
return numParams * Math.log(numObs) - 2 * logLikelihood;
|
|
186
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* QLIKE loss (Patton 2011) — standard loss function for volatility forecasts.
|
|
435
|
+
*
|
|
436
|
+
* QLIKE = (1/n) · Σ (RV_t / σ²_t − log(RV_t / σ²_t) − 1)
|
|
437
|
+
*
|
|
438
|
+
* Lower = better forecast. Neutral to calibration method — judges only
|
|
439
|
+
* how well the variance series predicts realized variance, regardless
|
|
440
|
+
* of how the model was calibrated (MLE, OLS, D², etc.).
|
|
441
|
+
*/
|
|
442
|
+
function qlike(varianceSeries, rv) {
|
|
443
|
+
const n = Math.min(varianceSeries.length, rv.length);
|
|
444
|
+
let sum = 0;
|
|
445
|
+
let count = 0;
|
|
446
|
+
for (let i = 0; i < n; i++) {
|
|
447
|
+
if (varianceSeries[i] <= 0 || rv[i] <= 0)
|
|
448
|
+
continue;
|
|
449
|
+
const ratio = rv[i] / varianceSeries[i];
|
|
450
|
+
sum += ratio - Math.log(ratio) - 1;
|
|
451
|
+
count++;
|
|
452
|
+
}
|
|
453
|
+
return count > 0 ? sum / count : Infinity;
|
|
454
|
+
}
|
|
187
455
|
|
|
188
456
|
/**
|
|
189
457
|
* GARCH(1,1) model
|
|
@@ -198,6 +466,7 @@ function calculateBIC(logLikelihood, numParams, numObs) {
|
|
|
198
466
|
*/
|
|
199
467
|
class Garch {
|
|
200
468
|
returns;
|
|
469
|
+
rv;
|
|
201
470
|
periodsPerYear;
|
|
202
471
|
initialVariance;
|
|
203
472
|
constructor(data, options = {}) {
|
|
@@ -208,11 +477,16 @@ class Garch {
|
|
|
208
477
|
// Determine if input is candles or prices
|
|
209
478
|
if (typeof data[0] === 'number') {
|
|
210
479
|
this.returns = calculateReturnsFromPrices(data);
|
|
480
|
+
this.initialVariance = sampleVariance(this.returns);
|
|
481
|
+
this.rv = null;
|
|
211
482
|
}
|
|
212
483
|
else {
|
|
213
|
-
|
|
484
|
+
const candles = data;
|
|
485
|
+
this.returns = calculateReturns(candles);
|
|
486
|
+
this.initialVariance = yangZhangVariance(candles);
|
|
487
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
488
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
214
489
|
}
|
|
215
|
-
this.initialVariance = sampleVariance(this.returns);
|
|
216
490
|
}
|
|
217
491
|
/**
|
|
218
492
|
* Calibrate GARCH(1,1) parameters using Maximum Likelihood Estimation
|
|
@@ -222,9 +496,10 @@ class Garch {
|
|
|
222
496
|
const returns = this.returns;
|
|
223
497
|
const n = returns.length;
|
|
224
498
|
const initVar = this.initialVariance;
|
|
225
|
-
|
|
499
|
+
const rv = this.rv;
|
|
500
|
+
// Student-t negative log-likelihood function
|
|
226
501
|
function negLogLikelihood(params) {
|
|
227
|
-
const [omega, alpha, beta] = params;
|
|
502
|
+
const [omega, alpha, beta, df] = params;
|
|
228
503
|
// Constraints
|
|
229
504
|
if (omega <= 1e-12)
|
|
230
505
|
return 1e10;
|
|
@@ -232,30 +507,37 @@ class Garch {
|
|
|
232
507
|
return 1e10;
|
|
233
508
|
if (alpha + beta >= 0.9999)
|
|
234
509
|
return 1e10;
|
|
510
|
+
if (df <= 2.01 || df > 100)
|
|
511
|
+
return 1e10;
|
|
512
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
513
|
+
const dfMinus2 = df - 2;
|
|
514
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
235
515
|
let variance = initVar;
|
|
236
516
|
let ll = 0;
|
|
237
517
|
for (let i = 0; i < n; i++) {
|
|
238
518
|
if (i > 0) {
|
|
239
|
-
|
|
519
|
+
const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
|
|
520
|
+
variance = omega + alpha * innovation + beta * variance;
|
|
240
521
|
}
|
|
241
522
|
if (variance <= 1e-12)
|
|
242
523
|
return 1e10;
|
|
243
|
-
//
|
|
244
|
-
ll += Math.log(variance) + (returns[i] ** 2) / variance;
|
|
524
|
+
// Student-t log-likelihood
|
|
525
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
245
526
|
}
|
|
246
|
-
return ll
|
|
527
|
+
return -(ll + constant);
|
|
247
528
|
}
|
|
248
529
|
// Initial guesses
|
|
249
530
|
const omega0 = initVar * 0.05;
|
|
250
531
|
const alpha0 = 0.1;
|
|
251
532
|
const beta0 = 0.85;
|
|
252
|
-
const
|
|
253
|
-
const [
|
|
533
|
+
const df0 = 5;
|
|
534
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, beta0, df0], { maxIter, tol, restarts: 3 });
|
|
535
|
+
const [omega, alpha, beta, df] = result.x;
|
|
254
536
|
const persistence = alpha + beta;
|
|
255
537
|
const unconditionalVariance = omega / (1 - persistence);
|
|
256
538
|
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
257
|
-
const logLikelihood = -result.fx
|
|
258
|
-
const numParams =
|
|
539
|
+
const logLikelihood = -result.fx;
|
|
540
|
+
const numParams = 4;
|
|
259
541
|
return {
|
|
260
542
|
params: {
|
|
261
543
|
omega,
|
|
@@ -264,6 +546,7 @@ class Garch {
|
|
|
264
546
|
persistence,
|
|
265
547
|
unconditionalVariance,
|
|
266
548
|
annualizedVol,
|
|
549
|
+
df,
|
|
267
550
|
},
|
|
268
551
|
diagnostics: {
|
|
269
552
|
logLikelihood,
|
|
@@ -285,7 +568,8 @@ class Garch {
|
|
|
285
568
|
variance.push(this.initialVariance);
|
|
286
569
|
}
|
|
287
570
|
else {
|
|
288
|
-
const
|
|
571
|
+
const innovation = this.rv ? this.rv[i - 1] : this.returns[i - 1] ** 2;
|
|
572
|
+
const v = omega + alpha * innovation + beta * variance[i - 1];
|
|
289
573
|
variance.push(v);
|
|
290
574
|
}
|
|
291
575
|
}
|
|
@@ -300,9 +584,11 @@ class Garch {
|
|
|
300
584
|
// Get last variance
|
|
301
585
|
const varianceSeries = this.getVarianceSeries(params);
|
|
302
586
|
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
303
|
-
const
|
|
587
|
+
const lastInnovation = this.rv
|
|
588
|
+
? this.rv[this.rv.length - 1]
|
|
589
|
+
: this.returns[this.returns.length - 1] ** 2;
|
|
304
590
|
// One-step ahead
|
|
305
|
-
let v = omega + alpha *
|
|
591
|
+
let v = omega + alpha * lastInnovation + beta * lastVariance;
|
|
306
592
|
variance.push(v);
|
|
307
593
|
// Multi-step ahead (converges to unconditional variance)
|
|
308
594
|
for (let h = 1; h < steps; h++) {
|
|
@@ -347,10 +633,11 @@ function calibrateGarch(data, options = {}) {
|
|
|
347
633
|
* - α (alpha): magnitude effect
|
|
348
634
|
* - γ (gamma): leverage effect (typically negative)
|
|
349
635
|
* - β (beta): persistence
|
|
350
|
-
* - E[|z|] =
|
|
636
|
+
* - E[|z|] = expectedAbsStudentT(df) for Student-t(df)
|
|
351
637
|
*/
|
|
352
638
|
class Egarch {
|
|
353
639
|
returns;
|
|
640
|
+
rv;
|
|
354
641
|
periodsPerYear;
|
|
355
642
|
initialVariance;
|
|
356
643
|
constructor(data, options = {}) {
|
|
@@ -360,11 +647,16 @@ class Egarch {
|
|
|
360
647
|
}
|
|
361
648
|
if (typeof data[0] === 'number') {
|
|
362
649
|
this.returns = calculateReturnsFromPrices(data);
|
|
650
|
+
this.initialVariance = sampleVariance(this.returns);
|
|
651
|
+
this.rv = null;
|
|
363
652
|
}
|
|
364
653
|
else {
|
|
365
|
-
|
|
654
|
+
const candles = data;
|
|
655
|
+
this.returns = calculateReturns(candles);
|
|
656
|
+
this.initialVariance = yangZhangVariance(candles);
|
|
657
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
658
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
366
659
|
}
|
|
367
|
-
this.initialVariance = sampleVariance(this.returns);
|
|
368
660
|
}
|
|
369
661
|
/**
|
|
370
662
|
* Calibrate EGARCH(1,1) parameters using Maximum Likelihood Estimation
|
|
@@ -374,20 +666,31 @@ class Egarch {
|
|
|
374
666
|
const returns = this.returns;
|
|
375
667
|
const n = returns.length;
|
|
376
668
|
const initLogVar = Math.log(this.initialVariance);
|
|
669
|
+
const rv = this.rv;
|
|
377
670
|
function negLogLikelihood(params) {
|
|
378
|
-
const [omega, alpha, gamma, beta] = params;
|
|
671
|
+
const [omega, alpha, gamma, beta, df] = params;
|
|
379
672
|
// EGARCH allows negative gamma, but beta should ensure stationarity
|
|
380
673
|
if (Math.abs(beta) >= 0.9999)
|
|
381
674
|
return 1e10;
|
|
675
|
+
if (df <= 2.01 || df > 100)
|
|
676
|
+
return 1e10;
|
|
677
|
+
const eAbsZ = expectedAbsStudentT(df);
|
|
678
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
679
|
+
const dfMinus2 = df - 2;
|
|
680
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
382
681
|
let logVariance = initLogVar;
|
|
383
682
|
let variance = Math.exp(logVariance);
|
|
384
683
|
let ll = 0;
|
|
385
684
|
for (let i = 0; i < n; i++) {
|
|
386
685
|
if (i > 0) {
|
|
387
686
|
const sigma = Math.sqrt(variance);
|
|
388
|
-
const z = returns[i - 1] / sigma;
|
|
687
|
+
const z = returns[i - 1] / sigma; // directional — kept for leverage
|
|
688
|
+
// Magnitude: √(RV/σ²) for candles, |z| for prices
|
|
689
|
+
const magnitude = rv
|
|
690
|
+
? Math.sqrt(rv[i - 1] / variance)
|
|
691
|
+
: Math.abs(z);
|
|
389
692
|
logVariance = omega
|
|
390
|
-
+ alpha * (
|
|
693
|
+
+ alpha * (magnitude - eAbsZ)
|
|
391
694
|
+ gamma * z
|
|
392
695
|
+ beta * logVariance;
|
|
393
696
|
// Prevent extreme values
|
|
@@ -396,9 +699,10 @@ class Egarch {
|
|
|
396
699
|
}
|
|
397
700
|
if (variance <= 1e-12 || !isFinite(variance))
|
|
398
701
|
return 1e10;
|
|
399
|
-
|
|
702
|
+
// Student-t log-likelihood
|
|
703
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
400
704
|
}
|
|
401
|
-
return ll
|
|
705
|
+
return -(ll + constant);
|
|
402
706
|
}
|
|
403
707
|
// Initial guesses
|
|
404
708
|
// omega approximates log of unconditional variance when other params are small
|
|
@@ -406,15 +710,16 @@ class Egarch {
|
|
|
406
710
|
const alpha0 = 0.1;
|
|
407
711
|
const gamma0 = -0.05; // Negative for typical leverage effect
|
|
408
712
|
const beta0 = 0.95;
|
|
409
|
-
const
|
|
410
|
-
const [
|
|
713
|
+
const df0 = 5;
|
|
714
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
|
|
715
|
+
const [omega, alpha, gamma, beta, df] = result.x;
|
|
411
716
|
// For EGARCH, unconditional variance: E[ln(σ²)] = ω/(1-β)
|
|
412
717
|
// So E[σ²] ≈ exp(ω/(1-β)) when α and γ effects average out
|
|
413
718
|
const unconditionalLogVar = omega / (1 - beta);
|
|
414
719
|
const unconditionalVariance = Math.exp(unconditionalLogVar);
|
|
415
720
|
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
416
|
-
const logLikelihood = -result.fx
|
|
417
|
-
const numParams =
|
|
721
|
+
const logLikelihood = -result.fx;
|
|
722
|
+
const numParams = 5;
|
|
418
723
|
return {
|
|
419
724
|
params: {
|
|
420
725
|
omega,
|
|
@@ -425,6 +730,7 @@ class Egarch {
|
|
|
425
730
|
unconditionalVariance,
|
|
426
731
|
annualizedVol,
|
|
427
732
|
leverageEffect: gamma,
|
|
733
|
+
df,
|
|
428
734
|
},
|
|
429
735
|
diagnostics: {
|
|
430
736
|
logLikelihood,
|
|
@@ -439,7 +745,8 @@ class Egarch {
|
|
|
439
745
|
* Calculate conditional variance series given parameters
|
|
440
746
|
*/
|
|
441
747
|
getVarianceSeries(params) {
|
|
442
|
-
const { omega, alpha, gamma, beta } = params;
|
|
748
|
+
const { omega, alpha, gamma, beta, df } = params;
|
|
749
|
+
const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
|
|
443
750
|
const variance = [];
|
|
444
751
|
let logVariance = Math.log(this.initialVariance);
|
|
445
752
|
for (let i = 0; i < this.returns.length; i++) {
|
|
@@ -449,8 +756,11 @@ class Egarch {
|
|
|
449
756
|
else {
|
|
450
757
|
const sigma = Math.sqrt(variance[i - 1]);
|
|
451
758
|
const z = this.returns[i - 1] / sigma;
|
|
759
|
+
const magnitude = this.rv
|
|
760
|
+
? Math.sqrt(this.rv[i - 1] / variance[i - 1])
|
|
761
|
+
: Math.abs(z);
|
|
452
762
|
logVariance = omega
|
|
453
|
-
+ alpha * (
|
|
763
|
+
+ alpha * (magnitude - eAbsZ)
|
|
454
764
|
+ gamma * z
|
|
455
765
|
+ beta * logVariance;
|
|
456
766
|
logVariance = Math.max(-50, Math.min(50, logVariance));
|
|
@@ -467,7 +777,8 @@ class Egarch {
|
|
|
467
777
|
* expected values of future shocks.
|
|
468
778
|
*/
|
|
469
779
|
forecast(params, steps = 1) {
|
|
470
|
-
const { omega, alpha, gamma, beta } = params;
|
|
780
|
+
const { omega, alpha, gamma, beta, df } = params;
|
|
781
|
+
const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
|
|
471
782
|
const variance = [];
|
|
472
783
|
const varianceSeries = this.getVarianceSeries(params);
|
|
473
784
|
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
@@ -475,12 +786,15 @@ class Egarch {
|
|
|
475
786
|
// One-step ahead using actual last return
|
|
476
787
|
const sigma = Math.sqrt(lastVariance);
|
|
477
788
|
const z = lastReturn / sigma;
|
|
789
|
+
const magnitude = this.rv
|
|
790
|
+
? Math.sqrt(this.rv[this.rv.length - 1] / lastVariance)
|
|
791
|
+
: Math.abs(z);
|
|
478
792
|
let logVariance = omega
|
|
479
|
-
+ alpha * (
|
|
793
|
+
+ alpha * (magnitude - eAbsZ)
|
|
480
794
|
+ gamma * z
|
|
481
795
|
+ beta * Math.log(lastVariance);
|
|
482
796
|
variance.push(Math.exp(logVariance));
|
|
483
|
-
// Multi-step: assume E[z] = 0, E[|z|] =
|
|
797
|
+
// Multi-step: assume E[z] = 0, E[|z|] = eAbsZ
|
|
484
798
|
// So the α and γ terms contribute 0 on average
|
|
485
799
|
for (let h = 1; h < steps; h++) {
|
|
486
800
|
logVariance = omega + beta * logVariance;
|
|
@@ -513,4 +827,965 @@ function calibrateEgarch(data, options = {}) {
|
|
|
513
827
|
return model.fit(options);
|
|
514
828
|
}
|
|
515
829
|
|
|
516
|
-
|
|
830
|
+
const DEFAULT_SHORT = 1;
|
|
831
|
+
const DEFAULT_MEDIUM = 5;
|
|
832
|
+
const DEFAULT_LONG = 22;
|
|
833
|
+
/**
|
|
834
|
+
* Solve linear system Ax = b via Gaussian elimination with partial pivoting.
|
|
835
|
+
* A is n×n, b is n-vector. Returns x.
|
|
836
|
+
*/
|
|
837
|
+
function solveLinearSystem(A, b) {
|
|
838
|
+
const n = A.length;
|
|
839
|
+
const M = A.map((row, i) => [...row, b[i]]);
|
|
840
|
+
for (let col = 0; col < n; col++) {
|
|
841
|
+
let maxRow = col;
|
|
842
|
+
let maxVal = Math.abs(M[col][col]);
|
|
843
|
+
for (let row = col + 1; row < n; row++) {
|
|
844
|
+
if (Math.abs(M[row][col]) > maxVal) {
|
|
845
|
+
maxVal = Math.abs(M[row][col]);
|
|
846
|
+
maxRow = row;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
[M[col], M[maxRow]] = [M[maxRow], M[col]];
|
|
850
|
+
if (Math.abs(M[col][col]) < 1e-15) {
|
|
851
|
+
throw new Error('Singular matrix in HAR-RV OLS');
|
|
852
|
+
}
|
|
853
|
+
for (let row = col + 1; row < n; row++) {
|
|
854
|
+
const factor = M[row][col] / M[col][col];
|
|
855
|
+
for (let j = col; j <= n; j++) {
|
|
856
|
+
M[row][j] -= factor * M[col][j];
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const x = new Array(n).fill(0);
|
|
861
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
862
|
+
x[i] = M[i][n];
|
|
863
|
+
for (let j = i + 1; j < n; j++) {
|
|
864
|
+
x[i] -= M[i][j] * x[j];
|
|
865
|
+
}
|
|
866
|
+
x[i] /= M[i][i];
|
|
867
|
+
}
|
|
868
|
+
return x;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* OLS regression: y = Xβ + ε
|
|
872
|
+
* Returns coefficients, residuals, R², RSS, TSS.
|
|
873
|
+
*/
|
|
874
|
+
function ols(X, y) {
|
|
875
|
+
const n = X.length;
|
|
876
|
+
const p = X[0].length;
|
|
877
|
+
// X'X
|
|
878
|
+
const XtX = Array.from({ length: p }, () => new Array(p).fill(0));
|
|
879
|
+
for (let i = 0; i < p; i++) {
|
|
880
|
+
for (let j = 0; j < p; j++) {
|
|
881
|
+
for (let k = 0; k < n; k++) {
|
|
882
|
+
XtX[i][j] += X[k][i] * X[k][j];
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// X'y
|
|
887
|
+
const Xty = new Array(p).fill(0);
|
|
888
|
+
for (let i = 0; i < p; i++) {
|
|
889
|
+
for (let k = 0; k < n; k++) {
|
|
890
|
+
Xty[i] += X[k][i] * y[k];
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const beta = solveLinearSystem(XtX, Xty);
|
|
894
|
+
const yMean = y.reduce((s, v) => s + v, 0) / n;
|
|
895
|
+
let rss = 0;
|
|
896
|
+
let tss = 0;
|
|
897
|
+
const residuals = [];
|
|
898
|
+
for (let i = 0; i < n; i++) {
|
|
899
|
+
let yHat = 0;
|
|
900
|
+
for (let j = 0; j < p; j++) {
|
|
901
|
+
yHat += X[i][j] * beta[j];
|
|
902
|
+
}
|
|
903
|
+
const res = y[i] - yHat;
|
|
904
|
+
residuals.push(res);
|
|
905
|
+
rss += res * res;
|
|
906
|
+
tss += (y[i] - yMean) ** 2;
|
|
907
|
+
}
|
|
908
|
+
const r2 = tss > 0 ? 1 - rss / tss : 0;
|
|
909
|
+
return { beta, residuals, rss, tss, r2 };
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Compute rolling mean of rv[t-lag+1 .. t] (inclusive).
|
|
913
|
+
*/
|
|
914
|
+
function rollingMean(rv, t, lag) {
|
|
915
|
+
let sum = 0;
|
|
916
|
+
for (let j = 0; j < lag; j++) {
|
|
917
|
+
sum += rv[t - j];
|
|
918
|
+
}
|
|
919
|
+
return sum / lag;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* HAR-RV model (Corsi, 2009)
|
|
923
|
+
*
|
|
924
|
+
* RV_{t+1} = β₀ + β₁·RV_short + β₂·RV_medium + β₃·RV_long + ε
|
|
925
|
+
*
|
|
926
|
+
* where:
|
|
927
|
+
* - RV_short = mean(rv[t-s+1..t]) (default s=1)
|
|
928
|
+
* - RV_medium = mean(rv[t-m+1..t]) (default m=5)
|
|
929
|
+
* - RV_long = mean(rv[t-l+1..t]) (default l=22)
|
|
930
|
+
* - rv[t] = Parkinson(candle_t) for OHLC data, r[t]² for prices-only
|
|
931
|
+
*
|
|
932
|
+
* Parkinson (1980): RV = (1/(4·ln2))·(ln(H/L))², ~5x more efficient than r².
|
|
933
|
+
*
|
|
934
|
+
* Uses OLS for estimation — closed-form, always converges.
|
|
935
|
+
*/
|
|
936
|
+
class HarRv {
|
|
937
|
+
returns;
|
|
938
|
+
rv;
|
|
939
|
+
periodsPerYear;
|
|
940
|
+
shortLag;
|
|
941
|
+
mediumLag;
|
|
942
|
+
longLag;
|
|
943
|
+
constructor(data, options = {}) {
|
|
944
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
945
|
+
this.shortLag = options.shortLag ?? DEFAULT_SHORT;
|
|
946
|
+
this.mediumLag = options.mediumLag ?? DEFAULT_MEDIUM;
|
|
947
|
+
this.longLag = options.longLag ?? DEFAULT_LONG;
|
|
948
|
+
const minRequired = this.longLag + 30;
|
|
949
|
+
if (data.length < minRequired) {
|
|
950
|
+
throw new Error(`Need at least ${minRequired} data points for HAR-RV estimation`);
|
|
951
|
+
}
|
|
952
|
+
if (typeof data[0] === 'number') {
|
|
953
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
954
|
+
// Prices only — no OHLC, fall back to squared returns
|
|
955
|
+
this.rv = this.returns.map(r => r * r);
|
|
956
|
+
}
|
|
957
|
+
else {
|
|
958
|
+
const candles = data;
|
|
959
|
+
this.returns = calculateReturns(candles);
|
|
960
|
+
// Parkinson (1980) per-candle RV: (1/(4·ln2))·(ln(H/L))²
|
|
961
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Calibrate HAR-RV via OLS.
|
|
966
|
+
*/
|
|
967
|
+
fit() {
|
|
968
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
969
|
+
const n = rv.length;
|
|
970
|
+
// Build regression data
|
|
971
|
+
// Usable range: t = longLag-1 .. n-2 (need longLag history, and rv[t+1] as target)
|
|
972
|
+
const startIdx = longLag - 1;
|
|
973
|
+
const endIdx = n - 2;
|
|
974
|
+
const nObs = endIdx - startIdx + 1;
|
|
975
|
+
const X = [];
|
|
976
|
+
const y = [];
|
|
977
|
+
for (let t = startIdx; t <= endIdx; t++) {
|
|
978
|
+
const rvShort = rollingMean(rv, t, shortLag);
|
|
979
|
+
const rvMedium = rollingMean(rv, t, mediumLag);
|
|
980
|
+
const rvLong = rollingMean(rv, t, longLag);
|
|
981
|
+
X.push([1, rvShort, rvMedium, rvLong]);
|
|
982
|
+
y.push(rv[t + 1]);
|
|
983
|
+
}
|
|
984
|
+
const result = ols(X, y);
|
|
985
|
+
const [beta0, betaShort, betaMedium, betaLong] = result.beta;
|
|
986
|
+
const persistence = betaShort + betaMedium + betaLong;
|
|
987
|
+
const unconditionalVariance = persistence < 1 && persistence > -1
|
|
988
|
+
? Math.max(beta0 / (1 - persistence), 1e-20)
|
|
989
|
+
: sampleVariance(this.returns);
|
|
990
|
+
const annualizedVol = Math.sqrt(Math.abs(unconditionalVariance) * this.periodsPerYear) * 100;
|
|
991
|
+
// Student-t log-likelihood on returns using HAR-RV fitted variances
|
|
992
|
+
const varianceSeries = this.getVarianceSeriesInternal(result.beta);
|
|
993
|
+
const df = profileStudentTDf(this.returns, varianceSeries);
|
|
994
|
+
const ll = -studentTNegLL(this.returns, varianceSeries, df);
|
|
995
|
+
const numParams = 5; // beta0, betaShort, betaMedium, betaLong, df
|
|
996
|
+
return {
|
|
997
|
+
params: {
|
|
998
|
+
beta0,
|
|
999
|
+
betaShort,
|
|
1000
|
+
betaMedium,
|
|
1001
|
+
betaLong,
|
|
1002
|
+
persistence,
|
|
1003
|
+
unconditionalVariance,
|
|
1004
|
+
annualizedVol,
|
|
1005
|
+
r2: result.r2,
|
|
1006
|
+
df,
|
|
1007
|
+
},
|
|
1008
|
+
diagnostics: {
|
|
1009
|
+
logLikelihood: ll,
|
|
1010
|
+
aic: calculateAIC(ll, numParams),
|
|
1011
|
+
bic: calculateBIC(ll, numParams, nObs),
|
|
1012
|
+
iterations: 1,
|
|
1013
|
+
converged: true,
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Internal: compute variance series from beta vector.
|
|
1019
|
+
*/
|
|
1020
|
+
getVarianceSeriesInternal(beta) {
|
|
1021
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1022
|
+
const n = rv.length;
|
|
1023
|
+
const fallback = sampleVariance(this.returns);
|
|
1024
|
+
const series = [];
|
|
1025
|
+
for (let i = 0; i < n; i++) {
|
|
1026
|
+
if (i < longLag) {
|
|
1027
|
+
// Not enough history — use sample variance
|
|
1028
|
+
series.push(fallback);
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
// HAR prediction for rv[i] based on rv[..i-1]
|
|
1032
|
+
const t = i - 1;
|
|
1033
|
+
const rvS = rollingMean(rv, t, shortLag);
|
|
1034
|
+
const rvM = rollingMean(rv, t, mediumLag);
|
|
1035
|
+
const rvL = rollingMean(rv, t, longLag);
|
|
1036
|
+
const predicted = beta[0] + beta[1] * rvS + beta[2] * rvM + beta[3] * rvL;
|
|
1037
|
+
series.push(Math.max(predicted, 1e-20));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return series;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Calculate conditional variance series given parameters.
|
|
1044
|
+
*/
|
|
1045
|
+
getVarianceSeries(params) {
|
|
1046
|
+
const beta = [params.beta0, params.betaShort, params.betaMedium, params.betaLong];
|
|
1047
|
+
return this.getVarianceSeriesInternal(beta);
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Forecast variance forward.
|
|
1051
|
+
*
|
|
1052
|
+
* Uses iterative substitution: each forecast step feeds back
|
|
1053
|
+
* into the rolling RV components for subsequent steps.
|
|
1054
|
+
*/
|
|
1055
|
+
forecast(params, steps = 1) {
|
|
1056
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1057
|
+
const { beta0, betaShort, betaMedium, betaLong } = params;
|
|
1058
|
+
// Working copy of recent rv values + forecasts appended
|
|
1059
|
+
const history = rv.slice();
|
|
1060
|
+
const variance = [];
|
|
1061
|
+
for (let h = 0; h < steps; h++) {
|
|
1062
|
+
const t = history.length - 1;
|
|
1063
|
+
const rvS = rollingMean(history, t, shortLag);
|
|
1064
|
+
const rvM = rollingMean(history, t, mediumLag);
|
|
1065
|
+
const rvL = rollingMean(history, t, longLag);
|
|
1066
|
+
const predicted = beta0 + betaShort * rvS + betaMedium * rvM + betaLong * rvL;
|
|
1067
|
+
const v = Math.max(predicted, 1e-20);
|
|
1068
|
+
variance.push(v);
|
|
1069
|
+
history.push(v);
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
variance,
|
|
1073
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1074
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Get the return series.
|
|
1079
|
+
*/
|
|
1080
|
+
getReturns() {
|
|
1081
|
+
return [...this.returns];
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Get realized variance series (squared returns).
|
|
1085
|
+
*/
|
|
1086
|
+
getRv() {
|
|
1087
|
+
return [...this.rv];
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Convenience function to calibrate HAR-RV from candles or prices.
|
|
1092
|
+
*/
|
|
1093
|
+
function calibrateHarRv(data, options = {}) {
|
|
1094
|
+
const model = new HarRv(data, options);
|
|
1095
|
+
return model.fit();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* GJR-GARCH(1,1) model (Glosten, Jagannathan & Runkle, 1993)
|
|
1100
|
+
*
|
|
1101
|
+
* σ²ₜ = ω + α·ε²ₜ₋₁ + γ·ε²ₜ₋₁·I(rₜ₋₁<0) + β·σ²ₜ₋₁
|
|
1102
|
+
*
|
|
1103
|
+
* where:
|
|
1104
|
+
* - ω (omega) > 0: constant term
|
|
1105
|
+
* - α (alpha) ≥ 0: symmetric shock response
|
|
1106
|
+
* - γ (gamma) ≥ 0: asymmetric leverage coefficient
|
|
1107
|
+
* - β (beta) ≥ 0: persistence
|
|
1108
|
+
* - I(r<0) = 1 when return is negative, 0 otherwise
|
|
1109
|
+
* - Stationarity: α + γ/2 + β < 1
|
|
1110
|
+
*
|
|
1111
|
+
* With Candle[] input, ε² is replaced by Parkinson per-candle RV.
|
|
1112
|
+
* Leverage direction still comes from close-to-close return sign.
|
|
1113
|
+
*/
|
|
1114
|
+
class GjrGarch {
|
|
1115
|
+
returns;
|
|
1116
|
+
rv;
|
|
1117
|
+
periodsPerYear;
|
|
1118
|
+
initialVariance;
|
|
1119
|
+
constructor(data, options = {}) {
|
|
1120
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1121
|
+
if (data.length < 50) {
|
|
1122
|
+
throw new Error('Need at least 50 data points for GJR-GARCH estimation');
|
|
1123
|
+
}
|
|
1124
|
+
if (typeof data[0] === 'number') {
|
|
1125
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1126
|
+
this.initialVariance = sampleVariance(this.returns);
|
|
1127
|
+
this.rv = null;
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
const candles = data;
|
|
1131
|
+
this.returns = calculateReturns(candles);
|
|
1132
|
+
this.initialVariance = yangZhangVariance(candles);
|
|
1133
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Calibrate GJR-GARCH(1,1) parameters using Maximum Likelihood Estimation
|
|
1138
|
+
*/
|
|
1139
|
+
fit(options = {}) {
|
|
1140
|
+
const { maxIter = 1000, tol = 1e-8 } = options;
|
|
1141
|
+
const returns = this.returns;
|
|
1142
|
+
const n = returns.length;
|
|
1143
|
+
const initVar = this.initialVariance;
|
|
1144
|
+
const rv = this.rv;
|
|
1145
|
+
function negLogLikelihood(params) {
|
|
1146
|
+
const [omega, alpha, gamma, beta, df] = params;
|
|
1147
|
+
if (omega <= 1e-12)
|
|
1148
|
+
return 1e10;
|
|
1149
|
+
if (alpha < 0 || gamma < 0 || beta < 0)
|
|
1150
|
+
return 1e10;
|
|
1151
|
+
if (alpha + gamma / 2 + beta >= 0.9999)
|
|
1152
|
+
return 1e10;
|
|
1153
|
+
if (df <= 2.01 || df > 100)
|
|
1154
|
+
return 1e10;
|
|
1155
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
1156
|
+
const dfMinus2 = df - 2;
|
|
1157
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
1158
|
+
let variance = initVar;
|
|
1159
|
+
let ll = 0;
|
|
1160
|
+
for (let i = 0; i < n; i++) {
|
|
1161
|
+
if (i > 0) {
|
|
1162
|
+
const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
|
|
1163
|
+
const indicator = returns[i - 1] < 0 ? 1 : 0;
|
|
1164
|
+
variance = omega + alpha * innovation + gamma * innovation * indicator + beta * variance;
|
|
1165
|
+
}
|
|
1166
|
+
if (variance <= 1e-12)
|
|
1167
|
+
return 1e10;
|
|
1168
|
+
// Student-t log-likelihood
|
|
1169
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
1170
|
+
}
|
|
1171
|
+
return -(ll + constant);
|
|
1172
|
+
}
|
|
1173
|
+
const omega0 = initVar * 0.05;
|
|
1174
|
+
const alpha0 = 0.05;
|
|
1175
|
+
const gamma0 = 0.1;
|
|
1176
|
+
const beta0 = 0.85;
|
|
1177
|
+
const df0 = 5;
|
|
1178
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
|
|
1179
|
+
const [omega, alpha, gamma, beta, df] = result.x;
|
|
1180
|
+
const persistence = alpha + gamma / 2 + beta;
|
|
1181
|
+
const unconditionalVariance = omega / (1 - persistence);
|
|
1182
|
+
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
1183
|
+
const logLikelihood = -result.fx;
|
|
1184
|
+
const numParams = 5;
|
|
1185
|
+
return {
|
|
1186
|
+
params: {
|
|
1187
|
+
omega,
|
|
1188
|
+
alpha,
|
|
1189
|
+
gamma,
|
|
1190
|
+
beta,
|
|
1191
|
+
persistence,
|
|
1192
|
+
unconditionalVariance,
|
|
1193
|
+
annualizedVol,
|
|
1194
|
+
leverageEffect: gamma,
|
|
1195
|
+
df,
|
|
1196
|
+
},
|
|
1197
|
+
diagnostics: {
|
|
1198
|
+
logLikelihood,
|
|
1199
|
+
aic: calculateAIC(logLikelihood, numParams),
|
|
1200
|
+
bic: calculateBIC(logLikelihood, numParams, n),
|
|
1201
|
+
iterations: result.iterations,
|
|
1202
|
+
converged: result.converged,
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Calculate conditional variance series given parameters
|
|
1208
|
+
*/
|
|
1209
|
+
getVarianceSeries(params) {
|
|
1210
|
+
const { omega, alpha, gamma, beta } = params;
|
|
1211
|
+
const variance = [];
|
|
1212
|
+
for (let i = 0; i < this.returns.length; i++) {
|
|
1213
|
+
if (i === 0) {
|
|
1214
|
+
variance.push(this.initialVariance);
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
const innovation = this.rv ? this.rv[i - 1] : this.returns[i - 1] ** 2;
|
|
1218
|
+
const indicator = this.returns[i - 1] < 0 ? 1 : 0;
|
|
1219
|
+
const v = omega + alpha * innovation + gamma * innovation * indicator + beta * variance[i - 1];
|
|
1220
|
+
variance.push(v);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return variance;
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Forecast variance forward
|
|
1227
|
+
*/
|
|
1228
|
+
forecast(params, steps = 1) {
|
|
1229
|
+
const { omega, alpha, gamma, beta } = params;
|
|
1230
|
+
const variance = [];
|
|
1231
|
+
const varianceSeries = this.getVarianceSeries(params);
|
|
1232
|
+
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
1233
|
+
const lastInnovation = this.rv
|
|
1234
|
+
? this.rv[this.rv.length - 1]
|
|
1235
|
+
: this.returns[this.returns.length - 1] ** 2;
|
|
1236
|
+
const lastIndicator = this.returns[this.returns.length - 1] < 0 ? 1 : 0;
|
|
1237
|
+
// One-step ahead using actual last return
|
|
1238
|
+
let v = omega + alpha * lastInnovation + gamma * lastInnovation * lastIndicator + beta * lastVariance;
|
|
1239
|
+
variance.push(v);
|
|
1240
|
+
// Multi-step: E[I(r<0)] = 0.5, so effective persistence = α + γ/2 + β
|
|
1241
|
+
for (let h = 1; h < steps; h++) {
|
|
1242
|
+
v = omega + (alpha + gamma / 2 + beta) * v;
|
|
1243
|
+
variance.push(v);
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
variance,
|
|
1247
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1248
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Get the return series
|
|
1253
|
+
*/
|
|
1254
|
+
getReturns() {
|
|
1255
|
+
return [...this.returns];
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Get initial variance estimate
|
|
1259
|
+
*/
|
|
1260
|
+
getInitialVariance() {
|
|
1261
|
+
return this.initialVariance;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Convenience function to calibrate GJR-GARCH(1,1) from candles
|
|
1266
|
+
*/
|
|
1267
|
+
function calibrateGjrGarch(data, options = {}) {
|
|
1268
|
+
const model = new GjrGarch(data, options);
|
|
1269
|
+
return model.fit(options);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const DEFAULT_LAGS = 10;
|
|
1273
|
+
/**
|
|
1274
|
+
* NoVaS (Normalizing and Variance-Stabilizing) model (Politis, 2003)
|
|
1275
|
+
*
|
|
1276
|
+
* Two-stage calibration:
|
|
1277
|
+
*
|
|
1278
|
+
* Stage 1 — D² minimization (model-free normality):
|
|
1279
|
+
* σ²_t = a_0 + a_1·X²_{t-1} + a_2·X²_{t-2} + ... + a_p·X²_{t-p}
|
|
1280
|
+
* W_t = X_t / σ_t
|
|
1281
|
+
* Minimize D² = S² + (K - 3)² where S, K are skewness and kurtosis of {W_t}.
|
|
1282
|
+
*
|
|
1283
|
+
* Stage 2 — OLS rescaling (forecast-optimal):
|
|
1284
|
+
* RV_{t+1} = β₀ + β₁·σ²_t(D²)
|
|
1285
|
+
* The D²-discovered σ²_t acts as a data-driven smoother over RV lags.
|
|
1286
|
+
* OLS rescales it to minimize forecast error (RSS on RV).
|
|
1287
|
+
* Only 2 parameters → robust on small samples with noisy per-candle RV.
|
|
1288
|
+
*
|
|
1289
|
+
* D² discovers lag structure (model-free). OLS rescales for prediction accuracy.
|
|
1290
|
+
* Both weight sets are stored in params — no identity loss.
|
|
1291
|
+
*/
|
|
1292
|
+
class NoVaS {
|
|
1293
|
+
returns;
|
|
1294
|
+
rv;
|
|
1295
|
+
periodsPerYear;
|
|
1296
|
+
lags;
|
|
1297
|
+
constructor(data, options = {}) {
|
|
1298
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1299
|
+
this.lags = options.lags ?? DEFAULT_LAGS;
|
|
1300
|
+
const minRequired = this.lags + 30;
|
|
1301
|
+
if (data.length < minRequired) {
|
|
1302
|
+
throw new Error(`Need at least ${minRequired} data points for NoVaS estimation`);
|
|
1303
|
+
}
|
|
1304
|
+
if (typeof data[0] === 'number') {
|
|
1305
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1306
|
+
this.rv = null;
|
|
1307
|
+
}
|
|
1308
|
+
else {
|
|
1309
|
+
const candles = data;
|
|
1310
|
+
this.returns = calculateReturns(candles);
|
|
1311
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
1312
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Calibrate NoVaS weights via two-stage procedure:
|
|
1317
|
+
* Stage 1: D² minimization (normality of W_t)
|
|
1318
|
+
* Stage 2: OLS rescaling of D²-variance (forecast-optimal)
|
|
1319
|
+
*/
|
|
1320
|
+
fit(options = {}) {
|
|
1321
|
+
const { maxIter = 2000, tol = 1e-8 } = options;
|
|
1322
|
+
const returns = this.returns;
|
|
1323
|
+
const n = returns.length;
|
|
1324
|
+
const p = this.lags;
|
|
1325
|
+
const initVar = sampleVariance(returns);
|
|
1326
|
+
// Innovation: Parkinson RV for candles, r² for prices
|
|
1327
|
+
const r2 = this.rv ?? returns.map(r => r * r);
|
|
1328
|
+
/**
|
|
1329
|
+
* Compute D² for a given weight vector.
|
|
1330
|
+
* D² = S² + (K - 3)² where S, K are skewness and kurtosis of W_t.
|
|
1331
|
+
*/
|
|
1332
|
+
function objectiveD2(rawWeights) {
|
|
1333
|
+
// Enforce constraints: a_j >= 0 via abs, a_0 > epsilon
|
|
1334
|
+
const weights = rawWeights.map(w => Math.abs(w));
|
|
1335
|
+
if (weights[0] < 1e-15)
|
|
1336
|
+
return 1e10;
|
|
1337
|
+
// Stationarity: sum(a_1..a_p) < 1
|
|
1338
|
+
let lagSum = 0;
|
|
1339
|
+
for (let j = 1; j <= p; j++)
|
|
1340
|
+
lagSum += weights[j];
|
|
1341
|
+
if (lagSum >= 0.9999)
|
|
1342
|
+
return 1e10;
|
|
1343
|
+
// Compute transformed series W_t = r_t / sqrt(sigma^2_t)
|
|
1344
|
+
let sumW = 0;
|
|
1345
|
+
let sumW2 = 0;
|
|
1346
|
+
let sumW3 = 0;
|
|
1347
|
+
let sumW4 = 0;
|
|
1348
|
+
let count = 0;
|
|
1349
|
+
for (let t = p; t < n; t++) {
|
|
1350
|
+
let variance = weights[0];
|
|
1351
|
+
for (let j = 1; j <= p; j++) {
|
|
1352
|
+
variance += weights[j] * r2[t - j];
|
|
1353
|
+
}
|
|
1354
|
+
if (variance <= 1e-15)
|
|
1355
|
+
return 1e10;
|
|
1356
|
+
const w = returns[t] / Math.sqrt(variance);
|
|
1357
|
+
if (!isFinite(w))
|
|
1358
|
+
return 1e10;
|
|
1359
|
+
sumW += w;
|
|
1360
|
+
sumW2 += w * w;
|
|
1361
|
+
sumW3 += w * w * w;
|
|
1362
|
+
sumW4 += w * w * w * w;
|
|
1363
|
+
count++;
|
|
1364
|
+
}
|
|
1365
|
+
if (count < 10)
|
|
1366
|
+
return 1e10;
|
|
1367
|
+
const mean = sumW / count;
|
|
1368
|
+
const m2 = sumW2 / count - mean * mean;
|
|
1369
|
+
if (m2 <= 1e-15)
|
|
1370
|
+
return 1e10;
|
|
1371
|
+
const m3 = sumW3 / count - 3 * mean * sumW2 / count + 2 * mean * mean * mean;
|
|
1372
|
+
const m4 = sumW4 / count - 4 * mean * sumW3 / count
|
|
1373
|
+
+ 6 * mean * mean * sumW2 / count - 3 * mean * mean * mean * mean;
|
|
1374
|
+
const skewness = m3 / (m2 * Math.sqrt(m2));
|
|
1375
|
+
const kurtosis = m4 / (m2 * m2);
|
|
1376
|
+
if (!isFinite(skewness) || !isFinite(kurtosis))
|
|
1377
|
+
return 1e10;
|
|
1378
|
+
return skewness * skewness + (kurtosis - 3) * (kurtosis - 3);
|
|
1379
|
+
}
|
|
1380
|
+
// Initial guess: intercept in variance units, lag weights dimensionless
|
|
1381
|
+
const lambda = 0.7;
|
|
1382
|
+
const x0 = [initVar * 0.1];
|
|
1383
|
+
for (let j = 1; j <= p; j++) {
|
|
1384
|
+
x0.push(0.9 * (1 - lambda) * Math.pow(lambda, j - 1));
|
|
1385
|
+
}
|
|
1386
|
+
const result = nelderMeadMultiStart(objectiveD2, x0, { maxIter, tol, restarts: 6 });
|
|
1387
|
+
// Extract final weights (abs for constraint enforcement)
|
|
1388
|
+
const weights = result.x.map(w => Math.abs(w));
|
|
1389
|
+
let persistence = 0;
|
|
1390
|
+
for (let j = 1; j <= p; j++)
|
|
1391
|
+
persistence += weights[j];
|
|
1392
|
+
const unconditionalVariance = persistence < 1 && persistence > -1
|
|
1393
|
+
? Math.max(weights[0] / (1 - persistence), 1e-20)
|
|
1394
|
+
: sampleVariance(returns);
|
|
1395
|
+
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
1396
|
+
// ── Stage 2: OLS rescaling of D²-variance ──────────────
|
|
1397
|
+
// RV_{t+1} = β₀ + β₁·σ²_t(D²)
|
|
1398
|
+
// D² weights discover lag structure; OLS rescales for forecast accuracy.
|
|
1399
|
+
// Only 2 parameters → robust on small samples with noisy per-candle RV.
|
|
1400
|
+
const d2Variance = this.getVarianceSeriesInternal(weights);
|
|
1401
|
+
let forecastWeights;
|
|
1402
|
+
let olsR2;
|
|
1403
|
+
try {
|
|
1404
|
+
let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, count = 0;
|
|
1405
|
+
for (let t = p; t < n - 1; t++) {
|
|
1406
|
+
const x = d2Variance[t];
|
|
1407
|
+
const y = r2[t + 1];
|
|
1408
|
+
sumX += x;
|
|
1409
|
+
sumY += y;
|
|
1410
|
+
sumXX += x * x;
|
|
1411
|
+
sumXY += x * y;
|
|
1412
|
+
count++;
|
|
1413
|
+
}
|
|
1414
|
+
const denom = count * sumXX - sumX * sumX;
|
|
1415
|
+
if (Math.abs(denom) < 1e-30)
|
|
1416
|
+
throw new Error('Degenerate variance series');
|
|
1417
|
+
const beta1 = (count * sumXY - sumX * sumY) / denom;
|
|
1418
|
+
const beta0 = (sumY - beta1 * sumX) / count;
|
|
1419
|
+
forecastWeights = [beta0, beta1];
|
|
1420
|
+
// R²
|
|
1421
|
+
const yMean = sumY / count;
|
|
1422
|
+
let rss = 0, tss = 0;
|
|
1423
|
+
for (let t = p; t < n - 1; t++) {
|
|
1424
|
+
const yHat = beta0 + beta1 * d2Variance[t];
|
|
1425
|
+
rss += (r2[t + 1] - yHat) ** 2;
|
|
1426
|
+
tss += (r2[t + 1] - yMean) ** 2;
|
|
1427
|
+
}
|
|
1428
|
+
olsR2 = tss > 0 ? 1 - rss / tss : 0;
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
// OLS failed — fall back to identity rescaling [0, 1]
|
|
1432
|
+
forecastWeights = [0, 1];
|
|
1433
|
+
olsR2 = 0;
|
|
1434
|
+
}
|
|
1435
|
+
// Student-t log-likelihood for AIC comparison with GARCH/EGARCH/HAR-RV
|
|
1436
|
+
const df = profileStudentTDf(returns, d2Variance);
|
|
1437
|
+
const ll = -studentTNegLL(returns, d2Variance, df);
|
|
1438
|
+
const numParams = p + 2; // weights + df
|
|
1439
|
+
const nObs = n - p; // usable observations for D²
|
|
1440
|
+
return {
|
|
1441
|
+
params: {
|
|
1442
|
+
weights,
|
|
1443
|
+
forecastWeights,
|
|
1444
|
+
lags: p,
|
|
1445
|
+
persistence,
|
|
1446
|
+
unconditionalVariance,
|
|
1447
|
+
annualizedVol,
|
|
1448
|
+
dSquared: result.fx,
|
|
1449
|
+
r2: olsR2,
|
|
1450
|
+
df,
|
|
1451
|
+
},
|
|
1452
|
+
diagnostics: {
|
|
1453
|
+
logLikelihood: ll,
|
|
1454
|
+
aic: calculateAIC(ll, numParams),
|
|
1455
|
+
bic: calculateBIC(ll, numParams, nObs),
|
|
1456
|
+
iterations: result.iterations,
|
|
1457
|
+
converged: result.converged,
|
|
1458
|
+
},
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Internal: compute variance series from D² weight vector.
|
|
1463
|
+
*/
|
|
1464
|
+
getVarianceSeriesInternal(weights) {
|
|
1465
|
+
const { returns, lags } = this;
|
|
1466
|
+
const n = returns.length;
|
|
1467
|
+
const r2 = this.rv ?? returns.map(r => r * r);
|
|
1468
|
+
const fallback = sampleVariance(returns);
|
|
1469
|
+
const series = [];
|
|
1470
|
+
for (let t = 0; t < n; t++) {
|
|
1471
|
+
if (t < lags) {
|
|
1472
|
+
series.push(fallback);
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
let variance = weights[0];
|
|
1476
|
+
for (let j = 1; j <= lags; j++) {
|
|
1477
|
+
variance += weights[j] * r2[t - j];
|
|
1478
|
+
}
|
|
1479
|
+
series.push(Math.max(variance, 1e-20));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return series;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Calculate conditional variance series using D² weights (normalization identity).
|
|
1486
|
+
*/
|
|
1487
|
+
getVarianceSeries(params) {
|
|
1488
|
+
return this.getVarianceSeriesInternal(params.weights);
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Calculate forecast variance series using OLS-rescaled D² variance.
|
|
1492
|
+
* forecast_σ²_t = β₀ + β₁·σ²_t(D²)
|
|
1493
|
+
* Used for QLIKE model comparison — measures forecast quality.
|
|
1494
|
+
*/
|
|
1495
|
+
getForecastVarianceSeries(params) {
|
|
1496
|
+
const d2Series = this.getVarianceSeriesInternal(params.weights);
|
|
1497
|
+
const [beta0, beta1] = params.forecastWeights;
|
|
1498
|
+
return d2Series.map(v => Math.max(beta0 + beta1 * v, 1e-20));
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Forecast variance forward using OLS-rescaled D² weights.
|
|
1502
|
+
*
|
|
1503
|
+
* Step 1: compute D²-based σ²_{t+h} using D² weights
|
|
1504
|
+
* Step 2: rescale via β₀ + β₁·σ²_{t+h}
|
|
1505
|
+
*/
|
|
1506
|
+
forecast(params, steps = 1) {
|
|
1507
|
+
const { weights, forecastWeights, lags } = params;
|
|
1508
|
+
const [beta0, beta1] = forecastWeights;
|
|
1509
|
+
const r2 = this.rv ?? this.returns.map(r => r * r);
|
|
1510
|
+
// Working buffer: past innovation values + forecasted variances
|
|
1511
|
+
const history = r2.slice();
|
|
1512
|
+
const variance = [];
|
|
1513
|
+
for (let h = 0; h < steps; h++) {
|
|
1514
|
+
const t = history.length;
|
|
1515
|
+
// D²-based variance at this step
|
|
1516
|
+
let d2v = weights[0];
|
|
1517
|
+
for (let j = 1; j <= lags; j++) {
|
|
1518
|
+
d2v += weights[j] * history[t - j];
|
|
1519
|
+
}
|
|
1520
|
+
d2v = Math.max(d2v, 1e-20);
|
|
1521
|
+
// OLS-rescaled forecast
|
|
1522
|
+
const v = Math.max(beta0 + beta1 * d2v, 1e-20);
|
|
1523
|
+
variance.push(v);
|
|
1524
|
+
history.push(v); // future E[RV] = σ²
|
|
1525
|
+
}
|
|
1526
|
+
return {
|
|
1527
|
+
variance,
|
|
1528
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1529
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Get the return series.
|
|
1534
|
+
*/
|
|
1535
|
+
getReturns() {
|
|
1536
|
+
return [...this.returns];
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Convenience function to calibrate NoVaS from candles or prices.
|
|
1541
|
+
*/
|
|
1542
|
+
function calibrateNoVaS(data, options = {}) {
|
|
1543
|
+
const model = new NoVaS(data, options);
|
|
1544
|
+
return model.fit(options);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const MIN_CANDLES = {
|
|
1548
|
+
'1m': 500,
|
|
1549
|
+
'3m': 500,
|
|
1550
|
+
'5m': 500,
|
|
1551
|
+
'15m': 300,
|
|
1552
|
+
'30m': 200,
|
|
1553
|
+
'1h': 200,
|
|
1554
|
+
'2h': 200,
|
|
1555
|
+
'4h': 200,
|
|
1556
|
+
'6h': 150,
|
|
1557
|
+
'8h': 150,
|
|
1558
|
+
};
|
|
1559
|
+
const RECOMMENDED_CANDLES = {
|
|
1560
|
+
'1m': 1500,
|
|
1561
|
+
'3m': 1500,
|
|
1562
|
+
'5m': 1500,
|
|
1563
|
+
'15m': 1000,
|
|
1564
|
+
'30m': 1000,
|
|
1565
|
+
'1h': 500,
|
|
1566
|
+
'2h': 500,
|
|
1567
|
+
'4h': 500,
|
|
1568
|
+
'6h': 300,
|
|
1569
|
+
'8h': 300,
|
|
1570
|
+
};
|
|
1571
|
+
const INTERVALS_PER_YEAR = {
|
|
1572
|
+
'1m': 525_600,
|
|
1573
|
+
'3m': 175_200,
|
|
1574
|
+
'5m': 105_120,
|
|
1575
|
+
'15m': 35_040,
|
|
1576
|
+
'30m': 17_520,
|
|
1577
|
+
'1h': 8_760,
|
|
1578
|
+
'2h': 4_380,
|
|
1579
|
+
'4h': 2_190,
|
|
1580
|
+
'6h': 1_460,
|
|
1581
|
+
'8h': 1_095,
|
|
1582
|
+
};
|
|
1583
|
+
function assertMinCandles(candles, interval) {
|
|
1584
|
+
const min = MIN_CANDLES[interval];
|
|
1585
|
+
if (candles.length < min) {
|
|
1586
|
+
throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
|
|
1587
|
+
}
|
|
1588
|
+
for (let i = 0; i < candles.length; i++) {
|
|
1589
|
+
const c = candles[i];
|
|
1590
|
+
if (!isFinite(c.close) || c.close <= 0) {
|
|
1591
|
+
throw new Error(`Invalid close price at candle ${i}: ${c.close}`);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
const recommended = RECOMMENDED_CANDLES[interval];
|
|
1595
|
+
if (candles.length < recommended) {
|
|
1596
|
+
console.warn(`[garch] ${interval}: ${candles.length} candles provided, recommend ≥${recommended} for reliable results. Check reliable: true in output.`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
function fitGarchFamily(candles, periodsPerYear, steps) {
|
|
1600
|
+
// Fit all three GARCH-family models and pick the best by AIC
|
|
1601
|
+
// (AIC is fair here — all three optimize the same Student-t LL)
|
|
1602
|
+
const garchModel = new Garch(candles, { periodsPerYear });
|
|
1603
|
+
const garchFit = garchModel.fit();
|
|
1604
|
+
let bestAic = garchFit.diagnostics.aic;
|
|
1605
|
+
let best = {
|
|
1606
|
+
forecast: garchModel.forecast(garchFit.params, steps),
|
|
1607
|
+
modelType: 'garch',
|
|
1608
|
+
converged: garchFit.diagnostics.converged,
|
|
1609
|
+
persistence: garchFit.params.persistence,
|
|
1610
|
+
varianceSeries: garchModel.getVarianceSeries(garchFit.params),
|
|
1611
|
+
returns: garchModel.getReturns(),
|
|
1612
|
+
};
|
|
1613
|
+
const egarchModel = new Egarch(candles, { periodsPerYear });
|
|
1614
|
+
const egarchFit = egarchModel.fit();
|
|
1615
|
+
if (egarchFit.diagnostics.aic < bestAic) {
|
|
1616
|
+
bestAic = egarchFit.diagnostics.aic;
|
|
1617
|
+
best = {
|
|
1618
|
+
forecast: egarchModel.forecast(egarchFit.params, steps),
|
|
1619
|
+
modelType: 'egarch',
|
|
1620
|
+
converged: egarchFit.diagnostics.converged,
|
|
1621
|
+
persistence: egarchFit.params.persistence,
|
|
1622
|
+
varianceSeries: egarchModel.getVarianceSeries(egarchFit.params),
|
|
1623
|
+
returns: egarchModel.getReturns(),
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
const gjrModel = new GjrGarch(candles, { periodsPerYear });
|
|
1627
|
+
const gjrFit = gjrModel.fit();
|
|
1628
|
+
if (gjrFit.diagnostics.aic < bestAic) {
|
|
1629
|
+
best = {
|
|
1630
|
+
forecast: gjrModel.forecast(gjrFit.params, steps),
|
|
1631
|
+
modelType: 'gjr-garch',
|
|
1632
|
+
converged: gjrFit.diagnostics.converged,
|
|
1633
|
+
persistence: gjrFit.params.persistence,
|
|
1634
|
+
varianceSeries: gjrModel.getVarianceSeries(gjrFit.params),
|
|
1635
|
+
returns: gjrModel.getReturns(),
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
return best;
|
|
1639
|
+
}
|
|
1640
|
+
function fitHarRv(candles, periodsPerYear, steps) {
|
|
1641
|
+
try {
|
|
1642
|
+
const model = new HarRv(candles, { periodsPerYear });
|
|
1643
|
+
const fit = model.fit();
|
|
1644
|
+
// Skip HAR-RV if persistence >= 1 (non-stationary) or R² too low
|
|
1645
|
+
if (fit.params.persistence >= 1 || fit.params.r2 < 0)
|
|
1646
|
+
return null;
|
|
1647
|
+
return {
|
|
1648
|
+
forecast: model.forecast(fit.params, steps),
|
|
1649
|
+
modelType: 'har-rv',
|
|
1650
|
+
converged: fit.diagnostics.converged,
|
|
1651
|
+
persistence: fit.params.persistence,
|
|
1652
|
+
varianceSeries: model.getVarianceSeries(fit.params),
|
|
1653
|
+
returns: model.getReturns(),
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
catch {
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
function fitNoVaS(candles, periodsPerYear, steps) {
|
|
1661
|
+
try {
|
|
1662
|
+
const model = new NoVaS(candles, { periodsPerYear });
|
|
1663
|
+
const fit = model.fit();
|
|
1664
|
+
// Skip if persistence >= 1 (non-stationary)
|
|
1665
|
+
if (fit.params.persistence >= 1)
|
|
1666
|
+
return null;
|
|
1667
|
+
return {
|
|
1668
|
+
forecast: model.forecast(fit.params, steps),
|
|
1669
|
+
modelType: 'novas',
|
|
1670
|
+
converged: fit.diagnostics.converged,
|
|
1671
|
+
persistence: fit.params.persistence,
|
|
1672
|
+
varianceSeries: model.getForecastVarianceSeries(fit.params),
|
|
1673
|
+
returns: model.getReturns(),
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
catch {
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
function fitModel(candles, periodsPerYear, steps) {
|
|
1681
|
+
const garchResult = fitGarchFamily(candles, periodsPerYear, steps);
|
|
1682
|
+
const harResult = fitHarRv(candles, periodsPerYear, steps);
|
|
1683
|
+
const novasResult = fitNoVaS(candles, periodsPerYear, steps);
|
|
1684
|
+
// Compute realized variance (Parkinson RV) for QLIKE scoring
|
|
1685
|
+
const returns = calculateReturns(candles);
|
|
1686
|
+
const rv = perCandleParkinson(candles, returns);
|
|
1687
|
+
// Pick model with lowest QLIKE (Patton 2011) — neutral forecast-error metric.
|
|
1688
|
+
// Unlike AIC (which favors MLE-calibrated models), QLIKE judges only
|
|
1689
|
+
// how well the variance series predicts realized variance.
|
|
1690
|
+
let best = garchResult;
|
|
1691
|
+
let bestScore = qlike(garchResult.varianceSeries, rv);
|
|
1692
|
+
if (harResult) {
|
|
1693
|
+
const score = qlike(harResult.varianceSeries, rv);
|
|
1694
|
+
if (score < bestScore) {
|
|
1695
|
+
best = harResult;
|
|
1696
|
+
bestScore = score;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (novasResult) {
|
|
1700
|
+
const score = qlike(novasResult.varianceSeries, rv);
|
|
1701
|
+
if (score < bestScore) {
|
|
1702
|
+
best = novasResult;
|
|
1703
|
+
bestScore = score;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
return best;
|
|
1707
|
+
}
|
|
1708
|
+
function checkReliable(fit) {
|
|
1709
|
+
if (!fit.converged || fit.persistence >= 0.999)
|
|
1710
|
+
return false;
|
|
1711
|
+
// Ljung-Box on squared standardized residuals
|
|
1712
|
+
const { returns, varianceSeries } = fit;
|
|
1713
|
+
const squared = returns.map((r, i) => {
|
|
1714
|
+
const z = r / Math.sqrt(varianceSeries[i]);
|
|
1715
|
+
return z * z;
|
|
1716
|
+
});
|
|
1717
|
+
const lb = ljungBox(squared, 10);
|
|
1718
|
+
return lb.pValue >= 0.05;
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Forecast expected price range for t+1 (next candle).
|
|
1722
|
+
*
|
|
1723
|
+
* Auto-selects GARCH or EGARCH based on leverage effect.
|
|
1724
|
+
* Returns ±1σ price corridor so you can set SL/TP yourself.
|
|
1725
|
+
*/
|
|
1726
|
+
function predict(candles, interval, currentPrice = candles[candles.length - 1].close) {
|
|
1727
|
+
assertMinCandles(candles, interval);
|
|
1728
|
+
const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], 1);
|
|
1729
|
+
const sigma = fit.forecast.volatility[0];
|
|
1730
|
+
const move = currentPrice * sigma;
|
|
1731
|
+
return {
|
|
1732
|
+
modelType: fit.modelType,
|
|
1733
|
+
currentPrice,
|
|
1734
|
+
sigma,
|
|
1735
|
+
move,
|
|
1736
|
+
upperPrice: currentPrice + move,
|
|
1737
|
+
lowerPrice: currentPrice - move,
|
|
1738
|
+
reliable: checkReliable(fit),
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Forecast expected price range over multiple candles.
|
|
1743
|
+
*
|
|
1744
|
+
* Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
|
|
1745
|
+
* Use for swing trades where you hold across multiple candles.
|
|
1746
|
+
*/
|
|
1747
|
+
function predictRange(candles, interval, steps, currentPrice = candles[candles.length - 1].close) {
|
|
1748
|
+
assertMinCandles(candles, interval);
|
|
1749
|
+
const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], steps);
|
|
1750
|
+
const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
|
|
1751
|
+
const sigma = Math.sqrt(cumulativeVariance);
|
|
1752
|
+
const move = currentPrice * sigma;
|
|
1753
|
+
return {
|
|
1754
|
+
modelType: fit.modelType,
|
|
1755
|
+
currentPrice,
|
|
1756
|
+
sigma,
|
|
1757
|
+
move,
|
|
1758
|
+
upperPrice: currentPrice + move,
|
|
1759
|
+
lowerPrice: currentPrice - move,
|
|
1760
|
+
reliable: checkReliable(fit),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
// ── Backtest ──────────────────────────────────────────────────
|
|
1764
|
+
const BACKTEST_REQUIRED_PERCENT = 68;
|
|
1765
|
+
const BACKTEST_WINDOW_RATIO = 0.75;
|
|
1766
|
+
/**
|
|
1767
|
+
* Walk-forward backtest of predict.
|
|
1768
|
+
*
|
|
1769
|
+
* Window is computed automatically: 75% of candles for fitting, 25% for testing.
|
|
1770
|
+
* Throws if not enough candles for the given interval.
|
|
1771
|
+
* Returns true if the model's hit rate meets the required threshold.
|
|
1772
|
+
* Default threshold is 68% (±1σ should contain ~68% of moves).
|
|
1773
|
+
*/
|
|
1774
|
+
function backtest(candles, interval, requiredPercent = BACKTEST_REQUIRED_PERCENT) {
|
|
1775
|
+
assertMinCandles(candles, interval);
|
|
1776
|
+
const window = Math.max(MIN_CANDLES[interval], Math.floor(candles.length * BACKTEST_WINDOW_RATIO));
|
|
1777
|
+
let hits = 0;
|
|
1778
|
+
let total = 0;
|
|
1779
|
+
for (let i = window; i < candles.length - 1; i++) {
|
|
1780
|
+
const slice = candles.slice(i - window, i + 1);
|
|
1781
|
+
const predicted = predict(slice, interval);
|
|
1782
|
+
const actual = candles[i + 1].close;
|
|
1783
|
+
if (actual >= predicted.lowerPrice && actual <= predicted.upperPrice) {
|
|
1784
|
+
hits++;
|
|
1785
|
+
}
|
|
1786
|
+
total++;
|
|
1787
|
+
}
|
|
1788
|
+
return (hits / total) * 100 >= requiredPercent;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
export { EXPECTED_ABS_NORMAL, Egarch, Garch, GjrGarch, HarRv, NoVaS, backtest, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, calibrateGjrGarch, calibrateHarRv, calibrateNoVaS, checkLeverageEffect, expectedAbsStudentT, garmanKlassVariance, ljungBox, logGamma, nelderMead, nelderMeadMultiStart, perCandleParkinson, predict, predictRange, profileStudentTDf, qlike, sampleVariance, sampleVarianceWithMean, studentTNegLL, yangZhangVariance };
|