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 +175 -121
- package/build/index.cjs +261 -8
- package/build/index.mjs +256 -9
- package/package.json +2 -1
- package/types.d.ts +66 -2
package/README.md
CHANGED
|
@@ -13,193 +13,247 @@
|
|
|
13
13
|
npm install garch
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## API
|
|
17
17
|
|
|
18
|
-
###
|
|
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 {
|
|
23
|
+
import { predict } from 'garch';
|
|
24
|
+
import type { Candle } from 'garch';
|
|
22
25
|
|
|
23
|
-
|
|
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
|
-
|
|
28
|
+
const result = predict(candles, '4h');
|
|
28
29
|
// {
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
//
|
|
38
|
-
const
|
|
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
|
-
|
|
43
|
+
**Parameters:**
|
|
50
44
|
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
```typescript
|
|
72
|
+
import { predictRange } from 'garch';
|
|
73
|
+
|
|
74
|
+
const range = predictRange(candles, '4h', 5);
|
|
66
75
|
// {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
-
|
|
86
|
+
**Parameters:**
|
|
78
87
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
```
|
|
97
|
+
---
|
|
90
98
|
|
|
91
|
-
###
|
|
99
|
+
### `backtest(candles, interval, requiredPercent?)`
|
|
92
100
|
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
103
|
+
```typescript
|
|
104
|
+
import { backtest } from 'garch';
|
|
98
105
|
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|
-
|
|
110
|
+
**Parameters:**
|
|
106
111
|
|
|
107
|
-
|
|
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
|
-
|
|
118
|
+
**Returns:** `boolean`
|
|
110
119
|
|
|
111
|
-
|
|
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
|
-
|
|
122
|
+
## Supported Intervals
|
|
118
123
|
|
|
119
|
-
|
|
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
|
-
|
|
137
|
+
## Timeframes
|
|
122
138
|
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
Lower timeframes contain more microstructure noise — use larger datasets to compensate.
|
|
128
155
|
|
|
129
|
-
|
|
156
|
+
## Math
|
|
130
157
|
|
|
131
|
-
|
|
158
|
+
### GARCH(1,1)
|
|
132
159
|
|
|
133
|
-
|
|
160
|
+
Conditional variance model (Bollerslev, 1986):
|
|
134
161
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
Parameter estimation via **Gaussian MLE** (maximum likelihood):
|
|
144
173
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
154
|
-
// Daily candles (default)
|
|
155
|
-
calibrateGarch(prices);
|
|
178
|
+
Multi-step forecast converges to unconditional variance:
|
|
156
179
|
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
**
|
|
192
|
+
Where **z_t = epsilon_t / sigma_t** is the standardized residual, **sqrt(2/pi) ~ 0.7979**.
|
|
165
193
|
|
|
166
|
-
**
|
|
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
|
-
|
|
199
|
+
### Model Auto-Selection
|
|
169
200
|
|
|
170
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
217
|
+
**Garman-Klass** (fallback):
|
|
182
218
|
|
|
183
219
|
```
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
- No positivity constraints needed (models log-variance)
|
|
189
|
-
- `|β|` < 1 for stationarity
|
|
223
|
+
~5x more efficient than close-to-close variance.
|
|
190
224
|
|
|
191
|
-
###
|
|
225
|
+
### Reliability Check
|
|
192
226
|
|
|
193
|
-
|
|
194
|
-
import { calibrateGarch, calibrateEgarch } from 'garch';
|
|
227
|
+
The `reliable` flag in `PredictionResult` is `true` when all three conditions hold:
|
|
195
228
|
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 };
|