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