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