garch 1.2.3 → 2.0.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.
Files changed (6) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +191 -26
  3. package/build/index.cjs +2039 -275
  4. package/build/index.mjs +2023 -276
  5. package/package.json +53 -48
  6. package/types.d.ts +347 -12
package/build/index.mjs CHANGED
@@ -88,9 +88,16 @@ function nelderMead(fn, x0, options = {}) {
88
88
  }
89
89
  }
90
90
  }
91
+ // Re-sort before returning: on maxIter exit the last iteration may have
92
+ // placed a better point at the worst-vertex slot without a sort pass.
93
+ let bestIdx = 0;
94
+ for (let i = 1; i <= n; i++) {
95
+ if (values[i] < values[bestIdx])
96
+ bestIdx = i;
97
+ }
91
98
  return {
92
- x: simplex[0],
93
- fx: values[0],
99
+ x: simplex[bestIdx],
100
+ fx: values[bestIdx],
94
101
  iterations,
95
102
  converged,
96
103
  };
@@ -110,15 +117,27 @@ function shrink(simplex, values, sigma, fn, n) {
110
117
  *
111
118
  * Perturbation uses golden-ratio quasi-random sequence for uniform
112
119
  * coverage of the search space without clustering.
120
+ *
121
+ * The restart budget adapts to the problem: after the scheduled restarts,
122
+ * exploration continues while restarts keep finding improvements, so easy
123
+ * unimodal fits stop early and rugged landscapes get extra effort. The
124
+ * schedule is deterministic — same inputs, same result.
113
125
  */
114
126
  const PHI = (1 + Math.sqrt(5)) / 2; // golden ratio
115
127
  function nelderMeadMultiStart(fn, x0, options = {}) {
116
- const { maxIter = 1000, tol = 1e-8, restarts = 3 } = options;
128
+ const { maxIter = 1000, tol = 1e-8, restarts = 3, extraStarts = [] } = options;
117
129
  const n = x0.length;
118
130
  // Run from original starting point
119
131
  let best = nelderMead(fn, x0, { maxIter, tol });
120
- // Run from perturbed starting points
121
- for (let k = 1; k <= restarts; k++) {
132
+ for (const start of extraStarts) {
133
+ const result = nelderMead(fn, start, { maxIter, tol });
134
+ if (result.fx < best.fx)
135
+ best = result;
136
+ }
137
+ // Scheduled restarts, then keep going while they pay off.
138
+ // An explicit restarts=0 opts out of multi-start entirely.
139
+ const maxRestarts = restarts === 0 ? 0 : restarts * 2 + 4;
140
+ for (let k = 1; k <= maxRestarts; k++) {
122
141
  const perturbed = new Array(n);
123
142
  for (let i = 0; i < n; i++) {
124
143
  // Quasi-random perturbation: golden-ratio sequence mapped to [-0.5, +0.5]
@@ -129,13 +148,95 @@ function nelderMeadMultiStart(fn, x0, options = {}) {
129
148
  : x0[i] * (1 + scale);
130
149
  }
131
150
  const result = nelderMead(fn, perturbed, { maxIter, tol });
151
+ const improved = result.fx < best.fx - tol * Math.max(1, Math.abs(best.fx));
132
152
  if (result.fx < best.fx) {
133
153
  best = result;
134
154
  }
155
+ // Past the scheduled budget, stop at the first non-improving restart
156
+ if (k >= restarts && !improved)
157
+ break;
158
+ }
159
+ // Polish: if the winning run hit maxIter, restart from its solution with
160
+ // a fresh simplex (up to 3 rounds). Starting at the best point, fx can
161
+ // only improve; the converged flag reflects the final round honestly.
162
+ for (let p = 0; p < 3 && !best.converged; p++) {
163
+ const polished = nelderMead(fn, best.x, { maxIter, tol });
164
+ if (polished.fx <= best.fx) {
165
+ best = polished;
166
+ }
167
+ else {
168
+ break;
169
+ }
135
170
  }
136
171
  return best;
137
172
  }
138
173
 
174
+ /**
175
+ * Typed error hierarchy so bot code can branch on error class instead of
176
+ * parsing message strings.
177
+ *
178
+ * try { predict(candles, '1h') }
179
+ * catch (e) {
180
+ * if (e instanceof NotEnoughDataError) await fetchMoreCandles();
181
+ * else if (e instanceof BadDataError) alertDataPipeline(e.message);
182
+ * else throw e;
183
+ * }
184
+ */
185
+ class GarchError extends Error {
186
+ constructor(message) {
187
+ super(message);
188
+ this.name = new.target.name;
189
+ }
190
+ }
191
+ /** The sample is too short for the requested interval/model. Fetch more candles. */
192
+ class NotEnoughDataError extends GarchError {
193
+ }
194
+ /** The candles themselves are broken: invalid OHLC, unsorted or duplicated timestamps. Fix the data pipeline. */
195
+ class BadDataError extends GarchError {
196
+ }
197
+ /** A call argument is out of range or of the wrong shape (interval, confidence, steps, currentPrice). Fix the call site. */
198
+ class InvalidArgumentError extends GarchError {
199
+ }
200
+
201
+ /**
202
+ * Validate OHLC integrity. Garbage candles (NaN, non-positive prices,
203
+ * high < low) otherwise propagate silently as NaN through every estimator.
204
+ */
205
+ function validateCandles(candles) {
206
+ for (let i = 0; i < candles.length; i++) {
207
+ const c = candles[i];
208
+ if (!isFinite(c.open) || c.open <= 0 || !isFinite(c.high) || c.high <= 0
209
+ || !isFinite(c.low) || c.low <= 0 || !isFinite(c.close) || c.close <= 0) {
210
+ throw new BadDataError(`Invalid OHLC at candle ${i}: open=${c.open} high=${c.high} low=${c.low} close=${c.close}`);
211
+ }
212
+ if (c.high < c.low) {
213
+ throw new BadDataError(`Invalid candle ${i}: high (${c.high}) < low (${c.low})`);
214
+ }
215
+ // Open/close must lie inside [low, high] — Parkinson, Garman-Klass and
216
+ // Yang-Zhang all assume it; violated candles silently distort every
217
+ // range-based estimator. Tiny relative slack absorbs float rounding.
218
+ const bodyHigh = Math.max(c.open, c.close);
219
+ const bodyLow = Math.min(c.open, c.close);
220
+ if (c.high < bodyHigh * (1 - 1e-9) || c.low > bodyLow * (1 + 1e-9)) {
221
+ throw new BadDataError(`Invalid candle ${i}: open/close outside [low, high] (open=${c.open} high=${c.high} low=${c.low} close=${c.close})`);
222
+ }
223
+ }
224
+ }
225
+ /**
226
+ * Linear-interpolation quantile of a pre-sorted (ascending) sample.
227
+ */
228
+ function empiricalQuantile(sortedAsc, p) {
229
+ const n = sortedAsc.length;
230
+ if (n === 0)
231
+ return NaN;
232
+ if (n === 1)
233
+ return sortedAsc[0];
234
+ const pos = Math.min(Math.max(p, 0), 1) * (n - 1);
235
+ const lo = Math.floor(pos);
236
+ const hi = Math.min(lo + 1, n - 1);
237
+ const frac = pos - lo;
238
+ return sortedAsc[lo] * (1 - frac) + sortedAsc[hi] * frac;
239
+ }
139
240
  /**
140
241
  * Calculate log returns from candles
141
242
  */
@@ -143,7 +244,7 @@ function calculateReturns(candles) {
143
244
  const returns = [];
144
245
  for (let i = 1; i < candles.length; i++) {
145
246
  if (!(candles[i].close > 0) || !(candles[i - 1].close > 0)) {
146
- throw new Error(`Invalid close price at index ${i}`);
247
+ throw new BadDataError(`Invalid close price at index ${i}`);
147
248
  }
148
249
  returns.push(Math.log(candles[i].close / candles[i - 1].close));
149
250
  }
@@ -156,7 +257,7 @@ function calculateReturnsFromPrices(prices) {
156
257
  const returns = [];
157
258
  for (let i = 1; i < prices.length; i++) {
158
259
  if (!(prices[i] > 0 && Number.isFinite(prices[i])) || !(prices[i - 1] > 0 && Number.isFinite(prices[i - 1]))) {
159
- throw new Error(`Invalid price at index ${i}`);
260
+ throw new BadDataError(`Invalid price at index ${i}`);
160
261
  }
161
262
  returns.push(Math.log(prices[i] / prices[i - 1]));
162
263
  }
@@ -373,7 +474,9 @@ function studentTNegLL(returns, varianceSeries, df) {
373
474
  let sum = 0;
374
475
  for (let i = 0; i < n; i++) {
375
476
  const v = varianceSeries[i];
376
- if (v <= 1e-12 || !isFinite(v))
477
+ // Positivity only — an absolute floor (1e-12) makes the likelihood
478
+ // scale-dependent and breaks df profiling on low-volatility series
479
+ if (v <= 0 || !isFinite(v))
377
480
  return 1e10;
378
481
  sum += 0.5 * Math.log(v) + halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / ((df - 2) * v));
379
482
  }
@@ -391,6 +494,117 @@ function expectedAbsStudentT(df) {
391
494
  return EXPECTED_ABS_NORMAL; // fallback
392
495
  return Math.sqrt((df - 2) / Math.PI) * Math.exp(logGamma((df - 1) / 2) - logGamma(df / 2));
393
496
  }
497
+ /**
498
+ * Continued-fraction evaluation for the regularized incomplete beta
499
+ * function (Lentz's method, Numerical Recipes §6.4).
500
+ */
501
+ function betaContinuedFraction(a, b, x) {
502
+ const MAX_ITER = 300;
503
+ const EPS = 3e-14;
504
+ const FPMIN = 1e-300;
505
+ const qab = a + b;
506
+ const qap = a + 1;
507
+ const qam = a - 1;
508
+ let c = 1;
509
+ let d = 1 - (qab * x) / qap;
510
+ if (Math.abs(d) < FPMIN)
511
+ d = FPMIN;
512
+ d = 1 / d;
513
+ let h = d;
514
+ for (let m = 1; m <= MAX_ITER; m++) {
515
+ const m2 = 2 * m;
516
+ let aa = (m * (b - m) * x) / ((qam + m2) * (a + m2));
517
+ d = 1 + aa * d;
518
+ if (Math.abs(d) < FPMIN)
519
+ d = FPMIN;
520
+ c = 1 + aa / c;
521
+ if (Math.abs(c) < FPMIN)
522
+ c = FPMIN;
523
+ d = 1 / d;
524
+ h *= d * c;
525
+ aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2));
526
+ d = 1 + aa * d;
527
+ if (Math.abs(d) < FPMIN)
528
+ d = FPMIN;
529
+ c = 1 + aa / c;
530
+ if (Math.abs(c) < FPMIN)
531
+ c = FPMIN;
532
+ d = 1 / d;
533
+ const del = d * c;
534
+ h *= del;
535
+ if (Math.abs(del - 1) < EPS)
536
+ break;
537
+ }
538
+ return h;
539
+ }
540
+ /**
541
+ * Regularized incomplete beta function I_x(a, b).
542
+ */
543
+ function incompleteBeta(x, a, b) {
544
+ if (x <= 0)
545
+ return 0;
546
+ if (x >= 1)
547
+ return 1;
548
+ const lnFront = logGamma(a + b) - logGamma(a) - logGamma(b)
549
+ + a * Math.log(x) + b * Math.log(1 - x);
550
+ const front = Math.exp(lnFront);
551
+ // Use the continued fraction in its region of fast convergence
552
+ if (x < (a + 1) / (a + b + 2)) {
553
+ return (front * betaContinuedFraction(a, b, x)) / a;
554
+ }
555
+ return 1 - (front * betaContinuedFraction(b, a, 1 - x)) / b;
556
+ }
557
+ /**
558
+ * CDF of the (raw, unstandardized) Student-t distribution with df degrees
559
+ * of freedom: P(T ≤ t).
560
+ */
561
+ function studentTCdf(t, df) {
562
+ if (!isFinite(t))
563
+ return t > 0 ? 1 : 0;
564
+ const x = df / (df + t * t);
565
+ const tail = 0.5 * incompleteBeta(x, df / 2, 0.5);
566
+ return t >= 0 ? 1 - tail : tail;
567
+ }
568
+ /**
569
+ * Two-sided quantile of the STANDARDIZED Student-t distribution
570
+ * (unit variance). The t-analog of probit(): returns z such that
571
+ * P(|Z| ≤ z) = confidence when Z ~ t(df) scaled to variance 1.
572
+ *
573
+ * This is what price corridors must use when the model was fitted with
574
+ * Student-t innovations: with fat tails (small df) the Gaussian probit
575
+ * makes 68% bands too wide and 99% bands dangerously narrow.
576
+ *
577
+ * Falls back to probit() for df > 1000 (where the difference from the
578
+ * Gaussian quantile is < 0.3% even at 99%) or df ≤ 2 (variance undefined).
579
+ */
580
+ function studentTProbit(confidence, df) {
581
+ if (confidence <= 0 || confidence >= 1) {
582
+ throw new Error(`confidence must be in (0, 1), got ${confidence}`);
583
+ }
584
+ if (!isFinite(df) || df > 1000 || df <= 2) {
585
+ return probit(confidence);
586
+ }
587
+ const p = (1 + confidence) / 2;
588
+ // Bracket the raw t quantile, then bisect on the CDF
589
+ let lo = 0;
590
+ let hi = 1;
591
+ while (studentTCdf(hi, df) < p && hi < 1e8)
592
+ hi *= 2;
593
+ for (let i = 0; i < 200; i++) {
594
+ const mid = 0.5 * (lo + hi);
595
+ if (studentTCdf(mid, df) < p) {
596
+ lo = mid;
597
+ }
598
+ else {
599
+ hi = mid;
600
+ }
601
+ if (hi - lo < 1e-12 * (1 + hi))
602
+ break;
603
+ }
604
+ const rawQuantile = 0.5 * (lo + hi);
605
+ // Standardize: t(df) has variance df/(df-2)
606
+ return rawQuantile * Math.sqrt((df - 2) / df);
607
+ }
394
608
  /**
395
609
  * 1D grid search for optimal df that minimizes Student-t neg-LL.
396
610
  * Used by HAR-RV and NoVaS where df is profiled after main optimization.
@@ -530,15 +744,18 @@ class Garch {
530
744
  throw new Error('Need at least 50 data points for GARCH estimation');
531
745
  }
532
746
  // Determine if input is candles or prices
747
+ // Variance floor keeps degenerate (constant-price) data from producing
748
+ // log(0)/division-by-zero downstream instead of a graceful bad fit.
533
749
  if (typeof data[0] === 'number') {
534
750
  this.returns = calculateReturnsFromPrices(data);
535
- this.initialVariance = sampleVariance(this.returns);
751
+ this.initialVariance = Math.max(sampleVariance(this.returns), 1e-300);
536
752
  this.rv = null;
537
753
  }
538
754
  else {
539
755
  const candles = data;
756
+ validateCandles(candles);
540
757
  this.returns = calculateReturns(candles);
541
- this.initialVariance = yangZhangVariance(candles);
758
+ this.initialVariance = Math.max(yangZhangVariance(candles), 1e-300);
542
759
  // Parkinson (1980) per-candle RV: ~5× more efficient than r²
543
760
  this.rv = perCandleParkinson(candles, this.returns);
544
761
  }
@@ -547,16 +764,33 @@ class Garch {
547
764
  * Calibrate GARCH(1,1) parameters using Maximum Likelihood Estimation
548
765
  */
549
766
  fit(options = {}) {
550
- const { maxIter = 1000, tol = 1e-8 } = options;
551
- const returns = this.returns;
552
- const n = returns.length;
553
- const initVar = this.initialVariance;
554
- const rv = this.rv;
767
+ const { maxIter = 1000, tol = 1e-8, forgetting = 1 } = options;
768
+ const n = this.returns.length;
769
+ // Exponential forgetting: observation t contributes with weight
770
+ // λ^(n−1−t) (newest = 1), so the fit adapts to regime shifts instead of
771
+ // weighting a year-old candle like yesterday's. λ = 1 disables.
772
+ const weights = new Array(n).fill(1);
773
+ if (forgetting < 1) {
774
+ for (let t = 0; t < n; t++)
775
+ weights[t] = Math.pow(forgetting, n - 1 - t);
776
+ }
777
+ const wTotal = weights.reduce((a, b) => a + b, 0);
778
+ // Calibrate in normalized space: returns are scaled to unit initial
779
+ // variance, so the likelihood floors, penalty constants, and optimizer
780
+ // tolerances are scale-free — a stablecoin pair and a high-vol altcoin
781
+ // follow the same optimizer path. Parameters are mapped back to the
782
+ // data scale after optimization.
783
+ const s2 = 1 / this.initialVariance;
784
+ const s = Math.sqrt(s2);
785
+ const returns = this.returns.map(r => r * s);
786
+ const rv = this.rv ? this.rv.map(v => v * s2) : null;
787
+ const initVar = 1;
788
+ const varFloor = 1e-12;
555
789
  // Student-t negative log-likelihood function
556
790
  function negLogLikelihood(params) {
557
791
  const [omega, alpha, beta, df] = params;
558
792
  // Constraints
559
- if (omega <= 1e-12)
793
+ if (omega <= varFloor)
560
794
  return 1e10;
561
795
  if (alpha < 0 || beta < 0)
562
796
  return 1e10;
@@ -566,7 +800,7 @@ class Garch {
566
800
  return 1e10;
567
801
  const halfDfPlus1 = (df + 1) / 2;
568
802
  const dfMinus2 = df - 2;
569
- const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
803
+ const constant = wTotal * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
570
804
  let variance = initVar;
571
805
  let ll = 0;
572
806
  for (let i = 0; i < n; i++) {
@@ -574,24 +808,44 @@ class Garch {
574
808
  const innovation = rv ? rv[i - 1] : returns[i - 1] ** 2;
575
809
  variance = omega + alpha * innovation + beta * variance;
576
810
  }
577
- if (variance <= 1e-12)
811
+ if (variance <= varFloor)
578
812
  return 1e10;
579
813
  // Student-t log-likelihood
580
- ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
814
+ ll += weights[i] * (-0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance)));
581
815
  }
582
816
  return -(ll + constant);
583
817
  }
584
- // Initial guesses
585
- const omega0 = initVar * 0.05;
818
+ // Initial guesses: variance targeting — ω₀ implied by the sample
819
+ // variance and the persistence seed, so the optimizer starts at the
820
+ // observed volatility level for any asset/interval.
586
821
  const alpha0 = 0.1;
587
822
  const beta0 = 0.85;
823
+ const omega0 = initVar * (1 - alpha0 - beta0);
588
824
  const df0 = 5;
589
- const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, beta0, df0], { maxIter, tol, restarts: 3 });
590
- const [omega, alpha, beta, df] = result.x;
825
+ // Warm start (previous window's optimum) replaces the cold seed: the
826
+ // multi-start perturbations then explore its neighborhood, and rolling
827
+ // refits converge in a fraction of the cold multi-start cost. The
828
+ // constraint set is window-independent, so a previous optimum is
829
+ // always feasible.
830
+ const wp = options.warmStart;
831
+ const warmValid = !!(wp && isFinite(wp.omega) && wp.omega > 0
832
+ && isFinite(wp.alpha) && isFinite(wp.beta) && isFinite(wp.df));
833
+ const x0 = warmValid
834
+ ? [wp.omega * s2, wp.alpha, wp.beta, wp.df]
835
+ : [omega0, alpha0, beta0, df0];
836
+ const result = nelderMeadMultiStart(negLogLikelihood, x0, {
837
+ maxIter,
838
+ tol,
839
+ restarts: warmValid ? 1 : 3,
840
+ });
841
+ // Map back to the data scale: ω scales with variance, α/β/df are scale-free
842
+ const [omegaScaled, alpha, beta, df] = result.x;
843
+ const omega = omegaScaled / s2;
591
844
  const persistence = alpha + beta;
592
845
  const unconditionalVariance = omega / (1 - persistence);
593
846
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
594
- const logLikelihood = -result.fx;
847
+ // Jacobian of the rescaling: LL in data units = LL(scaled) + n·ln s
848
+ const logLikelihood = -result.fx + n * Math.log(s);
595
849
  const numParams = 4;
596
850
  return {
597
851
  params: {
@@ -700,15 +954,18 @@ class Egarch {
700
954
  if (data.length < 50) {
701
955
  throw new Error('Need at least 50 data points for EGARCH estimation');
702
956
  }
957
+ // Variance floor keeps degenerate (constant-price) data from producing
958
+ // ω = ln(0) = -Infinity instead of a graceful bad fit.
703
959
  if (typeof data[0] === 'number') {
704
960
  this.returns = calculateReturnsFromPrices(data);
705
- this.initialVariance = sampleVariance(this.returns);
961
+ this.initialVariance = Math.max(sampleVariance(this.returns), 1e-300);
706
962
  this.rv = null;
707
963
  }
708
964
  else {
709
965
  const candles = data;
966
+ validateCandles(candles);
710
967
  this.returns = calculateReturns(candles);
711
- this.initialVariance = yangZhangVariance(candles);
968
+ this.initialVariance = Math.max(yangZhangVariance(candles), 1e-300);
712
969
  // Parkinson (1980) per-candle RV: ~5× more efficient than r²
713
970
  this.rv = perCandleParkinson(candles, this.returns);
714
971
  }
@@ -717,11 +974,28 @@ class Egarch {
717
974
  * Calibrate EGARCH(1,1) parameters using Maximum Likelihood Estimation
718
975
  */
719
976
  fit(options = {}) {
720
- const { maxIter = 1000, tol = 1e-8 } = options;
721
- const returns = this.returns;
722
- const n = returns.length;
723
- const initLogVar = Math.log(this.initialVariance);
724
- const rv = this.rv;
977
+ const { maxIter = 1000, tol = 1e-8, forgetting = 1 } = options;
978
+ const n = this.returns.length;
979
+ const initLogVarOrig = Math.log(this.initialVariance);
980
+ // Exponential forgetting: observation t contributes with weight
981
+ // λ^(n−1−t) (newest = 1); λ = 1 disables.
982
+ const weights = new Array(n).fill(1);
983
+ if (forgetting < 1) {
984
+ for (let t = 0; t < n; t++)
985
+ weights[t] = Math.pow(forgetting, n - 1 - t);
986
+ }
987
+ const wTotal = weights.reduce((a, b) => a + b, 0);
988
+ // Calibrate in normalized space: returns are scaled to unit initial
989
+ // variance, so the likelihood floors, the ±50 log-variance clamp, and
990
+ // optimizer tolerances are scale-free — a stablecoin pair and a
991
+ // high-vol altcoin follow the same optimizer path. ω is mapped back
992
+ // to the data scale after optimization.
993
+ const s2 = 1 / this.initialVariance;
994
+ const s = Math.sqrt(s2);
995
+ const returns = this.returns.map(r => r * s);
996
+ const rv = this.rv ? this.rv.map(v => v * s2) : null;
997
+ const initLogVar = 0; // ln of the scaled initial variance
998
+ const varFloor = 1e-12;
725
999
  function negLogLikelihood(params) {
726
1000
  const [omega, alpha, gamma, beta, df] = params;
727
1001
  // EGARCH allows negative gamma, but beta should ensure stationarity
@@ -729,10 +1003,22 @@ class Egarch {
729
1003
  return 1e10;
730
1004
  if (df <= 2.01 || df > 100)
731
1005
  return 1e10;
1006
+ // ω/(1−β) is the implied unconditional ln(σ²). When β rides toward ±1
1007
+ // on weakly-identified data the likelihood surface is flat along this
1008
+ // ridge and the implied long-run variance drifts orders of magnitude
1009
+ // away from the sample variance measured on the same data. Hard wall
1010
+ // at 4 orders of magnitude, plus a weak Gaussian prior (sd = 1 in
1011
+ // log-variance) that resolves the flat direction toward the sample
1012
+ // level while leaving well-identified fits untouched.
1013
+ const impliedLogVar = omega / (1 - beta);
1014
+ if (!isFinite(impliedLogVar) || Math.abs(impliedLogVar - initLogVar) > Math.log(1e4))
1015
+ return 1e10;
1016
+ const priorDev = impliedLogVar - initLogVar;
1017
+ const prior = 0.5 * priorDev * priorDev;
732
1018
  const eAbsZ = expectedAbsStudentT(df);
733
1019
  const halfDfPlus1 = (df + 1) / 2;
734
1020
  const dfMinus2 = df - 2;
735
- const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
1021
+ const constant = wTotal * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
736
1022
  let logVariance = initLogVar;
737
1023
  let variance = Math.exp(logVariance);
738
1024
  let ll = 0;
@@ -752,28 +1038,48 @@ class Egarch {
752
1038
  logVariance = Math.max(-50, Math.min(50, logVariance));
753
1039
  variance = Math.exp(logVariance);
754
1040
  }
755
- if (variance <= 1e-12 || !isFinite(variance))
1041
+ if (variance <= varFloor || !isFinite(variance))
756
1042
  return 1e10;
757
1043
  // Student-t log-likelihood
758
- ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
1044
+ ll += weights[i] * (-0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance)));
759
1045
  }
760
- return -(ll + constant);
1046
+ return -(ll + constant) + prior;
761
1047
  }
762
- // Initial guesses
763
- // omega approximates log of unconditional variance when other params are small
764
- const omega0 = initLogVar * 0.1;
1048
+ // Initial guesses: variance targeting in log space —
1049
+ // E[ln σ²] = ω/(1−β), so ω₀ = ln(σ̂²)·(1−β₀) starts the optimizer at
1050
+ // the observed volatility level (ω₀ = 0.1·ln σ̂² implied a level of
1051
+ // 2·ln σ̂² with β₀ = 0.95 — orders of magnitude off).
1052
+ const beta0 = 0.95;
1053
+ const omega0 = initLogVar * (1 - beta0);
765
1054
  const alpha0 = 0.1;
766
1055
  const gamma0 = -0.05; // Negative for typical leverage effect
767
- const beta0 = 0.95;
768
1056
  const df0 = 5;
769
- const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
770
- const [omega, alpha, gamma, beta, df] = result.x;
1057
+ // Warm start (previous window's optimum) replaces the cold seed with a
1058
+ // reduced restart budget. The hard wall on the implied unconditional
1059
+ // level moves with the sample variance between windows, so an
1060
+ // out-of-wall warm seed falls back to the cold start.
1061
+ const wp = options.warmStart;
1062
+ let warmX0 = null;
1063
+ if (wp && isFinite(wp.omega) && isFinite(wp.beta) && Math.abs(wp.beta) < 1) {
1064
+ const cand = [wp.omega - (1 - wp.beta) * initLogVarOrig, wp.alpha, wp.gamma, wp.beta, wp.df];
1065
+ if (Math.abs(cand[0] / (1 - wp.beta)) <= Math.log(1e4))
1066
+ warmX0 = cand;
1067
+ }
1068
+ const result = nelderMeadMultiStart(negLogLikelihood, warmX0 ?? [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: warmX0 ? 1 : 4 });
1069
+ // Map back to the data scale: ln σ²_orig = ln σ²_scaled + ln σ̂²_orig,
1070
+ // so ω_orig = ω_scaled + (1−β)·ln σ̂²_orig; α/γ/β/df are scale-free
1071
+ const [omegaScaled, alpha, gamma, beta, df] = result.x;
1072
+ const omega = omegaScaled + (1 - beta) * initLogVarOrig;
771
1073
  // For EGARCH, unconditional variance: E[ln(σ²)] = ω/(1-β)
772
1074
  // So E[σ²] ≈ exp(ω/(1-β)) when α and γ effects average out
773
1075
  const unconditionalLogVar = omega / (1 - beta);
774
1076
  const unconditionalVariance = Math.exp(unconditionalLogVar);
775
1077
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
776
- const logLikelihood = -result.fx;
1078
+ // Report the pure likelihood: strip the shrinkage prior evaluated at
1079
+ // the optimum (the prior deviation is scale-invariant), then add the
1080
+ // Jacobian of the rescaling: LL in data units = LL(scaled) + n·ln s
1081
+ const priorAtOptimum = 0.5 * (unconditionalLogVar - initLogVarOrig) ** 2;
1082
+ const logLikelihood = -(result.fx - priorAtOptimum) + n * Math.log(s);
777
1083
  const numParams = 5;
778
1084
  return {
779
1085
  params: {
@@ -824,6 +1130,33 @@ class Egarch {
824
1130
  }
825
1131
  return variance;
826
1132
  }
1133
+ /**
1134
+ * Mean drift of the magnitude term under the fitted dynamics.
1135
+ *
1136
+ * With RV magnitude, E[√(RV/σ²)] ≠ E[|z|]: the in-sample recursion
1137
+ * carries a mean offset α·m̄ per step that ω absorbed during fitting.
1138
+ * A multi-step forecast that drops the α term entirely would therefore
1139
+ * converge to a level systematically below the fitted dynamics.
1140
+ * Returns m̄ = mean(magnitude − E|z|) over the sample (0 for prices-only
1141
+ * input, where magnitude = |z| and the offset is sampling noise).
1142
+ */
1143
+ magnitudeDrift(params) {
1144
+ if (!this.rv)
1145
+ return 0;
1146
+ const { df } = params;
1147
+ const eAbsZ = df > 2 ? expectedAbsStudentT(df) : EXPECTED_ABS_NORMAL;
1148
+ const series = this.getVarianceSeries(params);
1149
+ let sum = 0;
1150
+ let count = 0;
1151
+ for (let i = 1; i < this.returns.length; i++) {
1152
+ const m = Math.sqrt(this.rv[i - 1] / series[i - 1]);
1153
+ if (!isFinite(m))
1154
+ continue;
1155
+ sum += m - eAbsZ;
1156
+ count++;
1157
+ }
1158
+ return count > 0 ? sum / count : 0;
1159
+ }
827
1160
  /**
828
1161
  * Forecast variance forward
829
1162
  *
@@ -848,11 +1181,15 @@ class Egarch {
848
1181
  + alpha * (magnitude - eAbsZ)
849
1182
  + gamma * z
850
1183
  + beta * Math.log(lastVariance);
1184
+ logVariance = Math.max(-50, Math.min(50, logVariance));
851
1185
  variance.push(Math.exp(logVariance));
852
- // Multi-step: assume E[z] = 0, E[|z|] = eAbsZ
853
- // So the α and γ terms contribute 0 on average
1186
+ // Multi-step: assume E[z] = 0. With RV magnitude the α term has a
1187
+ // nonzero mean α·m̄ under the fitted dynamics keep it as drift so
1188
+ // the forecast converges to the same level the fit implies.
1189
+ const drift = alpha * this.magnitudeDrift(params);
854
1190
  for (let h = 1; h < steps; h++) {
855
- logVariance = omega + beta * logVariance;
1191
+ logVariance = omega + drift + beta * logVariance;
1192
+ logVariance = Math.max(-50, Math.min(50, logVariance));
856
1193
  variance.push(Math.exp(logVariance));
857
1194
  }
858
1195
  return {
@@ -995,11 +1332,14 @@ class HarRv {
995
1332
  shortLag;
996
1333
  mediumLag;
997
1334
  longLag;
1335
+ logSpec;
1336
+ lnRv = null;
998
1337
  constructor(data, options = {}) {
999
1338
  this.periodsPerYear = options.periodsPerYear ?? 252;
1000
1339
  this.shortLag = options.shortLag ?? DEFAULT_SHORT;
1001
1340
  this.mediumLag = options.mediumLag ?? DEFAULT_MEDIUM;
1002
1341
  this.longLag = options.longLag ?? DEFAULT_LONG;
1342
+ this.logSpec = options.logSpec ?? false;
1003
1343
  const minRequired = this.longLag + 30;
1004
1344
  if (data.length < minRequired) {
1005
1345
  throw new Error(`Need at least ${minRequired} data points for HAR-RV estimation`);
@@ -1011,10 +1351,25 @@ class HarRv {
1011
1351
  }
1012
1352
  else {
1013
1353
  const candles = data;
1354
+ validateCandles(candles);
1014
1355
  this.returns = calculateReturns(candles);
1015
1356
  // Parkinson (1980) per-candle RV: (1/(4·ln2))·(ln(H/L))²
1016
1357
  this.rv = perCandleParkinson(candles, this.returns);
1017
1358
  }
1359
+ if (this.logSpec) {
1360
+ // ln RV with a floor at half the smallest positive observation: the
1361
+ // log regression must not see −Infinity from a flat candle
1362
+ let minPos = Infinity;
1363
+ for (const v of this.rv) {
1364
+ if (v > 0 && v < minPos)
1365
+ minPos = v;
1366
+ }
1367
+ if (!isFinite(minPos)) {
1368
+ throw new Error('log-HAR needs at least one positive realized-variance observation');
1369
+ }
1370
+ const floor = minPos * 0.5;
1371
+ this.lnRv = this.rv.map(v => Math.log(Math.max(v, floor)));
1372
+ }
1018
1373
  }
1019
1374
  /**
1020
1375
  * Calibrate HAR-RV via OLS.
@@ -1022,6 +1377,13 @@ class HarRv {
1022
1377
  fit() {
1023
1378
  const { rv, shortLag, mediumLag, longLag } = this;
1024
1379
  const n = rv.length;
1380
+ // Level spec: regress in normalized units (RV scaled to unit mean) so
1381
+ // the singular-matrix pivot threshold keeps detecting genuine
1382
+ // collinearity instead of tripping on low-volatility series.
1383
+ // Log spec: ln RV is already O(10) and shift-equivariant — no scaling.
1384
+ const meanRv = rv.reduce((s, v) => s + v, 0) / n;
1385
+ const s2 = meanRv > 0 ? 1 / meanRv : 1;
1386
+ const series = this.logSpec ? this.lnRv : rv.map(v => v * s2);
1025
1387
  // Build regression data
1026
1388
  // Usable range: t = longLag-1 .. n-2 (need longLag history, and rv[t+1] as target)
1027
1389
  const startIdx = longLag - 1;
@@ -1030,21 +1392,31 @@ class HarRv {
1030
1392
  const X = [];
1031
1393
  const y = [];
1032
1394
  for (let t = startIdx; t <= endIdx; t++) {
1033
- const rvShort = rollingMean(rv, t, shortLag);
1034
- const rvMedium = rollingMean(rv, t, mediumLag);
1035
- const rvLong = rollingMean(rv, t, longLag);
1395
+ const rvShort = rollingMean(series, t, shortLag);
1396
+ const rvMedium = rollingMean(series, t, mediumLag);
1397
+ const rvLong = rollingMean(series, t, longLag);
1036
1398
  X.push([1, rvShort, rvMedium, rvLong]);
1037
- y.push(rv[t + 1]);
1399
+ y.push(series[t + 1]);
1038
1400
  }
1039
1401
  const result = ols(X, y);
1040
- const [beta0, betaShort, betaMedium, betaLong] = result.beta;
1402
+ const [beta0Raw, betaShort, betaMedium, betaLong] = result.beta;
1403
+ // Level spec: intercept back to the data scale. Log spec: intercept is
1404
+ // already in data units (log scaling is a shift the OLS absorbed).
1405
+ const beta0 = this.logSpec ? beta0Raw : beta0Raw / s2;
1406
+ const residualLogVar = this.logSpec ? result.rss / nObs : undefined;
1041
1407
  const persistence = betaShort + betaMedium + betaLong;
1042
- const unconditionalVariance = persistence < 1 && persistence > -1
1043
- ? Math.max(beta0 / (1 - persistence), 1e-20)
1044
- : sampleVariance(this.returns);
1408
+ let unconditionalVariance;
1409
+ if (persistence < 1 && persistence > -1) {
1410
+ unconditionalVariance = this.logSpec
1411
+ ? Math.exp(Math.max(-720, Math.min(50, beta0 / (1 - persistence) + residualLogVar / 2)))
1412
+ : Math.max(beta0 / (1 - persistence), 1e-20);
1413
+ }
1414
+ else {
1415
+ unconditionalVariance = sampleVariance(this.returns);
1416
+ }
1045
1417
  const annualizedVol = Math.sqrt(Math.abs(unconditionalVariance) * this.periodsPerYear) * 100;
1046
1418
  // Student-t log-likelihood on returns using HAR-RV fitted variances
1047
- const varianceSeries = this.getVarianceSeriesInternal(result.beta);
1419
+ const varianceSeries = this.getVarianceSeriesInternal([beta0, betaShort, betaMedium, betaLong], residualLogVar ?? 0);
1048
1420
  const df = profileStudentTDf(this.returns, varianceSeries);
1049
1421
  const ll = -studentTNegLL(this.returns, varianceSeries, df);
1050
1422
  const numParams = 5; // beta0, betaShort, betaMedium, betaLong, df
@@ -1059,6 +1431,7 @@ class HarRv {
1059
1431
  annualizedVol,
1060
1432
  r2: result.r2,
1061
1433
  df,
1434
+ ...(this.logSpec ? { logSpec: true, residualLogVar } : {}),
1062
1435
  },
1063
1436
  diagnostics: {
1064
1437
  logLikelihood: ll,
@@ -1071,10 +1444,12 @@ class HarRv {
1071
1444
  }
1072
1445
  /**
1073
1446
  * Internal: compute variance series from beta vector.
1447
+ * For the log spec the prediction is E[RV] = exp(ŷ + σ²_ε/2).
1074
1448
  */
1075
- getVarianceSeriesInternal(beta) {
1076
- const { rv, shortLag, mediumLag, longLag } = this;
1077
- const n = rv.length;
1449
+ getVarianceSeriesInternal(beta, residualLogVar = 0) {
1450
+ const { shortLag, mediumLag, longLag } = this;
1451
+ const source = this.logSpec ? this.lnRv : this.rv;
1452
+ const n = source.length;
1078
1453
  const fallback = sampleVariance(this.returns);
1079
1454
  const series = [];
1080
1455
  for (let i = 0; i < n; i++) {
@@ -1085,11 +1460,13 @@ class HarRv {
1085
1460
  else {
1086
1461
  // HAR prediction for rv[i] based on rv[..i-1]
1087
1462
  const t = i - 1;
1088
- const rvS = rollingMean(rv, t, shortLag);
1089
- const rvM = rollingMean(rv, t, mediumLag);
1090
- const rvL = rollingMean(rv, t, longLag);
1463
+ const rvS = rollingMean(source, t, shortLag);
1464
+ const rvM = rollingMean(source, t, mediumLag);
1465
+ const rvL = rollingMean(source, t, longLag);
1091
1466
  const predicted = beta[0] + beta[1] * rvS + beta[2] * rvM + beta[3] * rvL;
1092
- series.push(Math.max(predicted, 1e-20));
1467
+ series.push(this.logSpec
1468
+ ? Math.exp(Math.max(-720, Math.min(50, predicted + residualLogVar / 2)))
1469
+ : Math.max(predicted, 1e-20));
1093
1470
  }
1094
1471
  }
1095
1472
  return series;
@@ -1099,19 +1476,21 @@ class HarRv {
1099
1476
  */
1100
1477
  getVarianceSeries(params) {
1101
1478
  const beta = [params.beta0, params.betaShort, params.betaMedium, params.betaLong];
1102
- return this.getVarianceSeriesInternal(beta);
1479
+ return this.getVarianceSeriesInternal(beta, params.residualLogVar ?? 0);
1103
1480
  }
1104
1481
  /**
1105
1482
  * Forecast variance forward.
1106
1483
  *
1107
1484
  * Uses iterative substitution: each forecast step feeds back
1108
- * into the rolling RV components for subsequent steps.
1485
+ * into the rolling RV components for subsequent steps (point forecasts
1486
+ * of ln RV for the log spec, bias-corrected on output).
1109
1487
  */
1110
1488
  forecast(params, steps = 1) {
1111
- const { rv, shortLag, mediumLag, longLag } = this;
1489
+ const { shortLag, mediumLag, longLag } = this;
1112
1490
  const { beta0, betaShort, betaMedium, betaLong } = params;
1113
- // Working copy of recent rv values + forecasts appended
1114
- const history = rv.slice();
1491
+ const residualLogVar = params.residualLogVar ?? 0;
1492
+ // Working copy of recent (ln) rv values + forecasts appended
1493
+ const history = this.logSpec ? this.lnRv.slice() : this.rv.slice();
1115
1494
  const variance = [];
1116
1495
  for (let h = 0; h < steps; h++) {
1117
1496
  const t = history.length - 1;
@@ -1119,9 +1498,15 @@ class HarRv {
1119
1498
  const rvM = rollingMean(history, t, mediumLag);
1120
1499
  const rvL = rollingMean(history, t, longLag);
1121
1500
  const predicted = beta0 + betaShort * rvS + betaMedium * rvM + betaLong * rvL;
1122
- const v = Math.max(predicted, 1e-20);
1123
- variance.push(v);
1124
- history.push(v);
1501
+ if (this.logSpec) {
1502
+ variance.push(Math.exp(Math.max(-720, Math.min(50, predicted + residualLogVar / 2))));
1503
+ history.push(predicted); // E[ln RV] feeds the recursion
1504
+ }
1505
+ else {
1506
+ const v = Math.max(predicted, 1e-20);
1507
+ variance.push(v);
1508
+ history.push(v);
1509
+ }
1125
1510
  }
1126
1511
  return {
1127
1512
  variance,
@@ -1158,7 +1543,7 @@ function calibrateHarRv(data, options = {}) {
1158
1543
  * where:
1159
1544
  * - ω (omega) > 0: constant term
1160
1545
  * - α (alpha) ≥ 0: symmetric shock response
1161
- * - γ (gamma) ≥ 0: asymmetric leverage coefficient
1546
+ * - γ (gamma) ≥ −α: asymmetric leverage coefficient (negative = inverted leverage)
1162
1547
  * - β (beta) ≥ 0: persistence
1163
1548
  * - I(r<0) = 1 when return is negative, 0 otherwise
1164
1549
  * - Stationarity: α + γ/2 + β < 1
@@ -1176,15 +1561,18 @@ class GjrGarch {
1176
1561
  if (data.length < 50) {
1177
1562
  throw new Error('Need at least 50 data points for GJR-GARCH estimation');
1178
1563
  }
1564
+ // Variance floor keeps degenerate (constant-price) data from producing
1565
+ // log(0)/division-by-zero downstream instead of a graceful bad fit.
1179
1566
  if (typeof data[0] === 'number') {
1180
1567
  this.returns = calculateReturnsFromPrices(data);
1181
- this.initialVariance = sampleVariance(this.returns);
1568
+ this.initialVariance = Math.max(sampleVariance(this.returns), 1e-300);
1182
1569
  this.rv = null;
1183
1570
  }
1184
1571
  else {
1185
1572
  const candles = data;
1573
+ validateCandles(candles);
1186
1574
  this.returns = calculateReturns(candles);
1187
- this.initialVariance = yangZhangVariance(candles);
1575
+ this.initialVariance = Math.max(yangZhangVariance(candles), 1e-300);
1188
1576
  this.rv = perCandleParkinson(candles, this.returns);
1189
1577
  }
1190
1578
  }
@@ -1192,16 +1580,38 @@ class GjrGarch {
1192
1580
  * Calibrate GJR-GARCH(1,1) parameters using Maximum Likelihood Estimation
1193
1581
  */
1194
1582
  fit(options = {}) {
1195
- const { maxIter = 1000, tol = 1e-8 } = options;
1196
- const returns = this.returns;
1197
- const n = returns.length;
1198
- const initVar = this.initialVariance;
1199
- const rv = this.rv;
1583
+ const { maxIter = 1000, tol = 1e-8, forgetting = 1 } = options;
1584
+ const n = this.returns.length;
1585
+ // Exponential forgetting: observation t contributes with weight
1586
+ // λ^(n−1−t) (newest = 1); λ = 1 disables.
1587
+ const weights = new Array(n).fill(1);
1588
+ if (forgetting < 1) {
1589
+ for (let t = 0; t < n; t++)
1590
+ weights[t] = Math.pow(forgetting, n - 1 - t);
1591
+ }
1592
+ const wTotal = weights.reduce((a, b) => a + b, 0);
1593
+ // Calibrate in normalized space: returns are scaled to unit initial
1594
+ // variance, so the likelihood floors, penalty constants, and optimizer
1595
+ // tolerances are scale-free — a stablecoin pair and a high-vol altcoin
1596
+ // follow the same optimizer path. Parameters are mapped back to the
1597
+ // data scale after optimization.
1598
+ const s2 = 1 / this.initialVariance;
1599
+ const s = Math.sqrt(s2);
1600
+ const returns = this.returns.map(r => r * s);
1601
+ const rv = this.rv ? this.rv.map(v => v * s2) : null;
1602
+ const initVar = 1;
1603
+ const varFloor = 1e-12;
1200
1604
  function negLogLikelihood(params) {
1201
1605
  const [omega, alpha, gamma, beta, df] = params;
1202
- if (omega <= 1e-12)
1606
+ if (omega <= varFloor)
1607
+ return 1e10;
1608
+ if (alpha < 0 || beta < 0)
1203
1609
  return 1e10;
1204
- if (alpha < 0 || gamma < 0 || beta < 0)
1610
+ // Positivity of σ² only needs the response to negative returns
1611
+ // (α + γ) to stay non-negative — γ itself may be negative (inverted
1612
+ // leverage: pumps driving volatility harder than dumps, common in
1613
+ // crypto), which a γ ≥ 0 constraint would silently censor.
1614
+ if (alpha + gamma < 0)
1205
1615
  return 1e10;
1206
1616
  if (alpha + gamma / 2 + beta >= 0.9999)
1207
1617
  return 1e10;
@@ -1209,7 +1619,7 @@ class GjrGarch {
1209
1619
  return 1e10;
1210
1620
  const halfDfPlus1 = (df + 1) / 2;
1211
1621
  const dfMinus2 = df - 2;
1212
- const constant = n * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
1622
+ const constant = wTotal * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2));
1213
1623
  let variance = initVar;
1214
1624
  let ll = 0;
1215
1625
  for (let i = 0; i < n; i++) {
@@ -1218,24 +1628,40 @@ class GjrGarch {
1218
1628
  const indicator = returns[i - 1] < 0 ? 1 : 0;
1219
1629
  variance = omega + alpha * innovation + gamma * innovation * indicator + beta * variance;
1220
1630
  }
1221
- if (variance <= 1e-12)
1631
+ if (variance <= varFloor)
1222
1632
  return 1e10;
1223
1633
  // Student-t log-likelihood
1224
- ll += -0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance));
1634
+ ll += weights[i] * (-0.5 * Math.log(variance) - halfDfPlus1 * Math.log(1 + (returns[i] ** 2) / (dfMinus2 * variance)));
1225
1635
  }
1226
1636
  return -(ll + constant);
1227
1637
  }
1228
- const omega0 = initVar * 0.05;
1638
+ // Variance targeting: ω₀ implied by sample variance and persistence seed
1229
1639
  const alpha0 = 0.05;
1230
1640
  const gamma0 = 0.1;
1231
1641
  const beta0 = 0.85;
1642
+ const omega0 = initVar * (1 - alpha0 - gamma0 / 2 - beta0);
1232
1643
  const df0 = 5;
1233
- const result = nelderMeadMultiStart(negLogLikelihood, [omega0, alpha0, gamma0, beta0, df0], { maxIter, tol, restarts: 4 });
1234
- const [omega, alpha, gamma, beta, df] = result.x;
1644
+ // Warm start (previous window's optimum) replaces the cold seed with a
1645
+ // reduced restart budget; feasibility is window-independent.
1646
+ const wp = options.warmStart;
1647
+ const warmValid = !!(wp && isFinite(wp.omega) && wp.omega > 0
1648
+ && isFinite(wp.alpha) && isFinite(wp.gamma) && isFinite(wp.beta) && isFinite(wp.df));
1649
+ const x0 = warmValid
1650
+ ? [wp.omega * s2, wp.alpha, wp.gamma, wp.beta, wp.df]
1651
+ : [omega0, alpha0, gamma0, beta0, df0];
1652
+ const result = nelderMeadMultiStart(negLogLikelihood, x0, {
1653
+ maxIter,
1654
+ tol,
1655
+ restarts: warmValid ? 1 : 4,
1656
+ });
1657
+ // Map back to the data scale: ω scales with variance, α/γ/β/df are scale-free
1658
+ const [omegaScaled, alpha, gamma, beta, df] = result.x;
1659
+ const omega = omegaScaled / s2;
1235
1660
  const persistence = alpha + gamma / 2 + beta;
1236
1661
  const unconditionalVariance = omega / (1 - persistence);
1237
1662
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
1238
- const logLikelihood = -result.fx;
1663
+ // Jacobian of the rescaling: LL in data units = LL(scaled) + n·ln s
1664
+ const logLikelihood = -result.fx + n * Math.log(s);
1239
1665
  const numParams = 5;
1240
1666
  return {
1241
1667
  params: {
@@ -1324,6 +1750,251 @@ function calibrateGjrGarch(data, options = {}) {
1324
1750
  return model.fit(options);
1325
1751
  }
1326
1752
 
1753
+ /**
1754
+ * Realized GARCH(1,1) (Hansen, Huang & Shek, 2012), log-linear, φ = 1.
1755
+ *
1756
+ * r_t = σ_t·z_t, z_t ~ standardized t(df)
1757
+ * ln σ²_t = ω + β·ln σ²_{t−1} + γ·ln RV_{t−1}
1758
+ * ln RV_t = ξ + ln σ²_t + τ₁·z_t + τ₂·(z²_t − 1) + u_t, u_t ~ N(0, σ²_u)
1759
+ *
1760
+ * Unlike the RV-in-place-of-ε² hybrids, the measurement equation estimates
1761
+ * the bias (ξ) and noise (σ_u) of the realized measure inside the joint
1762
+ * likelihood: RV information is weighted by how trustworthy it actually is,
1763
+ * and leverage enters through τ₁. Stationarity: β + γ < 1 (with φ = 1).
1764
+ */
1765
+ class RealizedGarch {
1766
+ returns;
1767
+ lnRv;
1768
+ periodsPerYear;
1769
+ initialVariance;
1770
+ constructor(data, options = {}) {
1771
+ this.periodsPerYear = options.periodsPerYear ?? 252;
1772
+ if (data.length < 50) {
1773
+ throw new Error('Need at least 50 data points for Realized GARCH estimation');
1774
+ }
1775
+ let rv;
1776
+ if (typeof data[0] === 'number') {
1777
+ this.returns = calculateReturnsFromPrices(data);
1778
+ this.initialVariance = Math.max(sampleVariance(this.returns), 1e-300);
1779
+ // Prices only — squared returns as the (noisy) realized measure
1780
+ rv = this.returns.map(r => r * r);
1781
+ }
1782
+ else {
1783
+ const candles = data;
1784
+ validateCandles(candles);
1785
+ this.returns = calculateReturns(candles);
1786
+ this.initialVariance = Math.max(yangZhangVariance(candles), 1e-300);
1787
+ rv = perCandleParkinson(candles, this.returns);
1788
+ }
1789
+ // ln RV with a floor at half the smallest positive observation: the
1790
+ // measurement equation lives in logs and a literal zero (flat candle)
1791
+ // must not inject −Infinity.
1792
+ let minPos = Infinity;
1793
+ for (const v of rv) {
1794
+ if (v > 0 && v < minPos)
1795
+ minPos = v;
1796
+ }
1797
+ if (!isFinite(minPos)) {
1798
+ throw new Error('Realized GARCH needs at least one positive realized-variance observation');
1799
+ }
1800
+ const floor = minPos * 0.5;
1801
+ this.lnRv = rv.map(v => Math.log(Math.max(v, floor)));
1802
+ }
1803
+ /**
1804
+ * Calibrate by joint MLE over returns and the realized measure.
1805
+ */
1806
+ fit(options = {}) {
1807
+ const { maxIter = 1000, tol = 1e-8, forgetting = 1 } = options;
1808
+ const n = this.returns.length;
1809
+ const initLogVarOrig = Math.log(this.initialVariance);
1810
+ // Normalized space: returns to unit initial variance, ln RV shifted
1811
+ // accordingly — ξ, τ, σ_u, df are scale-free; ω is mapped back below.
1812
+ const s2 = 1 / this.initialVariance;
1813
+ const s = Math.sqrt(s2);
1814
+ const returns = this.returns.map(r => r * s);
1815
+ const lnRv = this.lnRv.map(v => v + Math.log(s2));
1816
+ // Exponential forgetting: w_t = λ^(n−1−t), newest observation weight 1
1817
+ const weights = new Array(n).fill(1);
1818
+ if (forgetting < 1) {
1819
+ for (let t = 0; t < n; t++)
1820
+ weights[t] = Math.pow(forgetting, n - 1 - t);
1821
+ }
1822
+ const wTotal = weights.reduce((a, b) => a + b, 0);
1823
+ const LOG_2PI = Math.log(2 * Math.PI);
1824
+ function negLogLikelihood(params) {
1825
+ const [omega, beta, gamma, xi, tau1, tau2, lnSigmaU, df] = params;
1826
+ if (beta < 0 || gamma < 0)
1827
+ return 1e10;
1828
+ if (beta + gamma >= 0.9999)
1829
+ return 1e10;
1830
+ if (df <= 2.01 || df > 100)
1831
+ return 1e10;
1832
+ if (Math.abs(lnSigmaU) > 5)
1833
+ return 1e10;
1834
+ // Same flat-ridge treatment as EGARCH: hard wall on the implied
1835
+ // unconditional level plus a weak Gaussian prior toward the sample
1836
+ // variance (scaled space: ln σ̂² = 0)
1837
+ const impliedLogVar = (omega + gamma * xi) / (1 - beta - gamma);
1838
+ if (!isFinite(impliedLogVar) || Math.abs(impliedLogVar) > Math.log(1e4))
1839
+ return 1e10;
1840
+ const prior = 0.5 * impliedLogVar * impliedLogVar;
1841
+ const sigmaU2 = Math.exp(2 * lnSigmaU);
1842
+ const halfDfPlus1 = (df + 1) / 2;
1843
+ const dfMinus2 = df - 2;
1844
+ const constant = wTotal * (logGamma(halfDfPlus1) - logGamma(df / 2) - 0.5 * Math.log(Math.PI * dfMinus2)
1845
+ - 0.5 * (LOG_2PI + 2 * lnSigmaU));
1846
+ let logVariance = 0; // scaled initial variance = 1
1847
+ let ll = 0;
1848
+ for (let i = 0; i < n; i++) {
1849
+ if (i > 0) {
1850
+ logVariance = omega + beta * logVariance + gamma * lnRv[i - 1];
1851
+ logVariance = Math.max(-50, Math.min(50, logVariance));
1852
+ }
1853
+ const variance = Math.exp(logVariance);
1854
+ const z = returns[i] / Math.sqrt(variance);
1855
+ // Return likelihood (Student-t) + measurement likelihood (Gaussian)
1856
+ const u = lnRv[i] - xi - logVariance - tau1 * z - tau2 * (z * z - 1);
1857
+ ll += weights[i] * (-0.5 * logVariance - halfDfPlus1 * Math.log(1 + (z * z) / dfMinus2)
1858
+ - 0.5 * (u * u) / sigmaU2);
1859
+ if (!isFinite(ll))
1860
+ return 1e10;
1861
+ }
1862
+ return -(ll + constant) + prior;
1863
+ }
1864
+ // Initial guesses: variance targeting in scaled log space (level 0)
1865
+ const beta0 = 0.55;
1866
+ const gamma0 = 0.4;
1867
+ const xi0 = lnRv.reduce((a, b) => a + b, 0) / n; // E[ln RV] − E[ln σ²] with E[ln σ²]=0
1868
+ const omega0 = -gamma0 * xi0;
1869
+ const x0 = [omega0, beta0, gamma0, xi0, -0.05, 0.1, Math.log(0.5), 5];
1870
+ // Warm start (previous window's optimum) replaces the cold seed with a
1871
+ // reduced restart budget; the level wall moves with the sample, so an
1872
+ // out-of-wall warm seed falls back to the cold start.
1873
+ let warmX0 = null;
1874
+ const wp = options.warmStart;
1875
+ if (wp && isFinite(wp.omega) && isFinite(wp.beta) && isFinite(wp.gamma) && wp.beta + wp.gamma < 1) {
1876
+ const cand = [
1877
+ wp.omega - (1 - wp.beta - wp.gamma) * initLogVarOrig,
1878
+ wp.beta,
1879
+ wp.gamma,
1880
+ wp.xi,
1881
+ wp.tau1,
1882
+ wp.tau2,
1883
+ Math.log(Math.max(wp.sigmaU, 1e-4)),
1884
+ wp.df,
1885
+ ];
1886
+ const implied = (cand[0] + wp.gamma * wp.xi) / (1 - wp.beta - wp.gamma);
1887
+ if (Math.abs(implied) <= Math.log(1e4))
1888
+ warmX0 = cand;
1889
+ }
1890
+ // Cold restarts kept low: the variance-targeted seed plus the level
1891
+ // prior already resolve the ω/β/γ ridge, and the adaptive multi-start
1892
+ // extends the budget on its own when restarts keep improving.
1893
+ const result = nelderMeadMultiStart(negLogLikelihood, warmX0 ?? x0, {
1894
+ maxIter,
1895
+ tol,
1896
+ restarts: warmX0 ? 1 : 2,
1897
+ });
1898
+ // Map back: ln σ²_orig = ln σ²_scaled + ln σ̂²_orig, ξ is invariant, so
1899
+ // ω_orig = ω_scaled + (1 − β − γ)·ln σ̂²_orig
1900
+ const [omegaScaled, beta, gamma, xi, tau1, tau2, lnSigmaU, df] = result.x;
1901
+ const omega = omegaScaled + (1 - beta - gamma) * initLogVarOrig;
1902
+ const sigmaU = Math.exp(lnSigmaU);
1903
+ const persistence = beta + gamma;
1904
+ const unconditionalLogVar = (omega + gamma * xi) / (1 - persistence);
1905
+ const unconditionalVariance = Math.exp(unconditionalLogVar);
1906
+ const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
1907
+ // Strip the shrinkage prior (deviation is scale-invariant) and add the
1908
+ // returns Jacobian; the ln RV measurement density is shift-invariant.
1909
+ const priorAtOptimum = 0.5 * (unconditionalLogVar - initLogVarOrig) ** 2;
1910
+ const logLikelihood = -(result.fx - priorAtOptimum) + n * Math.log(s);
1911
+ const numParams = 8;
1912
+ return {
1913
+ params: {
1914
+ omega,
1915
+ beta,
1916
+ gamma,
1917
+ xi,
1918
+ tau1,
1919
+ tau2,
1920
+ sigmaU,
1921
+ persistence,
1922
+ unconditionalVariance,
1923
+ annualizedVol,
1924
+ df,
1925
+ },
1926
+ diagnostics: {
1927
+ logLikelihood,
1928
+ aic: calculateAIC(logLikelihood, numParams),
1929
+ bic: calculateBIC(logLikelihood, numParams, n),
1930
+ iterations: result.iterations,
1931
+ converged: result.converged,
1932
+ },
1933
+ };
1934
+ }
1935
+ /**
1936
+ * Conditional variance series (data scale). σ²_t is driven by RV_{t−1}
1937
+ * through the log recursion — no look-ahead.
1938
+ */
1939
+ getVarianceSeries(params) {
1940
+ const { omega, beta, gamma } = params;
1941
+ const variance = [];
1942
+ let logVariance = Math.log(this.initialVariance);
1943
+ for (let i = 0; i < this.returns.length; i++) {
1944
+ if (i > 0) {
1945
+ logVariance = omega + beta * logVariance + gamma * this.lnRv[i - 1];
1946
+ logVariance = Math.max(-720, Math.min(50, logVariance));
1947
+ }
1948
+ variance.push(Math.exp(logVariance));
1949
+ }
1950
+ return variance;
1951
+ }
1952
+ /**
1953
+ * Forecast variance forward. One step uses the actual last RV; further
1954
+ * steps substitute E[ln RV_t] = ξ + ln σ²_t, giving the reduced recursion
1955
+ * ln σ²_{t+h} = (ω + γξ) + (β + γ)·ln σ²_{t+h−1}.
1956
+ */
1957
+ forecast(params, steps = 1) {
1958
+ const { omega, beta, gamma, xi } = params;
1959
+ const series = this.getVarianceSeries(params);
1960
+ const variance = [];
1961
+ let logVariance = omega
1962
+ + beta * Math.log(series[series.length - 1])
1963
+ + gamma * this.lnRv[this.lnRv.length - 1];
1964
+ logVariance = Math.max(-720, Math.min(50, logVariance));
1965
+ variance.push(Math.exp(logVariance));
1966
+ for (let h = 1; h < steps; h++) {
1967
+ logVariance = omega + gamma * xi + (beta + gamma) * logVariance;
1968
+ logVariance = Math.max(-720, Math.min(50, logVariance));
1969
+ variance.push(Math.exp(logVariance));
1970
+ }
1971
+ return {
1972
+ variance,
1973
+ volatility: variance.map(v => Math.sqrt(v)),
1974
+ annualized: variance.map(v => Math.sqrt(v * this.periodsPerYear) * 100),
1975
+ };
1976
+ }
1977
+ /**
1978
+ * Get the return series.
1979
+ */
1980
+ getReturns() {
1981
+ return [...this.returns];
1982
+ }
1983
+ /**
1984
+ * Get initial variance estimate.
1985
+ */
1986
+ getInitialVariance() {
1987
+ return this.initialVariance;
1988
+ }
1989
+ }
1990
+ /**
1991
+ * Convenience function to calibrate Realized GARCH from candles or prices.
1992
+ */
1993
+ function calibrateRealizedGarch(data, options = {}) {
1994
+ const model = new RealizedGarch(data, options);
1995
+ return model.fit(options);
1996
+ }
1997
+
1327
1998
  const DEFAULT_LAGS = 10;
1328
1999
  /**
1329
2000
  * NoVaS (Normalizing and Variance-Stabilizing) model (Politis, 2003)
@@ -1362,6 +2033,7 @@ class NoVaS {
1362
2033
  }
1363
2034
  else {
1364
2035
  const candles = data;
2036
+ validateCandles(candles);
1365
2037
  this.returns = calculateReturns(candles);
1366
2038
  // Parkinson (1980) per-candle RV: ~5× more efficient than r²
1367
2039
  this.rv = perCandleParkinson(candles, this.returns);
@@ -1377,17 +2049,28 @@ class NoVaS {
1377
2049
  const returns = this.returns;
1378
2050
  const n = returns.length;
1379
2051
  const p = this.lags;
1380
- const initVar = sampleVariance(returns);
2052
+ const initVarOrig = sampleVariance(returns);
1381
2053
  // Innovation: Parkinson RV for candles, r² for prices
1382
2054
  const r2 = this.rv ?? returns.map(r => r * r);
2055
+ // Calibrate in normalized space: returns are scaled to unit sample
2056
+ // variance, so D² floors, the screening grid, and the stage-2 OLS
2057
+ // conditioning are scale-free. The intercept weight and β₀ are mapped
2058
+ // back to the data scale below; lag weights are scale-free.
2059
+ const s2 = initVarOrig > 0 ? 1 / initVarOrig : 1;
2060
+ const returnsS = returns.map(r => r * Math.sqrt(s2));
2061
+ const r2S = r2.map(v => v * s2);
2062
+ const initVar = 1;
2063
+ const unscaleWeights = (w) => [w[0] / s2, ...w.slice(1)];
1383
2064
  /**
1384
2065
  * Compute D² for a given weight vector.
1385
2066
  * D² = S² + (K - 3)² where S, K are skewness and kurtosis of W_t.
1386
2067
  */
2068
+ // In normalized space the sample variance is 1, so a fixed epsilon is safe
2069
+ const a0Floor = 1e-12;
1387
2070
  function objectiveD2(rawWeights) {
1388
2071
  // Enforce constraints: a_j >= 0 via abs, a_0 > epsilon
1389
2072
  const weights = rawWeights.map(w => Math.abs(w));
1390
- if (weights[0] < 1e-15)
2073
+ if (weights[0] < a0Floor)
1391
2074
  return 1e10;
1392
2075
  // Stationarity: sum(a_1..a_p) < 1
1393
2076
  let lagSum = 0;
@@ -1404,11 +2087,11 @@ class NoVaS {
1404
2087
  for (let t = p; t < n; t++) {
1405
2088
  let variance = weights[0];
1406
2089
  for (let j = 1; j <= p; j++) {
1407
- variance += weights[j] * r2[t - j];
2090
+ variance += weights[j] * r2S[t - j];
1408
2091
  }
1409
- if (variance <= 1e-15)
2092
+ if (variance <= 0)
1410
2093
  return 1e10;
1411
- const w = returns[t] / Math.sqrt(variance);
2094
+ const w = returnsS[t] / Math.sqrt(variance);
1412
2095
  if (!isFinite(w))
1413
2096
  return 1e10;
1414
2097
  sumW += w;
@@ -1438,28 +2121,70 @@ class NoVaS {
1438
2121
  for (let j = 1; j <= p; j++) {
1439
2122
  x0.push(0.9 * (1 - lambda) * Math.pow(lambda, j - 1));
1440
2123
  }
1441
- const result = nelderMeadMultiStart(objectiveD2, x0, { maxIter, tol, restarts: 6 });
1442
- // Extract final weights (abs for constraint enforcement)
1443
- const weights = result.x.map(w => Math.abs(w));
1444
- let persistence = 0;
1445
- for (let j = 1; j <= p; j++)
1446
- persistence += weights[j];
1447
- const unconditionalVariance = persistence < 1 && persistence > -1
1448
- ? Math.max(weights[0] / (1 - persistence), 1e-20)
1449
- : sampleVariance(returns);
1450
- const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
1451
- // ── Stage 2: OLS rescaling of D²-variance ──────────────
1452
- // RV_{t+1} = β₀ + β₁·σ²_t(D²)
1453
- // weights discover lag structure; OLS rescales for forecast accuracy.
1454
- // Only 2 parameters → robust on small samples with noisy per-candle RV.
1455
- const d2Variance = this.getVarianceSeriesInternal(weights);
1456
- let forecastWeights;
1457
- let olsR2;
1458
- try {
2124
+ // Warm start (previous window's data-scale weights): the screening
2125
+ // cloud exists to discover the lag structure, which a warm start
2126
+ // already carries skip it and run a reduced multi-start instead.
2127
+ const warm = options.warmWeights;
2128
+ const warmScaled = warm && warm.length === p + 1 && warm.every(v => isFinite(v))
2129
+ ? [warm[0] * s2, ...warm.slice(1)]
2130
+ : null;
2131
+ // Screening: the multi-start perturbation scales x0 multiplicatively and
2132
+ // preserves its exponential-decay shape, so far-lag weight structures are
2133
+ // unreachable from x0 alone (measured: 5. worse D² on two-spike ARCH
2134
+ // ground truth). costs one pass to evaluate, so scan a deterministic
2135
+ // low-discrepancy cloud of sparse weight shapes and hand the best few to
2136
+ // Nelder-Mead as explicit extra starts.
2137
+ const screened = [];
2138
+ if (!warmScaled) {
2139
+ // Kronecker sequence: one irrational stride per dimension
2140
+ const strides = [];
2141
+ for (let i = 0, prime = 2; i <= p + 1; prime++) {
2142
+ let isPrime = true;
2143
+ for (let q = 2; q * q <= prime; q++)
2144
+ if (prime % q === 0) {
2145
+ isPrime = false;
2146
+ break;
2147
+ }
2148
+ if (!isPrime)
2149
+ continue;
2150
+ strides.push(Math.sqrt(prime) % 1);
2151
+ i++;
2152
+ }
2153
+ for (let s = 1; s <= 384; s++) {
2154
+ // a0 log-uniform in [0.01, 1]·initVar
2155
+ const a0 = initVar * Math.pow(10, ((s * strides[0]) % 1) * 2 - 2);
2156
+ const raw = [];
2157
+ let rawSum = 0;
2158
+ for (let j = 1; j <= p; j++) {
2159
+ // ~half the lags exactly zero — spikes and gaps are in the span
2160
+ const u = (s * strides[j]) % 1;
2161
+ const v = Math.max(0, u * 2 - 1);
2162
+ raw.push(v);
2163
+ rawSum += v;
2164
+ }
2165
+ if (rawSum <= 0)
2166
+ continue;
2167
+ const target = 0.1 + 0.85 * ((s * strides[p + 1]) % 1);
2168
+ const w = [a0, ...raw.map(v => (v * target) / rawSum)];
2169
+ screened.push({ d2: objectiveD2(w), w });
2170
+ }
2171
+ screened.sort((a, b) => a.d2 - b.d2);
2172
+ }
2173
+ const extraStarts = warmScaled ? [] : screened.slice(0, 3).map(c => c.w);
2174
+ // Stage-2 OLS (RV_t ~ β₀ + β₁·σ²_t) for a given weight vector.
2175
+ // σ²_t is built from r2[t-1..t-p], so it already IS the one-step-ahead
2176
+ // prediction of rv[t] — pairing it with r2[t+1] (as before) calibrated
2177
+ // β one bar off from how getForecastVarianceSeries and forecast use it.
2178
+ // Used both to pick among D² candidates and for the final rescaling.
2179
+ // Takes and returns normalized-space quantities (β₁ is scale-free,
2180
+ // the caller unscales β₀); the degenerate-denominator threshold is
2181
+ // then meaningful at any data scale.
2182
+ const stage2 = (wScaled) => {
2183
+ const dv = this.getVarianceSeriesInternal(unscaleWeights(wScaled));
1459
2184
  let sumX = 0, sumY = 0, sumXX = 0, sumXY = 0, count = 0;
1460
- for (let t = p; t < n - 1; t++) {
1461
- const x = d2Variance[t];
1462
- const y = r2[t + 1];
2185
+ for (let t = p; t < n; t++) {
2186
+ const x = dv[t] * s2;
2187
+ const y = r2S[t];
1463
2188
  sumX += x;
1464
2189
  sumY += y;
1465
2190
  sumXX += x * x;
@@ -1468,25 +2193,62 @@ class NoVaS {
1468
2193
  }
1469
2194
  const denom = count * sumXX - sumX * sumX;
1470
2195
  if (Math.abs(denom) < 1e-30)
1471
- throw new Error('Degenerate variance series');
2196
+ return null;
1472
2197
  const beta1 = (count * sumXY - sumX * sumY) / denom;
1473
2198
  const beta0 = (sumY - beta1 * sumX) / count;
1474
- forecastWeights = [beta0, beta1];
1475
- // R²
1476
2199
  const yMean = sumY / count;
1477
2200
  let rss = 0, tss = 0;
1478
- for (let t = p; t < n - 1; t++) {
1479
- const yHat = beta0 + beta1 * d2Variance[t];
1480
- rss += (r2[t + 1] - yHat) ** 2;
1481
- tss += (r2[t + 1] - yMean) ** 2;
2201
+ for (let t = p; t < n; t++) {
2202
+ const yHat = beta0 + beta1 * dv[t] * s2;
2203
+ rss += (r2S[t] - yHat) ** 2;
2204
+ tss += (r2S[t] - yMean) ** 2;
2205
+ }
2206
+ return { beta0, beta1, rss, r2: tss > 0 ? 1 - rss / tss : 0 };
2207
+ };
2208
+ // D² is underdetermined: two moment conditions (skewness, kurtosis)
2209
+ // constrain p+1 weights, so its minimum is a manifold and near-zero D²
2210
+ // differences are sampling noise — sd of (K−3)² at n ≈ 500 is ~0.05.
2211
+ // Run NM from the exp-decay seed and from each screened start, then
2212
+ // among candidates within that noise floor of the best D² pick the best
2213
+ // RV forecaster: normality identifies the set, prediction picks the point.
2214
+ const D2_NOISE = 0.05;
2215
+ const candidateRuns = [
2216
+ nelderMeadMultiStart(objectiveD2, warmScaled ?? x0, { maxIter, tol, restarts: warmScaled ? 1 : 6 }),
2217
+ ...extraStarts.map(s => nelderMead(objectiveD2, s, { maxIter, tol })),
2218
+ ];
2219
+ const bestD2 = Math.min(...candidateRuns.map(r => r.fx));
2220
+ let result = candidateRuns[0];
2221
+ let bestRss = Infinity;
2222
+ for (const run of candidateRuns) {
2223
+ if (run.fx > bestD2 + D2_NOISE)
2224
+ continue;
2225
+ const s2 = stage2(run.x.map(Math.abs));
2226
+ const rss = s2 ? s2.rss : Infinity;
2227
+ if (rss < bestRss || (rss === bestRss && run.fx < result.fx)) {
2228
+ bestRss = rss;
2229
+ result = run;
1482
2230
  }
1483
- olsR2 = tss > 0 ? 1 - rss / tss : 0;
1484
- }
1485
- catch {
1486
- // OLS failed — fall back to identity rescaling [0, 1]
1487
- forecastWeights = [0, 1];
1488
- olsR2 = 0;
1489
2231
  }
2232
+ // Extract final weights (abs for constraint enforcement) and map the
2233
+ // intercept back to the data scale
2234
+ const weightsScaled = result.x.map(w => Math.abs(w));
2235
+ const weights = unscaleWeights(weightsScaled);
2236
+ let persistence = 0;
2237
+ for (let j = 1; j <= p; j++)
2238
+ persistence += weights[j];
2239
+ const unconditionalVariance = persistence < 1 && persistence > -1
2240
+ ? Math.max(weights[0] / (1 - persistence), 1e-20)
2241
+ : sampleVariance(returns);
2242
+ const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
2243
+ // ── Stage 2: OLS rescaling of D²-variance ──────────────
2244
+ // RV_{t+1} = β₀ + β₁·σ²_t(D²)
2245
+ // D² weights discover lag structure; OLS rescales for forecast accuracy.
2246
+ // Only 2 parameters → robust on small samples with noisy per-candle RV.
2247
+ const d2Variance = this.getVarianceSeriesInternal(weights);
2248
+ const s2Final = stage2(weightsScaled);
2249
+ // OLS failed (degenerate variance series) — fall back to identity rescaling
2250
+ const forecastWeights = s2Final ? [s2Final.beta0 / s2, s2Final.beta1] : [0, 1];
2251
+ const olsR2 = s2Final ? s2Final.r2 : 0;
1490
2252
  // Student-t log-likelihood for AIC comparison with GARCH/EGARCH/HAR-RV
1491
2253
  const df = profileStudentTDf(returns, d2Variance);
1492
2254
  const ll = -studentTNegLL(returns, d2Variance, df);
@@ -1635,132 +2397,967 @@ const INTERVALS_PER_YEAR = {
1635
2397
  '6h': 1_460,
1636
2398
  '8h': 1_095,
1637
2399
  };
2400
+ function validateInterval(interval) {
2401
+ if (!(interval in INTERVALS_PER_YEAR)) {
2402
+ throw new InvalidArgumentError(`Unknown interval '${interval}' — valid intervals: ${Object.keys(INTERVALS_PER_YEAR).join(', ')}`);
2403
+ }
2404
+ }
2405
+ function validateConfidence(confidence) {
2406
+ if (!(confidence > 0 && confidence < 1)) {
2407
+ const hint = confidence > 1 && confidence <= 100
2408
+ ? ` (did you pass percent? Use a fraction: ${confidence / 100})`
2409
+ : '';
2410
+ throw new InvalidArgumentError(`confidence must be in (0, 1), got ${confidence}${hint}`);
2411
+ }
2412
+ }
2413
+ /**
2414
+ * Accept both the positional form (currentPrice, confidence) and an options
2415
+ * object — `predict(candles, '1h', { confidence: 0.9 })` cannot be confused
2416
+ * with a price the way `predict(candles, '1h', 0.9)` can.
2417
+ */
2418
+ function resolvePredictArgs(candles, currentPriceOrOptions, confidence) {
2419
+ let price;
2420
+ let conf = confidence;
2421
+ if (currentPriceOrOptions !== null && typeof currentPriceOrOptions === 'object') {
2422
+ price = currentPriceOrOptions.currentPrice;
2423
+ conf = currentPriceOrOptions.confidence ?? confidence;
2424
+ }
2425
+ else {
2426
+ price = currentPriceOrOptions;
2427
+ }
2428
+ validateConfidence(conf);
2429
+ if (price !== null && price !== undefined) {
2430
+ if (!(Number.isFinite(price) && price > 0)) {
2431
+ throw new InvalidArgumentError(`currentPrice must be a positive finite number, got ${price}`);
2432
+ }
2433
+ return { currentPrice: price, confidence: conf };
2434
+ }
2435
+ return { currentPrice: candles[candles.length - 1].close, confidence: conf };
2436
+ }
1638
2437
  function assertMinCandles(candles, interval) {
2438
+ validateInterval(interval);
1639
2439
  const min = MIN_CANDLES[interval];
1640
2440
  if (candles.length < min) {
1641
- throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
2441
+ throw new NotEnoughDataError(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
1642
2442
  }
1643
- for (let i = 0; i < candles.length; i++) {
1644
- const c = candles[i];
1645
- if (!isFinite(c.close) || c.close <= 0) {
1646
- throw new Error(`Invalid close price at candle ${i}: ${c.close}`);
2443
+ validateCandles(candles);
2444
+ }
2445
+ // ── Data quality ──────────────────────────────────────────────
2446
+ /** Timestamps in ms (seconds are auto-scaled), or null when any candle lacks one. */
2447
+ function getTimestampsMs(candles) {
2448
+ if (!candles.every(c => Number.isFinite(c.timestamp)))
2449
+ return null;
2450
+ return candles.map(c => (c.timestamp < 1e12 ? c.timestamp * 1000 : c.timestamp));
2451
+ }
2452
+ /** Hard failures on broken timestamp ordering — garbage-in guards for predict. */
2453
+ function assertTimestampOrder(candles) {
2454
+ const ts = getTimestampsMs(candles);
2455
+ if (!ts)
2456
+ return;
2457
+ for (let i = 1; i < ts.length; i++) {
2458
+ if (ts[i] < ts[i - 1]) {
2459
+ throw new BadDataError(`Candles are not sorted by timestamp (index ${i}) — sort ascending before calling. Check your data feed.`);
2460
+ }
2461
+ if (ts[i] === ts[i - 1]) {
2462
+ throw new BadDataError(`Duplicate candle timestamp at index ${i} — deduplicate your data feed before calling.`);
1647
2463
  }
1648
2464
  }
2465
+ }
2466
+ function formatSpacing(ms) {
2467
+ if (ms >= 3_600_000)
2468
+ return `${(ms / 3_600_000).toFixed(1)}h`;
2469
+ if (ms >= 60_000)
2470
+ return `${(ms / 60_000).toFixed(1)}m`;
2471
+ return `${(ms / 1000).toFixed(0)}s`;
2472
+ }
2473
+ /** Non-critical data observations appended to PredictionResult.warnings. */
2474
+ function collectDataWarnings(candles, interval, warnings) {
1649
2475
  const recommended = RECOMMENDED_CANDLES[interval];
1650
- if (candles.length < recommended) ;
1651
- }
1652
- function fitGarchFamily(candles, periodsPerYear, steps) {
1653
- // Fit all three GARCH-family models and pick the best by AIC
1654
- // (AIC is fair hereall three optimize the same Student-t LL)
1655
- const garchModel = new Garch(candles, { periodsPerYear });
1656
- const garchFit = garchModel.fit();
1657
- let bestAic = garchFit.diagnostics.aic;
1658
- let best = {
1659
- forecast: garchModel.forecast(garchFit.params, steps),
1660
- modelType: 'garch',
1661
- converged: garchFit.diagnostics.converged,
1662
- persistence: garchFit.params.persistence,
1663
- varianceSeries: garchModel.getVarianceSeries(garchFit.params),
1664
- returns: garchModel.getReturns(),
2476
+ if (candles.length < recommended) {
2477
+ warnings.push({
2478
+ code: 'LOW_SAMPLE',
2479
+ critical: false,
2480
+ message: `${candles.length} candles provided; ≥${recommended} recommended for ${interval} estimates are noisier on short samples.`,
2481
+ });
2482
+ }
2483
+ const ts = getTimestampsMs(candles);
2484
+ if (!ts || ts.length < 3)
2485
+ return;
2486
+ const barMs = YEAR_MS / INTERVALS_PER_YEAR[interval];
2487
+ const spacings = [];
2488
+ for (let i = 1; i < ts.length; i++)
2489
+ spacings.push(ts[i] - ts[i - 1]);
2490
+ spacings.sort((a, b) => a - b);
2491
+ const median = spacings[Math.floor(spacings.length / 2)];
2492
+ if (median > 0 && Math.abs(median / barMs - 1) > 0.25) {
2493
+ warnings.push({
2494
+ code: 'INTERVAL_MISMATCH',
2495
+ critical: false,
2496
+ message: `Candle spacing looks like ~${formatSpacing(median)} while interval is '${interval}' — check the interval argument.`,
2497
+ });
2498
+ return; // gap counting is meaningless against the wrong bar size
2499
+ }
2500
+ let gaps = 0;
2501
+ for (const dt of spacings) {
2502
+ if (dt > barMs * 1.5)
2503
+ gaps += Math.round(dt / barMs) - 1;
2504
+ }
2505
+ const gapPct = (gaps / (candles.length + gaps)) * 100;
2506
+ if (gapPct > 1) {
2507
+ warnings.push({
2508
+ code: 'DATA_GAPS',
2509
+ critical: false,
2510
+ message: `~${gaps} missing bars (${gapPct.toFixed(1)}%) detected from timestamps — the seasonal profile and lag structure may be distorted. Check your feed for outages.`,
2511
+ });
2512
+ }
2513
+ }
2514
+ /**
2515
+ * Pre-flight data check with plain-language, actionable messages: run it on
2516
+ * a new data source before wiring it into predict. Errors are conditions
2517
+ * predict would throw on; warnings degrade quality but do not block.
2518
+ */
2519
+ function checkData(candles, interval) {
2520
+ validateInterval(interval);
2521
+ const issues = [];
2522
+ const recommended = RECOMMENDED_CANDLES[interval];
2523
+ if (candles.length < MIN_CANDLES[interval]) {
2524
+ issues.push({
2525
+ code: 'TOO_FEW_CANDLES',
2526
+ severity: 'error',
2527
+ message: `Need at least ${MIN_CANDLES[interval]} candles for ${interval}, got ${candles.length} — fetch more history.`,
2528
+ });
2529
+ }
2530
+ try {
2531
+ validateCandles(candles);
2532
+ }
2533
+ catch (e) {
2534
+ issues.push({
2535
+ code: 'INVALID_OHLC',
2536
+ severity: 'error',
2537
+ message: `${e.message}. Broken OHLC usually means a feed/parsing bug — check the failing candle in your pipeline.`,
2538
+ });
2539
+ }
2540
+ try {
2541
+ assertTimestampOrder(candles);
2542
+ }
2543
+ catch (e) {
2544
+ const msg = e.message;
2545
+ issues.push({
2546
+ code: msg.includes('Duplicate') ? 'DUPLICATE_TIMESTAMPS' : 'UNSORTED',
2547
+ severity: 'error',
2548
+ message: msg,
2549
+ });
2550
+ }
2551
+ const soft = [];
2552
+ collectDataWarnings(candles, interval, soft);
2553
+ for (const w of soft) {
2554
+ issues.push({ code: w.code, severity: 'warning', message: w.message });
2555
+ }
2556
+ let flat = 0;
2557
+ for (const c of candles) {
2558
+ if (c.high === c.low)
2559
+ flat++;
2560
+ }
2561
+ const flatPct = (flat / Math.max(candles.length, 1)) * 100;
2562
+ if (flatPct > 20) {
2563
+ issues.push({
2564
+ code: 'FLAT_CANDLES',
2565
+ severity: 'warning',
2566
+ message: `${flatPct.toFixed(0)}% of candles have high === low — range-based estimators degrade to squared returns. Synthetic or illiquid feed?`,
2567
+ });
2568
+ }
2569
+ return {
2570
+ ok: !issues.some(i => i.severity === 'error'),
2571
+ issues,
2572
+ recommendedCandles: recommended,
1665
2573
  };
1666
- const egarchModel = new Egarch(candles, { periodsPerYear });
1667
- const egarchFit = egarchModel.fit();
1668
- if (egarchFit.diagnostics.aic < bestAic) {
1669
- bestAic = egarchFit.diagnostics.aic;
1670
- best = {
1671
- forecast: egarchModel.forecast(egarchFit.params, steps),
1672
- modelType: 'egarch',
1673
- converged: egarchFit.diagnostics.converged,
1674
- persistence: egarchFit.params.persistence,
1675
- varianceSeries: egarchModel.getVarianceSeries(egarchFit.params),
1676
- returns: egarchModel.getReturns(),
1677
- };
2574
+ }
2575
+ // ── Intraday seasonality ──────────────────────────────────────
2576
+ const DAY_MS = 86_400_000;
2577
+ const YEAR_MS = 31_536_000_000;
2578
+ /**
2579
+ * Diurnal variance profile from per-candle Parkinson RV.
2580
+ *
2581
+ * Intraday markets have a strong time-of-day volatility pattern (sessions,
2582
+ * funding, weekends). A GARCH-family fit smears it into average persistence,
2583
+ * so corridors are systematically too narrow in active hours and too wide in
2584
+ * quiet ones. Factors are estimated per intraday bucket (≤24 per day, bars
2585
+ * grouped for sub-hour intervals), circularly smoothed, shrunk toward 1 by
2586
+ * bucket support, and gated by a χ² significance test against the RV
2587
+ * sampling noise (inflated for volatility clustering) — pure-GARCH data
2588
+ * without seasonality returns null and the pipeline is unchanged.
2589
+ *
2590
+ * Timestamps (ms or s), when present on every candle, anchor buckets to
2591
+ * real time of day and survive gaps; otherwise buckets are positional and
2592
+ * assume contiguous bars.
2593
+ */
2594
+ function computeSeasonality(candles, interval) {
2595
+ const periodsPerYear = INTERVALS_PER_YEAR[interval];
2596
+ const barsPerDay = Math.round(periodsPerYear / 365);
2597
+ if (barsPerDay < 3)
2598
+ return null;
2599
+ const buckets = Math.min(24, barsPerDay);
2600
+ const returns = calculateReturns(candles);
2601
+ const rv = perCandleParkinson(candles, returns);
2602
+ const n = returns.length;
2603
+ const nCandles = candles.length;
2604
+ const hasTs = candles.every(c => Number.isFinite(c.timestamp));
2605
+ const ts = hasTs
2606
+ ? candles.map(c => (c.timestamp < 1e12 ? c.timestamp * 1000 : c.timestamp))
2607
+ : null;
2608
+ const barMs = YEAR_MS / periodsPerYear;
2609
+ const bucketOfReturn = (t) => {
2610
+ const i = t + 1;
2611
+ if (ts) {
2612
+ const time = i < nCandles ? ts[i] : ts[nCandles - 1] + (i - (nCandles - 1)) * barMs;
2613
+ return Math.min(buckets - 1, Math.floor(((time % DAY_MS) / DAY_MS) * buckets));
2614
+ }
2615
+ return Math.min(buckets - 1, Math.floor(((i % barsPerDay) / barsPerDay) * buckets));
2616
+ };
2617
+ // Work in log-RV: per-observation RV is heavy-tailed, so level means are
2618
+ // dominated by single prints while log means have modest, comparable
2619
+ // variance across buckets. Bucket ratios of geometric means equal
2620
+ // variance ratios up to a constant (identical noise shape per bucket),
2621
+ // which the normalization below removes anyway.
2622
+ const logSums = new Array(buckets).fill(0);
2623
+ const counts = new Array(buckets).fill(0);
2624
+ let totalLog = 0;
2625
+ let totalCount = 0;
2626
+ for (let t = 0; t < n; t++) {
2627
+ if (!(rv[t] > 0))
2628
+ continue;
2629
+ const b = bucketOfReturn(t);
2630
+ const lv = Math.log(rv[t]);
2631
+ logSums[b] += lv;
2632
+ counts[b]++;
2633
+ totalLog += lv;
2634
+ totalCount++;
2635
+ }
2636
+ // Every bucket needs support — a sample that does not cover the day
2637
+ // (e.g. 500 one-minute candles) cannot identify a diurnal profile
2638
+ if (totalCount < buckets * 5 || counts.some(c => c < 5))
2639
+ return null;
2640
+ const overallLog = totalLog / totalCount;
2641
+ let varLog = 0;
2642
+ for (let t = 0; t < n; t++) {
2643
+ if (!(rv[t] > 0))
2644
+ continue;
2645
+ varLog += (Math.log(rv[t]) - overallLog) ** 2;
1678
2646
  }
1679
- const gjrModel = new GjrGarch(candles, { periodsPerYear });
1680
- const gjrFit = gjrModel.fit();
1681
- if (gjrFit.diagnostics.aic < bestAic) {
1682
- best = {
1683
- forecast: gjrModel.forecast(gjrFit.params, steps),
1684
- modelType: 'gjr-garch',
1685
- converged: gjrFit.diagnostics.converged,
1686
- persistence: gjrFit.params.persistence,
1687
- varianceSeries: gjrModel.getVarianceSeries(gjrFit.params),
1688
- returns: gjrModel.getReturns(),
1689
- };
2647
+ varLog /= totalCount;
2648
+ if (!(varLog > 0))
2649
+ return null;
2650
+ // Significance gate: χ² of bucket log-means against their sampling noise.
2651
+ // The per-observation variance is inflated ×2.25 (≈1. for volatility
2652
+ // clustering shrinking the effective sample), so the diurnal profile of
2653
+ // pure noise does not trigger deseasonalization.
2654
+ const rawLog = logSums.map((s, b) => s / counts[b] - overallLog);
2655
+ let chi2 = 0;
2656
+ for (let b = 0; b < buckets; b++) {
2657
+ chi2 += (counts[b] * rawLog[b] * rawLog[b]) / (varLog * 2.25);
2658
+ }
2659
+ const pValue = chi2Survival(chi2, buckets - 1);
2660
+ const smoothedLog = rawLog.map((_, b) => 0.25 * rawLog[(b + buckets - 1) % buckets] + 0.5 * rawLog[b] + 0.25 * rawLog[(b + 1) % buckets]);
2661
+ // Shrink toward 0 by bucket support so thin buckets cannot inject noise.
2662
+ // The prior is light (5 pseudo-obs): the χ² gate is the real protection
2663
+ // against fitting noise, and a heavy prior halves genuine profiles on
2664
+ // realistic windows (~17 obs/bucket), leaving residual seasonality.
2665
+ let factors = smoothedLog.map((v, b) => Math.exp(v * (counts[b] / (counts[b] + 5))));
2666
+ // Sample-weighted normalization keeps the overall variance level unchanged
2667
+ let m = 0;
2668
+ for (let t = 0; t < n; t++)
2669
+ m += factors[bucketOfReturn(t)];
2670
+ m /= n;
2671
+ factors = factors.map(v => v / m);
2672
+ const ratio = Math.max(...factors) / Math.min(...factors);
2673
+ if (ratio < 1.25 || pValue > 0.01)
2674
+ return null;
2675
+ return { factors, bucketOfReturn };
2676
+ }
2677
+ /**
2678
+ * Rescale each candle's log-moves by 1/√f(bucket) so the deseasonalized
2679
+ * series has a flat diurnal profile. Gaps (open vs prev close) are scaled
2680
+ * with the same factor; OHLC ordering is preserved (monotone log map).
2681
+ */
2682
+ function deseasonalizeCandles(candles, season) {
2683
+ const out = [candles[0]];
2684
+ let close = candles[0].close;
2685
+ for (let t = 0; t < candles.length - 1; t++) {
2686
+ const c = candles[t + 1];
2687
+ const prevOriginalClose = candles[t].close;
2688
+ const g = 1 / Math.sqrt(season.factors[season.bucketOfReturn(t)]);
2689
+ const open = close * Math.pow(c.open / prevOriginalClose, g);
2690
+ const newClose = open * Math.pow(c.close / c.open, g);
2691
+ const high = open * Math.pow(c.high / c.open, g);
2692
+ const low = open * Math.pow(c.low / c.open, g);
2693
+ out.push({ ...c, open, high, low, close: newClose });
2694
+ close = newClose;
2695
+ }
2696
+ return out;
2697
+ }
2698
+ /**
2699
+ * Candidate HAR lag triples for this interval and sample size.
2700
+ *
2701
+ * The textbook (1, 5, 22) encodes *daily equity* horizons (day/week/month
2702
+ * in trading days) and is wrong for intraday 24/7 markets. Candidates are
2703
+ * built from the candle interval itself — one bar / one day / one week in
2704
+ * bars — capped by what the sample can support (long lag ≤ n/5 and enough
2705
+ * rows for the OLS). The final choice among candidates is made by QLIKE
2706
+ * on the out-of-sample region, not by convention.
2707
+ */
2708
+ function selectHarLagCandidates(nReturns, periodsPerYear) {
2709
+ const maxLong = Math.floor(Math.min(nReturns / 5, nReturns - 31));
2710
+ const candidates = [];
2711
+ if (maxLong >= 22)
2712
+ candidates.push([1, 5, 22]);
2713
+ const barsPerDay = Math.round(periodsPerYear / 365);
2714
+ if (barsPerDay >= 3) {
2715
+ const barsPerWeek = 7 * barsPerDay;
2716
+ if (barsPerWeek <= maxLong) {
2717
+ candidates.push([1, barsPerDay, barsPerWeek]);
2718
+ }
2719
+ else if (barsPerDay * 2 <= maxLong) {
2720
+ // Week does not fit the sample — use the longest supported horizon
2721
+ candidates.push([1, barsPerDay, maxLong]);
2722
+ }
1690
2723
  }
1691
- return best;
2724
+ if (candidates.length === 0) {
2725
+ // Tiny sample: geometric spacing within what the data supports
2726
+ const long = Math.max(3, maxLong);
2727
+ candidates.push([1, Math.max(2, Math.round(Math.sqrt(long))), long]);
2728
+ }
2729
+ // Dedupe
2730
+ return candidates.filter((c, i) => candidates.findIndex(d => d[0] === c[0] && d[1] === c[1] && d[2] === c[2]) === i);
2731
+ }
2732
+ /**
2733
+ * NoVaS lag order grown with the sample (~n^(1/3), the standard
2734
+ * lag-order rate), instead of a fixed p = 10. Anchored to p = 10 at
2735
+ * n ≈ 500 (where a far-lag ARCH(10) ground truth is recovered best);
2736
+ * adapts down for short samples, up for long ones.
2737
+ */
2738
+ function adaptiveNovasLags(nReturns) {
2739
+ return Math.min(20, Math.max(5, Math.round(1.26 * Math.cbrt(nReturns))));
1692
2740
  }
1693
- function fitHarRv(candles, periodsPerYear, steps) {
2741
+ /**
2742
+ * Empirical variance-scale correction.
2743
+ *
2744
+ * RV-based models (HAR-RV, NoVaS) forecast Parkinson realized variance,
2745
+ * which is NOT the same quantity as the close-to-close return variance the
2746
+ * price corridor needs — range-based RV is systematically smaller whenever
2747
+ * moves happen between closes (gaps, thin wicks). The corridor must satisfy
2748
+ * Var(r_t / σ_t) = 1, so rescale by c = E[r²/σ²] measured on the sample.
2749
+ *
2750
+ * For MLE-calibrated GARCH-family fits c ≈ 1 (the likelihood already
2751
+ * self-calibrates the level), so this is a no-op there.
2752
+ *
2753
+ * z² is capped at 50 to keep a single extreme print from distorting the
2754
+ * scale (bias of the cap is <2% even at df = 5).
2755
+ *
2756
+ * `warmup` is the combination's own structural warm-up (longest lag /
2757
+ * seeding region), not a fixed constant.
2758
+ */
2759
+ function varianceScaleCorrection(returns, varianceSeries, warmup) {
2760
+ let sum = 0;
2761
+ let count = 0;
2762
+ for (let i = warmup; i < returns.length; i++) {
2763
+ const v = varianceSeries[i];
2764
+ if (!(v > 0) || !isFinite(v))
2765
+ continue;
2766
+ const z2 = (returns[i] * returns[i]) / v;
2767
+ if (!isFinite(z2))
2768
+ continue;
2769
+ sum += Math.min(z2, 50);
2770
+ count++;
2771
+ }
2772
+ if (count < 30)
2773
+ return 1;
2774
+ const c = sum / count;
2775
+ if (!isFinite(c) || c <= 0)
2776
+ return 1;
2777
+ // Sanity clamp: beyond this the fit is garbage and `reliable` flags it
2778
+ return Math.min(100, Math.max(0.01, c));
2779
+ }
2780
+ function applyScale(fit, c, periodsPerYear) {
2781
+ if (c === 1)
2782
+ return;
2783
+ fit.varianceSeries = fit.varianceSeries.map(v => v * c);
2784
+ const variance = fit.forecast.variance.map(v => v * c);
2785
+ fit.forecast = {
2786
+ variance,
2787
+ volatility: variance.map(v => Math.sqrt(v)),
2788
+ annualized: variance.map(v => Math.sqrt(v * periodsPerYear) * 100),
2789
+ };
2790
+ // simMembers stay on the model scale on purpose: the simulated corridor
2791
+ // multiplier is a standardized ratio, so a uniform scale c cancels.
2792
+ }
2793
+ const OOS_TRAIN_RATIO = 0.75;
2794
+ /**
2795
+ * Fit all candidate models, score them by out-of-sample QLIKE, and combine.
2796
+ *
2797
+ * Candidates are calibrated on the first 75% of the sample and their
2798
+ * one-step variance forecasts scored by QLIKE on the held-out 25% —
2799
+ * in-sample QLIKE favors the OLS-calibrated models (HAR, NoVaS stage 2)
2800
+ * exactly as much as they overfit. Final parameters are refitted on the
2801
+ * full sample.
2802
+ *
2803
+ * Combination instead of selection: n·QLIKE behaves like a deviance, so
2804
+ * weights w ∝ exp(−0.5·n_eval·ΔQLIKE) collapse to the winner when the gap
2805
+ * is decisive and average when candidates are within noise of each other
2806
+ * (forecast combination robustly beats picking one model on short samples).
2807
+ */
2808
+ /** Forgetting factor of the adaptive GARCH candidate (half-life ≈ 69 bars). */
2809
+ const FORGET_LAMBDA = 0.99;
2810
+ function fitModel(candles, periodsPerYear, steps, warm) {
2811
+ const returns = calculateReturns(candles);
2812
+ const rv = perCandleParkinson(candles, returns);
2813
+ const nReturns = returns.length;
2814
+ const nTrain = Math.floor(candles.length * OOS_TRAIN_RATIO);
2815
+ const trainCandles = candles.slice(0, nTrain);
2816
+ const evalStart = nTrain - 1; // first out-of-sample index in return space
2817
+ const nEval = nReturns - evalStart;
2818
+ const members = [];
2819
+ // ── GARCH (λ = 1) and adaptive GARCH (exponential forgetting) ──
2820
+ // Two candidates from the same recursion: the forgetting variant tracks
2821
+ // regime shifts, the plain one wins on stationary stretches — the OOS
2822
+ // score decides which regime the data is in.
2823
+ for (const forgetting of [1, FORGET_LAMBDA]) {
2824
+ try {
2825
+ const warmStart = forgetting === 1 ? warm?.garch : warm?.garchForget;
2826
+ const trainFit = new Garch(trainCandles, { periodsPerYear }).fit({ forgetting, warmStart });
2827
+ const model = new Garch(candles, { periodsPerYear });
2828
+ const oos = qlike(model.getVarianceSeries(trainFit.params).slice(evalStart), rv.slice(evalStart));
2829
+ const fit = model.fit({ forgetting, warmStart });
2830
+ const fc = model.forecast(fit.params, steps);
2831
+ if (warm) {
2832
+ if (forgetting === 1)
2833
+ warm.garch = fit.params;
2834
+ else
2835
+ warm.garchForget = fit.params;
2836
+ }
2837
+ members.push({
2838
+ modelType: 'garch',
2839
+ varianceSeries: model.getVarianceSeries(fit.params),
2840
+ forecast: fc,
2841
+ persistence: fit.params.persistence,
2842
+ converged: fit.diagnostics.converged,
2843
+ warmup: 0,
2844
+ oosQlike: oos,
2845
+ weight: 0,
2846
+ sim: { kind: 'garch', weight: 0, omega: fit.params.omega, alpha: fit.params.alpha, gamma: 0, beta: fit.params.beta, v1: fc.variance[0] },
2847
+ });
2848
+ }
2849
+ catch { /* degenerate data — other members cover */ }
2850
+ }
2851
+ // ── EGARCH ──
1694
2852
  try {
1695
- const model = new HarRv(candles, { periodsPerYear });
1696
- const fit = model.fit();
1697
- // Skip HAR-RV if persistence >= 1 (non-stationary) or R² too low
1698
- if (fit.params.persistence >= 1 || fit.params.r2 < 0)
1699
- return null;
1700
- return {
1701
- forecast: model.forecast(fit.params, steps),
1702
- modelType: 'har-rv',
2853
+ const trainFit = new Egarch(trainCandles, { periodsPerYear }).fit({ warmStart: warm?.egarch });
2854
+ const model = new Egarch(candles, { periodsPerYear });
2855
+ const oos = qlike(model.getVarianceSeries(trainFit.params).slice(evalStart), rv.slice(evalStart));
2856
+ const fit = model.fit({ warmStart: warm?.egarch });
2857
+ const fc = model.forecast(fit.params, steps);
2858
+ if (warm)
2859
+ warm.egarch = fit.params;
2860
+ members.push({
2861
+ modelType: 'egarch',
2862
+ varianceSeries: model.getVarianceSeries(fit.params),
2863
+ forecast: fc,
2864
+ persistence: fit.params.persistence,
1703
2865
  converged: fit.diagnostics.converged,
2866
+ warmup: 0,
2867
+ oosQlike: oos,
2868
+ weight: 0,
2869
+ sim: {
2870
+ kind: 'egarch',
2871
+ weight: 0,
2872
+ omega: fit.params.omega,
2873
+ alpha: fit.params.alpha,
2874
+ gamma: fit.params.gamma,
2875
+ beta: fit.params.beta,
2876
+ logv1: Math.log(Math.max(fc.variance[0], 1e-300)),
2877
+ eAbsZ: expectedAbsStudentT(fit.params.df),
2878
+ mbar: model.magnitudeDrift(fit.params),
2879
+ },
2880
+ });
2881
+ }
2882
+ catch { /* degenerate data */ }
2883
+ // ── GJR-GARCH ──
2884
+ try {
2885
+ const trainFit = new GjrGarch(trainCandles, { periodsPerYear }).fit({ warmStart: warm?.gjr });
2886
+ const model = new GjrGarch(candles, { periodsPerYear });
2887
+ const oos = qlike(model.getVarianceSeries(trainFit.params).slice(evalStart), rv.slice(evalStart));
2888
+ const fit = model.fit({ warmStart: warm?.gjr });
2889
+ const fc = model.forecast(fit.params, steps);
2890
+ if (warm)
2891
+ warm.gjr = fit.params;
2892
+ members.push({
2893
+ modelType: 'gjr-garch',
2894
+ varianceSeries: model.getVarianceSeries(fit.params),
2895
+ forecast: fc,
1704
2896
  persistence: fit.params.persistence,
2897
+ converged: fit.diagnostics.converged,
2898
+ warmup: 0,
2899
+ oosQlike: oos,
2900
+ weight: 0,
2901
+ sim: { kind: 'garch', weight: 0, omega: fit.params.omega, alpha: fit.params.alpha, gamma: fit.params.gamma, beta: fit.params.beta, v1: fc.variance[0] },
2902
+ });
2903
+ }
2904
+ catch { /* degenerate data */ }
2905
+ // ── Realized GARCH ──
2906
+ try {
2907
+ const trainFit = new RealizedGarch(trainCandles, { periodsPerYear }).fit({ warmStart: warm?.rgarch });
2908
+ const model = new RealizedGarch(candles, { periodsPerYear });
2909
+ const oos = qlike(model.getVarianceSeries(trainFit.params).slice(evalStart), rv.slice(evalStart));
2910
+ const fit = model.fit({ warmStart: warm?.rgarch });
2911
+ const fc = model.forecast(fit.params, steps);
2912
+ if (warm)
2913
+ warm.rgarch = fit.params;
2914
+ members.push({
2915
+ modelType: 'realized-garch',
1705
2916
  varianceSeries: model.getVarianceSeries(fit.params),
1706
- returns: model.getReturns(),
1707
- };
2917
+ forecast: fc,
2918
+ persistence: fit.params.persistence,
2919
+ converged: fit.diagnostics.converged,
2920
+ warmup: 0,
2921
+ oosQlike: oos,
2922
+ weight: 0,
2923
+ sim: {
2924
+ kind: 'rgarch',
2925
+ weight: 0,
2926
+ omega: fit.params.omega,
2927
+ beta: fit.params.beta,
2928
+ gamma: fit.params.gamma,
2929
+ xi: fit.params.xi,
2930
+ tau1: fit.params.tau1,
2931
+ tau2: fit.params.tau2,
2932
+ sigmaU: fit.params.sigmaU,
2933
+ logv1: Math.log(Math.max(fc.variance[0], 1e-300)),
2934
+ },
2935
+ });
1708
2936
  }
1709
- catch {
1710
- return null;
2937
+ catch { /* degenerate data */ }
2938
+ // ── HAR-RV (lag triple AND level/log spec chosen by the same OOS score) ──
2939
+ try {
2940
+ let bestLags = null;
2941
+ let bestLog = false;
2942
+ let bestScore = Infinity;
2943
+ // Warm state pins the previously selected configuration — a spec search
2944
+ // per rolling refit would mostly rediscover the same one
2945
+ const lagCandidates = warm?.harLags
2946
+ ? [warm.harLags]
2947
+ : selectHarLagCandidates(nTrain - 1, periodsPerYear);
2948
+ const specCandidates = warm?.harLags ? [warm.harLog ?? false] : [false, true];
2949
+ for (const [shortLag, mediumLag, longLag] of lagCandidates) {
2950
+ for (const logSpec of specCandidates) {
2951
+ try {
2952
+ const trainFit = new HarRv(trainCandles, { periodsPerYear, shortLag, mediumLag, longLag, logSpec }).fit();
2953
+ if (trainFit.params.persistence >= 1 || trainFit.params.r2 < 0)
2954
+ continue;
2955
+ const full = new HarRv(candles, { periodsPerYear, shortLag, mediumLag, longLag, logSpec });
2956
+ const score = qlike(full.getVarianceSeries(trainFit.params).slice(evalStart), rv.slice(evalStart));
2957
+ if (score < bestScore) {
2958
+ bestScore = score;
2959
+ bestLags = [shortLag, mediumLag, longLag];
2960
+ bestLog = logSpec;
2961
+ }
2962
+ }
2963
+ catch {
2964
+ continue;
2965
+ }
2966
+ }
2967
+ }
2968
+ if (bestLags) {
2969
+ const [shortLag, mediumLag, longLag] = bestLags;
2970
+ const model = new HarRv(candles, { periodsPerYear, shortLag, mediumLag, longLag, logSpec: bestLog });
2971
+ const fit = model.fit();
2972
+ if (fit.params.persistence < 1 && fit.params.r2 >= 0) {
2973
+ const fc = model.forecast(fit.params, steps);
2974
+ if (warm) {
2975
+ warm.harLags = bestLags;
2976
+ warm.harLog = bestLog;
2977
+ }
2978
+ members.push({
2979
+ modelType: 'har-rv',
2980
+ varianceSeries: model.getVarianceSeries(fit.params),
2981
+ forecast: fc,
2982
+ persistence: fit.params.persistence,
2983
+ converged: fit.diagnostics.converged,
2984
+ warmup: longLag,
2985
+ oosQlike: bestScore,
2986
+ weight: 0,
2987
+ sim: { kind: 'flat', weight: 0, path: fc.variance },
2988
+ });
2989
+ }
2990
+ }
1711
2991
  }
1712
- }
1713
- function fitNoVaS(candles, periodsPerYear, steps) {
2992
+ catch { /* degenerate data */ }
2993
+ // ── NoVaS ──
1714
2994
  try {
1715
- const model = new NoVaS(candles, { periodsPerYear });
1716
- const fit = model.fit();
1717
- // Skip if persistence >= 1 (non-stationary)
1718
- if (fit.params.persistence >= 1)
1719
- return null;
1720
- return {
1721
- forecast: model.forecast(fit.params, steps),
1722
- modelType: 'novas',
1723
- converged: fit.diagnostics.converged,
1724
- persistence: fit.params.persistence,
1725
- varianceSeries: model.getForecastVarianceSeries(fit.params),
1726
- returns: model.getReturns(),
1727
- };
2995
+ const lags = adaptiveNovasLags(nReturns);
2996
+ const warmWeights = warm?.novasWeights && warm.novasWeights.length === lags + 1
2997
+ ? warm.novasWeights
2998
+ : undefined;
2999
+ const trainFit = new NoVaS(trainCandles, { periodsPerYear, lags }).fit({ warmWeights });
3000
+ if (trainFit.params.persistence < 1) {
3001
+ const model = new NoVaS(candles, { periodsPerYear, lags });
3002
+ const oos = qlike(model.getForecastVarianceSeries(trainFit.params).slice(evalStart), rv.slice(evalStart));
3003
+ const fit = model.fit({ warmWeights });
3004
+ if (fit.params.persistence < 1) {
3005
+ const fc = model.forecast(fit.params, steps);
3006
+ if (warm)
3007
+ warm.novasWeights = fit.params.weights;
3008
+ members.push({
3009
+ modelType: 'novas',
3010
+ varianceSeries: model.getForecastVarianceSeries(fit.params),
3011
+ forecast: fc,
3012
+ persistence: fit.params.persistence,
3013
+ converged: fit.diagnostics.converged,
3014
+ warmup: lags,
3015
+ oosQlike: oos,
3016
+ weight: 0,
3017
+ sim: { kind: 'flat', weight: 0, path: fc.variance },
3018
+ });
3019
+ }
3020
+ }
1728
3021
  }
1729
- catch {
1730
- return null;
3022
+ catch { /* degenerate data */ }
3023
+ if (members.length === 0) {
3024
+ throw new Error('All volatility models failed to fit');
3025
+ }
3026
+ // ── Weights from OOS QLIKE ──
3027
+ const finiteScores = members.map(m => m.oosQlike).filter(q => isFinite(q));
3028
+ const qmin = finiteScores.length > 0 ? Math.min(...finiteScores) : NaN;
3029
+ let wsum = 0;
3030
+ for (const m of members) {
3031
+ const d = isFinite(m.oosQlike) && isFinite(qmin) ? m.oosQlike - qmin : isFinite(qmin) ? Infinity : 0;
3032
+ m.weight = Math.exp(-0.5 * nEval * d);
3033
+ wsum += m.weight;
3034
+ }
3035
+ for (const m of members)
3036
+ m.weight /= wsum;
3037
+ const kept = members.filter(m => m.weight >= 0.02);
3038
+ const keptSum = kept.reduce((s, m) => s + m.weight, 0);
3039
+ for (const m of kept)
3040
+ m.weight /= keptSum;
3041
+ const top = kept.reduce((a, b) => (b.weight > a.weight ? b : a));
3042
+ // ── Combine ──
3043
+ const combinedSeries = new Array(nReturns).fill(0);
3044
+ for (const m of kept) {
3045
+ for (let i = 0; i < nReturns; i++)
3046
+ combinedSeries[i] += m.weight * m.varianceSeries[i];
3047
+ }
3048
+ const combinedVariance = new Array(steps).fill(0);
3049
+ for (const m of kept) {
3050
+ for (let h = 0; h < steps; h++)
3051
+ combinedVariance[h] += m.weight * m.forecast.variance[h];
3052
+ }
3053
+ const best = {
3054
+ forecast: {
3055
+ variance: combinedVariance,
3056
+ volatility: combinedVariance.map(v => Math.sqrt(v)),
3057
+ annualized: combinedVariance.map(v => Math.sqrt(v * periodsPerYear) * 100),
3058
+ },
3059
+ modelType: top.modelType,
3060
+ converged: top.converged,
3061
+ persistence: top.persistence,
3062
+ varianceSeries: combinedSeries,
3063
+ returns,
3064
+ df: 5,
3065
+ warmup: Math.max(...kept.map(m => m.warmup)),
3066
+ zSorted: [],
3067
+ simMembers: kept.map(m => ({ ...m.sim, weight: m.weight })),
3068
+ weights: kept.reduce((acc, m) => {
3069
+ acc[m.modelType] = (acc[m.modelType] ?? 0) + m.weight;
3070
+ return acc;
3071
+ }, {}),
3072
+ };
3073
+ // Calibrate the combination to the return scale: QLIKE picks the best RV
3074
+ // forecasters, but the corridor needs the return-variance scale.
3075
+ // Warm-up comes from the combination's own structure (its longest lag /
3076
+ // seeding region), capped so calibration keeps a usable sample.
3077
+ const warmup = Math.min(Math.max(best.warmup, 10), Math.floor(nReturns / 4));
3078
+ best.warmup = warmup;
3079
+ const c = varianceScaleCorrection(best.returns, best.varianceSeries, warmup);
3080
+ applyScale(best, c, periodsPerYear);
3081
+ // Re-profile tail thickness on the corrected residuals — this df drives
3082
+ // the model half of the corridor quantile.
3083
+ best.df = profileStudentTDf(best.returns, best.varianceSeries);
3084
+ // Empirical calibration sample: signed z_t of the corrected residuals —
3085
+ // the two tails are calibrated separately (return distributions are
3086
+ // skewed; folding them into |z| hides that)
3087
+ const zs = [];
3088
+ for (let i = warmup; i < nReturns; i++) {
3089
+ const v = best.varianceSeries[i];
3090
+ if (!(v > 0) || !isFinite(v))
3091
+ continue;
3092
+ const z = best.returns[i] / Math.sqrt(v);
3093
+ if (isFinite(z))
3094
+ zs.push(z);
1731
3095
  }
3096
+ zs.sort((a, b) => a - b);
3097
+ best.zSorted = zs;
3098
+ return best;
1732
3099
  }
1733
- function fitModel(candles, periodsPerYear, steps) {
1734
- const garchResult = fitGarchFamily(candles, periodsPerYear, steps);
1735
- const harResult = fitHarRv(candles, periodsPerYear, steps);
1736
- const novasResult = fitNoVaS(candles, periodsPerYear, steps);
1737
- // Compute realized variance (Parkinson RV) for QLIKE scoring
1738
- const returns = calculateReturns(candles);
1739
- const rv = perCandleParkinson(candles, returns);
1740
- // Pick model with lowest QLIKE (Patton 2011) neutral forecast-error metric.
1741
- // Unlike AIC (which favors MLE-calibrated models), QLIKE judges only
1742
- // how well the variance series predicts realized variance.
1743
- let best = garchResult;
1744
- let bestScore = qlike(garchResult.varianceSeries, rv);
1745
- if (harResult) {
1746
- const score = qlike(harResult.varianceSeries, rv);
1747
- if (score < bestScore) {
1748
- best = harResult;
1749
- bestScore = score;
3100
+ // ── Model-implied horizon quantile by simulation ──────────────
3101
+ function mulberry32(seed) {
3102
+ let a = seed >>> 0;
3103
+ return () => {
3104
+ a |= 0;
3105
+ a = (a + 0x6d2b79f5) | 0;
3106
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
3107
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
3108
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
3109
+ };
3110
+ }
3111
+ function simRandn(rng) {
3112
+ const u1 = Math.max(rng(), 1e-12);
3113
+ const u2 = rng();
3114
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
3115
+ }
3116
+ // Horizon-simulation sizing: two tails × SIM_TAIL_DRAWS target samples,
3117
+ // bounded from both sides, with a hard cap on total work (B·steps).
3118
+ const SIM_TAIL_DRAWS = 200;
3119
+ const SIM_MIN_DRAWS = 500;
3120
+ const SIM_MAX_DRAWS = 40_000;
3121
+ const SIM_WORK_BUDGET = 20_000_000;
3122
+ /**
3123
+ * Marsaglia–Tsang gamma sampler (shape a ≥ 1).
3124
+ *
3125
+ * Rejection sampling accepts >96% of proposals for a ≥ 1, so the caps are
3126
+ * never reached on valid inputs — they are a hard termination guarantee:
3127
+ * a NaN slipping into the parameters would otherwise make every accept
3128
+ * condition false and spin both loops forever.
3129
+ */
3130
+ function gammaSample(a, rng) {
3131
+ const d = a - 1 / 3;
3132
+ const c = 1 / Math.sqrt(9 * d);
3133
+ for (let iter = 0; iter < 100; iter++) {
3134
+ let x = simRandn(rng);
3135
+ let v = 1 + c * x;
3136
+ for (let inner = 0; inner < 100 && v <= 0; inner++) {
3137
+ x = simRandn(rng);
3138
+ v = 1 + c * x;
1750
3139
  }
3140
+ if (!(v > 0))
3141
+ break;
3142
+ v = v * v * v;
3143
+ const u = rng();
3144
+ if (u < 1 - 0.0331 * x * x * x * x)
3145
+ return d * v;
3146
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v)))
3147
+ return d * v;
3148
+ }
3149
+ // Degrades a single draw to the distribution mean — never hangs
3150
+ return a;
3151
+ }
3152
+ /** Sampler for standardized (unit-variance) Student-t(df); Gaussian for df > 100. */
3153
+ function makeTSampler(df, rng) {
3154
+ if (!isFinite(df) || df > 100)
3155
+ return () => simRandn(rng);
3156
+ const scale = Math.sqrt((df - 2) / df);
3157
+ return () => {
3158
+ const chi2 = 2 * gammaSample(df / 2, rng);
3159
+ return (simRandn(rng) / Math.sqrt(chi2 / df)) * scale;
3160
+ };
3161
+ }
3162
+ /**
3163
+ * Model-implied h-step standardized-sum tail quantiles by simulation
3164
+ * through the fitted recursions (mixture across combination members).
3165
+ *
3166
+ * Replaces the old zGauss + (zT − zGauss)/h interpolation: simulation
3167
+ * captures volatility feedback within the horizon (a shock raises later
3168
+ * σ's), the seasonal σ-path weighting, and the CLT decay of fat tails —
3169
+ * all of which the linear interpolation only approximated. Sums are kept
3170
+ * signed, so leverage (γ in GJR/EGARCH, τ₁ in Realized GARCH) produces
3171
+ * genuinely asymmetric tails. Innovations are standardized t(df) draws
3172
+ * with the profiled df; the seed is fixed so results are deterministic.
3173
+ */
3174
+ function simulateHorizonTails(fit, steps, confidence, factorPath) {
3175
+ const members = fit.simMembers;
3176
+ if (!members || members.length === 0)
3177
+ return null;
3178
+ const f = factorPath && factorPath.length >= steps ? factorPath : new Array(steps).fill(1);
3179
+ const rng = mulberry32(0x5eed1e55);
3180
+ const drawZ = makeTSampler(fit.df, rng);
3181
+ // Draw count: enough that each requested tail holds ≥ SIM_TAIL_DRAWS
3182
+ // samples, clamped so B·steps never exceeds SIM_WORK_BUDGET path-steps —
3183
+ // runtime stays bounded no matter what horizon or confidence is asked.
3184
+ const B = Math.max(SIM_MIN_DRAWS, Math.min(SIM_MAX_DRAWS, Math.ceil((2 * SIM_TAIL_DRAWS) / Math.max(1 - confidence, 1e-4)), Math.floor(SIM_WORK_BUDGET / steps)));
3185
+ const cum = [];
3186
+ let acc = 0;
3187
+ for (const m of members) {
3188
+ acc += m.weight;
3189
+ cum.push(acc);
3190
+ }
3191
+ const draws = new Array(B);
3192
+ for (let b = 0; b < B; b++) {
3193
+ const u = rng() * acc;
3194
+ let mi = 0;
3195
+ while (mi < cum.length - 1 && u > cum[mi])
3196
+ mi++;
3197
+ const m = members[mi];
3198
+ let num = 0;
3199
+ let den = 0;
3200
+ if (m.kind === 'flat') {
3201
+ for (let j = 0; j < steps; j++) {
3202
+ const v = m.path[Math.min(j, m.path.length - 1)] * f[j];
3203
+ if (!(v > 0))
3204
+ continue;
3205
+ num += Math.sqrt(v) * drawZ();
3206
+ den += v;
3207
+ }
3208
+ }
3209
+ else if (m.kind === 'egarch') {
3210
+ let lv = m.logv1;
3211
+ for (let j = 0; j < steps; j++) {
3212
+ const v = Math.exp(lv) * f[j];
3213
+ const z = drawZ();
3214
+ num += Math.sqrt(v) * z;
3215
+ den += v;
3216
+ lv = m.omega + m.alpha * (Math.abs(z) + m.mbar - m.eAbsZ) + m.gamma * z + m.beta * lv;
3217
+ lv = Math.max(-50, Math.min(50, lv));
3218
+ }
3219
+ }
3220
+ else if (m.kind === 'rgarch') {
3221
+ let lv = m.logv1;
3222
+ for (let j = 0; j < steps; j++) {
3223
+ const v = Math.exp(lv) * f[j];
3224
+ const z = drawZ();
3225
+ num += Math.sqrt(v) * z;
3226
+ den += v;
3227
+ const lnRvSim = m.xi + lv + m.tau1 * z + m.tau2 * (z * z - 1) + m.sigmaU * simRandn(rng);
3228
+ lv = m.omega + m.beta * lv + m.gamma * lnRvSim;
3229
+ lv = Math.max(-50, Math.min(50, lv));
3230
+ }
3231
+ }
3232
+ else {
3233
+ let v = m.v1;
3234
+ for (let j = 0; j < steps; j++) {
3235
+ const vf = v * f[j];
3236
+ const z = drawZ();
3237
+ num += Math.sqrt(vf) * z;
3238
+ den += vf;
3239
+ const innov = v * z * z;
3240
+ v = m.omega + m.alpha * innov + (z < 0 ? m.gamma * innov : 0) + m.beta * v;
3241
+ if (!(v > 0) || !isFinite(v))
3242
+ v = m.v1;
3243
+ }
3244
+ }
3245
+ draws[b] = den > 0 ? num / Math.sqrt(den) : 0;
1751
3246
  }
1752
- if (novasResult) {
1753
- const score = qlike(novasResult.varianceSeries, rv);
1754
- if (score < bestScore) {
1755
- best = novasResult;
1756
- bestScore = score;
3247
+ draws.sort((a, b) => a - b);
3248
+ const pTail = (1 - confidence) / 2;
3249
+ const up = empiricalQuantile(draws, 1 - pTail);
3250
+ const down = -empiricalQuantile(draws, pTail);
3251
+ if (!isFinite(up) || !isFinite(down) || up <= 0 || down <= 0)
3252
+ return null;
3253
+ return { up, down };
3254
+ }
3255
+ /**
3256
+ * Signed h-step standardized-sum sample: Σr / √(Σσ²) over overlapping
3257
+ * windows of the post-warm-up region. This is the h-step analog of
3258
+ * fit.zSorted — it absorbs whatever the single-period model misses about
3259
+ * aggregation (volatility autocorrelation, Jensen bias in EGARCH
3260
+ * multi-step, fat tails washing out by CLT), separately per tail.
3261
+ */
3262
+ function horizonZ(fit, steps) {
3263
+ const { returns, varianceSeries, warmup } = fit;
3264
+ const out = [];
3265
+ for (let t = warmup; t + steps <= returns.length; t++) {
3266
+ let sumR = 0;
3267
+ let sumV = 0;
3268
+ let ok = true;
3269
+ for (let j = 0; j < steps; j++) {
3270
+ const v = varianceSeries[t + j];
3271
+ if (!(v > 0) || !isFinite(v)) {
3272
+ ok = false;
3273
+ break;
3274
+ }
3275
+ sumR += returns[t + j];
3276
+ sumV += v;
1757
3277
  }
3278
+ if (!ok)
3279
+ continue;
3280
+ const z = sumR / Math.sqrt(sumV);
3281
+ if (isFinite(z))
3282
+ out.push(z);
1758
3283
  }
1759
- return best;
3284
+ out.sort((a, b) => a - b);
3285
+ return out;
1760
3286
  }
1761
- function checkReliable(fit) {
1762
- if (!fit.converged || fit.persistence >= 0.999)
1763
- return false;
3287
+ /**
3288
+ * Corridor multipliers (upper and lower, calibrated separately) for a
3289
+ * two-sided confidence level at horizon `steps`.
3290
+ *
3291
+ * Each tail carries (1−confidence)/2 mass. The empirical quantile of the
3292
+ * signed standardized (h-step) return anchors each tail where the sample
3293
+ * supports it, and the model quantile takes over as that tail runs out of
3294
+ * observations. The blend weight per tail is the expected number of tail
3295
+ * exceedances m = n_eff·(1−confidence)/2 shrunk by a prior of 5 pseudo-
3296
+ * observations; overlapping h-step windows are discounted by 1/steps.
3297
+ *
3298
+ * The model half is the (symmetric) fitted t(df) quantile at one step and
3299
+ * the simulated model-implied tails (simulateHorizonTails — asymmetric
3300
+ * through the fitted leverage terms) at longer horizons.
3301
+ */
3302
+ function corridorZBounds(fit, confidence, steps = 1, factorPath) {
3303
+ const zGauss = probit(confidence);
3304
+ const zT = studentTProbit(confidence, fit.df);
3305
+ let modelUp;
3306
+ let modelDown;
3307
+ if (steps === 1) {
3308
+ modelUp = zT;
3309
+ modelDown = zT;
3310
+ }
3311
+ else {
3312
+ const sim = simulateHorizonTails(fit, steps, confidence, factorPath);
3313
+ const fallback = zGauss + (zT - zGauss) / steps;
3314
+ modelUp = sim ? sim.up : fallback;
3315
+ modelDown = sim ? sim.down : fallback;
3316
+ }
3317
+ const zs = steps === 1 ? fit.zSorted : horizonZ(fit, steps);
3318
+ const n = zs.length;
3319
+ if (n < 50)
3320
+ return { up: modelUp, down: modelDown };
3321
+ const pTail = (1 - confidence) / 2;
3322
+ const empUp = empiricalQuantile(zs, 1 - pTail);
3323
+ const empDown = -empiricalQuantile(zs, pTail);
3324
+ const effN = n / steps; // overlap discount
3325
+ const tailCount = effN * pTail;
3326
+ const w = tailCount / (tailCount + 5);
3327
+ const up = isFinite(empUp) && empUp > 0 ? w * empUp + (1 - w) * modelUp : modelUp;
3328
+ const down = isFinite(empDown) && empDown > 0 ? w * empDown + (1 - w) * modelDown : modelDown;
3329
+ return { up, down };
3330
+ }
3331
+ /** Reliability checks as explainable warnings; `reliable` = none critical fired. */
3332
+ function collectFitWarnings(fit, warnings) {
3333
+ if (!fit.converged) {
3334
+ warnings.push({
3335
+ code: 'NOT_CONVERGED',
3336
+ critical: true,
3337
+ message: 'The volatility optimizer did not converge — the corridor cannot be trusted. More candles usually helps.',
3338
+ });
3339
+ }
3340
+ if (fit.persistence >= 0.999) {
3341
+ warnings.push({
3342
+ code: 'HIGH_PERSISTENCE',
3343
+ critical: true,
3344
+ message: 'Volatility persistence hit the stationarity boundary (≥0.999) — long-run variance is unidentified and the corridor is unstable.',
3345
+ });
3346
+ }
3347
+ // Degenerate forecast: variance collapsed to the numerical clamp
3348
+ // (flat market, HAR/NoVaS 1e-20 floor) — a zero-width corridor is never
3349
+ // a reliable market forecast. Floor is relative to the sample variance
3350
+ // so legitimately low-volatility series are not flagged; a zero-variance
3351
+ // (flat) return series is always degenerate.
3352
+ const sv = sampleVariance(fit.returns);
3353
+ if (!(sv > 0) || !(fit.forecast.variance[0] > sv * 1e-8)) {
3354
+ warnings.push({
3355
+ code: 'DEGENERATE_VARIANCE',
3356
+ critical: true,
3357
+ message: 'Forecast variance collapsed to the numerical floor (flat or degenerate market) — a zero-width corridor is not a forecast.',
3358
+ });
3359
+ return; // Ljung-Box on degenerate residuals is meaningless
3360
+ }
1764
3361
  // Ljung-Box on squared standardized residuals
1765
3362
  const { returns, varianceSeries } = fit;
1766
3363
  const squared = returns.map((r, i) => {
@@ -1768,93 +3365,243 @@ function checkReliable(fit) {
1768
3365
  return z * z;
1769
3366
  });
1770
3367
  const lb = ljungBox(squared, 10);
1771
- return lb.pValue >= 0.05;
3368
+ if (!(lb.pValue >= 0.05)) {
3369
+ warnings.push({
3370
+ code: 'RESIDUAL_AUTOCORRELATION',
3371
+ critical: true,
3372
+ message: `Squared residuals stay autocorrelated (Ljung-Box p=${lb.pValue.toFixed(3)}) — the model did not fully capture volatility clustering; the corridor may understate risk.`,
3373
+ });
3374
+ }
1772
3375
  }
1773
- /**
1774
- * Forecast expected price range for t+1 (next candle).
1775
- *
1776
- * Auto-selects the best volatility model via QLIKE.
1777
- * Uses log-normal price bands: P·exp(±z·σ), where z = probit(confidence).
1778
- * @param confidence — two-sided probability in (0,1). Default ≈0.6827 (±1σ).
1779
- * Common values: 0.90 z=1.645, 0.95 z=1.96, 0.99 → z=2.576.
1780
- */
1781
- function predict(candles, interval, currentPrice, confidence = 0.6827) {
1782
- assertMinCandles(candles, interval);
1783
- currentPrice = currentPrice || candles[candles.length - 1].close;
1784
- const z = probit(confidence);
1785
- const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], 1);
1786
- const sigma = fit.forecast.volatility[0];
1787
- const upperPrice = currentPrice * Math.exp(z * sigma);
1788
- const lowerPrice = currentPrice * Math.exp(-z * sigma);
3376
+ // ── Prediction ────────────────────────────────────────────────
3377
+ function runPredict(candles, interval, steps, currentPrice, confidence, warm) {
3378
+ // Hard bound on the horizon: forecasts, seasonal paths, and the horizon
3379
+ // simulation are all O(steps) an unbounded horizon is unbounded work,
3380
+ // and a corridor further out than the whole sample is meaningless anyway.
3381
+ if (steps > candles.length) {
3382
+ throw new InvalidArgumentError(`steps must not exceed the sample length (${candles.length}), got ${steps}`);
3383
+ }
3384
+ assertTimestampOrder(candles);
3385
+ const warnings = [];
3386
+ collectDataWarnings(candles, interval, warnings);
3387
+ const periodsPerYear = INTERVALS_PER_YEAR[interval];
3388
+ const nReturns = candles.length - 1;
3389
+ // Deseasonalize, fit in flat-profile space, reseasonalize the forecast
3390
+ const season = computeSeasonality(candles, interval);
3391
+ const fitCandles = season ? deseasonalizeCandles(candles, season) : candles;
3392
+ const fit = fitModel(fitCandles, periodsPerYear, steps, warm);
3393
+ const factorPath = new Array(steps).fill(1);
3394
+ if (season) {
3395
+ for (let h = 0; h < steps; h++) {
3396
+ factorPath[h] = season.factors[season.bucketOfReturn(nReturns + h)];
3397
+ }
3398
+ const variance = fit.forecast.variance.map((v, h) => v * factorPath[h]);
3399
+ fit.forecast = {
3400
+ variance,
3401
+ volatility: variance.map(v => Math.sqrt(v)),
3402
+ annualized: variance.map(v => Math.sqrt(v * periodsPerYear) * 100),
3403
+ };
3404
+ }
3405
+ const { up: zUp, down: zDown } = corridorZBounds(fit, confidence, steps, factorPath);
3406
+ const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
3407
+ const sigma = Math.sqrt(cumulativeVariance);
3408
+ const upperPrice = currentPrice * Math.exp(zUp * sigma);
3409
+ const lowerPrice = currentPrice * Math.exp(-zDown * sigma);
3410
+ collectFitWarnings(fit, warnings);
1789
3411
  return {
1790
3412
  modelType: fit.modelType,
1791
3413
  currentPrice,
1792
3414
  sigma,
3415
+ df: fit.df,
3416
+ zScore: (zUp + zDown) / 2,
3417
+ zScoreUp: zUp,
3418
+ zScoreDown: zDown,
1793
3419
  move: upperPrice - currentPrice,
1794
3420
  movePercent: (upperPrice / currentPrice - 1) * 100,
1795
3421
  upperPrice,
1796
3422
  lowerPrice,
1797
- reliable: checkReliable(fit),
3423
+ reliable: warnings.every(w => !w.critical),
3424
+ warnings,
3425
+ modelWeights: fit.weights,
3426
+ seasonalityDetected: season !== null,
3427
+ };
3428
+ }
3429
+ /**
3430
+ * Stateful predictor for rolling use (bots, backtests): each subsequent
3431
+ * predict/predictRange warm-starts every optimizer from the previous
3432
+ * window's optimum with a reduced multi-start budget — same math, a
3433
+ * fraction of the cost. State is per-instrument: do not share one
3434
+ * predictor across symbols.
3435
+ */
3436
+ function createPredictor(interval) {
3437
+ validateInterval(interval);
3438
+ const warm = {};
3439
+ return {
3440
+ predict(candles, currentPriceOrOptions, confidence = 0.6827) {
3441
+ assertMinCandles(candles, interval);
3442
+ const args = resolvePredictArgs(candles, currentPriceOrOptions, confidence);
3443
+ return runPredict(candles, interval, 1, args.currentPrice, args.confidence, warm);
3444
+ },
3445
+ predictRange(candles, steps, currentPriceOrOptions, confidence = 0.6827) {
3446
+ assertMinCandles(candles, interval);
3447
+ if (!Number.isFinite(steps) || steps < 1) {
3448
+ throw new InvalidArgumentError(`steps must be a number >= 1, got ${steps}`);
3449
+ }
3450
+ const args = resolvePredictArgs(candles, currentPriceOrOptions, confidence);
3451
+ return runPredict(candles, interval, Math.floor(steps), args.currentPrice, args.confidence, warm);
3452
+ },
1798
3453
  };
1799
3454
  }
3455
+ /**
3456
+ * Forecast expected price range for t+1 (next candle).
3457
+ *
3458
+ * Combines all volatility models weighted by out-of-sample QLIKE,
3459
+ * deseasonalizes the diurnal variance profile when one is present,
3460
+ * rescales the variance to the return scale (Var(r/σ) = 1), and builds
3461
+ * bands P·exp(±z·σ) where z is calibrated on the data itself: the
3462
+ * empirical |z| quantile of the standardized residuals blended with the
3463
+ * fitted Student-t quantile as the tail runs out of observations (see
3464
+ * corridorZ). Empirical coverage tracks the requested confidence without
3465
+ * assuming a distributional shape.
3466
+ * @param confidence — two-sided probability in (0,1). Default ≈0.6827 (±1σ).
3467
+ * Common values: 0.90, 0.95, 0.99.
3468
+ */
3469
+ function predict(candles, interval, currentPriceOrOptions, confidence = 0.6827) {
3470
+ assertMinCandles(candles, interval);
3471
+ const args = resolvePredictArgs(candles, currentPriceOrOptions, confidence);
3472
+ return runPredict(candles, interval, 1, args.currentPrice, args.confidence);
3473
+ }
1800
3474
  /**
1801
3475
  * Forecast expected price range over multiple candles.
1802
3476
  *
1803
- * Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
1804
- * Uses log-normal price bands: P·exp(±z·σ), where z = probit(confidence).
3477
+ * Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N
3478
+ * periods, with each step's variance carrying its own seasonal factor.
3479
+ * Uses log-normal price bands P·exp(±z·σ) where z is calibrated at the
3480
+ * requested horizon: the empirical quantile of |h-step standardized sums|
3481
+ * from the sample itself, blended with the model-implied quantile simulated
3482
+ * through the fitted recursions (volatility feedback and fat-tail decay
3483
+ * included).
1805
3484
  * @param confidence — two-sided probability in (0,1). Default ≈0.6827 (±1σ).
3485
+ * @param steps — horizon in candles, 1 ≤ steps ≤ candles.length.
1806
3486
  */
1807
- function predictRange(candles, interval, steps, currentPrice, confidence = 0.6827) {
3487
+ function predictRange(candles, interval, steps, currentPriceOrOptions, confidence = 0.6827) {
1808
3488
  assertMinCandles(candles, interval);
1809
- const z = probit(confidence);
1810
- const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], steps);
1811
- currentPrice = currentPrice || candles[candles.length - 1].close;
1812
- const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
1813
- const sigma = Math.sqrt(cumulativeVariance);
1814
- const upperPrice = currentPrice * Math.exp(z * sigma);
1815
- const lowerPrice = currentPrice * Math.exp(-z * sigma);
1816
- return {
1817
- modelType: fit.modelType,
1818
- currentPrice,
1819
- sigma,
1820
- move: upperPrice - currentPrice,
1821
- movePercent: (upperPrice / currentPrice - 1) * 100,
1822
- upperPrice,
1823
- lowerPrice,
1824
- reliable: checkReliable(fit),
1825
- };
3489
+ if (!Number.isFinite(steps) || steps < 1) {
3490
+ throw new InvalidArgumentError(`steps must be a number >= 1, got ${steps}`);
3491
+ }
3492
+ steps = Math.floor(steps);
3493
+ const args = resolvePredictArgs(candles, currentPriceOrOptions, confidence);
3494
+ return runPredict(candles, interval, steps, args.currentPrice, args.confidence);
1826
3495
  }
1827
3496
  // ── Backtest ──────────────────────────────────────────────────
1828
3497
  const BACKTEST_WINDOW_RATIO = 0.75;
1829
3498
  /**
1830
- * Walk-forward backtest of predict.
3499
+ * Kupiec (1995) proportion-of-failures test: is the observed hit rate
3500
+ * statistically consistent with the nominal confidence, given how many
3501
+ * walk-forward points there are? A raw hitRate of 63% vs nominal 68% means
3502
+ * nothing without n — this answers "failure or noise" directly.
3503
+ */
3504
+ function kupiecTest(hits, total, confidence) {
3505
+ const misses = total - hits;
3506
+ const p = 1 - confidence; // nominal failure probability per observation
3507
+ const pHat = total > 0 ? misses / total : 0;
3508
+ const nominalPct = (confidence * 100).toFixed(1);
3509
+ const coveragePct = total > 0 ? ((hits / total) * 100).toFixed(1) : '0';
3510
+ // Binomial log-likelihood with the 0·ln(0) := 0 convention
3511
+ const ll = (prob) => {
3512
+ let v = 0;
3513
+ if (misses > 0)
3514
+ v += misses * Math.log(prob);
3515
+ if (hits > 0)
3516
+ v += hits * Math.log(1 - prob);
3517
+ return v;
3518
+ };
3519
+ const lr = pHat > 0 && pHat < 1 ? -2 * (ll(p) - ll(pHat)) : -2 * ll(p);
3520
+ const pValue = chi2Survival(Math.max(lr, 0), 1);
3521
+ if (total < 30) {
3522
+ return {
3523
+ verdict: 'inconclusive',
3524
+ pValue,
3525
+ message: `Only ${total} walk-forward points — too few to judge calibration. Aim for ≥30 (more candles or stride: 1).`,
3526
+ };
3527
+ }
3528
+ if (pValue < 0.05) {
3529
+ if (pHat > p) {
3530
+ return {
3531
+ verdict: 'too-narrow',
3532
+ pValue,
3533
+ message: `Coverage ${coveragePct}% is below the nominal ${nominalPct}% (Kupiec p=${pValue.toFixed(4)}) — the corridor is too narrow: real risk exceeds what it shows.`,
3534
+ };
3535
+ }
3536
+ return {
3537
+ verdict: 'too-wide',
3538
+ pValue,
3539
+ message: `Coverage ${coveragePct}% is above the nominal ${nominalPct}% (Kupiec p=${pValue.toFixed(4)}) — the corridor is too wide: decisions based on it are overly conservative.`,
3540
+ };
3541
+ }
3542
+ return {
3543
+ verdict: 'well-calibrated',
3544
+ pValue,
3545
+ message: `Coverage ${coveragePct}% vs nominal ${nominalPct}% over ${total} points is consistent with a calibrated corridor (Kupiec p=${pValue.toFixed(4)}).`,
3546
+ };
3547
+ }
3548
+ /**
3549
+ * Walk-forward calibration statistics for predict.
1831
3550
  *
1832
- * Window is computed automatically: 75% of candles for fitting, 25% for testing.
3551
+ * Refits the model on a rolling window (75% of candles, min MIN_CANDLES)
3552
+ * and checks whether the next close lands inside the predicted corridor.
3553
+ * A well-calibrated tool has hitRate ≈ confidence·100.
3554
+ *
3555
+ * Every refit costs a full multi-model calibration, so by default the test
3556
+ * points are subsampled to at most ~100 refits (stride grows with the test
3557
+ * span). Pass `stride: 1` to evaluate every candle when runtime is not a
3558
+ * concern, or any positive stride to control the trade-off yourself.
1833
3559
  * Throws if not enough candles for the given interval.
1834
- * Returns true if the model's hit rate meets the required threshold.
1835
3560
  * @param confidence — two-sided probability in (0,1) for the prediction band.
1836
3561
  * Default ≈0.6827 (±1σ).
1837
- * @param requiredPercent — minimum hit rate (0–100) to pass. Default 68.
1838
3562
  */
1839
- function backtest(candles, interval, confidence = 0.6827, requiredPercent = 68) {
3563
+ function backtestStats(candles, interval, confidence = 0.6827, options = {}) {
1840
3564
  assertMinCandles(candles, interval);
1841
- if (requiredPercent <= 0)
1842
- return true;
1843
- if (requiredPercent >= 100)
1844
- return false;
1845
3565
  const window = Math.max(MIN_CANDLES[interval], Math.floor(candles.length * BACKTEST_WINDOW_RATIO));
3566
+ if (candles.length - 1 <= window) {
3567
+ throw new Error(`Need at least ${window + 2} candles to backtest ${interval} interval, got ${candles.length}`);
3568
+ }
3569
+ const testSpan = candles.length - 1 - window;
3570
+ const stride = Math.max(1, Math.floor(options.stride ?? Math.ceil(testSpan / 100)));
1846
3571
  let hits = 0;
1847
3572
  let total = 0;
1848
- for (let i = window; i < candles.length - 1; i++) {
3573
+ // Rolling refits share warm-start state same estimates as cold fits up
3574
+ // to optimizer tolerance, at a fraction of the multi-start cost
3575
+ const warm = {};
3576
+ for (let i = window; i < candles.length - 1; i += stride) {
1849
3577
  const slice = candles.slice(i - window, i + 1);
1850
- const predicted = predict(slice, interval, slice[slice.length - 1].close, confidence);
3578
+ const price = slice[slice.length - 1].close;
3579
+ const predicted = runPredict(slice, interval, 1, price, confidence, warm);
1851
3580
  const actual = candles[i + 1].close;
1852
3581
  if (actual >= predicted.lowerPrice && actual <= predicted.upperPrice) {
1853
3582
  hits++;
1854
3583
  }
1855
3584
  total++;
1856
3585
  }
1857
- return (hits / total) * 100 >= requiredPercent;
3586
+ return { hits, total, hitRate: (hits / total) * 100, ...kupiecTest(hits, total, confidence) };
3587
+ }
3588
+ /**
3589
+ * Walk-forward backtest of predict.
3590
+ *
3591
+ * Window is computed automatically: 75% of candles for fitting, 25% for testing.
3592
+ * Throws if not enough candles for the given interval.
3593
+ * Returns true if the model's hit rate meets the required threshold.
3594
+ * @param confidence — two-sided probability in (0,1) for the prediction band.
3595
+ * Default ≈0.6827 (±1σ).
3596
+ * @param requiredPercent — minimum hit rate (0–100) to pass. Default 68.
3597
+ */
3598
+ function backtest(candles, interval, confidence = 0.6827, requiredPercent = 68) {
3599
+ assertMinCandles(candles, interval);
3600
+ if (requiredPercent <= 0)
3601
+ return true;
3602
+ if (requiredPercent > 100)
3603
+ return false;
3604
+ return backtestStats(candles, interval, confidence).hitRate >= requiredPercent;
1858
3605
  }
1859
3606
 
1860
- 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, probit, profileStudentTDf, qlike, sampleVariance, sampleVarianceWithMean, studentTNegLL, yangZhangVariance };
3607
+ export { BadDataError, EXPECTED_ABS_NORMAL, Egarch, Garch, GarchError, GjrGarch, HarRv, InvalidArgumentError, NoVaS, NotEnoughDataError, RealizedGarch, backtest, backtestStats, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, calibrateGjrGarch, calibrateHarRv, calibrateNoVaS, calibrateRealizedGarch, checkData, checkLeverageEffect, computeSeasonality, createPredictor, deseasonalizeCandles, empiricalQuantile, expectedAbsStudentT, garmanKlassVariance, incompleteBeta, kupiecTest, ljungBox, logGamma, nelderMead, nelderMeadMultiStart, perCandleParkinson, predict, predictRange, probit, profileStudentTDf, qlike, sampleVariance, sampleVarianceWithMean, studentTCdf, studentTNegLL, studentTProbit, validateCandles, yangZhangVariance };