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