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