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