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