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