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