garch 1.0.3 → 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 +278 -37
- package/build/index.cjs +1084 -43
- package/build/index.mjs +1072 -44
- package/package.json +1 -1
- package/types.d.ts +283 -4
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,28 @@ 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
|
+
}
|
|
293
457
|
|
|
294
458
|
/**
|
|
295
459
|
* GARCH(1,1) model
|
|
@@ -304,6 +468,7 @@ function calculateBIC(logLikelihood, numParams, numObs) {
|
|
|
304
468
|
*/
|
|
305
469
|
class Garch {
|
|
306
470
|
returns;
|
|
471
|
+
rv;
|
|
307
472
|
periodsPerYear;
|
|
308
473
|
initialVariance;
|
|
309
474
|
constructor(data, options = {}) {
|
|
@@ -315,11 +480,14 @@ class Garch {
|
|
|
315
480
|
if (typeof data[0] === 'number') {
|
|
316
481
|
this.returns = calculateReturnsFromPrices(data);
|
|
317
482
|
this.initialVariance = sampleVariance(this.returns);
|
|
483
|
+
this.rv = null;
|
|
318
484
|
}
|
|
319
485
|
else {
|
|
320
486
|
const candles = data;
|
|
321
487
|
this.returns = calculateReturns(candles);
|
|
322
488
|
this.initialVariance = yangZhangVariance(candles);
|
|
489
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
490
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
323
491
|
}
|
|
324
492
|
}
|
|
325
493
|
/**
|
|
@@ -330,9 +498,10 @@ class Garch {
|
|
|
330
498
|
const returns = this.returns;
|
|
331
499
|
const n = returns.length;
|
|
332
500
|
const initVar = this.initialVariance;
|
|
333
|
-
|
|
501
|
+
const rv = this.rv;
|
|
502
|
+
// Student-t negative log-likelihood function
|
|
334
503
|
function negLogLikelihood(params) {
|
|
335
|
-
const [omega, alpha, beta] = params;
|
|
504
|
+
const [omega, alpha, beta, df] = params;
|
|
336
505
|
// Constraints
|
|
337
506
|
if (omega <= 1e-12)
|
|
338
507
|
return 1e10;
|
|
@@ -340,30 +509,37 @@ class Garch {
|
|
|
340
509
|
return 1e10;
|
|
341
510
|
if (alpha + beta >= 0.9999)
|
|
342
511
|
return 1e10;
|
|
512
|
+
if (df <= 2.01 || df > 100)
|
|
513
|
+
return 1e10;
|
|
514
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
515
|
+
const dfMinus2 = df - 2;
|
|
516
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
343
517
|
let variance = initVar;
|
|
344
518
|
let ll = 0;
|
|
345
519
|
for (let i = 0; i < n; i++) {
|
|
346
520
|
if (i > 0) {
|
|
347
|
-
|
|
521
|
+
const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
|
|
522
|
+
variance = omega + alpha * innovation + beta * variance;
|
|
348
523
|
}
|
|
349
524
|
if (variance <= 1e-12)
|
|
350
525
|
return 1e10;
|
|
351
|
-
//
|
|
352
|
-
ll += Math.log(variance) + (returns[i] ** 2) / variance;
|
|
526
|
+
// Student-t log-likelihood
|
|
527
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
353
528
|
}
|
|
354
|
-
return ll
|
|
529
|
+
return -(ll + constant);
|
|
355
530
|
}
|
|
356
531
|
// Initial guesses
|
|
357
532
|
const omega0 = initVar * 0.05;
|
|
358
533
|
const alpha0 = 0.1;
|
|
359
534
|
const beta0 = 0.85;
|
|
360
|
-
const
|
|
361
|
-
const [
|
|
535
|
+
const df0 = 5;
|
|
536
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, beta0, df0], { maxIter, tol, restarts: 3 });
|
|
537
|
+
const [omega, alpha, beta, df] = result.x;
|
|
362
538
|
const persistence = alpha + beta;
|
|
363
539
|
const unconditionalVariance = omega / (1 - persistence);
|
|
364
540
|
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
365
541
|
const logLikelihood = -result.fx;
|
|
366
|
-
const numParams =
|
|
542
|
+
const numParams = 4;
|
|
367
543
|
return {
|
|
368
544
|
params: {
|
|
369
545
|
omega,
|
|
@@ -372,6 +548,7 @@ class Garch {
|
|
|
372
548
|
persistence,
|
|
373
549
|
unconditionalVariance,
|
|
374
550
|
annualizedVol,
|
|
551
|
+
df,
|
|
375
552
|
},
|
|
376
553
|
diagnostics: {
|
|
377
554
|
logLikelihood,
|
|
@@ -393,7 +570,8 @@ class Garch {
|
|
|
393
570
|
variance.push(this.initialVariance);
|
|
394
571
|
}
|
|
395
572
|
else {
|
|
396
|
-
const
|
|
573
|
+
const innovation = this.rv ? this.rv[i - 1] : this.returns[i - 1] ** 2;
|
|
574
|
+
const v = omega + alpha * innovation + beta * variance[i - 1];
|
|
397
575
|
variance.push(v);
|
|
398
576
|
}
|
|
399
577
|
}
|
|
@@ -408,9 +586,11 @@ class Garch {
|
|
|
408
586
|
// Get last variance
|
|
409
587
|
const varianceSeries = this.getVarianceSeries(params);
|
|
410
588
|
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
411
|
-
const
|
|
589
|
+
const lastInnovation = this.rv
|
|
590
|
+
? this.rv[this.rv.length - 1]
|
|
591
|
+
: this.returns[this.returns.length - 1] ** 2;
|
|
412
592
|
// One-step ahead
|
|
413
|
-
let v = omega + alpha *
|
|
593
|
+
let v = omega + alpha * lastInnovation + beta * lastVariance;
|
|
414
594
|
variance.push(v);
|
|
415
595
|
// Multi-step ahead (converges to unconditional variance)
|
|
416
596
|
for (let h = 1; h < steps; h++) {
|
|
@@ -455,10 +635,11 @@ function calibrateGarch(data, options = {}) {
|
|
|
455
635
|
* - α (alpha): magnitude effect
|
|
456
636
|
* - γ (gamma): leverage effect (typically negative)
|
|
457
637
|
* - β (beta): persistence
|
|
458
|
-
* - E[|z|] =
|
|
638
|
+
* - E[|z|] = expectedAbsStudentT(df) for Student-t(df)
|
|
459
639
|
*/
|
|
460
640
|
class Egarch {
|
|
461
641
|
returns;
|
|
642
|
+
rv;
|
|
462
643
|
periodsPerYear;
|
|
463
644
|
initialVariance;
|
|
464
645
|
constructor(data, options = {}) {
|
|
@@ -469,11 +650,14 @@ class Egarch {
|
|
|
469
650
|
if (typeof data[0] === 'number') {
|
|
470
651
|
this.returns = calculateReturnsFromPrices(data);
|
|
471
652
|
this.initialVariance = sampleVariance(this.returns);
|
|
653
|
+
this.rv = null;
|
|
472
654
|
}
|
|
473
655
|
else {
|
|
474
656
|
const candles = data;
|
|
475
657
|
this.returns = calculateReturns(candles);
|
|
476
658
|
this.initialVariance = yangZhangVariance(candles);
|
|
659
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
660
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
477
661
|
}
|
|
478
662
|
}
|
|
479
663
|
/**
|
|
@@ -484,20 +668,31 @@ class Egarch {
|
|
|
484
668
|
const returns = this.returns;
|
|
485
669
|
const n = returns.length;
|
|
486
670
|
const initLogVar = Math.log(this.initialVariance);
|
|
671
|
+
const rv = this.rv;
|
|
487
672
|
function negLogLikelihood(params) {
|
|
488
|
-
const [omega, alpha, gamma, beta] = params;
|
|
673
|
+
const [omega, alpha, gamma, beta, df] = params;
|
|
489
674
|
// EGARCH allows negative gamma, but beta should ensure stationarity
|
|
490
675
|
if (Math.abs(beta) >= 0.9999)
|
|
491
676
|
return 1e10;
|
|
677
|
+
if (df <= 2.01 || df > 100)
|
|
678
|
+
return 1e10;
|
|
679
|
+
const eAbsZ = expectedAbsStudentT(df);
|
|
680
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
681
|
+
const dfMinus2 = df - 2;
|
|
682
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
492
683
|
let logVariance = initLogVar;
|
|
493
684
|
let variance = Math.exp(logVariance);
|
|
494
685
|
let ll = 0;
|
|
495
686
|
for (let i = 0; i < n; i++) {
|
|
496
687
|
if (i > 0) {
|
|
497
688
|
const sigma = Math.sqrt(variance);
|
|
498
|
-
const z = returns[i - 1] / sigma;
|
|
689
|
+
const z = returns[i - 1] / sigma; // directional — kept for leverage
|
|
690
|
+
// Magnitude: √(RV/σ²) for candles, |z| for prices
|
|
691
|
+
const magnitude = rv
|
|
692
|
+
? Math.sqrt(rv[i - 1] / variance)
|
|
693
|
+
: Math.abs(z);
|
|
499
694
|
logVariance = omega
|
|
500
|
-
+ alpha * (
|
|
695
|
+
+ alpha * (magnitude - eAbsZ)
|
|
501
696
|
+ gamma * z
|
|
502
697
|
+ beta * logVariance;
|
|
503
698
|
// Prevent extreme values
|
|
@@ -506,9 +701,10 @@ class Egarch {
|
|
|
506
701
|
}
|
|
507
702
|
if (variance <= 1e-12 || !isFinite(variance))
|
|
508
703
|
return 1e10;
|
|
509
|
-
|
|
704
|
+
// Student-t log-likelihood
|
|
705
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
510
706
|
}
|
|
511
|
-
return ll
|
|
707
|
+
return -(ll + constant);
|
|
512
708
|
}
|
|
513
709
|
// Initial guesses
|
|
514
710
|
// omega approximates log of unconditional variance when other params are small
|
|
@@ -516,15 +712,16 @@ class Egarch {
|
|
|
516
712
|
const alpha0 = 0.1;
|
|
517
713
|
const gamma0 = -0.05; // Negative for typical leverage effect
|
|
518
714
|
const beta0 = 0.95;
|
|
519
|
-
const
|
|
520
|
-
const [
|
|
715
|
+
const df0 = 5;
|
|
716
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
|
|
717
|
+
const [omega, alpha, gamma, beta, df] = result.x;
|
|
521
718
|
// For EGARCH, unconditional variance: E[ln(σ²)] = ω/(1-β)
|
|
522
719
|
// So E[σ²] ≈ exp(ω/(1-β)) when α and γ effects average out
|
|
523
720
|
const unconditionalLogVar = omega / (1 - beta);
|
|
524
721
|
const unconditionalVariance = Math.exp(unconditionalLogVar);
|
|
525
722
|
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
526
723
|
const logLikelihood = -result.fx;
|
|
527
|
-
const numParams =
|
|
724
|
+
const numParams = 5;
|
|
528
725
|
return {
|
|
529
726
|
params: {
|
|
530
727
|
omega,
|
|
@@ -535,6 +732,7 @@ class Egarch {
|
|
|
535
732
|
unconditionalVariance,
|
|
536
733
|
annualizedVol,
|
|
537
734
|
leverageEffect: gamma,
|
|
735
|
+
df,
|
|
538
736
|
},
|
|
539
737
|
diagnostics: {
|
|
540
738
|
logLikelihood,
|
|
@@ -549,7 +747,8 @@ class Egarch {
|
|
|
549
747
|
* Calculate conditional variance series given parameters
|
|
550
748
|
*/
|
|
551
749
|
getVarianceSeries(params) {
|
|
552
|
-
const { omega, alpha, gamma, beta } = params;
|
|
750
|
+
const { omega, alpha, gamma, beta, df } = params;
|
|
751
|
+
const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
|
|
553
752
|
const variance = [];
|
|
554
753
|
let logVariance = Math.log(this.initialVariance);
|
|
555
754
|
for (let i = 0; i < this.returns.length; i++) {
|
|
@@ -559,8 +758,11 @@ class Egarch {
|
|
|
559
758
|
else {
|
|
560
759
|
const sigma = Math.sqrt(variance[i - 1]);
|
|
561
760
|
const z = this.returns[i - 1] / sigma;
|
|
761
|
+
const magnitude = this.rv
|
|
762
|
+
? Math.sqrt(this.rv[i - 1] / variance[i - 1])
|
|
763
|
+
: Math.abs(z);
|
|
562
764
|
logVariance = omega
|
|
563
|
-
+ alpha * (
|
|
765
|
+
+ alpha * (magnitude - eAbsZ)
|
|
564
766
|
+ gamma * z
|
|
565
767
|
+ beta * logVariance;
|
|
566
768
|
logVariance = Math.max(-50, Math.min(50, logVariance));
|
|
@@ -577,7 +779,8 @@ class Egarch {
|
|
|
577
779
|
* expected values of future shocks.
|
|
578
780
|
*/
|
|
579
781
|
forecast(params, steps = 1) {
|
|
580
|
-
const { omega, alpha, gamma, beta } = params;
|
|
782
|
+
const { omega, alpha, gamma, beta, df } = params;
|
|
783
|
+
const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
|
|
581
784
|
const variance = [];
|
|
582
785
|
const varianceSeries = this.getVarianceSeries(params);
|
|
583
786
|
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
@@ -585,12 +788,15 @@ class Egarch {
|
|
|
585
788
|
// One-step ahead using actual last return
|
|
586
789
|
const sigma = Math.sqrt(lastVariance);
|
|
587
790
|
const z = lastReturn / sigma;
|
|
791
|
+
const magnitude = this.rv
|
|
792
|
+
? Math.sqrt(this.rv[this.rv.length - 1] / lastVariance)
|
|
793
|
+
: Math.abs(z);
|
|
588
794
|
let logVariance = omega
|
|
589
|
-
+ alpha * (
|
|
795
|
+
+ alpha * (magnitude - eAbsZ)
|
|
590
796
|
+ gamma * z
|
|
591
797
|
+ beta * Math.log(lastVariance);
|
|
592
798
|
variance.push(Math.exp(logVariance));
|
|
593
|
-
// Multi-step: assume E[z] = 0, E[|z|] =
|
|
799
|
+
// Multi-step: assume E[z] = 0, E[|z|] = eAbsZ
|
|
594
800
|
// So the α and γ terms contribute 0 on average
|
|
595
801
|
for (let h = 1; h < steps; h++) {
|
|
596
802
|
logVariance = omega + beta * logVariance;
|
|
@@ -623,6 +829,723 @@ function calibrateEgarch(data, options = {}) {
|
|
|
623
829
|
return model.fit(options);
|
|
624
830
|
}
|
|
625
831
|
|
|
832
|
+
const DEFAULT_SHORT = 1;
|
|
833
|
+
const DEFAULT_MEDIUM = 5;
|
|
834
|
+
const DEFAULT_LONG = 22;
|
|
835
|
+
/**
|
|
836
|
+
* Solve linear system Ax = b via Gaussian elimination with partial pivoting.
|
|
837
|
+
* A is n×n, b is n-vector. Returns x.
|
|
838
|
+
*/
|
|
839
|
+
function solveLinearSystem(A, b) {
|
|
840
|
+
const n = A.length;
|
|
841
|
+
const M = A.map((row, i) => [...row, b[i]]);
|
|
842
|
+
for (let col = 0; col < n; col++) {
|
|
843
|
+
let maxRow = col;
|
|
844
|
+
let maxVal = Math.abs(M[col][col]);
|
|
845
|
+
for (let row = col + 1; row < n; row++) {
|
|
846
|
+
if (Math.abs(M[row][col]) > maxVal) {
|
|
847
|
+
maxVal = Math.abs(M[row][col]);
|
|
848
|
+
maxRow = row;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
[M[col], M[maxRow]] = [M[maxRow], M[col]];
|
|
852
|
+
if (Math.abs(M[col][col]) < 1e-15) {
|
|
853
|
+
throw new Error('Singular matrix in HAR-RV OLS');
|
|
854
|
+
}
|
|
855
|
+
for (let row = col + 1; row < n; row++) {
|
|
856
|
+
const factor = M[row][col] / M[col][col];
|
|
857
|
+
for (let j = col; j <= n; j++) {
|
|
858
|
+
M[row][j] -= factor * M[col][j];
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const x = new Array(n).fill(0);
|
|
863
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
864
|
+
x[i] = M[i][n];
|
|
865
|
+
for (let j = i + 1; j < n; j++) {
|
|
866
|
+
x[i] -= M[i][j] * x[j];
|
|
867
|
+
}
|
|
868
|
+
x[i] /= M[i][i];
|
|
869
|
+
}
|
|
870
|
+
return x;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* OLS regression: y = Xβ + ε
|
|
874
|
+
* Returns coefficients, residuals, R², RSS, TSS.
|
|
875
|
+
*/
|
|
876
|
+
function ols(X, y) {
|
|
877
|
+
const n = X.length;
|
|
878
|
+
const p = X[0].length;
|
|
879
|
+
// X'X
|
|
880
|
+
const XtX = Array.from({ length: p }, () => new Array(p).fill(0));
|
|
881
|
+
for (let i = 0; i < p; i++) {
|
|
882
|
+
for (let j = 0; j < p; j++) {
|
|
883
|
+
for (let k = 0; k < n; k++) {
|
|
884
|
+
XtX[i][j] += X[k][i] * X[k][j];
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// X'y
|
|
889
|
+
const Xty = new Array(p).fill(0);
|
|
890
|
+
for (let i = 0; i < p; i++) {
|
|
891
|
+
for (let k = 0; k < n; k++) {
|
|
892
|
+
Xty[i] += X[k][i] * y[k];
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const beta = solveLinearSystem(XtX, Xty);
|
|
896
|
+
const yMean = y.reduce((s, v) => s + v, 0) / n;
|
|
897
|
+
let rss = 0;
|
|
898
|
+
let tss = 0;
|
|
899
|
+
const residuals = [];
|
|
900
|
+
for (let i = 0; i < n; i++) {
|
|
901
|
+
let yHat = 0;
|
|
902
|
+
for (let j = 0; j < p; j++) {
|
|
903
|
+
yHat += X[i][j] * beta[j];
|
|
904
|
+
}
|
|
905
|
+
const res = y[i] - yHat;
|
|
906
|
+
residuals.push(res);
|
|
907
|
+
rss += res * res;
|
|
908
|
+
tss += (y[i] - yMean) ** 2;
|
|
909
|
+
}
|
|
910
|
+
const r2 = tss > 0 ? 1 - rss / tss : 0;
|
|
911
|
+
return { beta, residuals, rss, tss, r2 };
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Compute rolling mean of rv[t-lag+1 .. t] (inclusive).
|
|
915
|
+
*/
|
|
916
|
+
function rollingMean(rv, t, lag) {
|
|
917
|
+
let sum = 0;
|
|
918
|
+
for (let j = 0; j < lag; j++) {
|
|
919
|
+
sum += rv[t - j];
|
|
920
|
+
}
|
|
921
|
+
return sum / lag;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* HAR-RV model (Corsi, 2009)
|
|
925
|
+
*
|
|
926
|
+
* RV_{t+1} = β₀ + β₁·RV_short + β₂·RV_medium + β₃·RV_long + ε
|
|
927
|
+
*
|
|
928
|
+
* where:
|
|
929
|
+
* - RV_short = mean(rv[t-s+1..t]) (default s=1)
|
|
930
|
+
* - RV_medium = mean(rv[t-m+1..t]) (default m=5)
|
|
931
|
+
* - RV_long = mean(rv[t-l+1..t]) (default l=22)
|
|
932
|
+
* - rv[t] = Parkinson(candle_t) for OHLC data, r[t]² for prices-only
|
|
933
|
+
*
|
|
934
|
+
* Parkinson (1980): RV = (1/(4·ln2))·(ln(H/L))², ~5x more efficient than r².
|
|
935
|
+
*
|
|
936
|
+
* Uses OLS for estimation — closed-form, always converges.
|
|
937
|
+
*/
|
|
938
|
+
class HarRv {
|
|
939
|
+
returns;
|
|
940
|
+
rv;
|
|
941
|
+
periodsPerYear;
|
|
942
|
+
shortLag;
|
|
943
|
+
mediumLag;
|
|
944
|
+
longLag;
|
|
945
|
+
constructor(data, options = {}) {
|
|
946
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
947
|
+
this.shortLag = options.shortLag ?? DEFAULT_SHORT;
|
|
948
|
+
this.mediumLag = options.mediumLag ?? DEFAULT_MEDIUM;
|
|
949
|
+
this.longLag = options.longLag ?? DEFAULT_LONG;
|
|
950
|
+
const minRequired = this.longLag + 30;
|
|
951
|
+
if (data.length < minRequired) {
|
|
952
|
+
throw new Error(`Need at least ${minRequired} data points for HAR-RV estimation`);
|
|
953
|
+
}
|
|
954
|
+
if (typeof data[0] === 'number') {
|
|
955
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
956
|
+
// Prices only — no OHLC, fall back to squared returns
|
|
957
|
+
this.rv = this.returns.map(r => r * r);
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
const candles = data;
|
|
961
|
+
this.returns = calculateReturns(candles);
|
|
962
|
+
// Parkinson (1980) per-candle RV: (1/(4·ln2))·(ln(H/L))²
|
|
963
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Calibrate HAR-RV via OLS.
|
|
968
|
+
*/
|
|
969
|
+
fit() {
|
|
970
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
971
|
+
const n = rv.length;
|
|
972
|
+
// Build regression data
|
|
973
|
+
// Usable range: t = longLag-1 .. n-2 (need longLag history, and rv[t+1] as target)
|
|
974
|
+
const startIdx = longLag - 1;
|
|
975
|
+
const endIdx = n - 2;
|
|
976
|
+
const nObs = endIdx - startIdx + 1;
|
|
977
|
+
const X = [];
|
|
978
|
+
const y = [];
|
|
979
|
+
for (let t = startIdx; t <= endIdx; t++) {
|
|
980
|
+
const rvShort = rollingMean(rv, t, shortLag);
|
|
981
|
+
const rvMedium = rollingMean(rv, t, mediumLag);
|
|
982
|
+
const rvLong = rollingMean(rv, t, longLag);
|
|
983
|
+
X.push([1, rvShort, rvMedium, rvLong]);
|
|
984
|
+
y.push(rv[t + 1]);
|
|
985
|
+
}
|
|
986
|
+
const result = ols(X, y);
|
|
987
|
+
const [beta0, betaShort, betaMedium, betaLong] = result.beta;
|
|
988
|
+
const persistence = betaShort + betaMedium + betaLong;
|
|
989
|
+
const unconditionalVariance = persistence < 1 && persistence > -1
|
|
990
|
+
? Math.max(beta0 / (1 - persistence), 1e-20)
|
|
991
|
+
: sampleVariance(this.returns);
|
|
992
|
+
const annualizedVol = Math.sqrt(Math.abs(unconditionalVariance) * this.periodsPerYear) * 100;
|
|
993
|
+
// Student-t log-likelihood on returns using HAR-RV fitted variances
|
|
994
|
+
const varianceSeries = this.getVarianceSeriesInternal(result.beta);
|
|
995
|
+
const df = profileStudentTDf(this.returns, varianceSeries);
|
|
996
|
+
const ll = -studentTNegLL(this.returns, varianceSeries, df);
|
|
997
|
+
const numParams = 5; // beta0, betaShort, betaMedium, betaLong, df
|
|
998
|
+
return {
|
|
999
|
+
params: {
|
|
1000
|
+
beta0,
|
|
1001
|
+
betaShort,
|
|
1002
|
+
betaMedium,
|
|
1003
|
+
betaLong,
|
|
1004
|
+
persistence,
|
|
1005
|
+
unconditionalVariance,
|
|
1006
|
+
annualizedVol,
|
|
1007
|
+
r2: result.r2,
|
|
1008
|
+
df,
|
|
1009
|
+
},
|
|
1010
|
+
diagnostics: {
|
|
1011
|
+
logLikelihood: ll,
|
|
1012
|
+
aic: calculateAIC(ll, numParams),
|
|
1013
|
+
bic: calculateBIC(ll, numParams, nObs),
|
|
1014
|
+
iterations: 1,
|
|
1015
|
+
converged: true,
|
|
1016
|
+
},
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Internal: compute variance series from beta vector.
|
|
1021
|
+
*/
|
|
1022
|
+
getVarianceSeriesInternal(beta) {
|
|
1023
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1024
|
+
const n = rv.length;
|
|
1025
|
+
const fallback = sampleVariance(this.returns);
|
|
1026
|
+
const series = [];
|
|
1027
|
+
for (let i = 0; i < n; i++) {
|
|
1028
|
+
if (i < longLag) {
|
|
1029
|
+
// Not enough history — use sample variance
|
|
1030
|
+
series.push(fallback);
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
// HAR prediction for rv[i] based on rv[..i-1]
|
|
1034
|
+
const t = i - 1;
|
|
1035
|
+
const rvS = rollingMean(rv, t, shortLag);
|
|
1036
|
+
const rvM = rollingMean(rv, t, mediumLag);
|
|
1037
|
+
const rvL = rollingMean(rv, t, longLag);
|
|
1038
|
+
const predicted = beta[0] + beta[1] * rvS + beta[2] * rvM + beta[3] * rvL;
|
|
1039
|
+
series.push(Math.max(predicted, 1e-20));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return series;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Calculate conditional variance series given parameters.
|
|
1046
|
+
*/
|
|
1047
|
+
getVarianceSeries(params) {
|
|
1048
|
+
const beta = [params.beta0, params.betaShort, params.betaMedium, params.betaLong];
|
|
1049
|
+
return this.getVarianceSeriesInternal(beta);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Forecast variance forward.
|
|
1053
|
+
*
|
|
1054
|
+
* Uses iterative substitution: each forecast step feeds back
|
|
1055
|
+
* into the rolling RV components for subsequent steps.
|
|
1056
|
+
*/
|
|
1057
|
+
forecast(params, steps = 1) {
|
|
1058
|
+
const { rv, shortLag, mediumLag, longLag } = this;
|
|
1059
|
+
const { beta0, betaShort, betaMedium, betaLong } = params;
|
|
1060
|
+
// Working copy of recent rv values + forecasts appended
|
|
1061
|
+
const history = rv.slice();
|
|
1062
|
+
const variance = [];
|
|
1063
|
+
for (let h = 0; h < steps; h++) {
|
|
1064
|
+
const t = history.length - 1;
|
|
1065
|
+
const rvS = rollingMean(history, t, shortLag);
|
|
1066
|
+
const rvM = rollingMean(history, t, mediumLag);
|
|
1067
|
+
const rvL = rollingMean(history, t, longLag);
|
|
1068
|
+
const predicted = beta0 + betaShort * rvS + betaMedium * rvM + betaLong * rvL;
|
|
1069
|
+
const v = Math.max(predicted, 1e-20);
|
|
1070
|
+
variance.push(v);
|
|
1071
|
+
history.push(v);
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
variance,
|
|
1075
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1076
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Get the return series.
|
|
1081
|
+
*/
|
|
1082
|
+
getReturns() {
|
|
1083
|
+
return [...this.returns];
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Get realized variance series (squared returns).
|
|
1087
|
+
*/
|
|
1088
|
+
getRv() {
|
|
1089
|
+
return [...this.rv];
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Convenience function to calibrate HAR-RV from candles or prices.
|
|
1094
|
+
*/
|
|
1095
|
+
function calibrateHarRv(data, options = {}) {
|
|
1096
|
+
const model = new HarRv(data, options);
|
|
1097
|
+
return model.fit();
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* GJR-GARCH(1,1) model (Glosten, Jagannathan & Runkle, 1993)
|
|
1102
|
+
*
|
|
1103
|
+
* σ²ₜ = ω + α·ε²ₜ₋₁ + γ·ε²ₜ₋₁·I(rₜ₋₁<0) + β·σ²ₜ₋₁
|
|
1104
|
+
*
|
|
1105
|
+
* where:
|
|
1106
|
+
* - ω (omega) > 0: constant term
|
|
1107
|
+
* - α (alpha) ≥ 0: symmetric shock response
|
|
1108
|
+
* - γ (gamma) ≥ 0: asymmetric leverage coefficient
|
|
1109
|
+
* - β (beta) ≥ 0: persistence
|
|
1110
|
+
* - I(r<0) = 1 when return is negative, 0 otherwise
|
|
1111
|
+
* - Stationarity: α + γ/2 + β < 1
|
|
1112
|
+
*
|
|
1113
|
+
* With Candle[] input, ε² is replaced by Parkinson per-candle RV.
|
|
1114
|
+
* Leverage direction still comes from close-to-close return sign.
|
|
1115
|
+
*/
|
|
1116
|
+
class GjrGarch {
|
|
1117
|
+
returns;
|
|
1118
|
+
rv;
|
|
1119
|
+
periodsPerYear;
|
|
1120
|
+
initialVariance;
|
|
1121
|
+
constructor(data, options = {}) {
|
|
1122
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1123
|
+
if (data.length < 50) {
|
|
1124
|
+
throw new Error('Need at least 50 data points for GJR-GARCH estimation');
|
|
1125
|
+
}
|
|
1126
|
+
if (typeof data[0] === 'number') {
|
|
1127
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1128
|
+
this.initialVariance = sampleVariance(this.returns);
|
|
1129
|
+
this.rv = null;
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
const candles = data;
|
|
1133
|
+
this.returns = calculateReturns(candles);
|
|
1134
|
+
this.initialVariance = yangZhangVariance(candles);
|
|
1135
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Calibrate GJR-GARCH(1,1) parameters using Maximum Likelihood Estimation
|
|
1140
|
+
*/
|
|
1141
|
+
fit(options = {}) {
|
|
1142
|
+
const { maxIter = 1000, tol = 1e-8 } = options;
|
|
1143
|
+
const returns = this.returns;
|
|
1144
|
+
const n = returns.length;
|
|
1145
|
+
const initVar = this.initialVariance;
|
|
1146
|
+
const rv = this.rv;
|
|
1147
|
+
function negLogLikelihood(params) {
|
|
1148
|
+
const [omega, alpha, gamma, beta, df] = params;
|
|
1149
|
+
if (omega <= 1e-12)
|
|
1150
|
+
return 1e10;
|
|
1151
|
+
if (alpha < 0 || gamma < 0 || beta < 0)
|
|
1152
|
+
return 1e10;
|
|
1153
|
+
if (alpha + gamma / 2 + beta >= 0.9999)
|
|
1154
|
+
return 1e10;
|
|
1155
|
+
if (df <= 2.01 || df > 100)
|
|
1156
|
+
return 1e10;
|
|
1157
|
+
const halfDfPlus1 = (df + 1) / 2;
|
|
1158
|
+
const dfMinus2 = df - 2;
|
|
1159
|
+
const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
|
|
1160
|
+
let variance = initVar;
|
|
1161
|
+
let ll = 0;
|
|
1162
|
+
for (let i = 0; i < n; i++) {
|
|
1163
|
+
if (i > 0) {
|
|
1164
|
+
const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
|
|
1165
|
+
const indicator = returns[i - 1] < 0 ? 1 : 0;
|
|
1166
|
+
variance = omega + alpha * innovation + gamma * innovation * indicator + beta * variance;
|
|
1167
|
+
}
|
|
1168
|
+
if (variance <= 1e-12)
|
|
1169
|
+
return 1e10;
|
|
1170
|
+
// Student-t log-likelihood
|
|
1171
|
+
ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
|
|
1172
|
+
}
|
|
1173
|
+
return -(ll + constant);
|
|
1174
|
+
}
|
|
1175
|
+
const omega0 = initVar * 0.05;
|
|
1176
|
+
const alpha0 = 0.05;
|
|
1177
|
+
const gamma0 = 0.1;
|
|
1178
|
+
const beta0 = 0.85;
|
|
1179
|
+
const df0 = 5;
|
|
1180
|
+
const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
|
|
1181
|
+
const [omega, alpha, gamma, beta, df] = result.x;
|
|
1182
|
+
const persistence = alpha + gamma / 2 + beta;
|
|
1183
|
+
const unconditionalVariance = omega / (1 - persistence);
|
|
1184
|
+
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
1185
|
+
const logLikelihood = -result.fx;
|
|
1186
|
+
const numParams = 5;
|
|
1187
|
+
return {
|
|
1188
|
+
params: {
|
|
1189
|
+
omega,
|
|
1190
|
+
alpha,
|
|
1191
|
+
gamma,
|
|
1192
|
+
beta,
|
|
1193
|
+
persistence,
|
|
1194
|
+
unconditionalVariance,
|
|
1195
|
+
annualizedVol,
|
|
1196
|
+
leverageEffect: gamma,
|
|
1197
|
+
df,
|
|
1198
|
+
},
|
|
1199
|
+
diagnostics: {
|
|
1200
|
+
logLikelihood,
|
|
1201
|
+
aic: calculateAIC(logLikelihood, numParams),
|
|
1202
|
+
bic: calculateBIC(logLikelihood, numParams, n),
|
|
1203
|
+
iterations: result.iterations,
|
|
1204
|
+
converged: result.converged,
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Calculate conditional variance series given parameters
|
|
1210
|
+
*/
|
|
1211
|
+
getVarianceSeries(params) {
|
|
1212
|
+
const { omega, alpha, gamma, beta } = params;
|
|
1213
|
+
const variance = [];
|
|
1214
|
+
for (let i = 0; i < this.returns.length; i++) {
|
|
1215
|
+
if (i === 0) {
|
|
1216
|
+
variance.push(this.initialVariance);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
const innovation = this.rv ? this.rv[i - 1] : this.returns[i - 1] ** 2;
|
|
1220
|
+
const indicator = this.returns[i - 1] < 0 ? 1 : 0;
|
|
1221
|
+
const v = omega + alpha * innovation + gamma * innovation * indicator + beta * variance[i - 1];
|
|
1222
|
+
variance.push(v);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return variance;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Forecast variance forward
|
|
1229
|
+
*/
|
|
1230
|
+
forecast(params, steps = 1) {
|
|
1231
|
+
const { omega, alpha, gamma, beta } = params;
|
|
1232
|
+
const variance = [];
|
|
1233
|
+
const varianceSeries = this.getVarianceSeries(params);
|
|
1234
|
+
const lastVariance = varianceSeries[varianceSeries.length - 1];
|
|
1235
|
+
const lastInnovation = this.rv
|
|
1236
|
+
? this.rv[this.rv.length - 1]
|
|
1237
|
+
: this.returns[this.returns.length - 1] ** 2;
|
|
1238
|
+
const lastIndicator = this.returns[this.returns.length - 1] < 0 ? 1 : 0;
|
|
1239
|
+
// One-step ahead using actual last return
|
|
1240
|
+
let v = omega + alpha * lastInnovation + gamma * lastInnovation * lastIndicator + beta * lastVariance;
|
|
1241
|
+
variance.push(v);
|
|
1242
|
+
// Multi-step: E[I(r<0)] = 0.5, so effective persistence = α + γ/2 + β
|
|
1243
|
+
for (let h = 1; h < steps; h++) {
|
|
1244
|
+
v = omega + (alpha + gamma / 2 + beta) * v;
|
|
1245
|
+
variance.push(v);
|
|
1246
|
+
}
|
|
1247
|
+
return {
|
|
1248
|
+
variance,
|
|
1249
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1250
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Get the return series
|
|
1255
|
+
*/
|
|
1256
|
+
getReturns() {
|
|
1257
|
+
return [...this.returns];
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Get initial variance estimate
|
|
1261
|
+
*/
|
|
1262
|
+
getInitialVariance() {
|
|
1263
|
+
return this.initialVariance;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Convenience function to calibrate GJR-GARCH(1,1) from candles
|
|
1268
|
+
*/
|
|
1269
|
+
function calibrateGjrGarch(data, options = {}) {
|
|
1270
|
+
const model = new GjrGarch(data, options);
|
|
1271
|
+
return model.fit(options);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const DEFAULT_LAGS = 10;
|
|
1275
|
+
/**
|
|
1276
|
+
* NoVaS (Normalizing and Variance-Stabilizing) model (Politis, 2003)
|
|
1277
|
+
*
|
|
1278
|
+
* Two-stage calibration:
|
|
1279
|
+
*
|
|
1280
|
+
* Stage 1 — D² minimization (model-free normality):
|
|
1281
|
+
* σ²_t = a_0 + a_1·X²_{t-1} + a_2·X²_{t-2} + ... + a_p·X²_{t-p}
|
|
1282
|
+
* W_t = X_t / σ_t
|
|
1283
|
+
* Minimize D² = S² + (K - 3)² where S, K are skewness and kurtosis of {W_t}.
|
|
1284
|
+
*
|
|
1285
|
+
* Stage 2 — OLS rescaling (forecast-optimal):
|
|
1286
|
+
* RV_{t+1} = β₀ + β₁·σ²_t(D²)
|
|
1287
|
+
* The D²-discovered σ²_t acts as a data-driven smoother over RV lags.
|
|
1288
|
+
* OLS rescales it to minimize forecast error (RSS on RV).
|
|
1289
|
+
* Only 2 parameters → robust on small samples with noisy per-candle RV.
|
|
1290
|
+
*
|
|
1291
|
+
* D² discovers lag structure (model-free). OLS rescales for prediction accuracy.
|
|
1292
|
+
* Both weight sets are stored in params — no identity loss.
|
|
1293
|
+
*/
|
|
1294
|
+
class NoVaS {
|
|
1295
|
+
returns;
|
|
1296
|
+
rv;
|
|
1297
|
+
periodsPerYear;
|
|
1298
|
+
lags;
|
|
1299
|
+
constructor(data, options = {}) {
|
|
1300
|
+
this.periodsPerYear = options.periodsPerYear ?? 252;
|
|
1301
|
+
this.lags = options.lags ?? DEFAULT_LAGS;
|
|
1302
|
+
const minRequired = this.lags + 30;
|
|
1303
|
+
if (data.length < minRequired) {
|
|
1304
|
+
throw new Error(`Need at least ${minRequired} data points for NoVaS estimation`);
|
|
1305
|
+
}
|
|
1306
|
+
if (typeof data[0] === 'number') {
|
|
1307
|
+
this.returns = calculateReturnsFromPrices(data);
|
|
1308
|
+
this.rv = null;
|
|
1309
|
+
}
|
|
1310
|
+
else {
|
|
1311
|
+
const candles = data;
|
|
1312
|
+
this.returns = calculateReturns(candles);
|
|
1313
|
+
// Parkinson (1980) per-candle RV: ~5× more efficient than r²
|
|
1314
|
+
this.rv = perCandleParkinson(candles, this.returns);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Calibrate NoVaS weights via two-stage procedure:
|
|
1319
|
+
* Stage 1: D² minimization (normality of W_t)
|
|
1320
|
+
* Stage 2: OLS rescaling of D²-variance (forecast-optimal)
|
|
1321
|
+
*/
|
|
1322
|
+
fit(options = {}) {
|
|
1323
|
+
const { maxIter = 2000, tol = 1e-8 } = options;
|
|
1324
|
+
const returns = this.returns;
|
|
1325
|
+
const n = returns.length;
|
|
1326
|
+
const p = this.lags;
|
|
1327
|
+
const initVar = sampleVariance(returns);
|
|
1328
|
+
// Innovation: Parkinson RV for candles, r² for prices
|
|
1329
|
+
const r2 = this.rv ?? returns.map(r => r * r);
|
|
1330
|
+
/**
|
|
1331
|
+
* Compute D² for a given weight vector.
|
|
1332
|
+
* D² = S² + (K - 3)² where S, K are skewness and kurtosis of W_t.
|
|
1333
|
+
*/
|
|
1334
|
+
function objectiveD2(rawWeights) {
|
|
1335
|
+
// Enforce constraints: a_j >= 0 via abs, a_0 > epsilon
|
|
1336
|
+
const weights = rawWeights.map(w => Math.abs(w));
|
|
1337
|
+
if (weights[0] < 1e-15)
|
|
1338
|
+
return 1e10;
|
|
1339
|
+
// Stationarity: sum(a_1..a_p) < 1
|
|
1340
|
+
let lagSum = 0;
|
|
1341
|
+
for (let j = 1; j <= p; j++)
|
|
1342
|
+
lagSum += weights[j];
|
|
1343
|
+
if (lagSum >= 0.9999)
|
|
1344
|
+
return 1e10;
|
|
1345
|
+
// Compute transformed series W_t = r_t / sqrt(sigma^2_t)
|
|
1346
|
+
let sumW = 0;
|
|
1347
|
+
let sumW2 = 0;
|
|
1348
|
+
let sumW3 = 0;
|
|
1349
|
+
let sumW4 = 0;
|
|
1350
|
+
let count = 0;
|
|
1351
|
+
for (let t = p; t < n; t++) {
|
|
1352
|
+
let variance = weights[0];
|
|
1353
|
+
for (let j = 1; j <= p; j++) {
|
|
1354
|
+
variance += weights[j] * r2[t - j];
|
|
1355
|
+
}
|
|
1356
|
+
if (variance <= 1e-15)
|
|
1357
|
+
return 1e10;
|
|
1358
|
+
const w = returns[t] / Math.sqrt(variance);
|
|
1359
|
+
if (!isFinite(w))
|
|
1360
|
+
return 1e10;
|
|
1361
|
+
sumW += w;
|
|
1362
|
+
sumW2 += w * w;
|
|
1363
|
+
sumW3 += w * w * w;
|
|
1364
|
+
sumW4 += w * w * w * w;
|
|
1365
|
+
count++;
|
|
1366
|
+
}
|
|
1367
|
+
if (count < 10)
|
|
1368
|
+
return 1e10;
|
|
1369
|
+
const mean = sumW / count;
|
|
1370
|
+
const m2 = sumW2 / count - mean * mean;
|
|
1371
|
+
if (m2 <= 1e-15)
|
|
1372
|
+
return 1e10;
|
|
1373
|
+
const m3 = sumW3 / count - 3 * mean * sumW2 / count + 2 * mean * mean * mean;
|
|
1374
|
+
const m4 = sumW4 / count - 4 * mean * sumW3 / count
|
|
1375
|
+
+ 6 * mean * mean * sumW2 / count - 3 * mean * mean * mean * mean;
|
|
1376
|
+
const skewness = m3 / (m2 * Math.sqrt(m2));
|
|
1377
|
+
const kurtosis = m4 / (m2 * m2);
|
|
1378
|
+
if (!isFinite(skewness) || !isFinite(kurtosis))
|
|
1379
|
+
return 1e10;
|
|
1380
|
+
return skewness * skewness + (kurtosis - 3) * (kurtosis - 3);
|
|
1381
|
+
}
|
|
1382
|
+
// Initial guess: intercept in variance units, lag weights dimensionless
|
|
1383
|
+
const lambda = 0.7;
|
|
1384
|
+
const x0 = [initVar * 0.1];
|
|
1385
|
+
for (let j = 1; j <= p; j++) {
|
|
1386
|
+
x0.push(0.9 * (1 - lambda) * Math.pow(lambda, j - 1));
|
|
1387
|
+
}
|
|
1388
|
+
const result = nelderMeadMultiStart(objectiveD2, x0, { maxIter, tol, restarts: 6 });
|
|
1389
|
+
// Extract final weights (abs for constraint enforcement)
|
|
1390
|
+
const weights = result.x.map(w => Math.abs(w));
|
|
1391
|
+
let persistence = 0;
|
|
1392
|
+
for (let j = 1; j <= p; j++)
|
|
1393
|
+
persistence += weights[j];
|
|
1394
|
+
const unconditionalVariance = persistence < 1 && persistence > -1
|
|
1395
|
+
? Math.max(weights[0] / (1 - persistence), 1e-20)
|
|
1396
|
+
: sampleVariance(returns);
|
|
1397
|
+
const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
|
|
1398
|
+
// ── Stage 2: OLS rescaling of D²-variance ──────────────
|
|
1399
|
+
// RV_{t+1} = β₀ + β₁·σ²_t(D²)
|
|
1400
|
+
// D² weights discover lag structure; OLS rescales for forecast accuracy.
|
|
1401
|
+
// Only 2 parameters → robust on small samples with noisy per-candle RV.
|
|
1402
|
+
const d2Variance = this.getVarianceSeriesInternal(weights);
|
|
1403
|
+
let forecastWeights;
|
|
1404
|
+
let olsR2;
|
|
1405
|
+
try {
|
|
1406
|
+
let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, count = 0;
|
|
1407
|
+
for (let t = p; t < n - 1; t++) {
|
|
1408
|
+
const x = d2Variance[t];
|
|
1409
|
+
const y = r2[t + 1];
|
|
1410
|
+
sumX += x;
|
|
1411
|
+
sumY += y;
|
|
1412
|
+
sumXX += x * x;
|
|
1413
|
+
sumXY += x * y;
|
|
1414
|
+
count++;
|
|
1415
|
+
}
|
|
1416
|
+
const denom = count * sumXX - sumX * sumX;
|
|
1417
|
+
if (Math.abs(denom) < 1e-30)
|
|
1418
|
+
throw new Error('Degenerate variance series');
|
|
1419
|
+
const beta1 = (count * sumXY - sumX * sumY) / denom;
|
|
1420
|
+
const beta0 = (sumY - beta1 * sumX) / count;
|
|
1421
|
+
forecastWeights = [beta0, beta1];
|
|
1422
|
+
// R²
|
|
1423
|
+
const yMean = sumY / count;
|
|
1424
|
+
let rss = 0, tss = 0;
|
|
1425
|
+
for (let t = p; t < n - 1; t++) {
|
|
1426
|
+
const yHat = beta0 + beta1 * d2Variance[t];
|
|
1427
|
+
rss += (r2[t + 1] - yHat) ** 2;
|
|
1428
|
+
tss += (r2[t + 1] - yMean) ** 2;
|
|
1429
|
+
}
|
|
1430
|
+
olsR2 = tss > 0 ? 1 - rss / tss : 0;
|
|
1431
|
+
}
|
|
1432
|
+
catch {
|
|
1433
|
+
// OLS failed — fall back to identity rescaling [0, 1]
|
|
1434
|
+
forecastWeights = [0, 1];
|
|
1435
|
+
olsR2 = 0;
|
|
1436
|
+
}
|
|
1437
|
+
// Student-t log-likelihood for AIC comparison with GARCH/EGARCH/HAR-RV
|
|
1438
|
+
const df = profileStudentTDf(returns, d2Variance);
|
|
1439
|
+
const ll = -studentTNegLL(returns, d2Variance, df);
|
|
1440
|
+
const numParams = p + 2; // weights + df
|
|
1441
|
+
const nObs = n - p; // usable observations for D²
|
|
1442
|
+
return {
|
|
1443
|
+
params: {
|
|
1444
|
+
weights,
|
|
1445
|
+
forecastWeights,
|
|
1446
|
+
lags: p,
|
|
1447
|
+
persistence,
|
|
1448
|
+
unconditionalVariance,
|
|
1449
|
+
annualizedVol,
|
|
1450
|
+
dSquared: result.fx,
|
|
1451
|
+
r2: olsR2,
|
|
1452
|
+
df,
|
|
1453
|
+
},
|
|
1454
|
+
diagnostics: {
|
|
1455
|
+
logLikelihood: ll,
|
|
1456
|
+
aic: calculateAIC(ll, numParams),
|
|
1457
|
+
bic: calculateBIC(ll, numParams, nObs),
|
|
1458
|
+
iterations: result.iterations,
|
|
1459
|
+
converged: result.converged,
|
|
1460
|
+
},
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Internal: compute variance series from D² weight vector.
|
|
1465
|
+
*/
|
|
1466
|
+
getVarianceSeriesInternal(weights) {
|
|
1467
|
+
const { returns, lags } = this;
|
|
1468
|
+
const n = returns.length;
|
|
1469
|
+
const r2 = this.rv ?? returns.map(r => r * r);
|
|
1470
|
+
const fallback = sampleVariance(returns);
|
|
1471
|
+
const series = [];
|
|
1472
|
+
for (let t = 0; t < n; t++) {
|
|
1473
|
+
if (t < lags) {
|
|
1474
|
+
series.push(fallback);
|
|
1475
|
+
}
|
|
1476
|
+
else {
|
|
1477
|
+
let variance = weights[0];
|
|
1478
|
+
for (let j = 1; j <= lags; j++) {
|
|
1479
|
+
variance += weights[j] * r2[t - j];
|
|
1480
|
+
}
|
|
1481
|
+
series.push(Math.max(variance, 1e-20));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return series;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Calculate conditional variance series using D² weights (normalization identity).
|
|
1488
|
+
*/
|
|
1489
|
+
getVarianceSeries(params) {
|
|
1490
|
+
return this.getVarianceSeriesInternal(params.weights);
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Calculate forecast variance series using OLS-rescaled D² variance.
|
|
1494
|
+
* forecast_σ²_t = β₀ + β₁·σ²_t(D²)
|
|
1495
|
+
* Used for QLIKE model comparison — measures forecast quality.
|
|
1496
|
+
*/
|
|
1497
|
+
getForecastVarianceSeries(params) {
|
|
1498
|
+
const d2Series = this.getVarianceSeriesInternal(params.weights);
|
|
1499
|
+
const [beta0, beta1] = params.forecastWeights;
|
|
1500
|
+
return d2Series.map(v => Math.max(beta0 + beta1 * v, 1e-20));
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Forecast variance forward using OLS-rescaled D² weights.
|
|
1504
|
+
*
|
|
1505
|
+
* Step 1: compute D²-based σ²_{t+h} using D² weights
|
|
1506
|
+
* Step 2: rescale via β₀ + β₁·σ²_{t+h}
|
|
1507
|
+
*/
|
|
1508
|
+
forecast(params, steps = 1) {
|
|
1509
|
+
const { weights, forecastWeights, lags } = params;
|
|
1510
|
+
const [beta0, beta1] = forecastWeights;
|
|
1511
|
+
const r2 = this.rv ?? this.returns.map(r => r * r);
|
|
1512
|
+
// Working buffer: past innovation values + forecasted variances
|
|
1513
|
+
const history = r2.slice();
|
|
1514
|
+
const variance = [];
|
|
1515
|
+
for (let h = 0; h < steps; h++) {
|
|
1516
|
+
const t = history.length;
|
|
1517
|
+
// D²-based variance at this step
|
|
1518
|
+
let d2v = weights[0];
|
|
1519
|
+
for (let j = 1; j <= lags; j++) {
|
|
1520
|
+
d2v += weights[j] * history[t - j];
|
|
1521
|
+
}
|
|
1522
|
+
d2v = Math.max(d2v, 1e-20);
|
|
1523
|
+
// OLS-rescaled forecast
|
|
1524
|
+
const v = Math.max(beta0 + beta1 * d2v, 1e-20);
|
|
1525
|
+
variance.push(v);
|
|
1526
|
+
history.push(v); // future E[RV] = σ²
|
|
1527
|
+
}
|
|
1528
|
+
return {
|
|
1529
|
+
variance,
|
|
1530
|
+
volatility: variance.map(v => Math.sqrt(v)),
|
|
1531
|
+
annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Get the return series.
|
|
1536
|
+
*/
|
|
1537
|
+
getReturns() {
|
|
1538
|
+
return [...this.returns];
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Convenience function to calibrate NoVaS from candles or prices.
|
|
1543
|
+
*/
|
|
1544
|
+
function calibrateNoVaS(data, options = {}) {
|
|
1545
|
+
const model = new NoVaS(data, options);
|
|
1546
|
+
return model.fit(options);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
626
1549
|
const MIN_CANDLES = {
|
|
627
1550
|
'1m': 500,
|
|
628
1551
|
'3m': 500,
|
|
@@ -635,6 +1558,18 @@ const MIN_CANDLES = {
|
|
|
635
1558
|
'6h': 150,
|
|
636
1559
|
'8h': 150,
|
|
637
1560
|
};
|
|
1561
|
+
const RECOMMENDED_CANDLES = {
|
|
1562
|
+
'1m': 1500,
|
|
1563
|
+
'3m': 1500,
|
|
1564
|
+
'5m': 1500,
|
|
1565
|
+
'15m': 1000,
|
|
1566
|
+
'30m': 1000,
|
|
1567
|
+
'1h': 500,
|
|
1568
|
+
'2h': 500,
|
|
1569
|
+
'4h': 500,
|
|
1570
|
+
'6h': 300,
|
|
1571
|
+
'8h': 300,
|
|
1572
|
+
};
|
|
638
1573
|
const INTERVALS_PER_YEAR = {
|
|
639
1574
|
'1m': 525_600,
|
|
640
1575
|
'3m': 175_200,
|
|
@@ -652,32 +1587,125 @@ function assertMinCandles(candles, interval) {
|
|
|
652
1587
|
if (candles.length < min) {
|
|
653
1588
|
throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
|
|
654
1589
|
}
|
|
1590
|
+
for (let i = 0; i < candles.length; i++) {
|
|
1591
|
+
const c = candles[i];
|
|
1592
|
+
if (!isFinite(c.close) || c.close <= 0) {
|
|
1593
|
+
throw new Error(`Invalid close price at candle ${i}: ${c.close}`);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
const recommended = RECOMMENDED_CANDLES[interval];
|
|
1597
|
+
if (candles.length < recommended) {
|
|
1598
|
+
console.warn(`[garch] ${interval}: ${candles.length} candles provided, recommend ≥${recommended} for reliable results. Check reliable: true in output.`);
|
|
1599
|
+
}
|
|
655
1600
|
}
|
|
656
|
-
function
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1601
|
+
function fitGarchFamily(candles, periodsPerYear, steps) {
|
|
1602
|
+
// Fit all three GARCH-family models and pick the best by AIC
|
|
1603
|
+
// (AIC is fair here — all three optimize the same Student-t LL)
|
|
1604
|
+
const garchModel = new Garch(candles, { periodsPerYear });
|
|
1605
|
+
const garchFit = garchModel.fit();
|
|
1606
|
+
let bestAic = garchFit.diagnostics.aic;
|
|
1607
|
+
let best = {
|
|
1608
|
+
forecast: garchModel.forecast(garchFit.params, steps),
|
|
1609
|
+
modelType: 'garch',
|
|
1610
|
+
converged: garchFit.diagnostics.converged,
|
|
1611
|
+
persistence: garchFit.params.persistence,
|
|
1612
|
+
varianceSeries: garchModel.getVarianceSeries(garchFit.params),
|
|
1613
|
+
returns: garchModel.getReturns(),
|
|
1614
|
+
};
|
|
1615
|
+
const egarchModel = new Egarch(candles, { periodsPerYear });
|
|
1616
|
+
const egarchFit = egarchModel.fit();
|
|
1617
|
+
if (egarchFit.diagnostics.aic < bestAic) {
|
|
1618
|
+
bestAic = egarchFit.diagnostics.aic;
|
|
1619
|
+
best = {
|
|
1620
|
+
forecast: egarchModel.forecast(egarchFit.params, steps),
|
|
1621
|
+
modelType: 'egarch',
|
|
1622
|
+
converged: egarchFit.diagnostics.converged,
|
|
1623
|
+
persistence: egarchFit.params.persistence,
|
|
1624
|
+
varianceSeries: egarchModel.getVarianceSeries(egarchFit.params),
|
|
1625
|
+
returns: egarchModel.getReturns(),
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
const gjrModel = new GjrGarch(candles, { periodsPerYear });
|
|
1629
|
+
const gjrFit = gjrModel.fit();
|
|
1630
|
+
if (gjrFit.diagnostics.aic < bestAic) {
|
|
1631
|
+
best = {
|
|
1632
|
+
forecast: gjrModel.forecast(gjrFit.params, steps),
|
|
1633
|
+
modelType: 'gjr-garch',
|
|
1634
|
+
converged: gjrFit.diagnostics.converged,
|
|
1635
|
+
persistence: gjrFit.params.persistence,
|
|
1636
|
+
varianceSeries: gjrModel.getVarianceSeries(gjrFit.params),
|
|
1637
|
+
returns: gjrModel.getReturns(),
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
return best;
|
|
1641
|
+
}
|
|
1642
|
+
function fitHarRv(candles, periodsPerYear, steps) {
|
|
1643
|
+
try {
|
|
1644
|
+
const model = new HarRv(candles, { periodsPerYear });
|
|
661
1645
|
const fit = model.fit();
|
|
1646
|
+
// Skip HAR-RV if persistence >= 1 (non-stationary) or R² too low
|
|
1647
|
+
if (fit.params.persistence >= 1 || fit.params.r2 < 0)
|
|
1648
|
+
return null;
|
|
662
1649
|
return {
|
|
663
1650
|
forecast: model.forecast(fit.params, steps),
|
|
664
|
-
modelType: '
|
|
1651
|
+
modelType: 'har-rv',
|
|
665
1652
|
converged: fit.diagnostics.converged,
|
|
666
1653
|
persistence: fit.params.persistence,
|
|
667
1654
|
varianceSeries: model.getVarianceSeries(fit.params),
|
|
668
1655
|
returns: model.getReturns(),
|
|
669
1656
|
};
|
|
670
1657
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1658
|
+
catch {
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
function fitNoVaS(candles, periodsPerYear, steps) {
|
|
1663
|
+
try {
|
|
1664
|
+
const model = new NoVaS(candles, { periodsPerYear });
|
|
1665
|
+
const fit = model.fit();
|
|
1666
|
+
// Skip if persistence >= 1 (non-stationary)
|
|
1667
|
+
if (fit.params.persistence >= 1)
|
|
1668
|
+
return null;
|
|
1669
|
+
return {
|
|
1670
|
+
forecast: model.forecast(fit.params, steps),
|
|
1671
|
+
modelType: 'novas',
|
|
1672
|
+
converged: fit.diagnostics.converged,
|
|
1673
|
+
persistence: fit.params.persistence,
|
|
1674
|
+
varianceSeries: model.getForecastVarianceSeries(fit.params),
|
|
1675
|
+
returns: model.getReturns(),
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
catch {
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
function fitModel(candles, periodsPerYear, steps) {
|
|
1683
|
+
const garchResult = fitGarchFamily(candles, periodsPerYear, steps);
|
|
1684
|
+
const harResult = fitHarRv(candles, periodsPerYear, steps);
|
|
1685
|
+
const novasResult = fitNoVaS(candles, periodsPerYear, steps);
|
|
1686
|
+
// Compute realized variance (Parkinson RV) for QLIKE scoring
|
|
1687
|
+
const returns = calculateReturns(candles);
|
|
1688
|
+
const rv = perCandleParkinson(candles, returns);
|
|
1689
|
+
// Pick model with lowest QLIKE (Patton 2011) — neutral forecast-error metric.
|
|
1690
|
+
// Unlike AIC (which favors MLE-calibrated models), QLIKE judges only
|
|
1691
|
+
// how well the variance series predicts realized variance.
|
|
1692
|
+
let best = garchResult;
|
|
1693
|
+
let bestScore = qlike(garchResult.varianceSeries, rv);
|
|
1694
|
+
if (harResult) {
|
|
1695
|
+
const score = qlike(harResult.varianceSeries, rv);
|
|
1696
|
+
if (score < bestScore) {
|
|
1697
|
+
best = harResult;
|
|
1698
|
+
bestScore = score;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
if (novasResult) {
|
|
1702
|
+
const score = qlike(novasResult.varianceSeries, rv);
|
|
1703
|
+
if (score < bestScore) {
|
|
1704
|
+
best = novasResult;
|
|
1705
|
+
bestScore = score;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
return best;
|
|
681
1709
|
}
|
|
682
1710
|
function checkReliable(fit) {
|
|
683
1711
|
if (!fit.converged || fit.persistence >= 0.999)
|
|
@@ -765,17 +1793,30 @@ function backtest(candles, interval, requiredPercent = BACKTEST_REQUIRED_PERCENT
|
|
|
765
1793
|
exports.EXPECTED_ABS_NORMAL = EXPECTED_ABS_NORMAL;
|
|
766
1794
|
exports.Egarch = Egarch;
|
|
767
1795
|
exports.Garch = Garch;
|
|
1796
|
+
exports.GjrGarch = GjrGarch;
|
|
1797
|
+
exports.HarRv = HarRv;
|
|
1798
|
+
exports.NoVaS = NoVaS;
|
|
768
1799
|
exports.backtest = backtest;
|
|
769
1800
|
exports.calculateReturns = calculateReturns;
|
|
770
1801
|
exports.calculateReturnsFromPrices = calculateReturnsFromPrices;
|
|
771
1802
|
exports.calibrateEgarch = calibrateEgarch;
|
|
772
1803
|
exports.calibrateGarch = calibrateGarch;
|
|
1804
|
+
exports.calibrateGjrGarch = calibrateGjrGarch;
|
|
1805
|
+
exports.calibrateHarRv = calibrateHarRv;
|
|
1806
|
+
exports.calibrateNoVaS = calibrateNoVaS;
|
|
773
1807
|
exports.checkLeverageEffect = checkLeverageEffect;
|
|
1808
|
+
exports.expectedAbsStudentT = expectedAbsStudentT;
|
|
774
1809
|
exports.garmanKlassVariance = garmanKlassVariance;
|
|
775
1810
|
exports.ljungBox = ljungBox;
|
|
1811
|
+
exports.logGamma = logGamma;
|
|
776
1812
|
exports.nelderMead = nelderMead;
|
|
1813
|
+
exports.nelderMeadMultiStart = nelderMeadMultiStart;
|
|
1814
|
+
exports.perCandleParkinson = perCandleParkinson;
|
|
777
1815
|
exports.predict = predict;
|
|
778
1816
|
exports.predictRange = predictRange;
|
|
1817
|
+
exports.profileStudentTDf = profileStudentTDf;
|
|
1818
|
+
exports.qlike = qlike;
|
|
779
1819
|
exports.sampleVariance = sampleVariance;
|
|
780
1820
|
exports.sampleVarianceWithMean = sampleVarianceWithMean;
|
|
1821
|
+
exports.studentTNegLL = studentTNegLL;
|
|
781
1822
|
exports.yangZhangVariance = yangZhangVariance;
|