garch 1.0.3 → 1.2.0

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