@openfinclaw/fin-strategy-engine 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/index.test.ts +269 -0
- package/index.ts +578 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +40 -0
- package/src/backtest-engine.live.test.ts +313 -0
- package/src/backtest-engine.test.ts +368 -0
- package/src/backtest-engine.ts +362 -0
- package/src/builtin-strategies/bollinger-bands.test.ts +96 -0
- package/src/builtin-strategies/bollinger-bands.ts +75 -0
- package/src/builtin-strategies/custom-rule-engine.ts +274 -0
- package/src/builtin-strategies/macd-divergence.test.ts +122 -0
- package/src/builtin-strategies/macd-divergence.ts +77 -0
- package/src/builtin-strategies/multi-timeframe-confluence.test.ts +287 -0
- package/src/builtin-strategies/multi-timeframe-confluence.ts +253 -0
- package/src/builtin-strategies/regime-adaptive.test.ts +210 -0
- package/src/builtin-strategies/regime-adaptive.ts +285 -0
- package/src/builtin-strategies/risk-parity-triple-screen.test.ts +295 -0
- package/src/builtin-strategies/risk-parity-triple-screen.ts +295 -0
- package/src/builtin-strategies/rsi-mean-reversion.test.ts +143 -0
- package/src/builtin-strategies/rsi-mean-reversion.ts +74 -0
- package/src/builtin-strategies/sma-crossover.test.ts +113 -0
- package/src/builtin-strategies/sma-crossover.ts +85 -0
- package/src/builtin-strategies/trend-following-momentum.test.ts +228 -0
- package/src/builtin-strategies/trend-following-momentum.ts +209 -0
- package/src/builtin-strategies/volatility-mean-reversion.test.ts +193 -0
- package/src/builtin-strategies/volatility-mean-reversion.ts +212 -0
- package/src/composite-pipeline.live.test.ts +347 -0
- package/src/e2e-pipeline.test.ts +494 -0
- package/src/fitness.test.ts +103 -0
- package/src/fitness.ts +61 -0
- package/src/full-pipeline.live.test.ts +339 -0
- package/src/indicators.test.ts +224 -0
- package/src/indicators.ts +238 -0
- package/src/stats.test.ts +215 -0
- package/src/stats.ts +115 -0
- package/src/strategy-registry.test.ts +235 -0
- package/src/strategy-registry.ts +183 -0
- package/src/types.ts +19 -0
- package/src/walk-forward.test.ts +185 -0
- package/src/walk-forward.ts +114 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-pipeline E2E acceptance test: strategy → backtest → walk-forward → paper trading
|
|
3
|
+
*
|
|
4
|
+
* Proves the complete quant fund pipeline on Binance Testnet:
|
|
5
|
+
* 1. Connect to Binance testnet, fetch real OHLCV for 3 pairs
|
|
6
|
+
* 2. Create SMA crossover strategies for BTC/USDT, ETH/USDT, SOL/USDT
|
|
7
|
+
* 3. Backtest each strategy on real historical data
|
|
8
|
+
* 4. Walk-forward validate each strategy
|
|
9
|
+
* 5. Paper-trade all 3 strategies on live testnet prices
|
|
10
|
+
* 6. Verify P&L, metrics, and persistence
|
|
11
|
+
*
|
|
12
|
+
* Requires env vars:
|
|
13
|
+
* BINANCE_TESTNET_API_KEY
|
|
14
|
+
* BINANCE_TESTNET_SECRET
|
|
15
|
+
*
|
|
16
|
+
* Run:
|
|
17
|
+
* LIVE=1 pnpm test:live -- extensions/fin-strategy-engine/src/full-pipeline.live.test.ts
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
23
|
+
import { ExchangeRegistry } from "../../fin-core/src/exchange-registry.js";
|
|
24
|
+
import { PaperEngine } from "../../fin-paper-trading/src/paper-engine.js";
|
|
25
|
+
import { PaperStore } from "../../fin-paper-trading/src/paper-store.js";
|
|
26
|
+
import type { OHLCV } from "../../fin-shared-types/src/types.js";
|
|
27
|
+
import {
|
|
28
|
+
createCryptoAdapter,
|
|
29
|
+
type CcxtExchange,
|
|
30
|
+
} from "../../findoo-datahub-plugin/src/adapters/crypto-adapter.js";
|
|
31
|
+
import { OHLCVCache } from "../../findoo-datahub-plugin/src/ohlcv-cache.js";
|
|
32
|
+
import { BacktestEngine } from "./backtest-engine.js";
|
|
33
|
+
import { createSmaCrossover } from "./builtin-strategies/sma-crossover.js";
|
|
34
|
+
import type { BacktestConfig, BacktestResult } from "./types.js";
|
|
35
|
+
import { WalkForward } from "./walk-forward.js";
|
|
36
|
+
|
|
37
|
+
const LIVE = process.env.LIVE === "1" || process.env.BINANCE_E2E === "1";
|
|
38
|
+
const API_KEY = process.env.BINANCE_TESTNET_API_KEY ?? "";
|
|
39
|
+
const SECRET = process.env.BINANCE_TESTNET_SECRET ?? "";
|
|
40
|
+
|
|
41
|
+
const PAIRS = ["BTC/USDT", "ETH/USDT", "SOL/USDT"] as const;
|
|
42
|
+
|
|
43
|
+
describe.skipIf(!LIVE || !API_KEY || !SECRET)("Full Pipeline E2E — Binance Testnet", () => {
|
|
44
|
+
let registry: ExchangeRegistry;
|
|
45
|
+
let cache: OHLCVCache;
|
|
46
|
+
let adapter: ReturnType<typeof createCryptoAdapter>;
|
|
47
|
+
let backtestEngine: BacktestEngine;
|
|
48
|
+
let walkForward: WalkForward;
|
|
49
|
+
let paperEngine: PaperEngine;
|
|
50
|
+
let paperStore: PaperStore;
|
|
51
|
+
let tmpDir: string;
|
|
52
|
+
|
|
53
|
+
// Shared state between sequential test steps
|
|
54
|
+
const ohlcvData: Record<string, OHLCV[]> = {};
|
|
55
|
+
const backtestResults: Record<string, BacktestResult> = {};
|
|
56
|
+
const livePrices: Record<string, number> = {};
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
registry = new ExchangeRegistry();
|
|
60
|
+
registry.addExchange("binance-testnet", {
|
|
61
|
+
exchange: "binance",
|
|
62
|
+
apiKey: API_KEY,
|
|
63
|
+
secret: SECRET,
|
|
64
|
+
testnet: true,
|
|
65
|
+
defaultType: "spot",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
tmpDir = mkdtempSync(join(tmpdir(), "full-pipeline-e2e-"));
|
|
69
|
+
|
|
70
|
+
cache = new OHLCVCache(join(tmpDir, "ohlcv-cache.sqlite"));
|
|
71
|
+
adapter = createCryptoAdapter(
|
|
72
|
+
cache,
|
|
73
|
+
() => registry.getInstance("binance-testnet") as Promise<CcxtExchange>,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
backtestEngine = new BacktestEngine();
|
|
77
|
+
walkForward = new WalkForward(backtestEngine);
|
|
78
|
+
|
|
79
|
+
paperStore = new PaperStore(join(tmpDir, "paper.sqlite"));
|
|
80
|
+
paperEngine = new PaperEngine({ store: paperStore, slippageBps: 5, market: "crypto" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
cache?.close();
|
|
85
|
+
paperStore?.close();
|
|
86
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
87
|
+
await registry.closeAll();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------------
|
|
91
|
+
// Step 1: Fetch real OHLCV data for all 3 pairs
|
|
92
|
+
// ------------------------------------------------------------------
|
|
93
|
+
it("step 1: fetches OHLCV from Binance testnet for 3 pairs", async () => {
|
|
94
|
+
for (const symbol of PAIRS) {
|
|
95
|
+
const data = await adapter.getOHLCV({
|
|
96
|
+
symbol,
|
|
97
|
+
timeframe: "1h",
|
|
98
|
+
limit: 200,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(data.length).toBeGreaterThan(50);
|
|
102
|
+
expect(data[0]!.open).toBeGreaterThan(0);
|
|
103
|
+
expect(data[0]!.close).toBeGreaterThan(0);
|
|
104
|
+
expect(data[0]!.timestamp).toBeGreaterThan(0);
|
|
105
|
+
|
|
106
|
+
ohlcvData[symbol] = data;
|
|
107
|
+
console.log(
|
|
108
|
+
` [1] ${symbol}: ${data.length} bars (${data[0]!.close.toFixed(2)} → ${data[data.length - 1]!.close.toFixed(2)})`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}, 30_000);
|
|
112
|
+
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
// Step 2: Create SMA crossover strategies and run backtests
|
|
115
|
+
// ------------------------------------------------------------------
|
|
116
|
+
it("step 2: backtests SMA crossover for each pair", async () => {
|
|
117
|
+
const config: BacktestConfig = {
|
|
118
|
+
capital: 10_000,
|
|
119
|
+
commissionRate: 0.001,
|
|
120
|
+
slippageBps: 5,
|
|
121
|
+
market: "crypto",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const symbol of PAIRS) {
|
|
125
|
+
const strategy = createSmaCrossover({ fastPeriod: 5, slowPeriod: 20, sizePct: 90 });
|
|
126
|
+
strategy.id = `sma-${symbol.replace("/", "-").toLowerCase()}`;
|
|
127
|
+
strategy.symbols = [symbol];
|
|
128
|
+
|
|
129
|
+
const data = ohlcvData[symbol]!;
|
|
130
|
+
const result = await backtestEngine.run(strategy, data, config);
|
|
131
|
+
|
|
132
|
+
expect(result.equityCurve.length).toBe(data.length);
|
|
133
|
+
expect(result.initialCapital).toBe(10_000);
|
|
134
|
+
expect(typeof result.sharpe).toBe("number");
|
|
135
|
+
expect(typeof result.totalReturn).toBe("number");
|
|
136
|
+
|
|
137
|
+
backtestResults[symbol] = result;
|
|
138
|
+
console.log(
|
|
139
|
+
` [2] ${symbol}: return=${result.totalReturn.toFixed(2)}%, sharpe=${result.sharpe.toFixed(3)}, trades=${result.totalTrades}, final=$${result.finalEquity.toFixed(2)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------------
|
|
145
|
+
// Step 3: Walk-forward validation
|
|
146
|
+
// ------------------------------------------------------------------
|
|
147
|
+
it("step 3: walk-forward validates strategies", async () => {
|
|
148
|
+
const config: BacktestConfig = {
|
|
149
|
+
capital: 10_000,
|
|
150
|
+
commissionRate: 0.001,
|
|
151
|
+
slippageBps: 5,
|
|
152
|
+
market: "crypto",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
for (const symbol of PAIRS) {
|
|
156
|
+
const data = ohlcvData[symbol]!;
|
|
157
|
+
if (data.length < 60) {
|
|
158
|
+
console.log(` [3] ${symbol}: skipped (only ${data.length} bars, need 60+)`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const strategy = createSmaCrossover({ fastPeriod: 5, slowPeriod: 20, sizePct: 90 });
|
|
163
|
+
const result = await walkForward.validate(strategy, data, config, {
|
|
164
|
+
windows: 3,
|
|
165
|
+
threshold: 0.3, // relaxed for real testnet data
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.windows.length).toBeGreaterThan(0);
|
|
169
|
+
expect(typeof result.combinedTestSharpe).toBe("number");
|
|
170
|
+
expect(typeof result.avgTrainSharpe).toBe("number");
|
|
171
|
+
expect(typeof result.ratio).toBe("number");
|
|
172
|
+
|
|
173
|
+
console.log(
|
|
174
|
+
` [3] ${symbol}: passed=${result.passed}, ratio=${result.ratio.toFixed(3)}, testSharpe=${result.combinedTestSharpe.toFixed(3)}, trainSharpe=${result.avgTrainSharpe.toFixed(3)}, windows=${result.windows.length}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ------------------------------------------------------------------
|
|
180
|
+
// Step 4: Fetch live prices and paper-trade all 3 pairs
|
|
181
|
+
// ------------------------------------------------------------------
|
|
182
|
+
let accountId = "";
|
|
183
|
+
|
|
184
|
+
it("step 4: paper-trades all 3 pairs with live testnet prices", async () => {
|
|
185
|
+
// Fetch live prices
|
|
186
|
+
for (const symbol of PAIRS) {
|
|
187
|
+
const ticker = await adapter.getTicker(symbol);
|
|
188
|
+
expect(ticker.last).toBeGreaterThan(0);
|
|
189
|
+
livePrices[symbol] = ticker.last;
|
|
190
|
+
console.log(` [4] Live ${symbol}: $${ticker.last.toFixed(2)}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create paper account
|
|
194
|
+
const account = paperEngine.createAccount("Full Pipeline E2E", 100_000);
|
|
195
|
+
accountId = account.id;
|
|
196
|
+
expect(account.cash).toBe(100_000);
|
|
197
|
+
|
|
198
|
+
// Buy each pair
|
|
199
|
+
const quantities: Record<string, number> = {
|
|
200
|
+
"BTC/USDT": 0.01,
|
|
201
|
+
"ETH/USDT": 0.1,
|
|
202
|
+
"SOL/USDT": 1.0,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
for (const symbol of PAIRS) {
|
|
206
|
+
const order = paperEngine.submitOrder(
|
|
207
|
+
accountId,
|
|
208
|
+
{
|
|
209
|
+
symbol,
|
|
210
|
+
side: "buy",
|
|
211
|
+
type: "market",
|
|
212
|
+
quantity: quantities[symbol]!,
|
|
213
|
+
reason: `Pipeline E2E buy ${symbol}`,
|
|
214
|
+
strategyId: `sma-${symbol.replace("/", "-").toLowerCase()}`,
|
|
215
|
+
},
|
|
216
|
+
livePrices[symbol]!,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(order.status).toBe("filled");
|
|
220
|
+
expect(order.fillPrice).toBeGreaterThan(0);
|
|
221
|
+
expect(order.commission).toBeGreaterThan(0);
|
|
222
|
+
console.log(
|
|
223
|
+
` [4] Bought ${quantities[symbol]} ${symbol} @ $${order.fillPrice!.toFixed(2)} (comm: $${order.commission!.toFixed(4)})`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const state = paperEngine.getAccountState(accountId)!;
|
|
228
|
+
expect(state.positions).toHaveLength(3);
|
|
229
|
+
expect(state.cash).toBeLessThan(100_000);
|
|
230
|
+
console.log(
|
|
231
|
+
` [4] 3 positions open — cash: $${state.cash.toFixed(2)}, equity: $${state.equity.toFixed(2)}`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
paperEngine.recordSnapshot(accountId);
|
|
235
|
+
}, 30_000);
|
|
236
|
+
|
|
237
|
+
// ------------------------------------------------------------------
|
|
238
|
+
// Step 5: Sell all positions, verify P&L
|
|
239
|
+
// ------------------------------------------------------------------
|
|
240
|
+
it("step 5: sells all positions and verifies P&L", async () => {
|
|
241
|
+
const quantities: Record<string, number> = {
|
|
242
|
+
"BTC/USDT": 0.01,
|
|
243
|
+
"ETH/USDT": 0.1,
|
|
244
|
+
"SOL/USDT": 1.0,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Fetch updated prices
|
|
248
|
+
for (const symbol of PAIRS) {
|
|
249
|
+
const ticker = await adapter.getTicker(symbol);
|
|
250
|
+
livePrices[symbol] = ticker.last;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Sell each pair
|
|
254
|
+
for (const symbol of PAIRS) {
|
|
255
|
+
const order = paperEngine.submitOrder(
|
|
256
|
+
accountId,
|
|
257
|
+
{
|
|
258
|
+
symbol,
|
|
259
|
+
side: "sell",
|
|
260
|
+
type: "market",
|
|
261
|
+
quantity: quantities[symbol]!,
|
|
262
|
+
reason: `Pipeline E2E sell ${symbol}`,
|
|
263
|
+
strategyId: `sma-${symbol.replace("/", "-").toLowerCase()}`,
|
|
264
|
+
},
|
|
265
|
+
livePrices[symbol]!,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(order.status).toBe("filled");
|
|
269
|
+
expect(order.fillPrice).toBeGreaterThan(0);
|
|
270
|
+
console.log(` [5] Sold ${quantities[symbol]} ${symbol} @ $${order.fillPrice!.toFixed(2)}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const state = paperEngine.getAccountState(accountId)!;
|
|
274
|
+
expect(state.positions).toHaveLength(0);
|
|
275
|
+
|
|
276
|
+
const totalPnl = state.cash - 100_000;
|
|
277
|
+
console.log(` [5] All closed — cash: $${state.cash.toFixed(2)}, P&L: $${totalPnl.toFixed(4)}`);
|
|
278
|
+
|
|
279
|
+
paperEngine.recordSnapshot(accountId);
|
|
280
|
+
}, 30_000);
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------------
|
|
283
|
+
// Step 6: Verify persistence, metrics, and full pipeline integrity
|
|
284
|
+
// ------------------------------------------------------------------
|
|
285
|
+
it("step 6: verifies persistence, metrics, and pipeline integrity", () => {
|
|
286
|
+
// Reload from same SQLite
|
|
287
|
+
const engine2 = new PaperEngine({ store: paperStore, slippageBps: 5, market: "crypto" });
|
|
288
|
+
const reloaded = engine2.getAccountState(accountId);
|
|
289
|
+
expect(reloaded).not.toBeNull();
|
|
290
|
+
expect(reloaded!.name).toBe("Full Pipeline E2E");
|
|
291
|
+
expect(reloaded!.positions).toHaveLength(0);
|
|
292
|
+
|
|
293
|
+
// Orders
|
|
294
|
+
const orders = paperStore.getOrders(accountId);
|
|
295
|
+
const buys = orders.filter((o) => o.side === "buy" && o.status === "filled");
|
|
296
|
+
const sells = orders.filter((o) => o.side === "sell" && o.status === "filled");
|
|
297
|
+
expect(buys.length).toBe(3);
|
|
298
|
+
expect(sells.length).toBe(3);
|
|
299
|
+
|
|
300
|
+
// All 3 pairs present
|
|
301
|
+
const symbols = new Set(buys.map((o) => o.symbol));
|
|
302
|
+
for (const pair of PAIRS) {
|
|
303
|
+
expect(symbols.has(pair)).toBe(true);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Snapshots
|
|
307
|
+
const snapshots = paperStore.getSnapshots(accountId);
|
|
308
|
+
expect(snapshots.length).toBeGreaterThanOrEqual(2);
|
|
309
|
+
|
|
310
|
+
// Listing
|
|
311
|
+
const list = engine2.listAccounts();
|
|
312
|
+
expect(list.find((a) => a.id === accountId)).toBeDefined();
|
|
313
|
+
|
|
314
|
+
// Backtest results exist for all pairs
|
|
315
|
+
for (const symbol of PAIRS) {
|
|
316
|
+
expect(backtestResults[symbol]).toBeDefined();
|
|
317
|
+
expect(backtestResults[symbol]!.equityCurve.length).toBeGreaterThan(0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
console.log(` [6] Pipeline integrity verified:`);
|
|
321
|
+
console.log(` Orders: ${buys.length} buys + ${sells.length} sells`);
|
|
322
|
+
console.log(` Symbols: ${[...symbols].join(", ")}`);
|
|
323
|
+
console.log(` Snapshots: ${snapshots.length}`);
|
|
324
|
+
console.log(
|
|
325
|
+
` OHLCV bars: ${Object.entries(ohlcvData)
|
|
326
|
+
.map(([s, d]) => `${s}=${d.length}`)
|
|
327
|
+
.join(", ")}`,
|
|
328
|
+
);
|
|
329
|
+
console.log(
|
|
330
|
+
` Backtests: ${Object.entries(backtestResults)
|
|
331
|
+
.map(([s, r]) => `${s}=${r.totalReturn.toFixed(1)}%`)
|
|
332
|
+
.join(", ")}`,
|
|
333
|
+
);
|
|
334
|
+
console.log(` ---`);
|
|
335
|
+
console.log(
|
|
336
|
+
` ACCEPTANCE: Full pipeline E2E passed — strategy→backtest→walk-forward→paper-trade on Binance testnet.`,
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { sma, ema, rsi, macd, bollingerBands, atr } from "./indicators.js";
|
|
3
|
+
|
|
4
|
+
/** Helper: check relative error is within tolerance. */
|
|
5
|
+
function expectClose(actual: number, expected: number, tolerance = 0.0001) {
|
|
6
|
+
if (expected === 0) {
|
|
7
|
+
expect(Math.abs(actual)).toBeLessThan(tolerance);
|
|
8
|
+
} else {
|
|
9
|
+
expect(Math.abs((actual - expected) / expected)).toBeLessThan(tolerance);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("sma", () => {
|
|
14
|
+
it("computes simple moving average for a basic sequence", () => {
|
|
15
|
+
const result = sma([1, 2, 3, 4, 5], 3);
|
|
16
|
+
expect(result).toHaveLength(5);
|
|
17
|
+
expect(result[0]).toBeNaN();
|
|
18
|
+
expect(result[1]).toBeNaN();
|
|
19
|
+
expectClose(result[2], 2);
|
|
20
|
+
expectClose(result[3], 3);
|
|
21
|
+
expectClose(result[4], 4);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns empty array for empty input", () => {
|
|
25
|
+
expect(sma([], 3)).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns all NaN when period > data length", () => {
|
|
29
|
+
const result = sma([1, 2, 3], 5);
|
|
30
|
+
expect(result).toHaveLength(3);
|
|
31
|
+
result.forEach((v) => expect(v).toBeNaN());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("period=1 returns the input values", () => {
|
|
35
|
+
const data = [10, 20, 30];
|
|
36
|
+
const result = sma(data, 1);
|
|
37
|
+
expect(result).toEqual(data);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("ema", () => {
|
|
42
|
+
it("period=1 returns same as input", () => {
|
|
43
|
+
const data = [10, 20, 30, 40];
|
|
44
|
+
const result = ema(data, 1);
|
|
45
|
+
expect(result).toEqual(data);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("computes EMA for a known sequence", () => {
|
|
49
|
+
// EMA(10): multiplier = 2/(10+1) = 0.1818...
|
|
50
|
+
// Seed with SMA of first 10 values
|
|
51
|
+
const data = [
|
|
52
|
+
22.27, 22.19, 22.08, 22.17, 22.18, 22.13, 22.23, 22.43, 22.24, 22.29, 22.15, 22.39, 22.38,
|
|
53
|
+
22.61, 23.36,
|
|
54
|
+
];
|
|
55
|
+
const result = ema(data, 10);
|
|
56
|
+
expect(result).toHaveLength(15);
|
|
57
|
+
// First 9 should be NaN
|
|
58
|
+
for (let i = 0; i < 9; i++) {
|
|
59
|
+
expect(result[i]).toBeNaN();
|
|
60
|
+
}
|
|
61
|
+
// Index 9 = SMA of first 10 = (22.27+22.19+22.08+22.17+22.18+22.13+22.23+22.43+22.24+22.29)/10 = 22.221
|
|
62
|
+
expectClose(result[9], 22.221);
|
|
63
|
+
// Index 10: EMA = 22.15*0.1818 + 22.221*(1-0.1818) = 22.2081...
|
|
64
|
+
expectClose(result[10], 22.2081, 0.001);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns empty for empty input", () => {
|
|
68
|
+
expect(ema([], 3)).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("all NaN when period > data length", () => {
|
|
72
|
+
const result = ema([1, 2], 5);
|
|
73
|
+
result.forEach((v) => expect(v).toBeNaN());
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("rsi", () => {
|
|
78
|
+
it("all up moves → RSI near 100", () => {
|
|
79
|
+
const data = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25];
|
|
80
|
+
const result = rsi(data, 14);
|
|
81
|
+
const lastValid = result.filter((v) => !Number.isNaN(v));
|
|
82
|
+
expect(lastValid.length).toBeGreaterThan(0);
|
|
83
|
+
lastValid.forEach((v) => expect(v).toBeCloseTo(100, 0));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("all down moves → RSI near 0", () => {
|
|
87
|
+
const data = [25, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10];
|
|
88
|
+
const result = rsi(data, 14);
|
|
89
|
+
const lastValid = result.filter((v) => !Number.isNaN(v));
|
|
90
|
+
expect(lastValid.length).toBeGreaterThan(0);
|
|
91
|
+
lastValid.forEach((v) => expect(v).toBeCloseTo(0, 0));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("known sequence produces values in 0-100 range", () => {
|
|
95
|
+
const data = [
|
|
96
|
+
44, 44.34, 44.09, 43.61, 44.33, 44.83, 45.1, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28,
|
|
97
|
+
46.28, 46.0, 46.03, 46.41, 46.22, 45.64,
|
|
98
|
+
];
|
|
99
|
+
const result = rsi(data, 14);
|
|
100
|
+
const valid = result.filter((v) => !Number.isNaN(v));
|
|
101
|
+
valid.forEach((v) => {
|
|
102
|
+
expect(v).toBeGreaterThanOrEqual(0);
|
|
103
|
+
expect(v).toBeLessThanOrEqual(100);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns NaN for insufficient data", () => {
|
|
108
|
+
const result = rsi([1, 2, 3], 14);
|
|
109
|
+
result.forEach((v) => expect(v).toBeNaN());
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("macd", () => {
|
|
114
|
+
it("uses default parameters 12/26/9", () => {
|
|
115
|
+
// Generate 50 data points
|
|
116
|
+
const data = Array.from({ length: 50 }, (_, i) => 100 + Math.sin(i * 0.5) * 10);
|
|
117
|
+
const result = macd(data);
|
|
118
|
+
expect(result.macd).toHaveLength(50);
|
|
119
|
+
expect(result.signal).toHaveLength(50);
|
|
120
|
+
expect(result.histogram).toHaveLength(50);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("histogram = macd - signal", () => {
|
|
124
|
+
const data = Array.from({ length: 50 }, (_, i) => 100 + i * 0.5 + Math.sin(i) * 3);
|
|
125
|
+
const result = macd(data);
|
|
126
|
+
for (let i = 0; i < result.histogram.length; i++) {
|
|
127
|
+
if (!Number.isNaN(result.macd[i]) && !Number.isNaN(result.signal[i])) {
|
|
128
|
+
expectClose(result.histogram[i], result.macd[i] - result.signal[i], 0.0001);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("custom parameters produce valid output", () => {
|
|
134
|
+
const data = Array.from({ length: 40 }, (_, i) => 50 + i);
|
|
135
|
+
const result = macd(data, 5, 10, 3);
|
|
136
|
+
expect(result.macd).toHaveLength(40);
|
|
137
|
+
// Valid MACD values should appear after slow period
|
|
138
|
+
const validMacd = result.macd.filter((v) => !Number.isNaN(v));
|
|
139
|
+
expect(validMacd.length).toBeGreaterThan(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns all NaN for insufficient data", () => {
|
|
143
|
+
const result = macd([1, 2, 3]);
|
|
144
|
+
result.macd.forEach((v) => expect(v).toBeNaN());
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("bollingerBands", () => {
|
|
149
|
+
it("middle band equals SMA", () => {
|
|
150
|
+
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
|
|
151
|
+
const bb = bollingerBands(data, 20, 2);
|
|
152
|
+
const smaResult = sma(data, 20);
|
|
153
|
+
for (let i = 0; i < data.length; i++) {
|
|
154
|
+
if (!Number.isNaN(smaResult[i])) {
|
|
155
|
+
expectClose(bb.middle[i], smaResult[i]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("bands are symmetric around middle", () => {
|
|
161
|
+
const data = [20, 21, 22, 19, 18, 20, 22, 24, 23, 21, 20, 19, 21, 23, 22, 20, 21, 22, 20, 19];
|
|
162
|
+
const bb = bollingerBands(data, 20, 2);
|
|
163
|
+
for (let i = 0; i < data.length; i++) {
|
|
164
|
+
if (!Number.isNaN(bb.middle[i])) {
|
|
165
|
+
const upperDiff = bb.upper[i] - bb.middle[i];
|
|
166
|
+
const lowerDiff = bb.middle[i] - bb.lower[i];
|
|
167
|
+
expectClose(upperDiff, lowerDiff);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("upper > middle > lower for non-constant data", () => {
|
|
173
|
+
const data = [20, 21, 22, 19, 18, 20, 22, 24, 23, 21, 20, 19, 21, 23, 22, 20, 21, 22, 20, 19];
|
|
174
|
+
const bb = bollingerBands(data, 20, 2);
|
|
175
|
+
for (let i = 0; i < data.length; i++) {
|
|
176
|
+
if (!Number.isNaN(bb.middle[i])) {
|
|
177
|
+
expect(bb.upper[i]).toBeGreaterThan(bb.middle[i]);
|
|
178
|
+
expect(bb.middle[i]).toBeGreaterThan(bb.lower[i]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns NaN for insufficient data", () => {
|
|
184
|
+
const bb = bollingerBands([1, 2], 20, 2);
|
|
185
|
+
bb.upper.forEach((v) => expect(v).toBeNaN());
|
|
186
|
+
bb.middle.forEach((v) => expect(v).toBeNaN());
|
|
187
|
+
bb.lower.forEach((v) => expect(v).toBeNaN());
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("atr", () => {
|
|
192
|
+
it("all same price → ATR = 0", () => {
|
|
193
|
+
const price = [100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100];
|
|
194
|
+
const result = atr(price, price, price, 14);
|
|
195
|
+
const valid = result.filter((v) => !Number.isNaN(v));
|
|
196
|
+
valid.forEach((v) => expectClose(v, 0));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("computes ATR for known OHLC data", () => {
|
|
200
|
+
// Simple test: highs always 2 above close, lows always 2 below close
|
|
201
|
+
const closes = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62];
|
|
202
|
+
const highs = closes.map((c) => c + 2);
|
|
203
|
+
const lows = closes.map((c) => c - 2);
|
|
204
|
+
const result = atr(highs, lows, closes, 14);
|
|
205
|
+
// True range for each bar (after first): max(H-L, |H-prevC|, |L-prevC|)
|
|
206
|
+
// H-L = 4, |H-prevC| = |c+2 - (c-1)| = 3, |L-prevC| = |c-2 - (c-1)| = 3
|
|
207
|
+
// So TR = 4 for each bar
|
|
208
|
+
const valid = result.filter((v) => !Number.isNaN(v));
|
|
209
|
+
expect(valid.length).toBeGreaterThan(0);
|
|
210
|
+
valid.forEach((v) => expectClose(v, 4, 0.01));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns correct length and has NaN for warm-up period", () => {
|
|
214
|
+
const closes = Array.from({ length: 30 }, (_, i) => 100 + i);
|
|
215
|
+
const highs = closes.map((c) => c + 1);
|
|
216
|
+
const lows = closes.map((c) => c - 1);
|
|
217
|
+
const result = atr(highs, lows, closes, 14);
|
|
218
|
+
expect(result).toHaveLength(30);
|
|
219
|
+
// First 14 values should be NaN (need 14 periods of TR + first bar has no prev close)
|
|
220
|
+
for (let i = 0; i < 14; i++) {
|
|
221
|
+
expect(result[i]).toBeNaN();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|