garch 1.0.2 → 1.0.3

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 CHANGED
@@ -13,193 +13,247 @@
13
13
  npm install garch
14
14
  ```
15
15
 
16
- ## Usage
16
+ ## API
17
17
 
18
- ### GARCH(1,1)
18
+ ### `predict(candles, interval, currentPrice?)`
19
+
20
+ Forecast expected price range for the next candle (t+1). Auto-selects GARCH or EGARCH based on leverage effect. Returns a +-1 sigma price corridor.
19
21
 
20
22
  ```typescript
21
- import { calibrateGarch, Garch } from 'garch';
23
+ import { predict } from 'garch';
24
+ import type { Candle } from 'garch';
22
25
 
23
- // From price array
24
- const prices = [100, 101, 99, 102, 98, ...];
25
- const result = calibrateGarch(prices, { periodsPerYear: 252 });
26
+ const candles: Candle[] = await fetchCandles('BTCUSDT', '4h', 200);
26
27
 
27
- console.log(result.params);
28
+ const result = predict(candles, '4h');
28
29
  // {
29
- // omega: 0.000012,
30
- // alpha: 0.08,
31
- // beta: 0.89,
32
- // persistence: 0.97,
33
- // unconditionalVariance: 0.0004,
34
- // annualizedVol: 31.7
30
+ // currentPrice: 97500,
31
+ // sigma: 0.012, // 1.2% expected move
32
+ // move: 1170, // +/-$1170 price range
33
+ // upperPrice: 98670, // ceiling for next candle
34
+ // lowerPrice: 96330, // floor for next candle
35
+ // modelType: 'egarch',
36
+ // reliable: true
35
37
  // }
36
38
 
37
- // Or use the class for more control
38
- const model = new Garch(prices, { periodsPerYear: 252 });
39
- const fit = model.fit();
40
-
41
- // Get variance series
42
- const variance = model.getVarianceSeries(fit.params);
43
-
44
- // Forecast 10 periods ahead
45
- const forecast = model.forecast(fit.params, 10);
46
- console.log(forecast.annualized); // [32.1, 31.9, 31.8, ...]
39
+ // Pass VWAP or any reference price as 3rd argument
40
+ const result = predict(candles, '4h', vwap);
47
41
  ```
48
42
 
49
- ### EGARCH(1,1)
43
+ **Parameters:**
50
44
 
51
- EGARCH captures asymmetric volatility (leverage effect):
45
+ | Parameter | Type | Default | Description |
46
+ |-----------|------|---------|-------------|
47
+ | `candles` | `Candle[]` | required | OHLCV candle data |
48
+ | `interval` | `CandleInterval` | required | Candle timeframe |
49
+ | `currentPrice` | `number` | last close | Reference price to center the corridor |
50
+
51
+ **Returns:** `PredictionResult`
52
52
 
53
53
  ```typescript
54
- import { calibrateEgarch, Egarch, checkLeverageEffect } from 'garch';
54
+ interface PredictionResult {
55
+ currentPrice: number; // Reference price
56
+ sigma: number; // One-period volatility (decimal, e.g. 0.012 = 1.2%)
57
+ move: number; // +/- price move = currentPrice * sigma
58
+ upperPrice: number; // currentPrice + move
59
+ lowerPrice: number; // currentPrice - move
60
+ modelType: 'garch' | 'egarch'; // Auto-selected model
61
+ reliable: boolean; // Quality flag (convergence + persistence + Ljung-Box)
62
+ }
63
+ ```
64
+
65
+ ---
55
66
 
56
- // Check if EGARCH is warranted
57
- const returns = calculateReturnsFromPrices(prices);
58
- const leverage = checkLeverageEffect(returns);
59
- console.log(leverage);
60
- // { negativeVol: 0.021, positiveVol: 0.015, ratio: 1.4, recommendation: 'egarch' }
67
+ ### `predictRange(candles, interval, steps, currentPrice?)`
61
68
 
62
- // Fit EGARCH
63
- const result = calibrateEgarch(prices, { periodsPerYear: 365 }); // crypto = 365
69
+ Forecast cumulative expected price range over multiple candles. Cumulative sigma = sqrt(sigma_1^2 + sigma_2^2 + ... + sigma_n^2). Use for swing trades where you hold across multiple periods.
64
70
 
65
- console.log(result.params);
71
+ ```typescript
72
+ import { predictRange } from 'garch';
73
+
74
+ const range = predictRange(candles, '4h', 5);
66
75
  // {
67
- // omega: -0.12,
68
- // alpha: 0.15,
69
- // gamma: -0.08, // negative = leverage effect
70
- // beta: 0.95,
71
- // persistence: 0.95,
72
- // annualizedVol: 45.2,
73
- // leverageEffect: -0.08
76
+ // currentPrice: 97500,
77
+ // sigma: 0.027, // cumulative ~2.7% over 5 candles
78
+ // move: 2632, // +/-$2632 total range
79
+ // upperPrice: 100132,
80
+ // lowerPrice: 94868,
81
+ // modelType: 'egarch',
82
+ // reliable: true
74
83
  // }
75
84
  ```
76
85
 
77
- ### From OHLCV Candles
86
+ **Parameters:**
78
87
 
79
- ```typescript
80
- import { Candle, calibrateGarch } from 'garch';
88
+ | Parameter | Type | Default | Description |
89
+ |-----------|------|---------|-------------|
90
+ | `candles` | `Candle[]` | required | OHLCV candle data |
91
+ | `interval` | `CandleInterval` | required | Candle timeframe |
92
+ | `steps` | `number` | required | Number of candles to forecast over |
93
+ | `currentPrice` | `number` | last close | Reference price |
81
94
 
82
- const candles: Candle[] = [
83
- { open: 100, high: 102, low: 99, close: 101, volume: 1000 },
84
- { open: 101, high: 103, low: 100, close: 99, volume: 1200 },
85
- // ...
86
- ];
95
+ **Returns:** `PredictionResult` (same structure as `predict`)
87
96
 
88
- const result = calibrateGarch(candles);
89
- ```
97
+ ---
90
98
 
91
- ### Model Selection
99
+ ### `backtest(candles, interval, requiredPercent?)`
92
100
 
93
- ```typescript
94
- import { calibrateGarch, calibrateEgarch } from 'garch';
101
+ Walk-forward validation of `predict`. Uses 75% of candles for fitting, 25% for testing. Checks if the model's +-1 sigma corridor captures actual price moves at the required hit rate.
95
102
 
96
- const garch = calibrateGarch(prices);
97
- const egarch = calibrateEgarch(prices);
103
+ ```typescript
104
+ import { backtest } from 'garch';
98
105
 
99
- // Compare using AIC (lower is better)
100
- if (egarch.diagnostics.aic < garch.diagnostics.aic) {
101
- console.log('EGARCH fits better');
102
- }
106
+ backtest(candles, '4h'); // true -- hit rate >= 68% (default)
107
+ backtest(candles, '4h', 50); // true -- hit rate >= 50% (custom)
103
108
  ```
