garch 1.0.3 → 1.2.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 +401 -56
- package/build/index.cjs +1166 -60
- package/build/index.mjs +1154 -61
- package/package.json +1 -1
- package/types.d.ts +295 -11
package/build/index.cjs
CHANGED
|
@@ -11,7 +11,7 @@ function nelderMead(fn, x0, options = {}) {
|
|
|
11
11
|
const simplex = [x0.slice()];
|
|
12
12
|
for (let i = 0; i < n; i++) {
|
|
13
13
|
const point = x0.slice();
|
|
14
|
-
const delta = point[i] === 0 ? 0.00025 : point[i] * 0.
|
|
14
|
+
const delta = point[i] === 0 ? 0.00025 : point[i] * 0.20;
|
|
15
15
|
point[i] += delta;
|
|
16
16
|
simplex.push(point);
|
|
17
17
|
}
|
|
@@ -105,6 +105,38 @@ function shrink(simplex, values, sigma, fn, n) {
|
|
|
105
105
|
values[i] = fn(simplex[i]);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Multi-start Nelder-Mead: runs NM from multiple deterministic starting
|
|
110
|
+
* points and returns the best result. Escapes local minima by exploring
|
|
111
|
+
* different basins of attraction.
|
|
112
|
+
*
|
|
113
|
+
* Perturbation uses golden-ratio quasi-random sequence for uniform
|
|
114
|
+
* coverage of the search space without clustering.
|
|
115
|
+
*/
|
|
116
|
+
const PHI = (1 + Math.sqrt(5)) / 2; // golden ratio
|
|
117
|
+
function nelderMeadMultiStart(fn, x0, options = {}) {
|
|
118
|
+
const { maxIter = 1000, tol = 1e-8, restarts = 3 } = options;
|
|
119
|
+
const n = x0.length;
|
|
120
|
+
// Run from original starting point
|
|
121
|
+
let best = nelderMead(fn, x0, { maxIter, tol });
|
|
122
|
+
// Run from perturbed starting points
|
|
123
|
+
for (let k = 1; k <= restarts; k++) {
|
|
124
|
+
const perturbed = new Array(n);
|
|
125
|
+
for (let i = 0; i < n; i++) {
|
|
126
|
+
// Quasi-random perturbation: golden-ratio sequence mapped to [-0.5, +0.5]
|
|
127
|
+
const frac = (k * (i + 1) * PHI) % 1;
|
|
128
|
+
const scale = frac - 0.5; // range [-0.5, +0.5]
|
|
129
|
+
perturbed[i] = x0[i] === 0
|
|
130
|
+
? 0.001 * scale
|
|
131
|
+
: x0[i] * (1 + scale);
|
|
132
|
+
}
|
|
133
|
+
const result = nelderMead(fn, perturbed, { maxIter, tol });
|
|
134
|
+
if (result.fx < best.fx) {
|
|
135
|
+
best = result;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return best;
|
|
139
|
+
}
|
|
108
140
|
|
|
109
141
|
/**
|
|
110
142
|
* Calculate log returns from candles
|
|
@@ -225,6 +257,26 @@ function yangZhangVariance(candles) {
|
|
|
225
257
|
const rsVar = rsSum / count;
|
|
226
258
|
return overnightVar + k * closeVar + (1 - k) * rsVar;
|
|
227
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* Per-candle Parkinson (1980) realized variance proxy.
|
|
262
|
+
*
|
|
263
|
+
* RV_i = (1/(4·ln2)) · ln(H/L)²
|
|
264
|
+
*
|
|
265
|
+
* ~5× more efficient than squared returns. Falls back to r² when H === L.
|
|
266
|
+
* rv[i] aligned with returns[i], using candles[i+1]'s OHLC.
|
|
267
|
+
*/
|
|
268
|
+
function perCandleParkinson(candles, returns) {
|
|
269
|
+
const coeff = 1 / (4 * Math.LN2);
|
|
270
|
+
const rv = [];
|
|
271
|
+
for (let i = 0; i < returns.length; i++) {
|
|
272
|
+
const c = candles[i + 1];
|
|
273
|
+
const hl = Math.log(c.high / c.low);
|
|
274
|
+
const parkinson = coeff * hl * hl;
|
|
275
|
+
// Fall back to r² if high === low (zero range)
|
|
276
|
+
rv.push(parkinson > 0 ? parkinson : returns[i] * returns[i]);
|
|
277
|
+
}
|
|
278
|
+
return rv;
|
|
279
|
+
}
|
|
228
280
|
/**
|
|
229
281
|
* Expected value of |Z| where Z ~ N(0,1)
|
|
230
282
|
* E[|Z|] = sqrt(2/π)
|
|
@@ -278,6 +330,96 @@ function ljungBox(data, maxLag) {
|
|
|
278
330
|
Q *= n * (n + 2);
|
|
279
331
|
return { statistic: Q, pValue: chi2Survival(Q, maxLag) };
|
|
280
332
|
}
|
|
333
|
+
// ── Student-t distribution helpers ─────────────────────────────
|
|
334
|
+
/**
|
|
335
|
+
* Log-Gamma function via Lanczos approximation (g=7, n=9).
|
|
336
|
+
* Accurate to ~15 digits for x > 0.
|
|
337
|
+
*/
|
|
338
|
+
function logGamma(x) {
|
|
339
|
+
if (x <= 0)
|
|
340
|
+
return Infinity;
|
|
341
|
+
const g = 7;
|
|
342
|
+
const c = [
|
|
343
|
+
0.99999999999980993,
|
|
344
|
+
676.5203681218851,
|
|
345
|
+
-1259.1392167224028,
|
|
346
|
+
771.32342877765313,
|
|
347
|
+
-176.6150291621406,
|
|
348
|
+
12.507343278686905,
|
|
349
|
+
-0.13857109526572012,
|
|
350
|
+
9.9843695780195716e-6,
|
|
351
|
+
1.5056327351493116e-7,
|
|
352
|
+
];
|
|
353
|
+
let sum = c[0];
|
|
354
|
+
for (let i = 1; i < g + 2; i++) {
|
|
355
|
+
sum += c[i] / (x - 1 + i);
|
|
356
|
+
}
|
|
357
|
+
const t = x - 1 + g + 0.5;
|
|
358
|
+
return 0.5 * Math.log(2 * Math.PI) + (x - 0.5) * Math.log(t) - t + Math.log(sum);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Per-observation Student-t negative log-likelihood contribution.
|
|
362
|
+
*
|
|
363
|
+
* For standardized t(df) with variance σ²_t:
|
|
364
|
+
* -LL_i = 0.5·ln(σ²_t) + ((df+1)/2)·ln(1 + r²_t / ((df-2)·σ²_t))
|
|
365
|
+
* - lnΓ((df+1)/2) + lnΓ(df/2) + 0.5·ln(π·(df-2))
|
|
366
|
+
*
|
|
367
|
+
* Returns the per-observation neg-LL (without the constant terms).
|
|
368
|
+
* Caller accumulates and adds the constant once.
|
|
369
|
+
*/
|
|
370
|
+
function studentTNegLL(returns, varianceSeries, df) {
|
|
371
|
+
const n = returns.length;
|
|
372
|
+
// Constant part (same for all observations)
|
|
373
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
374
|
+
const constant = n * (logGamma(df / 2) - logGamma(halfDfPlus1) + 0.5 * Math.log(Math.PI * (df - 2)));
|
|
375
|
+
let sum = 0;
|
|
376
|
+
for (let i = 0; i < n; i++) {
|
|
377
|
+
const v = varianceSeries[i];
|
|
378
|
+
if (v <= 1e-12 || !isFinite(v))
|
|
379
|
+
return 1e10;
|
|
380
|
+
sum += 0.5 * Math.log(v) + halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / ((df - 2) * v));
|
|
381
|
+
}
|
|
382
|
+
return sum + constant;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* E[|Z|] where Z follows a standardized Student-t(df) distribution (variance = 1).
|
|
386
|
+
*
|
|
387
|
+
* E[|Z|] = √((df-2)/π) · Γ((df-1)/2) / Γ(df/2)
|
|
388
|
+
*
|
|
389
|
+
* Converges to √(2/π) as df → ∞ (Gaussian limit).
|
|
390
|
+
*/
|
|
391
|
+
function expectedAbsStudentT(df) {
|
|
392
|
+
if (df <= 2)
|
|
393
|
+
return EXPECTED_ABS_NORMAL; // fallback
|
|
394
|
+
return Math.sqrt((df - 2) / Math.PI) * Math.exp(logGamma((df - 1) / 2) - logGamma(df / 2));
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* 1D grid search for optimal df that minimizes Student-t neg-LL.
|
|
398
|
+
* Used by HAR-RV and NoVaS where df is profiled after main optimization.
|
|
399
|
+
*/
|
|
400
|
+
function profileStudentTDf(returns, varianceSeries) {
|
|
401
|
+
let bestDf = 30;
|
|
402
|
+
let bestNLL = studentTNegLL(returns, varianceSeries, 30);
|
|
403
|
+
// Coarse grid: 2.5 to 50
|
|
404
|
+
for (let df = 2.5; df <= 50; df += 0.5) {
|
|
405
|
+
const nll = studentTNegLL(returns, varianceSeries, df);
|
|
406
|
+
if (nll < bestNLL) {
|
|
407
|
+
bestNLL = nll;
|
|
408
|
+
bestDf = df;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Fine grid around best
|
|
412
|
+
const lo = Math.max(2.1, bestDf - 1);
|
|
413
|
+
const hi = bestDf + 1;
|
|
414
|
+
for (let df = lo; df <= hi; df += 0.05) {
|
|
415
|
+
const nll = studentTNegLL(returns, varianceSeries, df);
|
|
416
|
+
if (nll < bestNLL) {
|
|
417
|
+
bestNLL = nll;
|
|
418
|
+
bestDf = df;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return bestDf;
|
|
422
|
+
}
|
|
281
423
|
/**
|
|
282
424
|
* Calculate AIC (Akaike Information Criterion)
|
|
283
425
|
*/
|
|
@@ -290,6 +432,83 @@ function calculateAIC(logLikelihood, numParams) {
|
|
|
290
432
|
function calculateBIC(logLikelihood, numParams, numObs) {
|
|
291
433
|
return numParams * Math.log(numObs) - 2 * logLikelihood;
|
|
292
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* QLIKE loss (Patton 2011) — standard loss function for volatility forecasts.
|
|
437
|
+
*
|
|
438
|
+
* QLIKE = (1/n) · Σ (RV_t / σ²_t − log(RV_t / σ²_t) − 1)
|
|
439
|
+
*
|
|
440
|
+
* Lower = better forecast. Neutral to calibration method — judges only
|
|
441
|
+
* how well the variance series predicts realized variance, regardless
|
|
442
|
+
* of how the model was calibrated (MLE, OLS, D², etc.).
|
|
443
|
+
*/
|
|
444
|
+
function qlike(varianceSeries, rv) {
|
|
445
|
+
const n = Math.min(varianceSeries.length, rv.length);
|
|
446
|
+
let sum = 0;
|
|
447
|
+
let count = 0;
|
|
448
|
+
for (let i = 0; i < n; i++) {
|
|
449
|
+
if (varianceSeries[i] <= 0 || rv[i] <= 0)
|
|
450
|
+
continue;
|
|
451
|
+
const ratio = rv[i] / varianceSeries[i];
|
|
452
|
+
sum += ratio - Math.log(ratio) - 1;
|
|
453
|
+
count++;
|
|
454
|
+
}
|
|
455
|
+
return count > 0 ? sum / count : Infinity;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Inverse standard normal CDF (probit function).
|
|
459
|
+
* Converts a two-sided confidence level (e.g. 0.95) to the corresponding
|
|
460
|
+
* z-score (e.g. 1.96).
|
|
461
|
+
*
|
|
462
|
+
* Uses Acklam's rational approximation (max relative error < 1.15e-9).
|
|
463
|
+
*/
|
|
464
|
+
function probit(confidence) {
|
|
465
|
+
if (confidence <= 0 || confidence >= 1) {
|
|
466
|
+
throw new Error(`confidence must be in (0, 1), got ${confidence}`);
|
|
467
|
+
}
|
|
468
|
+
// Convert two-sided confidence to upper-tail probability
|
|
469
|
+
const p = (1 + confidence) / 2;
|
|
470
|
+
// Acklam's inverse normal approximation
|
|
471
|
+
const a1 = -39.69683028665376;
|
|
472
|
+
const a2 = 2.209460984245205e+02;
|
|
473
|
+
const a3 = -275.9285104469687;
|
|
474
|
+
const a4 = 1.383577518672690e+02;
|
|
475
|
+
const a5 = -30.66479806614716;
|
|
476
|
+
const a6 = 2.506628277459239e+00;
|
|
477
|
+
const b1 = -54.47609879822406;
|
|
478
|
+
const b2 = 1.615858368580409e+02;
|
|
479
|
+
const b3 = -155.6989798598866;
|
|
480
|
+
const b4 = 6.680131188771972e+01;
|
|
481
|
+
const b5 = -13.28068155288572;
|
|
482
|
+
const c1 = -0.007784894002430293;
|
|
483
|
+
const c2 = -0.3223964580411365;
|
|
484
|
+
const c3 = -2.400758277161838;
|
|
485
|
+
const c4 = -2.549732539343734;
|
|
486
|
+
const c5 = 4.374664141464968e+00;
|
|
487
|
+
const c6 = 2.938163982698783e+00;
|
|
488
|
+
const d1 = 7.784695709041462e-03;
|
|
489
|
+
const d2 = 3.224671290700398e-01;
|
|
490
|
+
const d3 = 2.445134137142996e+00;
|
|
491
|
+
const d4 = 3.754408661907416e+00;
|
|
492
|
+
const pLow = 0.02425;
|
|
493
|
+
const pHigh = 1 - pLow;
|
|
494
|
+
let q, r;
|
|
495
|
+
if (p < pLow) {
|
|
496
|
+
q = Math.sqrt(-2 * Math.log(p));
|
|
497
|
+
return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
|
|
498
|
+
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
|
|
499
|
+
}
|
|
500
|
+
else if (p <= pHigh) {
|
|
501
|
+
q = p - 0.5;
|
|
502
|
+
r = q * q;
|
|
503
|
+
return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q /
|
|
504
|
+
(((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
508
|
+
return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
|
|
509
|
+
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
293
512
|
|
|
294
513
|
/**
|
|
295
514
|
* GARCH(1,1) model
|
|
@@ -304,6 +523,7 @@ function calculateBIC(logLikelihood, numParams, numObs) {
|
|
|
304
523
|
*/
|
|
305
524
|
class Garch {
|
|
306
525
|
returns;
|
|
526
|
+
rv;
|
|
307
527
|
periodsPerYear;
|
|
308
528
|
initialVariance;
|
|
309
529
|
constructor(data, options = {}) {
|
|
@@ -315,11 +535,14 @@ class Garch {
|
|
|
315
535
|
if (typeof data[0] === 'number') {
|
|
316
536
|
this.returns = calculateReturnsFromPrices(data);
|
|
317
537
|
this.initialVariance = sampleVariance(this.returns);
|
|
538
|
+
this.rv = null;
|
|
318
539
|
}
|
|
319
540
|
else {
|
|
320
541
|
const candles = data;
|
|
321
542
|
this.returns = calculateReturns(candles);
|
|
322
543
|
this.initialVariance = yangZhangVariance(candles);
|
|
544
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
545
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
323
546
|
}
|
|
324
547
|
}
|
|
325
548
|
/**
|
|
@@ -330,9 +553,10 @@ class Garch {
|
|
|
330
553
|
const returns = this.returns;
|
|
331
554
|
const n = returns.length;
|
|
332
555
|
const initVar = this.initialVariance;
|
|
333
|
-
|
|
556
|
+
const rv = this.rv;
|
|
557
|
+
// Student-t negative log-likelihood function
|
|
334
558
|
function negLogLikelihood(params) {
|
|
335
|
-
const [omega, alpha, beta] = params;
|
|
559
|
+
const [omega, alpha, beta, df] = params;
|
|
336
560
|
// Constraints
|
|
337
561
|
if (omega <= 1e-12)
|
|
338
562
|
return 1e10;
|
|
@@ -340,30 +564,37 @@ class Garch {
|
|
|
340
564
|
return 1e10;
|
|
341
565
|
if (alpha + beta >= 0.9999)
|
|
342
566
|
return 1e10;
|
|
567
|
+
if (df <= 2.01 || df > 100)
|
|
568
|
+
return 1e10;
|
|
569
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
570
|
+
const dfMinus2 = df - 2;
|
|
571
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
343
572
|
let variance = initVar;
|
|
344
573
|
let ll = 0;
|
|
345
574
|
for (let i = 0; i < n; i++) {
|
|
346
575
|
if (i > 0) {
|
|
347
|
-
|
|
576
|
+
const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
|
|
577
|
+
variance = omega + alpha * innovation + beta * variance;
|
|
348
578
|
}
|
|
349
579
|
if (variance <= 1e-12)
|
|
350
580
|
return 1e10;
|
|
351
|
-
//
|
|
352
|
-
ll += Math.log(variance) + (returns[i] ** 2) / variance;
|
|
581
|
+
// Student-t log-likelihood
|
|
582
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
353
583
|
}
|
|
354
|
-
return ll
|
|
584
|
+
return -(ll + constant);
|
|
355
585
|
}
|
|
356
586
|
// Initial guesses
|
|
357
587
|
const omega0 = initVar * 0.05;
|
|
358
588
|
const alpha0 = 0.1;
|
|
359
589
|
const beta0 = 0.85;
|
|
360
|
-
const
|
|
361
|
-
const [
|
|
590
|
+
const df0 = 5;
|
|
591
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, beta0, df0], { maxIter, tol, restarts: 3 });
|
|
592
|
+
const [omega, alpha, beta, df] = result.x;
|
|
362
593
|
const persistence = alpha + beta;
|
|
363
594
|
const unconditionalVariance = omega / (1 - persistence);
|
|
364
595
|
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
365
596
|
const logLikelihood = -result.fx;
|
|
366
|
-
const numParams =
|
|
597
|
+
const numParams = 4;
|
|
367
598
|
return {
|
|
368
599
|
params: {
|
|
369
600
|
omega,
|
|
@@ -372,6 +603,7 @@ class Garch {
|
|
|
372
603
|
persistence,
|
|
373
604
|
unconditionalVariance,
|
|
374
605
|
annualizedVol,
|
|
606
|
+
df,
|
|
375
607
|
},
|
|
376
608
|
diagnostics: {
|
|
377
609
|
logLikelihood,
|
|
@@ -393,7 +625,8 @@ class Garch {
|
|
|
393
625
|
variance.push(this.initialVariance);
|
|
394
626
|
}
|
|
395
627
|
else {
|
|
396
|
-
const
|
|
628
|
+
const innovation = this.rv ? this.rv[i - 1] : this.returns[i - 1] ** 2;
|
|
629
|
+
const v = omega + alpha * innovation + beta * variance[i - 1];
|
|
397
630
|
variance.push(v);
|
|
398
631
|
}
|
|
399
632
|
}
|
|
@@ -408,9 +641,11 @@ class Garch {
|
|
|
408
641
|
// Get last variance
|
|
409
642
|
const varianceSeries = this.getVarianceSeries(params);
|
|
410
643
|
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
411
|
-
const
|
|
644
|
+
const lastInnovation = this.rv
|
|
645
|
+
? this.rv[this.rv.length - 1]
|
|
646
|
+
: this.returns[this.returns.length - 1] ** 2;
|
|
412
647
|
// One-step ahead
|
|
413
|
-
let v = omega + alpha *
|
|
648
|
+
let v = omega + alpha * lastInnovation + beta * lastVariance;
|
|
414
649
|
variance.push(v);
|
|
415
650
|
// Multi-step ahead (converges to unconditional variance)
|
|
416
651
|
for (let h = 1; h < steps; h++) {
|
|
@@ -455,10 +690,11 @@ function calibrateGarch(data, options = {}) {
|
|
|
455
690
|
* - α (alpha): magnitude effect
|
|
456
691
|
* - γ (gamma): leverage effect (typically negative)
|
|
457
692
|
* - β (beta): persistence
|
|
458
|
-
* - E[|z|] =
|
|
693
|
+
* - E[|z|] = expectedAbsStudentT(df) for Student-t(df)
|
|
459
694
|
*/
|
|
460
695
|
class Egarch {
|
|
461
696
|
returns;
|
|
697
|
+
rv;
|
|
462
698
|
periodsPerYear;
|
|
463
699
|
initialVariance;
|
|
464
700
|
constructor(data, options = {}) {
|
|
@@ -469,11 +705,14 @@ class Egarch {
|
|
|
469
705
|
if (typeof data[0] === 'number') {
|
|
470
706
|
this.returns = calculateReturnsFromPrices(data);
|
|
471
707
|
this.initialVariance = sampleVariance(this.returns);
|
|
708
|
+
this.rv = null;
|
|
472
709
|
}
|
|
473
710
|
else {
|
|
474
711
|
const candles = data;
|
|
475
712
|
this.returns = calculateReturns(candles);
|
|
476
713
|
this.initialVariance = yangZhangVariance(candles);
|
|
714
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
715
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
477
716
|
}
|
|
478
717
|
}
|
|
479
718
|
/**
|
|
@@ -484,20 +723,31 @@ class Egarch {
|
|
|
484
723
|
const returns = this.returns;
|
|
485
724
|
const n = returns.length;
|
|
486
725
|
const initLogVar = Math.log(this.initialVariance);
|
|
726
|
+
const rv = this.rv;
|
|
487
727
|
function negLogLikelihood(params) {
|
|
488
|
-
const [omega, alpha, gamma, beta] = params;
|
|
728
|
+
const [omega, alpha, gamma, beta, df] = params;
|
|
489
729
|
// EGARCH allows negative gamma, but beta should ensure stationarity
|
|
490
730
|
if (Math.abs(beta) >= 0.9999)
|
|
491
731
|
return 1e10;
|
|
732
|
+
if (df <= 2.01 || df > 100)
|
|
733
|
+
return 1e10;
|
|
734
|
+
const eAbsZ = expectedAbsStudentT(df);
|
|
735
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
736
|
+
const dfMinus2 = df - 2;
|
|
737
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
492
738
|
let logVariance = initLogVar;
|
|
493
739
|
let variance = Math.exp(logVariance);
|
|
494
740
|
let ll = 0;
|
|
495
741
|
for (let i = 0; i < n; i++) {
|
|
496
742
|
if (i > 0) {
|
|
497
743
|
const sigma = Math.sqrt(variance);
|
|
498
|
-
const z = returns[i - 1] / sigma;
|
|
744
|
+
const z = returns[i - 1] / sigma; // directional — kept for leverage
|
|
745
|
+
// Magnitude: √(RV/σ²) for candles, |z| for prices
|
|
746
|
+
const magnitude = rv
|
|
747
|
+
? Math.sqrt(rv[i - 1] / variance)
|
|
748
|
+
: Math.abs(z);
|
|
499
749
|
logVariance = omega
|
|
500
|
-
+ alpha * (
|
|
750
|
+
+ alpha * (magnitude - eAbsZ)
|
|
501
751
|
+ gamma * z
|
|
502
752
|
+ beta * logVariance;
|
|
503
753
|
// Prevent extreme values
|
|
@@ -506,9 +756,10 @@ class Egarch {
|
|
|
506
756
|
}
|
|
507
757
|
if (variance <= 1e-12 || !isFinite(variance))
|
|
508
758
|
return 1e10;
|
|
509
|
-
|
|
759
|
+
// Student-t log-likelihood
|
|
760
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
510
761
|
}
|
|
511
|
-
return ll
|
|
762
|
+
return -(ll + constant);
|
|
512
763
|
}
|
|
513
764
|
// Initial guesses
|
|
514
765
|
// omega approximates log of unconditional variance when other params are small
|
|
@@ -516,15 +767,16 @@ class Egarch {
|
|
|
516
767
|
const alpha0 = 0.1;
|
|
517
768
|
const gamma0 = -0.05; // Negative for typical leverage effect
|
|
518
769
|
const beta0 = 0.95;
|
|
519
|
-
const
|
|
520
|
-
const [
|
|
770
|
+
const df0 = 5;
|
|
771
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
|
|
772
|
+
const [omega, alpha, gamma, beta, df] = result.x;
|
|
521
773
|
// For EGARCH, unconditional variance: E[ln(σ²)] = ω/(1-β)
|
|
522
774
|
// So E[σ²] ≈ exp(ω/(1-β)) when α and γ effects average out
|
|
523
775
|
const unconditionalLogVar = omega / (1 - beta);
|
|
524
776
|
const unconditionalVariance = Math.exp(unconditionalLogVar);
|
|
525
777
|
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
526
778
|
const logLikelihood = -result.fx;
|
|
527
|
-
const numParams =
|
|
779
|
+
const numParams = 5;
|
|
528
780
|
return {
|
|
529
781
|
params: {
|
|
530
782
|
omega,
|
|
@@ -535,6 +787,7 @@ class Egarch {
|
|
|
535
787
|
unconditionalVariance,
|
|
536
788
|
annualizedVol,
|
|
537
789
|
leverageEffect: gamma,
|
|
790
|
+
df,
|
|
538
791
|
},
|
|
539
792
|
diagnostics: {
|
|
540
793
|
logLikelihood,
|
|
@@ -549,7 +802,8 @@ class Egarch {
|
|
|
549
802
|
* Calculate conditional variance series given parameters
|
|
550
803
|
*/
|
|
551
804
|
getVarianceSeries(params) {
|
|
552
|
-
const { omega, alpha, gamma, beta } = params;
|
|
805
|
+
const { omega, alpha, gamma, beta, df } = params;
|
|
806
|
+
const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
|
|
553
807
|
const variance = [];
|
|
554
808
|
let logVariance = Math.log(this.initialVariance);
|
|
555
809
|
for (let i = 0; i < this.returns.length; i++) {
|
|
@@ -559,8 +813,11 @@ class Egarch {
|
|
|
559
813
|
else {
|
|
560
814
|
const sigma = Math.sqrt(variance[i - 1]);
|
|
561
815
|
const z = this.returns[i - 1] / sigma;
|
|
816
|
+
const magnitude = this.rv
|
|
817
|
+
? Math.sqrt(this.rv[i - 1] / variance[i - 1])
|
|
818
|
+
: Math.abs(z);
|
|
562
819
|
logVariance = omega
|
|
563
|
-
+ alpha * (
|
|
820
|
+
+ alpha * (magnitude - eAbsZ)
|
|
564
821
|
+ gamma * z
|
|
565
822
|
+ beta * logVariance;
|
|
566
823
|
logVariance = Math.max(-50, Math.min(50, logVariance));
|
|
@@ -577,7 +834,8 @@ class Egarch {
|
|
|
577
834
|
* expected values of future shocks.
|
|
578
835
|
*/
|
|
579
836
|
forecast(params, steps = 1) {
|
|
580
|
-
const { omega, alpha, gamma, beta } = params;
|
|
837
|
+
const { omega, alpha, gamma, beta, df } = params;
|
|
838
|
+
const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
|
|
581
839
|
const variance = [];
|
|
582
840
|
const varianceSeries = this.getVarianceSeries(params);
|
|
583
841
|
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
@@ -585,12 +843,15 @@ class Egarch {
|
|
|
585
843
|
// One-step ahead using actual last return
|
|
586
844
|
const sigma = Math.sqrt(lastVariance);
|
|
587
845
|
const z = lastReturn / sigma;
|
|
846
|
+
const magnitude = this.rv
|
|
847
|
+
? Math.sqrt(this.rv[this.rv.length - 1] / lastVariance)
|
|
848
|
+
: Math.abs(z);
|
|
588
849
|
let logVariance = omega
|
|
589
|
-
+ alpha * (
|
|
850
|
+
+ alpha * (magnitude - eAbsZ)
|
|
590
851
|
+ gamma * z
|
|
591
852
|
+ beta * Math.log(lastVariance);
|
|
592
853
|
variance.push(Math.exp(logVariance));
|
|
593
|
-
// Multi-step: assume E[z] = 0, E[|z|] =
|
|
854
|
+
// Multi-step: assume E[z] = 0, E[|z|] = eAbsZ
|
|
594
855
|
// So the α and γ terms contribute 0 on average
|
|
595
856
|
for (let h = 1; h < steps; h++) {
|
|
596
857
|
logVariance = omega + beta * logVariance;
|
|
@@ -623,6 +884,723 @@ function calibrateEgarch(data, options = {}) {
|
|
|
623
884
|
return model.fit(options);
|
|
624
885
|
}
|
|
625
886
|
|
|
887
|
+
const DEFAULT_SHORT = 1;
|
|
888
|
+
const DEFAULT_MEDIUM = 5;
|
|
889
|
+
const DEFAULT_LONG = 22;
|
|
890
|
+
/**
|
|
891
|
+
* Solve linear system Ax = b via Gaussian elimination with partial pivoting.
|
|
892
|
+
* A is n×n, b is n-vector. Returns x.
|
|
893
|
+
*/
|
|
894
|
+
function solveLinearSystem(A, b) {
|
|
895
|
+
const n = A.length;
|
|
896
|
+
const M = A.map((row, i) => [...row, b[i]]);
|
|
897
|
+
for (let col = 0; col < n; col++) {
|
|
898
|
+
let maxRow = col;
|
|
899
|
+
let maxVal = Math.abs(M[col][col]);
|
|
900
|
+
for (let row = col + 1; row < n; row++) {
|
|
901
|
+
if (Math.abs(M[row][col]) > maxVal) {
|
|
902
|
+
maxVal = Math.abs(M[row][col]);
|
|
903
|
+
maxRow = row;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
[M[col], M[maxRow]] = [M[maxRow], M[col]];
|
|
907
|
+
if (Math.abs(M[col][col]) < 1e-15) {
|
|
908
|
+
throw new Error('Singular matrix in HAR-RV OLS');
|
|
909
|
+
}
|
|
910
|
+
for (let row = col + 1; row < n; row++) {
|
|
911
|
+
const factor = M[row][col] / M[col][col];
|
|
912
|
+
for (let j = col; j <= n; j++) {
|
|
913
|
+
M[row][j] -= factor * M[col][j];
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const x = new Array(n).fill(0);
|
|
918
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
919
|
+
x[i] = M[i][n];
|
|
920
|
+
for (let j = i + 1; j < n; j++) {
|
|
921
|
+
x[i] -= M[i][j] * x[j];
|
|
922
|
+
}
|
|
923
|
+
x[i] /= M[i][i];
|
|
924
|
+
}
|
|
925
|
+
return x;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* OLS regression: y = Xβ + ε
|
|
929
|
+
* Returns coefficients, residuals, R², RSS, TSS.
|
|
930
|
+
*/
|
|
931
|
+
function ols(X, y) {
|
|
932
|
+
const n = X.length;
|
|
933
|
+
const p = X[0].length;
|
|
934
|
+
// X'X
|
|
935
|
+
const XtX = Array.from({ length: p }, () => new Array(p).fill(0));
|
|
936
|
+
for (let i = 0; i < p; i++) {
|
|
937
|
+
for (let j = 0; j < p; j++) {
|
|
938
|
+
for (let k = 0; k < n; k++) {
|
|
939
|
+
XtX[i][j] += X[k][i] * X[k][j];
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
// X'y
|
|
944
|
+
const Xty = new Array(p).fill(0);
|
|
945
|
+
for (let i = 0; i < p; i++) {
|
|
946
|
+
for (let k = 0; k < n; k++) {
|
|
947
|
+
Xty[i] += X[k][i] * y[k];
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const beta = solveLinearSystem(XtX, Xty);
|
|
951
|
+
const yMean = y.reduce((s, v) => s + v, 0) / n;
|
|
952
|
+
let rss = 0;
|
|
953
|
+
let tss = 0;
|
|
954
|
+
const residuals = [];
|
|
955
|
+
for (let i = 0; i < n; i++) {
|
|
956
|
+
let yHat = 0;
|
|
957
|
+
for (let j = 0; j < p; j++) {
|
|
958
|
+
yHat += X[i][j] * beta[j];
|
|
959
|
+
}
|
|
960
|
+
const res = y[i] - yHat;
|
|
961
|
+
residuals.push(res);
|
|
962
|
+
rss += res * res;
|
|
963
|
+
tss += (y[i] - yMean) ** 2;
|
|
964
|
+
}
|
|
965
|
+
const r2 = tss > 0 ? 1 - rss / tss : 0;
|
|
966
|
+
return { beta, residuals, rss, tss, r2 };
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Compute rolling mean of rv[t-lag+1 .. t] (inclusive).
|
|
970
|
+
*/
|
|
971
|
+
function rollingMean(rv, t, lag) {
|
|
972
|
+
let sum = 0;
|
|
973
|
+
for (let j = 0; j < lag; j++) {
|
|
974
|
+
sum += rv[t - j];
|
|
975
|
+
}
|
|
976
|
+
return sum / lag;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* HAR-RV model (Corsi, 2009)
|
|
980
|
+
*
|
|
981
|
+
* RV_{t+1} = β₀ + β₁·RV_short + β₂·RV_medium + β₃·RV_long + ε
|
|
982
|
+
*
|
|
983
|
+
* where:
|
|
984
|
+
* - RV_short = mean(rv[t-s+1..t]) (default s=1)
|
|
985
|
+
* - RV_medium = mean(rv[t-m+1..t]) (default m=5)
|
|
986
|
+
* - RV_long = mean(rv[t-l+1..t]) (default l=22)
|
|
987
|
+
* - rv[t] = Parkinson(candle_t) for OHLC data, r[t]² for prices-only
|
|
988
|
+
*
|
|
989
|
+
* Parkinson (1980): RV = (1/(4·ln2))·(ln(H/L))², ~5x more efficient than r².
|
|
990
|
+
*
|
|
991
|
+
* Uses OLS for estimation — closed-form, always converges.
|
|
992
|
+
*/
|
|
993
|
+
class HarRv {
|
|
994
|
+
returns;
|
|
995
|
+
rv;
|
|
996
|
+
periodsPerYear;
|
|
997
|
+
shortLag;
|
|
998
|
+
mediumLag;
|
|
999
|
+
longLag;
|
|
1000
|
+
constructor(data, options = {}) {
|
|
1001
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1002
|
+
this.shortLag = options.shortLag ?? DEFAULT_SHORT;
|
|
1003
|
+
this.mediumLag = options.mediumLag ?? DEFAULT_MEDIUM;
|
|
1004
|
+
this.longLag = options.longLag ?? DEFAULT_LONG;
|
|
1005
|
+
const minRequired = this.longLag + 30;
|
|
1006
|
+
if (data.length < minRequired) {
|
|
1007
|
+
throw new Error(`Need at least ${minRequired} data points for HAR-RV estimation`);
|
|
1008
|
+
}
|
|
1009
|
+
if (typeof data[0] === 'number') {
|
|
1010
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1011
|
+
// Prices only — no OHLC, fall back to squared returns
|
|
1012
|
+
this.rv = this.returns.map(r => r * r);
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
const candles = data;
|
|
1016
|
+
this.returns = calculateReturns(candles);
|
|
1017
|
+
// Parkinson (1980) per-candle RV: (1/(4·ln2))·(ln(H/L))²
|
|
1018
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Calibrate HAR-RV via OLS.
|
|
1023
|
+
*/
|
|
1024
|
+
fit() {
|
|
1025
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1026
|
+
const n = rv.length;
|
|
1027
|
+
// Build regression data
|
|
1028
|
+
// Usable range: t = longLag-1 .. n-2 (need longLag history, and rv[t+1] as target)
|
|
1029
|
+
const startIdx = longLag - 1;
|
|
1030
|
+
const endIdx = n - 2;
|
|
1031
|
+
const nObs = endIdx - startIdx + 1;
|
|
1032
|
+
const X = [];
|
|
1033
|
+
const y = [];
|
|
1034
|
+
for (let t = startIdx; t <= endIdx; t++) {
|
|
1035
|
+
const rvShort = rollingMean(rv, t, shortLag);
|
|
1036
|
+
const rvMedium = rollingMean(rv, t, mediumLag);
|
|
1037
|
+
const rvLong = rollingMean(rv, t, longLag);
|
|
1038
|
+
X.push([1, rvShort, rvMedium, rvLong]);
|
|
1039
|
+
y.push(rv[t + 1]);
|
|
1040
|
+
}
|
|
1041
|
+
const result = ols(X, y);
|
|
1042
|
+
const [beta0, betaShort, betaMedium, betaLong] = result.beta;
|
|
1043
|
+
const persistence = betaShort + betaMedium + betaLong;
|
|
1044
|
+
const unconditionalVariance = persistence < 1 && persistence > -1
|
|
1045
|
+
? Math.max(beta0 / (1 - persistence), 1e-20)
|
|
1046
|
+
: sampleVariance(this.returns);
|
|
1047
|
+
const annualizedVol = Math.sqrt(Math.abs(unconditionalVariance) * this.periodsPerYear) * 100;
|
|
1048
|
+
// Student-t log-likelihood on returns using HAR-RV fitted variances
|
|
1049
|
+
const varianceSeries = this.getVarianceSeriesInternal(result.beta);
|
|
1050
|
+
const df = profileStudentTDf(this.returns, varianceSeries);
|
|
1051
|
+
const ll = -studentTNegLL(this.returns, varianceSeries, df);
|
|
1052
|
+
const numParams = 5; // beta0, betaShort, betaMedium, betaLong, df
|
|
1053
|
+
return {
|
|
1054
|
+
params: {
|
|
1055
|
+
beta0,
|
|
1056
|
+
betaShort,
|
|
1057
|
+
betaMedium,
|
|
1058
|
+
betaLong,
|
|
1059
|
+
persistence,
|
|
1060
|
+
unconditionalVariance,
|
|
1061
|
+
annualizedVol,
|
|
1062
|
+
r2: result.r2,
|
|
1063
|
+
df,
|
|
1064
|
+
},
|
|
1065
|
+
diagnostics: {
|
|
1066
|
+
logLikelihood: ll,
|
|
1067
|
+
aic: calculateAIC(ll, numParams),
|
|
1068
|
+
bic: calculateBIC(ll, numParams, nObs),
|
|
1069
|
+
iterations: 1,
|
|
1070
|
+
converged: true,
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Internal: compute variance series from beta vector.
|
|
1076
|
+
*/
|
|
1077
|
+
getVarianceSeriesInternal(beta) {
|
|
1078
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1079
|
+
const n = rv.length;
|
|
1080
|
+
const fallback = sampleVariance(this.returns);
|
|
1081
|
+
const series = [];
|
|
1082
|
+
for (let i = 0; i < n; i++) {
|
|
1083
|
+
if (i < longLag) {
|
|
1084
|
+
// Not enough history — use sample variance
|
|
1085
|
+
series.push(fallback);
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
// HAR prediction for rv[i] based on rv[..i-1]
|
|
1089
|
+
const t = i - 1;
|
|
1090
|
+
const rvS = rollingMean(rv, t, shortLag);
|
|
1091
|
+
const rvM = rollingMean(rv, t, mediumLag);
|
|
1092
|
+
const rvL = rollingMean(rv, t, longLag);
|
|
1093
|
+
const predicted = beta[0] + beta[1] * rvS + beta[2] * rvM + beta[3] * rvL;
|
|
1094
|
+
series.push(Math.max(predicted, 1e-20));
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return series;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Calculate conditional variance series given parameters.
|
|
1101
|
+
*/
|
|
1102
|
+
getVarianceSeries(params) {
|
|
1103
|
+
const beta = [params.beta0, params.betaShort, params.betaMedium, params.betaLong];
|
|
1104
|
+
return this.getVarianceSeriesInternal(beta);
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Forecast variance forward.
|
|
1108
|
+
*
|
|
1109
|
+
* Uses iterative substitution: each forecast step feeds back
|
|
1110
|
+
* into the rolling RV components for subsequent steps.
|
|
1111
|
+
*/
|
|
1112
|
+
forecast(params, steps = 1) {
|
|
1113
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1114
|
+
const { beta0, betaShort, betaMedium, betaLong } = params;
|
|
1115
|
+
// Working copy of recent rv values + forecasts appended
|
|
1116
|
+
const history = rv.slice();
|
|
1117
|
+
const variance = [];
|
|
1118
|
+
for (let h = 0; h < steps; h++) {
|
|
1119
|
+
const t = history.length - 1;
|
|
1120
|
+
const rvS = rollingMean(history, t, shortLag);
|
|
1121
|
+
const rvM = rollingMean(history, t, mediumLag);
|
|
1122
|
+
const rvL = rollingMean(history, t, longLag);
|
|
1123
|
+
const predicted = beta0 + betaShort * rvS + betaMedium * rvM + betaLong * rvL;
|
|
1124
|
+
const v = Math.max(predicted, 1e-20);
|
|
1125
|
+
variance.push(v);
|
|
1126
|
+
history.push(v);
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
variance,
|
|
1130
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1131
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Get the return series.
|
|
1136
|
+
*/
|
|
1137
|
+
getReturns() {
|
|
1138
|
+
return [...this.returns];
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Get realized variance series (squared returns).
|
|
1142
|
+
*/
|
|
1143
|
+
getRv() {
|
|
1144
|
+
return [...this.rv];
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Convenience function to calibrate HAR-RV from candles or prices.
|
|
1149
|
+
*/
|
|
1150
|
+
function calibrateHarRv(data, options = {}) {
|
|
1151
|
+
const model = new HarRv(data, options);
|
|
1152
|
+
return model.fit();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* GJR-GARCH(1,1) model (Glosten, Jagannathan & Runkle, 1993)
|
|
1157
|
+
*
|
|
1158
|
+
* σ²ₜ = ω + α·ε²ₜ₋₁ + γ·ε²ₜ₋₁·I(rₜ₋₁<0) + β·σ²ₜ₋₁
|
|
1159
|
+
*
|
|
1160
|
+
* where:
|
|
1161
|
+
* - ω (omega) > 0: constant term
|
|
1162
|
+
* - α (alpha) ≥ 0: symmetric shock response
|
|
1163
|
+
* - γ (gamma) ≥ 0: asymmetric leverage coefficient
|
|
1164
|
+
* - β (beta) ≥ 0: persistence
|
|
1165
|
+
* - I(r<0) = 1 when return is negative, 0 otherwise
|
|
1166
|
+
* - Stationarity: α + γ/2 + β < 1
|
|
1167
|
+
*
|
|
1168
|
+
* With Candle[] input, ε² is replaced by Parkinson per-candle RV.
|
|
1169
|
+
* Leverage direction still comes from close-to-close return sign.
|
|
1170
|
+
*/
|
|
1171
|
+
class GjrGarch {
|
|
1172
|
+
returns;
|
|
1173
|
+
rv;
|
|
1174
|
+
periodsPerYear;
|
|
1175
|
+
initialVariance;
|
|
1176
|
+
constructor(data, options = {}) {
|
|
1177
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1178
|
+
if (data.length < 50) {
|
|
1179
|
+
throw new Error('Need at least 50 data points for GJR-GARCH estimation');
|
|
1180
|
+
}
|
|
1181
|
+
if (typeof data[0] === 'number') {
|
|
1182
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1183
|
+
this.initialVariance = sampleVariance(this.returns);
|
|
1184
|
+
this.rv = null;
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
const candles = data;
|
|
1188
|
+
this.returns = calculateReturns(candles);
|
|
1189
|
+
this.initialVariance = yangZhangVariance(candles);
|
|
1190
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Calibrate GJR-GARCH(1,1) parameters using Maximum Likelihood Estimation
|
|
1195
|
+
*/
|
|
1196
|
+
fit(options = {}) {
|
|
1197
|
+
const { maxIter = 1000, tol = 1e-8 } = options;
|
|
1198
|
+
const returns = this.returns;
|
|
1199
|
+
const n = returns.length;
|
|
1200
|
+
const initVar = this.initialVariance;
|
|
1201
|
+
const rv = this.rv;
|
|
1202
|
+
function negLogLikelihood(params) {
|
|
1203
|
+
const [omega, alpha, gamma, beta, df] = params;
|
|
1204
|
+
if (omega <= 1e-12)
|
|
1205
|
+
return 1e10;
|
|
1206
|
+
if (alpha < 0 || gamma < 0 || beta < 0)
|
|
1207
|
+
return 1e10;
|
|
1208
|
+
if (alpha + gamma / 2 + beta >= 0.9999)
|
|
1209
|
+
return 1e10;
|
|
1210
|
+
if (df <= 2.01 || df > 100)
|
|
1211
|
+
return 1e10;
|
|
1212
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
1213
|
+
const dfMinus2 = df - 2;
|
|
1214
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
1215
|
+
let variance = initVar;
|
|
1216
|
+
let ll = 0;
|
|
1217
|
+
for (let i = 0; i < n; i++) {
|
|
1218
|
+
if (i > 0) {
|
|
1219
|
+
const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
|
|
1220
|
+
const indicator = returns[i - 1] < 0 ? 1 : 0;
|
|
1221
|
+
variance = omega + alpha * innovation + gamma * innovation * indicator + beta * variance;
|
|
1222
|
+
}
|
|
1223
|
+
if (variance <= 1e-12)
|
|
1224
|
+
return 1e10;
|
|
1225
|
+
// Student-t log-likelihood
|
|
1226
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
1227
|
+
}
|
|
1228
|
+
return -(ll + constant);
|
|
1229
|
+
}
|
|
1230
|
+
const omega0 = initVar * 0.05;
|
|
1231
|
+
const alpha0 = 0.05;
|
|
1232
|
+
const gamma0 = 0.1;
|
|
1233
|
+
const beta0 = 0.85;
|
|
1234
|
+
const df0 = 5;
|
|
1235
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
|
|
1236
|
+
const [omega, alpha, gamma, beta, df] = result.x;
|
|
1237
|
+
const persistence = alpha + gamma / 2 + beta;
|
|
1238
|
+
const unconditionalVariance = omega / (1 - persistence);
|
|
1239
|
+
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
1240
|
+
const logLikelihood = -result.fx;
|
|
1241
|
+
const numParams = 5;
|
|
1242
|
+
return {
|
|
1243
|
+
params: {
|
|
1244
|
+
omega,
|
|
1245
|
+
alpha,
|
|
1246
|
+
gamma,
|
|
1247
|
+
beta,
|
|
1248
|
+
persistence,
|
|
1249
|
+
unconditionalVariance,
|
|
1250
|
+
annualizedVol,
|
|
1251
|
+
leverageEffect: gamma,
|
|
1252
|
+
df,
|
|
1253
|
+
},
|
|
1254
|
+
diagnostics: {
|
|
1255
|
+
logLikelihood,
|
|
1256
|
+
aic: calculateAIC(logLikelihood, numParams),
|
|
1257
|
+
bic: calculateBIC(logLikelihood, numParams, n),
|
|
1258
|
+
iterations: result.iterations,
|
|
1259
|
+
converged: result.converged,
|
|
1260
|
+
},
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Calculate conditional variance series given parameters
|
|
1265
|
+
*/
|
|
1266
|
+
getVarianceSeries(params) {
|
|
1267
|
+
const { omega, alpha, gamma, beta } = params;
|
|
1268
|
+
const variance = [];
|
|
1269
|
+
for (let i = 0; i < this.returns.length; i++) {
|
|
1270
|
+
if (i === 0) {
|
|
1271
|
+
variance.push(this.initialVariance);
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
const innovation = this.rv ? this.rv[i - 1] : this.returns[i - 1] ** 2;
|
|
1275
|
+
const indicator = this.returns[i - 1] < 0 ? 1 : 0;
|
|
1276
|
+
const v = omega + alpha * innovation + gamma * innovation * indicator + beta * variance[i - 1];
|
|
1277
|
+
variance.push(v);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return variance;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Forecast variance forward
|
|
1284
|
+
*/
|
|
1285
|
+
forecast(params, steps = 1) {
|
|
1286
|
+
const { omega, alpha, gamma, beta } = params;
|
|
1287
|
+
const variance = [];
|
|
1288
|
+
const varianceSeries = this.getVarianceSeries(params);
|
|
1289
|
+
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
1290
|
+
const lastInnovation = this.rv
|
|
1291
|
+
? this.rv[this.rv.length - 1]
|
|
1292
|
+
: this.returns[this.returns.length - 1] ** 2;
|
|
1293
|
+
const lastIndicator = this.returns[this.returns.length - 1] < 0 ? 1 : 0;
|
|
1294
|
+
// One-step ahead using actual last return
|
|
1295
|
+
let v = omega + alpha * lastInnovation + gamma * lastInnovation * lastIndicator + beta * lastVariance;
|
|
1296
|
+
variance.push(v);
|
|
1297
|
+
// Multi-step: E[I(r<0)] = 0.5, so effective persistence = α + γ/2 + β
|
|
1298
|
+
for (let h = 1; h < steps; h++) {
|
|
1299
|
+
v = omega + (alpha + gamma / 2 + beta) * v;
|
|
1300
|
+
variance.push(v);
|
|
1301
|
+
}
|
|
1302
|
+
return {
|
|
1303
|
+
variance,
|
|
1304
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1305
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Get the return series
|
|
1310
|
+
*/
|
|
1311
|
+
getReturns() {
|
|
1312
|
+
return [...this.returns];
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Get initial variance estimate
|
|
1316
|
+
*/
|
|
1317
|
+
getInitialVariance() {
|
|
1318
|
+
return this.initialVariance;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Convenience function to calibrate GJR-GARCH(1,1) from candles
|
|
1323
|
+
*/
|
|
1324
|
+
function calibrateGjrGarch(data, options = {}) {
|
|
1325
|
+
const model = new GjrGarch(data, options);
|
|
1326
|
+
return model.fit(options);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const DEFAULT_LAGS = 10;
|
|
1330
|
+
/**
|
|
1331
|
+
* NoVaS (Normalizing and Variance-Stabilizing) model (Politis, 2003)
|
|
1332
|
+
*
|
|
1333
|
+
* Two-stage calibration:
|
|
1334
|
+
*
|
|
1335
|
+
* Stage 1 — D² minimization (model-free normality):
|
|
1336
|
+
* σ²_t = a_0 + a_1·X²_{t-1} + a_2·X²_{t-2} + ... + a_p·X²_{t-p}
|
|
1337
|
+
* W_t = X_t / σ_t
|
|
1338
|
+
* Minimize D² = S² + (K - 3)² where S, K are skewness and kurtosis of {W_t}.
|
|
1339
|
+
*
|
|
1340
|
+
* Stage 2 — OLS rescaling (forecast-optimal):
|
|
1341
|
+
* RV_{t+1} = β₀ + β₁·σ²_t(D²)
|
|
1342
|
+
* The D²-discovered σ²_t acts as a data-driven smoother over RV lags.
|
|
1343
|
+
* OLS rescales it to minimize forecast error (RSS on RV).
|
|
1344
|
+
* Only 2 parameters → robust on small samples with noisy per-candle RV.
|
|
1345
|
+
*
|
|
1346
|
+
* D² discovers lag structure (model-free). OLS rescales for prediction accuracy.
|
|
1347
|
+
* Both weight sets are stored in params — no identity loss.
|
|
1348
|
+
*/
|
|
1349
|
+
class NoVaS {
|
|
1350
|
+
returns;
|
|
1351
|
+
rv;
|
|
1352
|
+
periodsPerYear;
|
|
1353
|
+
lags;
|
|
1354
|
+
constructor(data, options = {}) {
|
|
1355
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1356
|
+
this.lags = options.lags ?? DEFAULT_LAGS;
|
|
1357
|
+
const minRequired = this.lags + 30;
|
|
1358
|
+
if (data.length < minRequired) {
|
|
1359
|
+
throw new Error(`Need at least ${minRequired} data points for NoVaS estimation`);
|
|
1360
|
+
}
|
|
1361
|
+
if (typeof data[0] === 'number') {
|
|
1362
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1363
|
+
this.rv = null;
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
const candles = data;
|
|
1367
|
+
this.returns = calculateReturns(candles);
|
|
1368
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
1369
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Calibrate NoVaS weights via two-stage procedure:
|
|
1374
|
+
* Stage 1: D² minimization (normality of W_t)
|
|
1375
|
+
* Stage 2: OLS rescaling of D²-variance (forecast-optimal)
|
|
1376
|
+
*/
|
|
1377
|
+
fit(options = {}) {
|
|
1378
|
+
const { maxIter = 2000, tol = 1e-8 } = options;
|
|
1379
|
+
const returns = this.returns;
|
|
1380
|
+
const n = returns.length;
|
|
1381
|
+
const p = this.lags;
|
|
1382
|
+
const initVar = sampleVariance(returns);
|
|
1383
|
+
// Innovation: Parkinson RV for candles, r² for prices
|
|
1384
|
+
const r2 = this.rv ?? returns.map(r => r * r);
|
|
1385
|
+
/**
|
|
1386
|
+
* Compute D² for a given weight vector.
|
|
1387
|
+
* D² = S² + (K - 3)² where S, K are skewness and kurtosis of W_t.
|
|
1388
|
+
*/
|
|
1389
|
+
function objectiveD2(rawWeights) {
|
|
1390
|
+
// Enforce constraints: a_j >= 0 via abs, a_0 > epsilon
|
|
1391
|
+
const weights = rawWeights.map(w => Math.abs(w));
|
|
1392
|
+
if (weights[0] < 1e-15)
|
|
1393
|
+
return 1e10;
|
|
1394
|
+
// Stationarity: sum(a_1..a_p) < 1
|
|
1395
|
+
let lagSum = 0;
|
|
1396
|
+
for (let j = 1; j <= p; j++)
|
|
1397
|
+
lagSum += weights[j];
|
|
1398
|
+
if (lagSum >= 0.9999)
|
|
1399
|
+
return 1e10;
|
|
1400
|
+
// Compute transformed series W_t = r_t / sqrt(sigma^2_t)
|
|
1401
|
+
let sumW = 0;
|
|
1402
|
+
let sumW2 = 0;
|
|
1403
|
+
let sumW3 = 0;
|
|
1404
|
+
let sumW4 = 0;
|
|
1405
|
+
let count = 0;
|
|
1406
|
+
for (let t = p; t < n; t++) {
|
|
1407
|
+
let variance = weights[0];
|
|
1408
|
+
for (let j = 1; j <= p; j++) {
|
|
1409
|
+
variance += weights[j] * r2[t - j];
|
|
1410
|
+
}
|
|
1411
|
+
if (variance <= 1e-15)
|
|
1412
|
+
return 1e10;
|
|
1413
|
+
const w = returns[t] / Math.sqrt(variance);
|
|
1414
|
+
if (!isFinite(w))
|
|
1415
|
+
return 1e10;
|
|
1416
|
+
sumW += w;
|
|
1417
|
+
sumW2 += w * w;
|
|
1418
|
+
sumW3 += w * w * w;
|
|
1419
|
+
sumW4 += w * w * w * w;
|
|
1420
|
+
count++;
|
|
1421
|
+
}
|
|
1422
|
+
if (count < 10)
|
|
1423
|
+
return 1e10;
|
|
1424
|
+
const mean = sumW / count;
|
|
1425
|
+
const m2 = sumW2 / count - mean * mean;
|
|
1426
|
+
if (m2 <= 1e-15)
|
|
1427
|
+
return 1e10;
|
|
1428
|
+
const m3 = sumW3 / count - 3 * mean * sumW2 / count + 2 * mean * mean * mean;
|
|
1429
|
+
const m4 = sumW4 / count - 4 * mean * sumW3 / count
|
|
1430
|
+
+ 6 * mean * mean * sumW2 / count - 3 * mean * mean * mean * mean;
|
|
1431
|
+
const skewness = m3 / (m2 * Math.sqrt(m2));
|
|
1432
|
+
const kurtosis = m4 / (m2 * m2);
|
|
1433
|
+
if (!isFinite(skewness) || !isFinite(kurtosis))
|
|
1434
|
+
return 1e10;
|
|
1435
|
+
return skewness * skewness + (kurtosis - 3) * (kurtosis - 3);
|
|
1436
|
+
}
|
|
1437
|
+
// Initial guess: intercept in variance units, lag weights dimensionless
|
|
1438
|
+
const lambda = 0.7;
|
|
1439
|
+
const x0 = [initVar * 0.1];
|
|
1440
|
+
for (let j = 1; j <= p; j++) {
|
|
1441
|
+
x0.push(0.9 * (1 - lambda) * Math.pow(lambda, j - 1));
|
|
1442
|
+
}
|
|
1443
|
+
const result = nelderMeadMultiStart(objectiveD2, x0, { maxIter, tol, restarts: 6 });
|
|
1444
|
+
// Extract final weights (abs for constraint enforcement)
|
|
1445
|
+
const weights = result.x.map(w => Math.abs(w));
|
|
1446
|
+
let persistence = 0;
|
|
1447
|
+
for (let j = 1; j <= p; j++)
|
|
1448
|
+
persistence += weights[j];
|
|
1449
|
+
const unconditionalVariance = persistence < 1 && persistence > -1
|
|
1450
|
+
? Math.max(weights[0] / (1 - persistence), 1e-20)
|
|
1451
|
+
: sampleVariance(returns);
|
|
1452
|
+
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
1453
|
+
// ── Stage 2: OLS rescaling of D²-variance ──────────────
|
|
1454
|
+
// RV_{t+1} = β₀ + β₁·σ²_t(D²)
|
|
1455
|
+
// D² weights discover lag structure; OLS rescales for forecast accuracy.
|
|
1456
|
+
// Only 2 parameters → robust on small samples with noisy per-candle RV.
|
|
1457
|
+
const d2Variance = this.getVarianceSeriesInternal(weights);
|
|
1458
|
+
let forecastWeights;
|
|
1459
|
+
let olsR2;
|
|
1460
|
+
try {
|
|
1461
|
+
let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, count = 0;
|
|
1462
|
+
for (let t = p; t < n - 1; t++) {
|
|
1463
|
+
const x = d2Variance[t];
|
|
1464
|
+
const y = r2[t + 1];
|
|
1465
|
+
sumX += x;
|
|
1466
|
+
sumY += y;
|
|
1467
|
+
sumXX += x * x;
|
|
1468
|
+
sumXY += x * y;
|
|
1469
|
+
count++;
|
|
1470
|
+
}
|
|
1471
|
+
const denom = count * sumXX - sumX * sumX;
|
|
1472
|
+
if (Math.abs(denom) < 1e-30)
|
|
1473
|
+
throw new Error('Degenerate variance series');
|
|
1474
|
+
const beta1 = (count * sumXY - sumX * sumY) / denom;
|
|
1475
|
+
const beta0 = (sumY - beta1 * sumX) / count;
|
|
1476
|
+
forecastWeights = [beta0, beta1];
|
|
1477
|
+
// R²
|
|
1478
|
+
const yMean = sumY / count;
|
|
1479
|
+
let rss = 0, tss = 0;
|
|
1480
|
+
for (let t = p; t < n - 1; t++) {
|
|
1481
|
+
const yHat = beta0 + beta1 * d2Variance[t];
|
|
1482
|
+
rss += (r2[t + 1] - yHat) ** 2;
|
|
1483
|
+
tss += (r2[t + 1] - yMean) ** 2;
|
|
1484
|
+
}
|
|
1485
|
+
olsR2 = tss > 0 ? 1 - rss / tss : 0;
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
// OLS failed — fall back to identity rescaling [0, 1]
|
|
1489
|
+
forecastWeights = [0, 1];
|
|
1490
|
+
olsR2 = 0;
|
|
1491
|
+
}
|
|
1492
|
+
// Student-t log-likelihood for AIC comparison with GARCH/EGARCH/HAR-RV
|
|
1493
|
+
const df = profileStudentTDf(returns, d2Variance);
|
|
1494
|
+
const ll = -studentTNegLL(returns, d2Variance, df);
|
|
1495
|
+
const numParams = p + 2; // weights + df
|
|
1496
|
+
const nObs = n - p; // usable observations for D²
|
|
1497
|
+
return {
|
|
1498
|
+
params: {
|
|
1499
|
+
weights,
|
|
1500
|
+
forecastWeights,
|
|
1501
|
+
lags: p,
|
|
1502
|
+
persistence,
|
|
1503
|
+
unconditionalVariance,
|
|
1504
|
+
annualizedVol,
|
|
1505
|
+
dSquared: result.fx,
|
|
1506
|
+
r2: olsR2,
|
|
1507
|
+
df,
|
|
1508
|
+
},
|
|
1509
|
+
diagnostics: {
|
|
1510
|
+
logLikelihood: ll,
|
|
1511
|
+
aic: calculateAIC(ll, numParams),
|
|
1512
|
+
bic: calculateBIC(ll, numParams, nObs),
|
|
1513
|
+
iterations: result.iterations,
|
|
1514
|
+
converged: result.converged,
|
|
1515
|
+
},
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Internal: compute variance series from D² weight vector.
|
|
1520
|
+
*/
|
|
1521
|
+
getVarianceSeriesInternal(weights) {
|
|
1522
|
+
const { returns, lags } = this;
|
|
1523
|
+
const n = returns.length;
|
|
1524
|
+
const r2 = this.rv ?? returns.map(r => r * r);
|
|
1525
|
+
const fallback = sampleVariance(returns);
|
|
1526
|
+
const series = [];
|
|
1527
|
+
for (let t = 0; t < n; t++) {
|
|
1528
|
+
if (t < lags) {
|
|
1529
|
+
series.push(fallback);
|
|
1530
|
+
}
|
|
1531
|
+
else {
|
|
1532
|
+
let variance = weights[0];
|
|
1533
|
+
for (let j = 1; j <= lags; j++) {
|
|
1534
|
+
variance += weights[j] * r2[t - j];
|
|
1535
|
+
}
|
|
1536
|
+
series.push(Math.max(variance, 1e-20));
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return series;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Calculate conditional variance series using D² weights (normalization identity).
|
|
1543
|
+
*/
|
|
1544
|
+
getVarianceSeries(params) {
|
|
1545
|
+
return this.getVarianceSeriesInternal(params.weights);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Calculate forecast variance series using OLS-rescaled D² variance.
|
|
1549
|
+
* forecast_σ²_t = β₀ + β₁·σ²_t(D²)
|
|
1550
|
+
* Used for QLIKE model comparison — measures forecast quality.
|
|
1551
|
+
*/
|
|
1552
|
+
getForecastVarianceSeries(params) {
|
|
1553
|
+
const d2Series = this.getVarianceSeriesInternal(params.weights);
|
|
1554
|
+
const [beta0, beta1] = params.forecastWeights;
|
|
1555
|
+
return d2Series.map(v => Math.max(beta0 + beta1 * v, 1e-20));
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Forecast variance forward using OLS-rescaled D² weights.
|
|
1559
|
+
*
|
|
1560
|
+
* Step 1: compute D²-based σ²_{t+h} using D² weights
|
|
1561
|
+
* Step 2: rescale via β₀ + β₁·σ²_{t+h}
|
|
1562
|
+
*/
|
|
1563
|
+
forecast(params, steps = 1) {
|
|
1564
|
+
const { weights, forecastWeights, lags } = params;
|
|
1565
|
+
const [beta0, beta1] = forecastWeights;
|
|
1566
|
+
const r2 = this.rv ?? this.returns.map(r => r * r);
|
|
1567
|
+
// Working buffer: past innovation values + forecasted variances
|
|
1568
|
+
const history = r2.slice();
|
|
1569
|
+
const variance = [];
|
|
1570
|
+
for (let h = 0; h < steps; h++) {
|
|
1571
|
+
const t = history.length;
|
|
1572
|
+
// D²-based variance at this step
|
|
1573
|
+
let d2v = weights[0];
|
|
1574
|
+
for (let j = 1; j <= lags; j++) {
|
|
1575
|
+
d2v += weights[j] * history[t - j];
|
|
1576
|
+
}
|
|
1577
|
+
d2v = Math.max(d2v, 1e-20);
|
|
1578
|
+
// OLS-rescaled forecast
|
|
1579
|
+
const v = Math.max(beta0 + beta1 * d2v, 1e-20);
|
|
1580
|
+
variance.push(v);
|
|
1581
|
+
history.push(v); // future E[RV] = σ²
|
|
1582
|
+
}
|
|
1583
|
+
return {
|
|
1584
|
+
variance,
|
|
1585
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1586
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Get the return series.
|
|
1591
|
+
*/
|
|
1592
|
+
getReturns() {
|
|
1593
|
+
return [...this.returns];
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Convenience function to calibrate NoVaS from candles or prices.
|
|
1598
|
+
*/
|
|
1599
|
+
function calibrateNoVaS(data, options = {}) {
|
|
1600
|
+
const model = new NoVaS(data, options);
|
|
1601
|
+
return model.fit(options);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
626
1604
|
const MIN_CANDLES = {
|
|
627
1605
|
'1m': 500,
|
|
628
1606
|
'3m': 500,
|
|
@@ -635,6 +1613,18 @@ const MIN_CANDLES = {
|
|
|
635
1613
|
'6h': 150,
|
|
636
1614
|
'8h': 150,
|
|
637
1615
|
};
|
|
1616
|
+
const RECOMMENDED_CANDLES = {
|
|
1617
|
+
'1m': 1500,
|
|
1618
|
+
'3m': 1500,
|
|
1619
|
+
'5m': 1500,
|
|
1620
|
+
'15m': 1000,
|
|
1621
|
+
'30m': 1000,
|
|
1622
|
+
'1h': 500,
|
|
1623
|
+
'2h': 500,
|
|
1624
|
+
'4h': 500,
|
|
1625
|
+
'6h': 300,
|
|
1626
|
+
'8h': 300,
|
|
1627
|
+
};
|
|
638
1628
|
const INTERVALS_PER_YEAR = {
|
|
639
1629
|
'1m': 525_600,
|
|
640
1630
|
'3m': 175_200,
|
|
@@ -652,32 +1642,123 @@ function assertMinCandles(candles, interval) {
|
|
|
652
1642
|
if (candles.length < min) {
|
|
653
1643
|
throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
|
|
654
1644
|
}
|
|
1645
|
+
for (let i = 0; i < candles.length; i++) {
|
|
1646
|
+
const c = candles[i];
|
|
1647
|
+
if (!isFinite(c.close) || c.close <= 0) {
|
|
1648
|
+
throw new Error(`Invalid close price at candle ${i}: ${c.close}`);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
const recommended = RECOMMENDED_CANDLES[interval];
|
|
1652
|
+
if (candles.length < recommended) ;
|
|
655
1653
|
}
|
|
656
|
-
function
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1654
|
+
function fitGarchFamily(candles, periodsPerYear, steps) {
|
|
1655
|
+
// Fit all three GARCH-family models and pick the best by AIC
|
|
1656
|
+
// (AIC is fair here — all three optimize the same Student-t LL)
|
|
1657
|
+
const garchModel = new Garch(candles, { periodsPerYear });
|
|
1658
|
+
const garchFit = garchModel.fit();
|
|
1659
|
+
let bestAic = garchFit.diagnostics.aic;
|
|
1660
|
+
let best = {
|
|
1661
|
+
forecast: garchModel.forecast(garchFit.params, steps),
|
|
1662
|
+
modelType: 'garch',
|
|
1663
|
+
converged: garchFit.diagnostics.converged,
|
|
1664
|
+
persistence: garchFit.params.persistence,
|
|
1665
|
+
varianceSeries: garchModel.getVarianceSeries(garchFit.params),
|
|
1666
|
+
returns: garchModel.getReturns(),
|
|
1667
|
+
};
|
|
1668
|
+
const egarchModel = new Egarch(candles, { periodsPerYear });
|
|
1669
|
+
const egarchFit = egarchModel.fit();
|
|
1670
|
+
if (egarchFit.diagnostics.aic < bestAic) {
|
|
1671
|
+
bestAic = egarchFit.diagnostics.aic;
|
|
1672
|
+
best = {
|
|
1673
|
+
forecast: egarchModel.forecast(egarchFit.params, steps),
|
|
1674
|
+
modelType: 'egarch',
|
|
1675
|
+
converged: egarchFit.diagnostics.converged,
|
|
1676
|
+
persistence: egarchFit.params.persistence,
|
|
1677
|
+
varianceSeries: egarchModel.getVarianceSeries(egarchFit.params),
|
|
1678
|
+
returns: egarchModel.getReturns(),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
const gjrModel = new GjrGarch(candles, { periodsPerYear });
|
|
1682
|
+
const gjrFit = gjrModel.fit();
|
|
1683
|
+
if (gjrFit.diagnostics.aic < bestAic) {
|
|
1684
|
+
best = {
|
|
1685
|
+
forecast: gjrModel.forecast(gjrFit.params, steps),
|
|
1686
|
+
modelType: 'gjr-garch',
|
|
1687
|
+
converged: gjrFit.diagnostics.converged,
|
|
1688
|
+
persistence: gjrFit.params.persistence,
|
|
1689
|
+
varianceSeries: gjrModel.getVarianceSeries(gjrFit.params),
|
|
1690
|
+
returns: gjrModel.getReturns(),
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
return best;
|
|
1694
|
+
}
|
|
1695
|
+
function fitHarRv(candles, periodsPerYear, steps) {
|
|
1696
|
+
try {
|
|
1697
|
+
const model = new HarRv(candles, { periodsPerYear });
|
|
661
1698
|
const fit = model.fit();
|
|
1699
|
+
// Skip HAR-RV if persistence >= 1 (non-stationary) or R² too low
|
|
1700
|
+
if (fit.params.persistence >= 1 || fit.params.r2 < 0)
|
|
1701
|
+
return null;
|
|
662
1702
|
return {
|
|
663
1703
|
forecast: model.forecast(fit.params, steps),
|
|
664
|
-
modelType: '
|
|
1704
|
+
modelType: 'har-rv',
|
|
665
1705
|
converged: fit.diagnostics.converged,
|
|
666
1706
|
persistence: fit.params.persistence,
|
|
667
1707
|
varianceSeries: model.getVarianceSeries(fit.params),
|
|
668
1708
|
returns: model.getReturns(),
|
|
669
1709
|
};
|
|
670
1710
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1711
|
+
catch {
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
function fitNoVaS(candles, periodsPerYear, steps) {
|
|
1716
|
+
try {
|
|
1717
|
+
const model = new NoVaS(candles, { periodsPerYear });
|
|
1718
|
+
const fit = model.fit();
|
|
1719
|
+
// Skip if persistence >= 1 (non-stationary)
|
|
1720
|
+
if (fit.params.persistence >= 1)
|
|
1721
|
+
return null;
|
|
1722
|
+
return {
|
|
1723
|
+
forecast: model.forecast(fit.params, steps),
|
|
1724
|
+
modelType: 'novas',
|
|
1725
|
+
converged: fit.diagnostics.converged,
|
|
1726
|
+
persistence: fit.params.persistence,
|
|
1727
|
+
varianceSeries: model.getForecastVarianceSeries(fit.params),
|
|
1728
|
+
returns: model.getReturns(),
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
catch {
|
|
1732
|
+
return null;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
function fitModel(candles, periodsPerYear, steps) {
|
|
1736
|
+
const garchResult = fitGarchFamily(candles, periodsPerYear, steps);
|
|
1737
|
+
const harResult = fitHarRv(candles, periodsPerYear, steps);
|
|
1738
|
+
const novasResult = fitNoVaS(candles, periodsPerYear, steps);
|
|
1739
|
+
// Compute realized variance (Parkinson RV) for QLIKE scoring
|
|
1740
|
+
const returns = calculateReturns(candles);
|
|
1741
|
+
const rv = perCandleParkinson(candles, returns);
|
|
1742
|
+
// Pick model with lowest QLIKE (Patton 2011) — neutral forecast-error metric.
|
|
1743
|
+
// Unlike AIC (which favors MLE-calibrated models), QLIKE judges only
|
|
1744
|
+
// how well the variance series predicts realized variance.
|
|
1745
|
+
let best = garchResult;
|
|
1746
|
+
let bestScore = qlike(garchResult.varianceSeries, rv);
|
|
1747
|
+
if (harResult) {
|
|
1748
|
+
const score = qlike(harResult.varianceSeries, rv);
|
|
1749
|
+
if (score < bestScore) {
|
|
1750
|
+
best = harResult;
|
|
1751
|
+
bestScore = score;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if (novasResult) {
|
|
1755
|
+
const score = qlike(novasResult.varianceSeries, rv);
|
|
1756
|
+
if (score < bestScore) {
|
|
1757
|
+
best = novasResult;
|
|
1758
|
+
bestScore = score;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return best;
|
|
681
1762
|
}
|
|
682
1763
|
function checkReliable(fit) {
|
|
683
1764
|
if (!fit.converged || fit.persistence >= 0.999)
|
|
@@ -694,21 +1775,25 @@ function checkReliable(fit) {
|
|
|
694
1775
|
/**
|
|
695
1776
|
* Forecast expected price range for t+1 (next candle).
|
|
696
1777
|
*
|
|
697
|
-
* Auto-selects
|
|
698
|
-
*
|
|
1778
|
+
* Auto-selects the best volatility model via QLIKE.
|
|
1779
|
+
* Uses log-normal price bands: P·exp(±z·σ), where z = probit(confidence).
|
|
1780
|
+
* @param confidence — two-sided probability in (0,1). Default ≈0.6827 (±1σ).
|
|
1781
|
+
* Common values: 0.90 → z=1.645, 0.95 → z=1.96, 0.99 → z=2.576.
|
|
699
1782
|
*/
|
|
700
|
-
function predict(candles, interval, currentPrice = candles[candles.length - 1].close) {
|
|
1783
|
+
function predict(candles, interval, currentPrice = candles[candles.length - 1].close, confidence = 0.6827) {
|
|
701
1784
|
assertMinCandles(candles, interval);
|
|
1785
|
+
const z = probit(confidence);
|
|
702
1786
|
const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], 1);
|
|
703
1787
|
const sigma = fit.forecast.volatility[0];
|
|
704
|
-
const
|
|
1788
|
+
const upperPrice = currentPrice * Math.exp(z * sigma);
|
|
1789
|
+
const lowerPrice = currentPrice * Math.exp(-z * sigma);
|
|
705
1790
|
return {
|
|
706
1791
|
modelType: fit.modelType,
|
|
707
1792
|
currentPrice,
|
|
708
1793
|
sigma,
|
|
709
|
-
move,
|
|
710
|
-
upperPrice
|
|
711
|
-
lowerPrice
|
|
1794
|
+
move: upperPrice - currentPrice,
|
|
1795
|
+
upperPrice,
|
|
1796
|
+
lowerPrice,
|
|
712
1797
|
reliable: checkReliable(fit),
|
|
713
1798
|
};
|
|
714
1799
|
}
|
|
@@ -716,26 +1801,28 @@ function predict(candles, interval, currentPrice = candles[candles.length - 1].c
|
|
|
716
1801
|
* Forecast expected price range over multiple candles.
|
|
717
1802
|
*
|
|
718
1803
|
* Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
|
|
719
|
-
*
|
|
1804
|
+
* Uses log-normal price bands: P·exp(±z·σ), where z = probit(confidence).
|
|
1805
|
+
* @param confidence — two-sided probability in (0,1). Default ≈0.6827 (±1σ).
|
|
720
1806
|
*/
|
|
721
|
-
function predictRange(candles, interval, steps, currentPrice = candles[candles.length - 1].close) {
|
|
1807
|
+
function predictRange(candles, interval, steps, currentPrice = candles[candles.length - 1].close, confidence = 0.6827) {
|
|
722
1808
|
assertMinCandles(candles, interval);
|
|
1809
|
+
const z = probit(confidence);
|
|
723
1810
|
const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], steps);
|
|
724
1811
|
const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
|
|
725
1812
|
const sigma = Math.sqrt(cumulativeVariance);
|
|
726
|
-
const
|
|
1813
|
+
const upperPrice = currentPrice * Math.exp(z * sigma);
|
|
1814
|
+
const lowerPrice = currentPrice * Math.exp(-z * sigma);
|
|
727
1815
|
return {
|
|
728
1816
|
modelType: fit.modelType,
|
|
729
1817
|
currentPrice,
|
|
730
1818
|
sigma,
|
|
731
|
-
move,
|
|
732
|
-
upperPrice
|
|
733
|
-
lowerPrice
|
|
1819
|
+
move: upperPrice - currentPrice,
|
|
1820
|
+
upperPrice,
|
|
1821
|
+
lowerPrice,
|
|
734
1822
|
reliable: checkReliable(fit),
|
|
735
1823
|
};
|
|
736
1824
|
}
|
|
737
1825
|
// ── Backtest ──────────────────────────────────────────────────
|
|
738
|
-
const BACKTEST_REQUIRED_PERCENT = 68;
|
|
739
1826
|
const BACKTEST_WINDOW_RATIO = 0.75;
|
|
740
1827
|
/**
|
|
741
1828
|
* Walk-forward backtest of predict.
|
|
@@ -743,16 +1830,22 @@ const BACKTEST_WINDOW_RATIO = 0.75;
|
|
|
743
1830
|
* Window is computed automatically: 75% of candles for fitting, 25% for testing.
|
|
744
1831
|
* Throws if not enough candles for the given interval.
|
|
745
1832
|
* Returns true if the model's hit rate meets the required threshold.
|
|
746
|
-
*
|
|
1833
|
+
* @param confidence — two-sided probability in (0,1) for the prediction band.
|
|
1834
|
+
* Default ≈0.6827 (±1σ).
|
|
1835
|
+
* @param requiredPercent — minimum hit rate (0–100) to pass. Default 68.
|
|
747
1836
|
*/
|
|
748
|
-
function backtest(candles, interval, requiredPercent =
|
|
1837
|
+
function backtest(candles, interval, confidence = 0.6827, requiredPercent = 68) {
|
|
749
1838
|
assertMinCandles(candles, interval);
|
|
1839
|
+
if (requiredPercent <= 0)
|
|
1840
|
+
return true;
|
|
1841
|
+
if (requiredPercent >= 100)
|
|
1842
|
+
return false;
|
|
750
1843
|
const window = Math.max(MIN_CANDLES[interval], Math.floor(candles.length * BACKTEST_WINDOW_RATIO));
|
|
751
1844
|
let hits = 0;
|
|
752
1845
|
let total = 0;
|
|
753
1846
|
for (let i = window; i < candles.length - 1; i++) {
|
|
754
1847
|
const slice = candles.slice(i - window, i + 1);
|
|
755
|
-
const predicted = predict(slice, interval);
|
|
1848
|
+
const predicted = predict(slice, interval, slice[slice.length - 1].close, confidence);
|
|
756
1849
|
const actual = candles[i + 1].close;
|
|
757
1850
|
if (actual >= predicted.lowerPrice && actual <= predicted.upperPrice) {
|
|
758
1851
|
hits++;
|
|
@@ -765,17 +1858,30 @@ function backtest(candles, interval, requiredPercent = BACKTEST_REQUIRED_PERCENT
|
|
|
765
1858
|
exports.EXPECTED_ABS_NORMAL = EXPECTED_ABS_NORMAL;
|
|
766
1859
|
exports.Egarch = Egarch;
|
|
767
1860
|
exports.Garch = Garch;
|
|
1861
|
+
exports.GjrGarch = GjrGarch;
|
|
1862
|
+
exports.HarRv = HarRv;
|
|
1863
|
+
exports.NoVaS = NoVaS;
|
|
768
1864
|
exports.backtest = backtest;
|
|
769
1865
|
exports.calculateReturns = calculateReturns;
|
|
770
1866
|
exports.calculateReturnsFromPrices = calculateReturnsFromPrices;
|
|
771
1867
|
exports.calibrateEgarch = calibrateEgarch;
|
|
772
1868
|
exports.calibrateGarch = calibrateGarch;
|
|
1869
|
+
exports.calibrateGjrGarch = calibrateGjrGarch;
|
|
1870
|
+
exports.calibrateHarRv = calibrateHarRv;
|
|
1871
|
+
exports.calibrateNoVaS = calibrateNoVaS;
|
|
773
1872
|
exports.checkLeverageEffect = checkLeverageEffect;
|
|
1873
|
+
exports.expectedAbsStudentT = expectedAbsStudentT;
|
|
774
1874
|
exports.garmanKlassVariance = garmanKlassVariance;
|
|
775
1875
|
exports.ljungBox = ljungBox;
|
|
1876
|
+
exports.logGamma = logGamma;
|
|
776
1877
|
exports.nelderMead = nelderMead;
|
|
1878
|
+
exports.nelderMeadMultiStart = nelderMeadMultiStart;
|
|
1879
|
+
exports.perCandleParkinson = perCandleParkinson;
|
|
777
1880
|
exports.predict = predict;
|
|
778
1881
|
exports.predictRange = predictRange;
|
|
1882
|
+
exports.profileStudentTDf = profileStudentTDf;
|
|
1883
|
+
exports.qlike = qlike;
|
|
779
1884
|
exports.sampleVariance = sampleVariance;
|
|
780
1885
|
exports.sampleVarianceWithMean = sampleVarianceWithMean;
|
|
1886
|
+
exports.studentTNegLL = studentTNegLL;
|
|
781
1887
|
exports.yangZhangVariance = yangZhangVariance;
|