garch 1.0.3 → 1.1.0

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