104
109
 
105
- ## API
110
+ **Parameters:**
106
111
 
107
- ### `calibrateGarch(data, options?)`
112
+ | Parameter | Type | Default | Description |
113
+ |-----------|------|---------|-------------|
114
+ | `candles` | `Candle[]` | required | OHLCV candle data |
115
+ | `interval` | `CandleInterval` | required | Candle timeframe |
116
+ | `requiredPercent` | `number` | `68` | Minimum hit rate (+-1 sigma ~ 68% theoretically) |
108
117
 
109
- Calibrate GARCH(1,1) model.
118
+ **Returns:** `boolean`
110
119
 
111
- **Parameters:**
112
- - `data`: `Candle[]` or `number[]` (prices)
113
- - `options.periodsPerYear`: Annualization factor (default: 252)
114
- - `options.maxIter`: Maximum optimizer iterations (default: 1000)
115
- - `options.tol`: Convergence tolerance (default: 1e-8)
120
+ ---
116
121
 
117
- **Returns:** `CalibrationResult<GarchParams>`
122
+ ## Supported Intervals
118
123
 
119
- ### `calibrateEgarch(data, options?)`
124
+ | Interval | Min Candles | Periods/Year | Coverage |
125
+ |----------|-------------|--------------|----------|
126
+ | `1m` | 500 | 525,600 | ~8-16 hours |
127
+ | `3m` | 500 | 175,200 | ~25 hours |
128
+ | `5m` | 500 | 105,120 | ~1.7 days |
129
+ | `15m` | 300 | 35,040 | ~3 days |
130
+ | `30m` | 200 | 17,520 | ~4 days |
131
+ | `1h` | 200 | 8,760 | ~8 days |
132
+ | `2h` | 200 | 4,380 | ~17 days |
133
+ | `4h` | 200 | 2,190 | ~33 days |
134
+ | `6h` | 150 | 1,460 | ~37 days |
135
+ | `8h` | 150 | 1,095 | ~50 days |
120
136
 
121
- Calibrate EGARCH(1,1) model.
137
+ ## Timeframes
122
138
 
123
- **Parameters:** Same as `calibrateGarch`
139
+ The `periodsPerYear` value controls annualization of volatility. When using `predict`/`predictRange`/`backtest`, this is handled automatically via the `interval` parameter. When using `Garch`/`Egarch` classes directly, pass `periodsPerYear` manually.
124
140
 
125
- **Returns:** `CalibrationResult<EgarchParams>`
141
+ | Timeframe | `periodsPerYear` | Notes |
142
+ |-----------|-----------------|-------|
143
+ | **1m** | `525,600` | 1440/day x 365 |
144
+ | **3m** | `175,200` | 480/day x 365 |
145
+ | **5m** | `105,120` | 288/day x 365 |
146
+ | **15m** | `35,040` | 96/day x 365 |
147
+ | **30m** | `17,520` | 48/day x 365 |
148
+ | **1h** | `8,760` | 24/day x 365 |
149
+ | **2h** | `4,380` | 12/day x 365 |
150
+ | **4h** | `2,190` | 6/day x 365 |
151
+ | **6h** | `1,460` | 4/day x 365 |
152
+ | **8h** | `1,095` | 3/day x 365 |
126
153
 
127
- ### `checkLeverageEffect(returns)`
154
+ Lower timeframes contain more microstructure noise — use larger datasets to compensate.
128
155
 
129
- Check for asymmetric volatility.
156
+ ## Math
130
157
 
131
- **Returns:** `{ negativeVol, positiveVol, ratio, recommendation }`
158
+ ### GARCH(1,1)
132
159
 
133
- ### Classes
160
+ Conditional variance model (Bollerslev, 1986):
134
161
 
135
- `Garch` and `Egarch` classes provide:
136
- - `.fit(options?)` - Calibrate parameters
137
- - `.getVarianceSeries(params)` - Compute conditional variance
138
- - `.forecast(params, steps)` - Multi-step variance forecast
139
- - `.getReturns()` - Get computed returns
162
+ ```
163
+ sigma_t^2 = omega + alpha * epsilon_{t-1}^2 + beta * sigma_{t-1}^2
164
+ ```
140
165
 
