garch 1.0.2 → 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
@@ -110,7 +142,7 @@ function shrink(simplex, values, sigma, fn, n) {
110
142
  function calculateReturns(candles) {
111
143
  const returns = [];
112
144
  for (let i = 1; i < candles.length; i++) {
113
- if (candles[i].close <= 0 || candles[i - 1].close <= 0) {
145
+ if (!(candles[i].close > 0) || !(candles[i - 1].close > 0)) {
114
146
  throw new Error(`Invalid close price at index ${i}`);
115
147
  }
116
148
  returns.push(Math.log(candles[i].close / candles[i - 1].close));
@@ -123,7 +155,7 @@ function calculateReturns(candles) {
123
155
  function calculateReturnsFromPrices(prices) {
124
156
  const returns = [];
125
157
  for (let i = 1; i < prices.length; i++) {
126
- if (prices[i] <= 0 || prices[i - 1] <= 0) {
158
+ if (!(prices[i] > 0 && Number.isFinite(prices[i])) || !(prices[i - 1] > 0 && Number.isFinite(prices[i - 1]))) {
127
159
  throw new Error(`Invalid price at index ${i}`);
128
160
  }
129
161
  returns.push(Math.log(prices[i] / prices[i - 1]));
@@ -167,11 +199,225 @@ function checkLeverageEffect(returns) {
167
199
  recommendation: ratio > 1.2 ? 'egarch' : 'garch',
168
200
  };
169
201
  }
202
+ /**
203
+ * Garman-Klass (1980) variance estimator using OHLC data.
204
+ *
205
+ * σ²_GK = (1/n) Σ [ 0.5·(ln(H/L))² − (2ln2−1)·(ln(C/O))² ]
206
+ *
207
+ * ~5x more efficient than close-to-close variance.
208
+ */
209
+ function garmanKlassVariance(candles) {
210
+ const n = candles.length;
211
+ const coeff = 2 * Math.LN2 - 1;
212
+ let sum = 0;
213
+ for (let i = 0; i < n; i++) {
214
+ const { open, high, low, close } = candles[i];
215
+ const hl = Math.log(high / low);
216
+ const co = Math.log(close / open);
217
+ sum += 0.5 * hl * hl - coeff * co * co;
218
+ }
219
+ return sum / n;
220
+ }
221
+ /**
222
+ * Yang-Zhang (2000) variance estimator using OHLC data.
223
+ *
224
+ * Combines overnight (open vs prev close), open-to-close,
225
+ * and Rogers-Satchell components. More efficient than Garman-Klass
226
+ * and handles overnight gaps (relevant for stocks).
227
+ *
228
+ * σ²_YZ = σ²_overnight + k·σ²_close + (1−k)·σ²_RS
229
+ */
230
+ function yangZhangVariance(candles) {
231
+ const n = candles.length;
232
+ if (n < 2)
233
+ return garmanKlassVariance(candles);
234
+ const k = 0.34 / (1.34 + (n + 1) / (n - 1));
235
+ let overnightSum = 0;
236
+ let closeSum = 0;
237
+ let rsSum = 0;
238
+ let count = 0;
239
+ for (let i = 1; i < n; i++) {
240
+ const prevClose = candles[i - 1].close;
241
+ const { open, high, low, close } = candles[i];
242
+ const overnight = Math.log(open / prevClose);
243
+ const co = Math.log(close / open);
244
+ const hc = Math.log(high / close);
245
+ const ho = Math.log(high / open);
246
+ const lc = Math.log(low / close);
247
+ const lo = Math.log(low / open);
248
+ overnightSum += overnight * overnight;
249
+ closeSum += co * co;
250
+ rsSum += ho * hc + lo * lc;
251
+ count++;
252
+ }
253
+ const overnightVar = overnightSum / count;
254
+ const closeVar = closeSum / count;
255
+ const rsVar = rsSum / count;
256
+ return overnightVar + k * closeVar + (1 - k) * rsVar;
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
+ }
170
278
  /**
171
279
  * Expected value of |Z| where Z ~ N(0,1)
172
280
  * E[|Z|] = sqrt(2/π)
173
281
  */
174
282
  const EXPECTED_ABS_NORMAL = Math.sqrt(2 / Math.PI);
283
+ /**
284
+ * Chi-squared survival function approximation (Wilson-Hilferty).
285
+ * P(X > x) where X ~ χ²(df)
286
+ */
287
+ function chi2Survival(x, df) {
288
+ if (df <= 0 || x < 0)
289
+ return 1;
290
+ // Wilson-Hilferty normal approximation
291
+ const z = Math.cbrt(x / df) - (1 - 2 / (9 * df));
292
+ const denom = Math.sqrt(2 / (9 * df));
293
+ const normZ = z / denom;
294
+ // Standard normal CDF via error function approximation
295
+ return 1 - normalCDF(normZ);
296
+ }
297
+ function normalCDF(x) {
298
+ const t = 1 / (1 + 0.2316419 * Math.abs(x));
299
+ const d = 0.3989422804014327; // 1/sqrt(2π)
300
+ const p = d * Math.exp(-0.5 * x * x) *
301
+ (t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))));
302
+ return x >= 0 ? 1 - p : p;
303
+ }
304
+ /**
305
+ * Ljung-Box test for autocorrelation.
306
+ *
307
+ * Q = n(n+2) Σ(k=1..m) ρ²_k / (n−k)
308
+ *
309
+ * Under H₀ (no autocorrelation), Q ~ χ²(m).
310
+ * Use on squared standardized residuals to test GARCH adequacy.
311
+ */
312
+ function ljungBox(data, maxLag) {
313
+ const n = data.length;
314
+ const mean = data.reduce((s, v) => s + v, 0) / n;
315
+ const gamma0 = data.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
316
+ if (gamma0 === 0)
317
+ return { statistic: 0, pValue: 1 };
318
+ let Q = 0;
319
+ for (let k = 1; k <= maxLag; k++) {
320
+ let gammaK = 0;
321
+ for (let t = k; t < n; t++) {
322
+ gammaK += (data[t] - mean) * (data[t - k] - mean);
323
+ }
324
+ gammaK /= n;
325
+ const rhoK = gammaK / gamma0;
326
+ Q += (rhoK * rhoK) / (n - k);
327
+ }
328
+ Q *= n * (n + 2);
329
+ return { statistic: Q, pValue: chi2Survival(Q, maxLag) };
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
+ }
175
421
  /**
176
422
  * Calculate AIC (Akaike Information Criterion)
177
423
  */
@@ -184,6 +430,28 @@ function calculateAIC(logLikelihood, numParams) {
184
430
  function calculateBIC(logLikelihood, numParams, numObs) {
185
431
  return numParams * Math.log(numObs) - 2 * logLikelihood;
186
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
+ }
187
455
 
188
456
  /**
189
457
  * GARCH(1,1) model
@@ -198,6 +466,7 @@ function calculateBIC(logLikelihood, numParams, numObs) {
198
466
  */
199
467
  class Garch {
200
468
  returns;
469
+ rv;
201
470
  periodsPerYear;
202
471
  initialVariance;
203
472
  constructor(data, options = {}) {
@@ -208,11 +477,16 @@ class Garch {
208
477
  // Determine if input is candles or prices
209
478
  if (typeof data[0] === 'number') {
210
479
  this.returns = calculateReturnsFromPrices(data);
480
+ this.initialVariance = sampleVariance(this.returns);
481
+ this.rv = null;
211
482
  }
212
483
  else {
213
- this.returns = calculateReturns(data);
484
+ const candles = data;
485
+ this.returns = calculateReturns(candles);
486
+ this.initialVariance = yangZhangVariance(candles);
487
+ // Parkinson (1980) per-candle RV: ~5× more efficient than r²
488
+ this.rv = perCandleParkinson(candles, this.returns);
214
489
  }
215
- this.initialVariance = sampleVariance(this.returns);
216
490
  }
217
491
  /**
218
492
  * Calibrate GARCH(1,1) parameters using Maximum Likelihood Estimation
@@ -222,9 +496,10 @@ class Garch {
222
496
  const returns = this.returns;
223
497
  const n = returns.length;
224
498
  const initVar = this.initialVariance;
225
- // Negative log-likelihood function
499
+ const rv = this.rv;
500
+ // Student-t negative log-likelihood function
226
501
  function negLogLikelihood(params) {
227
- const [omega, alpha, beta] = params;
502
+ const [omega, alpha, beta, df] = params;
228
503
  // Constraints
229
504
  if (omega <= 1e-12)
230
505
  return 1e10;
@@ -232,30 +507,37 @@ class Garch {
232
507
  return 1e10;
233
508
  if (alpha + beta >= 0.9999)
234
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));
235
515
  let variance = initVar;
236
516
  let ll = 0;
237
517
  for (let i = 0; i < n; i++) {
238
518
  if (i > 0) {
239
- 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;
240
521
  }
241
522
  if (variance <= 1e-12)
242
523
  return 1e10;
243
- // Gaussian log-likelihood (dropping constant)
244
- 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));
245
526
  }
246
- return ll / 2;
527
+ return -(ll + constant);
247
528
  }
248
529
  // Initial guesses
249
530
  const omega0 = initVar * 0.05;
250
531
  const alpha0 = 0.1;
251
532
  const beta0 = 0.85;
252
- const result = nelderMead(negLogLikelihood, [omega0, alpha0, beta0], { maxIter, tol });
253
- 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;
254
536
  const persistence = alpha + beta;
255
537
  const unconditionalVariance = omega / (1 - persistence);
256
538
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
257
- const logLikelihood = -result.fx * 2;
258
- const numParams = 3;
539
+ const logLikelihood = -result.fx;
540
+ const numParams = 4;
259
541
  return {
260
542
  params: {
261
543
  omega,
@@ -264,6 +546,7 @@ class Garch {
264
546
  persistence,
265
547
  unconditionalVariance,
266
548
  annualizedVol,
549
+ df,
267
550
  },
268
551
  diagnostics: {
269
552
  logLikelihood,
@@ -285,7 +568,8 @@ class Garch {
285
568
  variance.push(this.initialVariance);
286
569
  }
287
570
  else {
288
- 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];
289
573
  variance.push(v);
290
574
  }
291
575
  }
@@ -300,9 +584,11 @@ class Garch {
300
584
  // Get last variance
301
585
  const varianceSeries = this.getVarianceSeries(params);
302
586
  const lastVariance = varianceSeries[varianceSeries.length - 1];
303
- 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;
304
590
  // One-step ahead
305
- let v = omega + alpha * lastReturn ** 2 + beta * lastVariance;
591
+ let v = omega + alpha * lastInnovation + beta * lastVariance;
306
592
  variance.push(v);
307
593
  // Multi-step ahead (converges to unconditional variance)
308
594
  for (let h = 1; h < steps; h++) {
@@ -347,10 +633,11 @@ function calibrateGarch(data, options = {}) {
347
633
  * - α (alpha): magnitude effect
348
634
  * - γ (gamma): leverage effect (typically negative)
349
635
  * - β (beta): persistence
350
- * - E[|z|] = (2/π) for standard normal
636
+ * - E[|z|] = expectedAbsStudentT(df) for Student-t(df)
351
637
  */
352
638
  class Egarch {
353
639
  returns;
640
+ rv;
354
641
  periodsPerYear;
355
642
  initialVariance;
356
643
  constructor(data, options = {}) {
@@ -360,11 +647,16 @@ class Egarch {
360
647
  }
361
648
  if (typeof data[0] === 'number') {
362
649
  this.returns = calculateReturnsFromPrices(data);
650
+ this.initialVariance = sampleVariance(this.returns);
651
+ this.rv = null;
363
652
  }
364
653
  else {
365
- this.returns = calculateReturns(data);
654
+ const candles = data;
655
+ this.returns = calculateReturns(candles);
656
+ this.initialVariance = yangZhangVariance(candles);
657
+ // Parkinson (1980) per-candle RV: ~5× more efficient than r²
658
+ this.rv = perCandleParkinson(candles, this.returns);
366
659
  }
367
- this.initialVariance = sampleVariance(this.returns);
368
660
  }
369
661
  /**
370
662
  * Calibrate EGARCH(1,1) parameters using Maximum Likelihood Estimation
@@ -374,20 +666,31 @@ class Egarch {
374
666
  const returns = this.returns;
375
667
  const n = returns.length;
376
668
  const initLogVar = Math.log(this.initialVariance);
669
+ const rv = this.rv;
377
670
  function negLogLikelihood(params) {
378
- const [omega, alpha, gamma, beta] = params;
671
+ const [omega, alpha, gamma, beta, df] = params;
379
672
  // EGARCH allows negative gamma, but beta should ensure stationarity
380
673
  if (Math.abs(beta) >= 0.9999)
381
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));
382
681
  let logVariance = initLogVar;
383
682
  let variance = Math.exp(logVariance);
384
683
  let ll = 0;
385
684
  for (let i = 0; i < n; i++) {
386
685
  if (i > 0) {
387
686
  const sigma = Math.sqrt(variance);
388
- 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);
389
692
  logVariance = omega
390
- + alpha * (Math.abs(z) - EXPECTED_ABS_NORMAL)
693
+ + alpha * (magnitude - eAbsZ)
391
694
  + gamma * z
392
695
  + beta * logVariance;
393
696
  // Prevent extreme values
@@ -396,9 +699,10 @@ class Egarch {
396
699
  }
397
700
  if (variance <= 1e-12 || !isFinite(variance))
398
701
  return 1e10;
399
- 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));
400
704
  }
401
- return ll / 2;
705
+ return -(ll + constant);
402
706
  }
403
707
  // Initial guesses
404
708
  // omega approximates log of unconditional variance when other params are small
@@ -406,15 +710,16 @@ class Egarch {
406
710
  const alpha0 = 0.1;
407
711
  const gamma0 = -0.05; // Negative for typical leverage effect
408
712
  const beta0 = 0.95;
409
- const result = nelderMead(negLogLikelihood, [omega0, alpha0, gamma0, beta0], { maxIter, tol });
410
- 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;
411
716
  // For EGARCH, unconditional variance: E[ln(σ²)] = ω/(1-β)
412
717
  // So E[σ²] ≈ exp(ω/(1-β)) when α and γ effects average out
413
718
  const unconditionalLogVar = omega / (1 - beta);
414
719
  const unconditionalVariance = Math.exp(unconditionalLogVar);
415
720
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
416
- const logLikelihood = -result.fx * 2;
417
- const numParams = 4;
721
+ const logLikelihood = -result.fx;
722
+ const numParams = 5;
418
723
  return {
419
724
  params: {
420
725
  omega,
@@ -425,6 +730,7 @@ class Egarch {
425
730
  unconditionalVariance,
426
731
  annualizedVol,
427
732
  leverageEffect: gamma,
733
+ df,
428
734
  },
429
735
  diagnostics: {
430
736
  logLikelihood,
@@ -439,7 +745,8 @@ class Egarch {
439
745
  * Calculate conditional variance series given parameters
440
746
  */
441
747
  getVarianceSeries(params) {
442
- const { omega, alpha, gamma, beta } = params;
748
+ const { omega, alpha, gamma, beta, df } = params;
749
+ const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
443
750
  const variance = [];
444
751
  let logVariance = Math.log(this.initialVariance);
445
752
  for (let i = 0; i < this.returns.length; i++) {
@@ -449,8 +756,11 @@ class Egarch {
449
756
  else {
450
757
  const sigma = Math.sqrt(variance[i - 1]);
451
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);
452
762
  logVariance = omega
453
- + alpha * (Math.abs(z) - EXPECTED_ABS_NORMAL)
763
+ + alpha * (magnitude - eAbsZ)
454
764
  + gamma * z
455
765
  + beta * logVariance;
456
766
  logVariance = Math.max(-50, Math.min(50, logVariance));
@@ -467,7 +777,8 @@ class Egarch {
467
777
  * expected values of future shocks.
468
778
  */
469
779
  forecast(params, steps = 1) {
470
- const { omega, alpha, gamma, beta } = params;
780
+ const { omega, alpha, gamma, beta, df } = params;
781
+ const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
471
782
  const variance = [];
472
783
  const varianceSeries = this.getVarianceSeries(params);
473
784
  const lastVariance = varianceSeries[varianceSeries.length - 1];
@@ -475,12 +786,15 @@ class Egarch {
475
786
  // One-step ahead using actual last return
476
787
  const sigma = Math.sqrt(lastVariance);
477
788
  const z = lastReturn / sigma;
789
+ const magnitude = this.rv
790
+ ? Math.sqrt(this.rv[this.rv.length - 1] / lastVariance)
791
+ : Math.abs(z);
478
792
  let logVariance = omega
479
- + alpha * (Math.abs(z) - EXPECTED_ABS_NORMAL)
793
+ + alpha * (magnitude - eAbsZ)
480
794
  + gamma * z
481
795
  + beta * Math.log(lastVariance);
482
796
  variance.push(Math.exp(logVariance));
483
- // Multi-step: assume E[z] = 0, E[|z|] = √(2/π)
797
+ // Multi-step: assume E[z] = 0, E[|z|] = eAbsZ
484
798
  // So the α and γ terms contribute 0 on average
485
799
  for (let h = 1; h < steps; h++) {
486
800
  logVariance = omega + beta * logVariance;
@@ -513,4 +827,965 @@ function calibrateEgarch(data, options = {}) {
513
827
  return model.fit(options);
514
828
  }
515
829
 
516
- export { EXPECTED_ABS_NORMAL, Egarch, Garch, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, checkLeverageEffect, nelderMead, sampleVariance, sampleVarianceWithMean };
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
+
1547
+ const MIN_CANDLES = {
1548
+ '1m': 500,
1549
+ '3m': 500,
1550
+ '5m': 500,
1551
+ '15m': 300,
1552
+ '30m': 200,
1553
+ '1h': 200,
1554
+ '2h': 200,
1555
+ '4h': 200,
1556
+ '6h': 150,
1557
+ '8h': 150,
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
+ };
1571
+ const INTERVALS_PER_YEAR = {
1572
+ '1m': 525_600,
1573
+ '3m': 175_200,
1574
+ '5m': 105_120,
1575
+ '15m': 35_040,
1576
+ '30m': 17_520,
1577
+ '1h': 8_760,
1578
+ '2h': 4_380,
1579
+ '4h': 2_190,
1580
+ '6h': 1_460,
1581
+ '8h': 1_095,
1582
+ };
1583
+ function assertMinCandles(candles, interval) {
1584
+ const min = MIN_CANDLES[interval];
1585
+ if (candles.length < min) {
1586
+ throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
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
+ }
1598
+ }
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 });
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;
1647
+ return {
1648
+ forecast: model.forecast(fit.params, steps),
1649
+ modelType: 'har-rv',
1650
+ converged: fit.diagnostics.converged,
1651
+ persistence: fit.params.persistence,
1652
+ varianceSeries: model.getVarianceSeries(fit.params),
1653
+ returns: model.getReturns(),
1654
+ };
1655
+ }
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;
1707
+ }
1708
+ function checkReliable(fit) {
1709
+ if (!fit.converged || fit.persistence >= 0.999)
1710
+ return false;
1711
+ // Ljung-Box on squared standardized residuals
1712
+ const { returns, varianceSeries } = fit;
1713
+ const squared = returns.map((r, i) => {
1714
+ const z = r / Math.sqrt(varianceSeries[i]);
1715
+ return z * z;
1716
+ });
1717
+ const lb = ljungBox(squared, 10);
1718
+ return lb.pValue >= 0.05;
1719
+ }
1720
+ /**
1721
+ * Forecast expected price range for t+1 (next candle).
1722
+ *
1723
+ * Auto-selects GARCH or EGARCH based on leverage effect.
1724
+ * Returns ±1σ price corridor so you can set SL/TP yourself.
1725
+ */
1726
+ function predict(candles, interval, currentPrice = candles[candles.length - 1].close) {
1727
+ assertMinCandles(candles, interval);
1728
+ const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], 1);
1729
+ const sigma = fit.forecast.volatility[0];
1730
+ const move = currentPrice * sigma;
1731
+ return {
1732
+ modelType: fit.modelType,
1733
+ currentPrice,
1734
+ sigma,
1735
+ move,
1736
+ upperPrice: currentPrice + move,
1737
+ lowerPrice: currentPrice - move,
1738
+ reliable: checkReliable(fit),
1739
+ };
1740
+ }
1741
+ /**
1742
+ * Forecast expected price range over multiple candles.
1743
+ *
1744
+ * Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
1745
+ * Use for swing trades where you hold across multiple candles.
1746
+ */
1747
+ function predictRange(candles, interval, steps, currentPrice = candles[candles.length - 1].close) {
1748
+ assertMinCandles(candles, interval);
1749
+ const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], steps);
1750
+ const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
1751
+ const sigma = Math.sqrt(cumulativeVariance);
1752
+ const move = currentPrice * sigma;
1753
+ return {
1754
+ modelType: fit.modelType,
1755
+ currentPrice,
1756
+ sigma,
1757
+ move,
1758
+ upperPrice: currentPrice + move,
1759
+ lowerPrice: currentPrice - move,
1760
+ reliable: checkReliable(fit),
1761
+ };
1762
+ }
1763
+ // ── Backtest ──────────────────────────────────────────────────
1764
+ const BACKTEST_REQUIRED_PERCENT = 68;
1765
+ const BACKTEST_WINDOW_RATIO = 0.75;
1766
+ /**
1767
+ * Walk-forward backtest of predict.
1768
+ *
1769
+ * Window is computed automatically: 75% of candles for fitting, 25% for testing.
1770
+ * Throws if not enough candles for the given interval.
1771
+ * Returns true if the model's hit rate meets the required threshold.
1772
+ * Default threshold is 68% (±1σ should contain ~68% of moves).
1773
+ */
1774
+ function backtest(candles, interval, requiredPercent = BACKTEST_REQUIRED_PERCENT) {
1775
+ assertMinCandles(candles, interval);
1776
+ const window = Math.max(MIN_CANDLES[interval], Math.floor(candles.length * BACKTEST_WINDOW_RATIO));
1777
+ let hits = 0;
1778
+ let total = 0;
1779
+ for (let i = window; i < candles.length - 1; i++) {
1780
+ const slice = candles.slice(i - window, i + 1);
1781
+ const predicted = predict(slice, interval);
1782
+ const actual = candles[i + 1].close;
1783
+ if (actual >= predicted.lowerPrice && actual <= predicted.upperPrice) {
1784
+ hits++;
1785
+ }
1786
+ total++;
1787
+ }
1788
+ return (hits / total) * 100 >= requiredPercent;
1789
+ }
1790
+
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 };