141
- ## Timeframes
166
+ - **omega** > 0 — long-run variance anchor
167
+ - **alpha** >= 0 — shock reaction (how much yesterday's surprise matters)
168
+ - **beta** >= 0 — persistence (memory of past variance)
169
+ - Stationarity constraint: **alpha + beta < 1**
170
+ - Unconditional variance: **E[sigma^2] = omega / (1 - alpha - beta)**
142
171
 
143
- The library works with any candle timeframe. The only thing that changes is the `periodsPerYear` option, which controls annualization of volatility.
172
+ Parameter estimation via **Gaussian MLE** (maximum likelihood):
144
173
 
145
- | Timeframe | `periodsPerYear` | Notes |
146
- |-----------|-----------------|-------|
147
- | **1d** | `252` (default) | Trading days per year |
148
- | **4h** | `1512` | 252 × 6 |
149
- | **1h** | `6048` (crypto) / `1638` (stocks) | Crypto trades 24/7, stocks ~6.5h/day |
150
- | **15m** | `24192` (crypto) / `6552` (stocks) | 96 or 26 bars per day × 252 |
151
- | **1m** | `362880` (crypto) / `393120` (stocks) | 1440 or 390 bars per day × 252 |
174
+ ```
175
+ LL = -0.5 * sum[ ln(sigma_t^2) + epsilon_t^2 / sigma_t^2 ]
176
+ ```
152
177
 
153
- ```typescript
154
- // Daily candles (default)
155
- calibrateGarch(prices);
178
+ Multi-step forecast converges to unconditional variance:
156
179
 
157
- // 4-hour candles
158
- calibrateGarch(prices, { periodsPerYear: 1512 });
180
+ ```
181
+ sigma_{t+h}^2 = omega + (alpha + beta) * sigma_{t+h-1}^2
182
+ ```
183
+
184
+ ### EGARCH(1,1)
185
+
186
+ Exponential GARCH (Nelson, 1991). Models log-variance, capturing asymmetric volatility:
159
187
 
160
- // 15-minute candles (crypto, 24/7 market)
161
- calibrateGarch(prices, { periodsPerYear: 24192 });
188
+ ```
189
+ ln(sigma_t^2) = omega + alpha * (|z_{t-1}| - sqrt(2/pi)) + gamma * z_{t-1} + beta * ln(sigma_{t-1}^2)
162
190
  ```
163
191
 
164
- **Minimum data:** 50 candles are required for stable parameter estimation.
192
+ Where **z_t = epsilon_t / sigma_t** is the standardized residual, **sqrt(2/pi) ~ 0.7979**.
165
193
 
166
- **Recommended timeframes:** 1d and 4h are the most reliable for GARCH models. Lower timeframes (15m, 1m) contain more microstructure noise which can degrade calibration quality — use larger datasets to compensate.
194
+ - **gamma** < 0 leverage effect (negative returns increase vol more than positive)
195
+ - No positivity constraints needed (log-variance is always real)
196
+ - Stationarity: **|beta| < 1**
197
+ - Unconditional variance: **E[sigma^2] ~ exp(omega / (1 - beta))**
167
198
 
168
- ## Model Details
199
+ ### Model Auto-Selection
169
200
 
170
- ### GARCH(1,1)
201
+ `predict` and `predictRange` automatically choose between GARCH and EGARCH:
202
+
203
+ 1. Compute volatility of negative returns vs. positive returns
204
+ 2. If ratio > 1.2 — use EGARCH (significant leverage effect detected)
205
+ 3. Otherwise — use simpler GARCH
206
+
207
+ ### Variance Estimators
208
+
209
+ **Yang-Zhang** (used as initial variance for model fitting):
171
210
 
172
211
  ```
173
- σ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁
212
+ sigma^2_YZ = sigma^2_overnight + k * sigma^2_close + (1-k) * sigma^2_RS
174
213
  ```
175
214
 
176
- - `ω` (omega) > 0: constant term
177
- - `α` (alpha) ≥ 0: reaction to shocks
178
- - `β` (beta) ≥ 0: persistence
179
- - Stationarity: α + β < 1
215
+ Combines overnight gaps, open-to-close moves, and Rogers-Satchell intraday range. More robust than close-to-close for OHLC data.
180
216
 
181
- ### EGARCH(1,1)
217
+ **Garman-Klass** (fallback):
182
218
 
183
219
  ```
184
- ln(σ²ₜ) = ω + α·(|zₜ₋₁| - E[|z|]) + γ·zₜ₋₁ + β·ln(σ²ₜ₋₁)
220
+ sigma^2_GK = (1/n) * sum[ 0.5 * ln(H/L)^2 - (2*ln2 - 1) * ln(C/O)^2 ]
185
221
  ```
186
222
 
187
- - `γ` (gamma) < 0: leverage effect (negative returns increase vol more)
188
- - No positivity constraints needed (models log-variance)
189
- - `|β|` < 1 for stationarity
223
+ ~5x more efficient than close-to-close variance.
190
224
 
191
- ### Model Selection
225
+ ### Reliability Check
192
226
 
193
- ```typescript
194
- import { calibrateGarch, calibrateEgarch } from 'garch';
227
+ The `reliable` flag in `PredictionResult` is `true` when all three conditions hold:
195
228
 
196
- const garch = calibrateGarch(prices);
197
- const egarch = calibrateEgarch(prices);
229
+ 1. Optimizer converged
230
+ 2. Persistence < 0.999 (not near unit root)
231
+ 3. Ljung-Box test on squared standardized residuals: p-value >= 0.05 (no residual autocorrelation)
198
232
 
199
- // Compare using AIC (lower is better)
200
- if (egarch.diagnostics.aic < garch.diagnostics.aic) {
201
- console.log('EGARCH fits better leverage effect is significant');
202
- }
233
+ ### Optimization
234
+
235
+ Parameters are estimated via **Nelder-Mead** simplex method (derivative-free). Default: 1000 iterations, tolerance 1e-8. Model comparison uses **AIC** (2k - 2LL) and **BIC** (k*ln(n) - 2LL).
236
+
237
+ ## Tests
238
+
239
+ **400 tests** across **17 test files**. All passing.
240
+
241
+ | Category | Files | Tests | What's covered |
242
+ |----------|-------|-------|----------------|
243
+ | Mathematical formulas | `math.test.ts` | 45 | GARCH/EGARCH variance recursion, log-likelihood, forecast formulas, AIC/BIC, Yang-Zhang, Garman-Klass, Ljung-Box, chi-squared |
244
+ | Full pipeline coverage | `plan-coverage.test.ts` | 73 | End-to-end: fit, forecast, predict, predictRange, backtest, model selection |
245
+ | GARCH unit | `garch.test.ts` | 10 | Parameter estimation, variance series, forecast convergence, candle vs price input |
246
+ | EGARCH unit | `egarch.test.ts` | 11 | Leverage detection, asymmetric volatility, model comparison via AIC |
247
+ | Optimizer | `optimizer.test.ts`, `optimizer-shrink.test.ts` | 16 | Nelder-Mead on Rosenbrock/quadratic/parabolic, convergence, shrinking |
248
+ | Statistical properties | `properties.test.ts` | 13 | Parameter recovery from synthetic data, local LL maximum, unconditional variance |
249
+ | Regression | `regression.test.ts` | 9 | Parameter recovery, deterministic outputs |
250
+ | Stability | `stability.test.ts` | 10 | Long-term forecast behavior, variance convergence |
251
+ | Robustness | `robustness.test.ts` | 53 | Extreme moves, stress scenarios |
252
+ | Edge cases | `edge-cases.test.ts`, `coverage-gaps*.test.ts` | 148 | Insufficient data, near-unit-root, zero returns, constant prices, negative prices, overflow/underflow, trending data, 10K+ data points |
253
+ | Miscellaneous | `misc.test.ts` | 12 | Integration scenarios, different intervals |
254
+
255
+ ```bash
256
+ npm test # run all tests
203
257
  ```
204
258
 
205
259
  ## License
package/build/index.cjs CHANGED
@@ -112,7 +112,7 @@ function shrink(simplex, values, sigma, fn, n) {
112
112
  function calculateReturns(candles) {
113
113
  const returns = [];
114
114
  for (let i = 1; i < candles.length; i++) {
115
- if (candles[i].close <= 0 || candles[i - 1].close <= 0) {
115
+ if (!(candles[i].close > 0) || !(candles[i - 1].close > 0)) {
116
116
  throw new Error(`Invalid close price at index ${i}`);
117
117
  }
118
118
  returns.push(Math.log(candles[i].close / candles[i - 1].close));
@@ -125,7 +125,7 @@ function calculateReturns(candles) {
125
125
  function calculateReturnsFromPrices(prices) {
126
126
  const returns = [];
127
127
  for (let i = 1; i < prices.length; i++) {
128
- if (prices[i] <= 0 || prices[i - 1] <= 0) {
128
+ if (!(prices[i] > 0 && Number.isFinite(prices[i])) || !(prices[i - 1] > 0 && Number.isFinite(prices[i - 1]))) {
129
129
  throw new Error(`Invalid price at index ${i}`);
130
130
  }
131
131
  returns.push(Math.log(prices[i] / prices[i - 1]));
@@ -169,11 +169,115 @@ function checkLeverageEffect(returns) {
169
169
  recommendation: ratio > 1.2 ? 'egarch' : 'garch',
170
170
  };
171
171
  }
172
+ /**
173
+ * Garman-Klass (1980) variance estimator using OHLC data.
174
+ *
175
+ * σ²_GK = (1/n) Σ [ 0.5·(ln(H/L))² − (2ln2−1)·(ln(C/O))² ]
176
+ *
177
+ * ~5x more efficient than close-to-close variance.
178
+ */
179
+ function garmanKlassVariance(candles) {
180
+ const n = candles.length;
181
+ const coeff = 2 * Math.LN2 - 1;
182
+ let sum = 0;
183
+ for (let i = 0; i < n; i++) {
184
+ const { open, high, low, close } = candles[i];
185
+ const hl = Math.log(high / low);
186
+ const co = Math.log(close / open);
187
+ sum += 0.5 * hl * hl - coeff * co * co;
188
+ }
189
+ return sum / n;
190
+ }
191
+ /**
192
+ * Yang-Zhang (2000) variance estimator using OHLC data.
193
+ *
194
+ * Combines overnight (open vs prev close), open-to-close,
195
+ * and Rogers-Satchell components. More efficient than Garman-Klass
196
+ * and handles overnight gaps (relevant for stocks).
197
+ *
198
+ * σ²_YZ = σ²_overnight + k·σ²_close + (1−k)·σ²_RS
199
+ */
200
+ function yangZhangVariance(candles) {
201
+ const n = candles.length;
202
+ if (n < 2)
203
+ return garmanKlassVariance(candles);
204
+ const k = 0.34 / (1.34 + (n + 1) / (n - 1));
205
+ let overnightSum = 0;
206
+ let closeSum = 0;
207
+ let rsSum = 0;
208
+ let count = 0;
209
+ for (let i = 1; i < n; i++) {
210
+ const prevClose = candles[i - 1].close;
211
+ const { open, high, low, close } = candles[i];
212
+ const overnight = Math.log(open / prevClose);
213
+ const co = Math.log(close / open);
214
+ const hc = Math.log(high / close);
215
+ const ho = Math.log(high / open);
216
+ const lc = Math.log(low / close);
217
+ const lo = Math.log(low / open);
218
+ overnightSum += overnight * overnight;
219
+ closeSum += co * co;
220
+ rsSum += ho * hc + lo * lc;
221
+ count++;
222
+ }
223
+ const overnightVar = overnightSum / count;
224
+ const closeVar = closeSum / count;
225
+ const rsVar = rsSum / count;
226
+ return overnightVar + k * closeVar + (1 - k) * rsVar;
227
+ }
172
228
  /**
173
229
  * Expected value of |Z| where Z ~ N(0,1)
174
230
  * E[|Z|] = sqrt(2/π)
175
231
  */
176
232
  const EXPECTED_ABS_NORMAL = Math.sqrt(2 / Math.PI);
233
+ /**
234
+ * Chi-squared survival function approximation (Wilson-Hilferty).
235
+ * P(X > x) where X ~ χ²(df)
236
+ */
237
+ function chi2Survival(x, df) {
238
+ if (df <= 0 || x < 0)
239
+ return 1;
240
+ // Wilson-Hilferty normal approximation
241
+ const z = Math.cbrt(x / df) - (1 - 2 / (9 * df));
242
+ const denom = Math.sqrt(2 / (9 * df));
243
+ const normZ = z / denom;
244
+ // Standard normal CDF via error function approximation
245
+ return 1 - normalCDF(normZ);
246
+ }
247
+ function normalCDF(x) {
248
+ const t = 1 / (1 + 0.2316419 * Math.abs(x));
249
+ const d = 0.3989422804014327; // 1/sqrt(2π)
250
+ const p = d * Math.exp(-0.5 * x * x) *
251
+ (t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))));
252
+ return x >= 0 ? 1 - p : p;
253
+ }
254
+ /**
255
+ * Ljung-Box test for autocorrelation.
256
+ *
257
+ * Q = n(n+2) Σ(k=1..m) ρ²_k / (n−k)
258
+ *
259
+ * Under H₀ (no autocorrelation), Q ~ χ²(m).
260
+ * Use on squared standardized residuals to test GARCH adequacy.
261
+ */
262
+ function ljungBox(data, maxLag) {
263
+ const n = data.length;
264
+ const mean = data.reduce((s, v) => s + v, 0) / n;
265
+ const gamma0 = data.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
266
+ if (gamma0 === 0)
267
+ return { statistic: 0, pValue: 1 };
268
+ let Q = 0;
269
+ for (let k = 1; k <= maxLag; k++) {
270
+ let gammaK = 0;
271
+ for (let t = k; t < n; t++) {
272
+ gammaK += (data[t] - mean) * (data[t - k] - mean);
273
+ }
274
+ gammaK /= n;
275
+ const rhoK = gammaK / gamma0;
276
+ Q += (rhoK * rhoK) / (n - k);
277
+ }
278
+ Q *= n * (n + 2);
279
+ return { statistic: Q, pValue: chi2Survival(Q, maxLag) };
280
+ }
177
281
  /**
178
282
  * Calculate AIC (Akaike Information Criterion)
179
283
  */
@@ -210,11 +314,13 @@ class Garch {
210
314
  // Determine if input is candles or prices
211
315
  if (typeof data[0] === 'number') {
212
316
  this.returns = calculateReturnsFromPrices(data);
317
+ this.initialVariance = sampleVariance(this.returns);
213
318
  }
214
319
  else {
215
- this.returns = calculateReturns(data);
320
+ const candles = data;
321
+ this.returns = calculateReturns(candles);
322
+ this.initialVariance = yangZhangVariance(candles);
216
323
  }
217
- this.initialVariance = sampleVariance(this.returns);
218
324
  }
219
325
  /**
220
326
  * Calibrate GARCH(1,1) parameters using Maximum Likelihood Estimation
@@ -256,7 +362,7 @@ class Garch {
256
362
  const persistence = alpha + beta;
257
363
  const unconditionalVariance = omega / (1 - persistence);
258
364
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
259
- const logLikelihood = -result.fx * 2;
365
+ const logLikelihood = -result.fx;
260
366
  const numParams = 3;
261
367
  return {
262
368
  params: {
@@ -362,11 +468,13 @@ class Egarch {
362
468
  }
363
469
  if (typeof data[0] === 'number') {
364
470
  this.returns = calculateReturnsFromPrices(data);
471
+ this.initialVariance = sampleVariance(this.returns);
365
472
  }
366
473
  else {
367
- this.returns = calculateReturns(data);
474
+ const candles = data;
475
+ this.returns = calculateReturns(candles);
476
+ this.initialVariance = yangZhangVariance(candles);
368
477
  }
369
- this.initialVariance = sampleVariance(this.returns);
370
478
  }
371
479
  /**
372
480
  * Calibrate EGARCH(1,1) parameters using Maximum Likelihood Estimation
@@ -415,7 +523,7 @@ class Egarch {
415
523
  const unconditionalLogVar = omega / (1 - beta);
416
524
  const unconditionalVariance = Math.exp(unconditionalLogVar);
417
525
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
418
- const logLikelihood = -result.fx * 2;
526
+ const logLikelihood = -result.fx;
419
527
  const numParams = 4;
420
528
  return {
421
529
  params: {
@@ -515,14 +623,159 @@ function calibrateEgarch(data, options = {}) {
515
623
  return model.fit(options);
516
624
  }
517
625
 
626
+ const MIN_CANDLES = {
627
+ '1m': 500,
628
+ '3m': 500,
629
+ '5m': 500,
630
+ '15m': 300,
631
+ '30m': 200,
632
+ '1h': 200,
633
+ '2h': 200,
634
+ '4h': 200,
635
+ '6h': 150,
636
+ '8h': 150,
637
+ };
638
+ const INTERVALS_PER_YEAR = {
639
+ '1m': 525_600,
640
+ '3m': 175_200,
641
+ '5m': 105_120,
642
+ '15m': 35_040,
643
+ '30m': 17_520,
644
+ '1h': 8_760,
645
+ '2h': 4_380,
646
+ '4h': 2_190,
647
+ '6h': 1_460,
648
+ '8h': 1_095,
649
+ };
650
+ function assertMinCandles(candles, interval) {
651
+ const min = MIN_CANDLES[interval];
652
+ if (candles.length < min) {
653
+ throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
654
+ }
655
+ }
656
+ function fitModel(candles, periodsPerYear, steps) {
657
+ const returns = calculateReturnsFromPrices(candles.map(c => c.close));
658
+ const leverage = checkLeverageEffect(returns);
659
+ if (leverage.recommendation === 'egarch') {
660
+ const model = new Egarch(candles, { periodsPerYear });
661
+ const fit = model.fit();
662
+ return {
663
+ forecast: model.forecast(fit.params, steps),
664
+ modelType: 'egarch',
665
+ converged: fit.diagnostics.converged,
666
+ persistence: fit.params.persistence,
667
+ varianceSeries: model.getVarianceSeries(fit.params),
668
+ returns: model.getReturns(),
669
+ };
670
+ }
671
+ const model = new Garch(candles, { periodsPerYear });
672
+ const fit = model.fit();
673
+ return {
674
+ forecast: model.forecast(fit.params, steps),
675
+ modelType: 'garch',
676
+ converged: fit.diagnostics.converged,
677
+ persistence: fit.params.persistence,
678
+ varianceSeries: model.getVarianceSeries(fit.params),
679
+ returns: model.getReturns(),
680
+ };
681
+ }
682
+ function checkReliable(fit) {
683
+ if (!fit.converged || fit.persistence >= 0.999)
684
+ return false;
685
+ // Ljung-Box on squared standardized residuals
686
+ const { returns, varianceSeries } = fit;
687
+ const squared = returns.map((r, i) => {
688
+ const z = r / Math.sqrt(varianceSeries[i]);
689
+ return z * z;
690
+ });
691
+ const lb = ljungBox(squared, 10);
692
+ return lb.pValue >= 0.05;
693
+ }
694
+ /**
695
+ * Forecast expected price range for t+1 (next candle).
696
+ *
697
+ * Auto-selects GARCH or EGARCH based on leverage effect.
698
+ * Returns ±1σ price corridor so you can set SL/TP yourself.
699
+ */
700
+ function predict(candles, interval, currentPrice = candles[candles.length - 1].close) {
701
+ assertMinCandles(candles, interval);
702
+ const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], 1);
703
+ const sigma = fit.forecast.volatility[0];
704
+ const move = currentPrice * sigma;
705
+ return {
706
+ modelType: fit.modelType,
707
+ currentPrice,
708
+ sigma,
709
+ move,
710
+ upperPrice: currentPrice + move,
711
+ lowerPrice: currentPrice - move,
712
+ reliable: checkReliable(fit),
713
+ };
714
+ }
715
+ /**
716
+ * Forecast expected price range over multiple candles.
717
+ *
718
+ * Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
719
+ * Use for swing trades where you hold across multiple candles.
720
+ */
721
+ function predictRange(candles, interval, steps, currentPrice = candles[candles.length - 1].close) {
722
+ assertMinCandles(candles, interval);
723
+ const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], steps);
724
+ const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
725
+ const sigma = Math.sqrt(cumulativeVariance);
726
+ const move = currentPrice * sigma;
727
+ return {
728
+ modelType: fit.modelType,
729
+ currentPrice,
730
+ sigma,
731
+ move,
732
+ upperPrice: currentPrice + move,
733
+ lowerPrice: currentPrice - move,
734
+ reliable: checkReliable(fit),
735
+ };
736
+ }
737
+ // ── Backtest ──────────────────────────────────────────────────
738
+ const BACKTEST_REQUIRED_PERCENT = 68;
739
+ const BACKTEST_WINDOW_RATIO = 0.75;
740
+ /**
741
+ * Walk-forward backtest of predict.
742
+ *
743
+ * Window is computed automatically: 75% of candles for fitting, 25% for testing.
744
+ * Throws if not enough candles for the given interval.
745
+ * Returns true if the model's hit rate meets the required threshold.
746
+ * Default threshold is 68% (±1σ should contain ~68% of moves).
747
+ */
748
+ function backtest(candles, interval, requiredPercent = BACKTEST_REQUIRED_PERCENT) {
749
+ assertMinCandles(candles, interval);
750
+ const window = Math.max(MIN_CANDLES[interval], Math.floor(candles.length * BACKTEST_WINDOW_RATIO));
751
+ let hits = 0;
752
+ let total = 0;
753
+ for (let i = window; i < candles.length - 1; i++) {
754
+ const slice = candles.slice(i - window, i + 1);
755
+ const predicted = predict(slice, interval);
756
+ const actual = candles[i + 1].close;
757
+ if (actual >= predicted.lowerPrice && actual <= predicted.upperPrice) {
758
+ hits++;
759
+ }
760
+ total++;
761
+ }
762
+ return (hits / total) * 100 >= requiredPercent;
763
+ }
764
+
518
765
  exports.EXPECTED_ABS_NORMAL = EXPECTED_ABS_NORMAL;
519
766
  exports.Egarch = Egarch;
520
767
  exports.Garch = Garch;
768
+ exports.backtest = backtest;
521
769
  exports.calculateReturns = calculateReturns;
522
770
  exports.calculateReturnsFromPrices = calculateReturnsFromPrices;
523
771
  exports.calibrateEgarch = calibrateEgarch;
524
772
  exports.calibrateGarch = calibrateGarch;
525
773
  exports.checkLeverageEffect = checkLeverageEffect;
774
+ exports.garmanKlassVariance = garmanKlassVariance;
775
+ exports.ljungBox = ljungBox;
526
776
  exports.nelderMead = nelderMead;
777
+ exports.predict = predict;
778
+ exports.predictRange = predictRange;
527
779
  exports.sampleVariance = sampleVariance;
528
780
  exports.sampleVarianceWithMean = sampleVarianceWithMean;
781
+ exports.yangZhangVariance = yangZhangVariance;
package/build/index.mjs CHANGED
@@ -110,7 +110,7 @@ function shrink(simplex, values, sigma, fn, n) {
110
110
  function calculateReturns(candles) {
111
111
  const returns = [];
112
112
  for (let i = 1; i < candles.length; i++) {
113
- if (candles[i].close <= 0 || candles[i - 1].close <= 0) {
113
+ if (!(candles[i].close > 0) || !(candles[i - 1].close > 0)) {
114
114
  throw new Error(`Invalid close price at index ${i}`);
115
115
  }
116
116
  returns.push(Math.log(candles[i].close / candles[i - 1].close));
@@ -123,7 +123,7 @@ function calculateReturns(candles) {
123
123
  function calculateReturnsFromPrices(prices) {
124
124
  const returns = [];
125
125
  for (let i = 1; i < prices.length; i++) {
126
- if (prices[i] <= 0 || prices[i - 1] <= 0) {
126
+ if (!(prices[i] > 0 && Number.isFinite(prices[i])) || !(prices[i - 1] > 0 && Number.isFinite(prices[i - 1]))) {
127
127
  throw new Error(`Invalid price at index ${i}`);
128
128
  }
129
129
  returns.push(Math.log(prices[i] / prices[i - 1]));
@@ -167,11 +167,115 @@ function checkLeverageEffect(returns) {
167
167
  recommendation: ratio > 1.2 ? 'egarch' : 'garch',
168
168
  };
169
169
  }
170
+ /**
171
+ * Garman-Klass (1980) variance estimator using OHLC data.
172
+ *
173
+ * σ²_GK = (1/n) Σ [ 0.5·(ln(H/L))² − (2ln2−1)·(ln(C/O))² ]
174
+ *
175
+ * ~5x more efficient than close-to-close variance.
176
+ */
177
+ function garmanKlassVariance(candles) {
178
+ const n = candles.length;
179
+ const coeff = 2 * Math.LN2 - 1;
180
+ let sum = 0;
181
+ for (let i = 0; i < n; i++) {
182
+ const { open, high, low, close } = candles[i];
183
+ const hl = Math.log(high / low);
184
+ const co = Math.log(close / open);
185
+ sum += 0.5 * hl * hl - coeff * co * co;
186
+ }
187
+ return sum / n;
188
+ }
189
+ /**
190
+ * Yang-Zhang (2000) variance estimator using OHLC data.
191
+ *
192
+ * Combines overnight (open vs prev close), open-to-close,
193
+ * and Rogers-Satchell components. More efficient than Garman-Klass
194
+ * and handles overnight gaps (relevant for stocks).
195
+ *
196
+ * σ²_YZ = σ²_overnight + k·σ²_close + (1−k)·σ²_RS
197
+ */
198
+ function yangZhangVariance(candles) {
199
+ const n = candles.length;
200
+ if (n < 2)
201
+ return garmanKlassVariance(candles);
202
+ const k = 0.34 / (1.34 + (n + 1) / (n - 1));
203
+ let overnightSum = 0;
204
+ let closeSum = 0;
205
+ let rsSum = 0;
206
+ let count = 0;
207
+ for (let i = 1; i < n; i++) {
208
+ const prevClose = candles[i - 1].close;
209
+ const { open, high, low, close } = candles[i];
210
+ const overnight = Math.log(open / prevClose);
211
+ const co = Math.log(close / open);
212
+ const hc = Math.log(high / close);
213
+ const ho = Math.log(high / open);
214
+ const lc = Math.log(low / close);
215
+ const lo = Math.log(low / open);
216
+ overnightSum += overnight * overnight;
217
+ closeSum += co * co;
218
+ rsSum += ho * hc + lo * lc;
219
+ count++;
220
+ }
221
+ const overnightVar = overnightSum / count;
222
+ const closeVar = closeSum / count;
223
+ const rsVar = rsSum / count;
224
+ return overnightVar + k * closeVar + (1 - k) * rsVar;
225
+ }
170
226
  /**
171
227
  * Expected value of |Z| where Z ~ N(0,1)
172
228
  * E[|Z|] = sqrt(2/π)
173
229
  */
174
230
  const EXPECTED_ABS_NORMAL = Math.sqrt(2 / Math.PI);
231
+ /**
232
+ * Chi-squared survival function approximation (Wilson-Hilferty).
233
+ * P(X > x) where X ~ χ²(df)
234
+ */
235
+ function chi2Survival(x, df) {
236
+ if (df <= 0 || x < 0)
237
+ return 1;
238
+ // Wilson-Hilferty normal approximation
239
+ const z = Math.cbrt(x / df) - (1 - 2 / (9 * df));
240
+ const denom = Math.sqrt(2 / (9 * df));
241
+ const normZ = z / denom;
242
+ // Standard normal CDF via error function approximation
243
+ return 1 - normalCDF(normZ);
244
+ }
245
+ function normalCDF(x) {
246
+ const t = 1 / (1 + 0.2316419 * Math.abs(x));
247
+ const d = 0.3989422804014327; // 1/sqrt(2π)
248
+ const p = d * Math.exp(-0.5 * x * x) *
249
+ (t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))));
250
+ return x >= 0 ? 1 - p : p;
251
+ }
252
+ /**
253
+ * Ljung-Box test for autocorrelation.
254
+ *
255
+ * Q = n(n+2) Σ(k=1..m) ρ²_k / (n−k)
256
+ *
257
+ * Under H₀ (no autocorrelation), Q ~ χ²(m).
258
+ * Use on squared standardized residuals to test GARCH adequacy.
259
+ */
260
+ function ljungBox(data, maxLag) {
261
+ const n = data.length;
262
+ const mean = data.reduce((s, v) => s + v, 0) / n;
263
+ const gamma0 = data.reduce((s, v) => s + (v - mean) ** 2, 0) / n;
264
+ if (gamma0 === 0)
265
+ return { statistic: 0, pValue: 1 };
266
+ let Q = 0;
267
+ for (let k = 1; k <= maxLag; k++) {
268
+ let gammaK = 0;
269
+ for (let t = k; t < n; t++) {
270
+ gammaK += (data[t] - mean) * (data[t - k] - mean);
271
+ }
272
+ gammaK /= n;
273
+ const rhoK = gammaK / gamma0;
274
+ Q += (rhoK * rhoK) / (n - k);
275
+ }
276
+ Q *= n * (n + 2);
277
+ return { statistic: Q, pValue: chi2Survival(Q, maxLag) };
278
+ }
175
279
  /**
176
280
  * Calculate AIC (Akaike Information Criterion)
177
281
  */
@@ -208,11 +312,13 @@ class Garch {
208
312
  // Determine if input is candles or prices
209
313
  if (typeof data[0] === 'number') {
210
314
  this.returns = calculateReturnsFromPrices(data);
315
+ this.initialVariance = sampleVariance(this.returns);
211
316
  }
212
317
  else {
213
- this.returns = calculateReturns(data);
318
+ const candles = data;
319
+ this.returns = calculateReturns(candles);
320
+ this.initialVariance = yangZhangVariance(candles);
214
321
  }
215
- this.initialVariance = sampleVariance(this.returns);
216
322
  }
217
323
  /**
218
324
  * Calibrate GARCH(1,1) parameters using Maximum Likelihood Estimation
@@ -254,7 +360,7 @@ class Garch {
254
360
  const persistence = alpha + beta;
255
361
  const unconditionalVariance = omega / (1 - persistence);
256
362
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
257
- const logLikelihood = -result.fx * 2;
363
+ const logLikelihood = -result.fx;
258
364
  const numParams = 3;
259
365
  return {
260
366
  params: {
@@ -360,11 +466,13 @@ class Egarch {
360
466
  }
361
467
  if (typeof data[0] === 'number') {
362
468
  this.returns = calculateReturnsFromPrices(data);
469
+ this.initialVariance = sampleVariance(this.returns);
363
470
  }
364
471
  else {
365
- this.returns = calculateReturns(data);
472
+ const candles = data;
473
+ this.returns = calculateReturns(candles);
474
+ this.initialVariance = yangZhangVariance(candles);
366
475
  }
367
- this.initialVariance = sampleVariance(this.returns);
368
476
  }
369
477
  /**
370
478
  * Calibrate EGARCH(1,1) parameters using Maximum Likelihood Estimation
@@ -413,7 +521,7 @@ class Egarch {
413
521
  const unconditionalLogVar = omega / (1 - beta);
414
522
  const unconditionalVariance = Math.exp(unconditionalLogVar);
415
523
  const annualizedVol = Math.sqrt(unconditionalVariance * this.periodsPerYear) * 100;
416
- const logLikelihood = -result.fx * 2;
524
+ const logLikelihood = -result.fx;
417
525
  const numParams = 4;
418
526
  return {
419
527
  params: {
@@ -513,4 +621,143 @@ function calibrateEgarch(data, options = {}) {
513
621
  return model.fit(options);
514
622
  }
515
623
 
516
- export { EXPECTED_ABS_NORMAL, Egarch, Garch, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, checkLeverageEffect, nelderMead, sampleVariance, sampleVarianceWithMean };
624
+ const MIN_CANDLES = {
625
+ '1m': 500,
626
+ '3m': 500,
627
+ '5m': 500,
628
+ '15m': 300,
629
+ '30m': 200,
630
+ '1h': 200,
631
+ '2h': 200,
632
+ '4h': 200,
633
+ '6h': 150,
634
+ '8h': 150,
635
+ };
636
+ const INTERVALS_PER_YEAR = {
637
+ '1m': 525_600,
638
+ '3m': 175_200,
639
+ '5m': 105_120,
640
+ '15m': 35_040,
641
+ '30m': 17_520,
642
+ '1h': 8_760,
643
+ '2h': 4_380,
644
+ '4h': 2_190,
645
+ '6h': 1_460,
646
+ '8h': 1_095,
647
+ };
648
+ function assertMinCandles(candles, interval) {
649
+ const min = MIN_CANDLES[interval];
650
+ if (candles.length < min) {
651
+ throw new Error(`Need at least ${min} candles for ${interval} interval, got ${candles.length}`);
652
+ }
653
+ }
654
+ function fitModel(candles, periodsPerYear, steps) {
655
+ const returns = calculateReturnsFromPrices(candles.map(c => c.close));
656
+ const leverage = checkLeverageEffect(returns);
657
+ if (leverage.recommendation === 'egarch') {
658
+ const model = new Egarch(candles, { periodsPerYear });
659
+ const fit = model.fit();
660
+ return {
661
+ forecast: model.forecast(fit.params, steps),
662
+ modelType: 'egarch',
663
+ converged: fit.diagnostics.converged,
664
+ persistence: fit.params.persistence,
665
+ varianceSeries: model.getVarianceSeries(fit.params),
666
+ returns: model.getReturns(),
667
+ };
668
+ }
669
+ const model = new Garch(candles, { periodsPerYear });
670
+ const fit = model.fit();
671
+ return {
672
+ forecast: model.forecast(fit.params, steps),
673
+ modelType: 'garch',
674
+ converged: fit.diagnostics.converged,
675
+ persistence: fit.params.persistence,
676
+ varianceSeries: model.getVarianceSeries(fit.params),
677
+ returns: model.getReturns(),
678
+ };
679
+ }
680
+ function checkReliable(fit) {
681
+ if (!fit.converged || fit.persistence >= 0.999)
682
+ return false;
683
+ // Ljung-Box on squared standardized residuals
684
+ const { returns, varianceSeries } = fit;
685
+ const squared = returns.map((r, i) => {
686
+ const z = r / Math.sqrt(varianceSeries[i]);
687
+ return z * z;
688
+ });
689
+ const lb = ljungBox(squared, 10);
690
+ return lb.pValue >= 0.05;
691
+ }
692
+ /**
693
+ * Forecast expected price range for t+1 (next candle).
694
+ *
695
+ * Auto-selects GARCH or EGARCH based on leverage effect.
696
+ * Returns ±1σ price corridor so you can set SL/TP yourself.
697
+ */
698
+ function predict(candles, interval, currentPrice = candles[candles.length - 1].close) {
699
+ assertMinCandles(candles, interval);
700
+ const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], 1);
701
+ const sigma = fit.forecast.volatility[0];
702
+ const move = currentPrice * sigma;
703
+ return {
704
+ modelType: fit.modelType,
705
+ currentPrice,
706
+ sigma,
707
+ move,
708
+ upperPrice: currentPrice + move,
709
+ lowerPrice: currentPrice - move,
710
+ reliable: checkReliable(fit),
711
+ };
712
+ }
713
+ /**
714
+ * Forecast expected price range over multiple candles.
715
+ *
716
+ * Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
717
+ * Use for swing trades where you hold across multiple candles.
718
+ */
719
+ function predictRange(candles, interval, steps, currentPrice = candles[candles.length - 1].close) {
720
+ assertMinCandles(candles, interval);
721
+ const fit = fitModel(candles, INTERVALS_PER_YEAR[interval], steps);
722
+ const cumulativeVariance = fit.forecast.variance.reduce((sum, v) => sum + v, 0);
723
+ const sigma = Math.sqrt(cumulativeVariance);
724
+ const move = currentPrice * sigma;
725
+ return {
726
+ modelType: fit.modelType,
727
+ currentPrice,
728
+ sigma,
729
+ move,
730
+ upperPrice: currentPrice + move,
731
+ lowerPrice: currentPrice - move,
732
+ reliable: checkReliable(fit),
733
+ };
734
+ }
735
+ // ── Backtest ──────────────────────────────────────────────────
736
+ const BACKTEST_REQUIRED_PERCENT = 68;
737
+ const BACKTEST_WINDOW_RATIO = 0.75;
738
+ /**
739
+ * Walk-forward backtest of predict.
740
+ *
741
+ * Window is computed automatically: 75% of candles for fitting, 25% for testing.
742
+ * Throws if not enough candles for the given interval.
743
+ * Returns true if the model's hit rate meets the required threshold.
744
+ * Default threshold is 68% (±1σ should contain ~68% of moves).
745
+ */
746
+ function backtest(candles, interval, requiredPercent = BACKTEST_REQUIRED_PERCENT) {
747
+ assertMinCandles(candles, interval);
748
+ const window = Math.max(MIN_CANDLES[interval], Math.floor(candles.length * BACKTEST_WINDOW_RATIO));
749
+ let hits = 0;
750
+ let total = 0;
751
+ for (let i = window; i < candles.length - 1; i++) {
752
+ const slice = candles.slice(i - window, i + 1);
753
+ const predicted = predict(slice, interval);
754
+ const actual = candles[i + 1].close;
755
+ if (actual >= predicted.lowerPrice && actual <= predicted.upperPrice) {
756
+ hits++;
757
+ }
758
+ total++;
759
+ }
760
+ return (hits / total) * 100 >= requiredPercent;
761
+ }
762
+
763
+ export { EXPECTED_ABS_NORMAL, Egarch, Garch, backtest, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, checkLeverageEffect, garmanKlassVariance, ljungBox, nelderMead, predict, predictRange, sampleVariance, sampleVarianceWithMean, yangZhangVariance };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "garch",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "GARCH and EGARCH volatility models for TypeScript",
5
5
  "type": "module",
6
6
  "main": "./build/index.cjs",
@@ -37,6 +37,7 @@
37
37
  "devDependencies": {
38
38
  "@rollup/plugin-typescript": "^12.3.0",
39
39
  "@types/node": "^20.10.0",
40
+ "@vitest/coverage-v8": "^1.6.1",
40
41
  "rollup": "^4.57.1",
41
42
  "rollup-plugin-dts": "^6.3.0",
42
43
  "rollup-plugin-peer-deps-external": "^2.2.4",
package/types.d.ts CHANGED
@@ -178,11 +178,75 @@ declare function sampleVarianceWithMean(returns: number[]): number;
178
178
  * Check for leverage effect (asymmetry in volatility)
179
179
  */
180
180
  declare function checkLeverageEffect(returns: number[]): LeverageStats;
181
+ /**
182
+ * Garman-Klass (1980) variance estimator using OHLC data.
183
+ *
184
+ * σ²_GK = (1/n) Σ [ 0.5·(ln(H/L))² − (2ln2−1)·(ln(C/O))² ]
185
+ *
186
+ * ~5x more efficient than close-to-close variance.
187
+ */
188
+ declare function garmanKlassVariance(candles: Candle[]): number;
189
+ /**
190
+ * Yang-Zhang (2000) variance estimator using OHLC data.
191
+ *
192
+ * Combines overnight (open vs prev close), open-to-close,
193
+ * and Rogers-Satchell components. More efficient than Garman-Klass
194
+ * and handles overnight gaps (relevant for stocks).
195
+ *
196
+ * σ²_YZ = σ²_overnight + k·σ²_close + (1−k)·σ²_RS
197
+ */
198
+ declare function yangZhangVariance(candles: Candle[]): number;
181
199
  /**
182
200
  * Expected value of |Z| where Z ~ N(0,1)
183
201
  * E[|Z|] = sqrt(2/π)
184
202
  */
185
203
  declare const EXPECTED_ABS_NORMAL: number;
204
+ /**
205
+ * Ljung-Box test for autocorrelation.
206
+ *
207
+ * Q = n(n+2) Σ(k=1..m) ρ²_k / (n−k)
208
+ *
209
+ * Under H₀ (no autocorrelation), Q ~ χ²(m).
210
+ * Use on squared standardized residuals to test GARCH adequacy.
211
+ */
212
+ declare function ljungBox(data: number[], maxLag: number): {
213
+ statistic: number;
214
+ pValue: number;
215
+ };
216
+
217
+ type CandleInterval = '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h';
218
+ interface PredictionResult {
219
+ currentPrice: number;
220
+ sigma: number;
221
+ move: number;
222
+ upperPrice: number;
223
+ lowerPrice: number;
224
+ modelType: 'garch' | 'egarch';
225
+ reliable: boolean;
226
+ }
227
+ /**
228
+ * Forecast expected price range for t+1 (next candle).
229
+ *
230
+ * Auto-selects GARCH or EGARCH based on leverage effect.
231
+ * Returns ±1σ price corridor so you can set SL/TP yourself.
232
+ */
233
+ declare function predict(candles: Candle[], interval: CandleInterval, currentPrice?: number): PredictionResult;
234
+ /**
235
+ * Forecast expected price range over multiple candles.
236
+ *
237
+ * Cumulative σ = √(σ₁² + σ₂² + ... + σₙ²) — total expected move over N periods.
238
+ * Use for swing trades where you hold across multiple candles.
239
+ */
240
+ declare function predictRange(candles: Candle[], interval: CandleInterval, steps: number, currentPrice?: number): PredictionResult;
241
+ /**
242
+ * Walk-forward backtest of predict.
243
+ *
244
+ * Window is computed automatically: 75% of candles for fitting, 25% for testing.
245
+ * Throws if not enough candles for the given interval.
246
+ * Returns true if the model's hit rate meets the required threshold.
247
+ * Default threshold is 68% (±1σ should contain ~68% of moves).
248
+ */
249
+ declare function backtest(candles: Candle[], interval: CandleInterval, requiredPercent?: number): boolean;
186
250
 
187
251
  declare function nelderMead(fn: (x: number[]) => number, x0: number[], options?: {
188
252
  maxIter?: number;
@@ -193,5 +257,5 @@ declare function nelderMead(fn: (x: number[]) => number, x0: number[], options?:
193
257
  sigma?: number;
194
258
  }): OptimizerResult;
195
259
 
196
- export { EXPECTED_ABS_NORMAL, Egarch, Garch, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, checkLeverageEffect, nelderMead, sampleVariance, sampleVarianceWithMean };
197
- export type { CalibrationResult, Candle, EgarchOptions, EgarchParams, GarchOptions, GarchParams, LeverageStats, OptimizerResult, VolatilityForecast };
260
+ export { EXPECTED_ABS_NORMAL, Egarch, Garch, backtest, calculateReturns, calculateReturnsFromPrices, calibrateEgarch, calibrateGarch, checkLeverageEffect, garmanKlassVariance, ljungBox, nelderMead, predict, predictRange, sampleVariance, sampleVarianceWithMean, yangZhangVariance };
261
+ export type { CalibrationResult, Candle, CandleInterval, EgarchOptions, EgarchParams, GarchOptions, GarchParams, LeverageStats, OptimizerResult, PredictionResult, VolatilityForecast };