@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/index.test.ts +269 -0
  3. package/index.ts +578 -0
  4. package/openclaw.plugin.json +11 -0
  5. package/package.json +40 -0
  6. package/src/backtest-engine.live.test.ts +313 -0
  7. package/src/backtest-engine.test.ts +368 -0
  8. package/src/backtest-engine.ts +362 -0
  9. package/src/builtin-strategies/bollinger-bands.test.ts +96 -0
  10. package/src/builtin-strategies/bollinger-bands.ts +75 -0
  11. package/src/builtin-strategies/custom-rule-engine.ts +274 -0
  12. package/src/builtin-strategies/macd-divergence.test.ts +122 -0
  13. package/src/builtin-strategies/macd-divergence.ts +77 -0
  14. package/src/builtin-strategies/multi-timeframe-confluence.test.ts +287 -0
  15. package/src/builtin-strategies/multi-timeframe-confluence.ts +253 -0
  16. package/src/builtin-strategies/regime-adaptive.test.ts +210 -0
  17. package/src/builtin-strategies/regime-adaptive.ts +285 -0
  18. package/src/builtin-strategies/risk-parity-triple-screen.test.ts +295 -0
  19. package/src/builtin-strategies/risk-parity-triple-screen.ts +295 -0
  20. package/src/builtin-strategies/rsi-mean-reversion.test.ts +143 -0
  21. package/src/builtin-strategies/rsi-mean-reversion.ts +74 -0
  22. package/src/builtin-strategies/sma-crossover.test.ts +113 -0
  23. package/src/builtin-strategies/sma-crossover.ts +85 -0
  24. package/src/builtin-strategies/trend-following-momentum.test.ts +228 -0
  25. package/src/builtin-strategies/trend-following-momentum.ts +209 -0
  26. package/src/builtin-strategies/volatility-mean-reversion.test.ts +193 -0
  27. package/src/builtin-strategies/volatility-mean-reversion.ts +212 -0
  28. package/src/composite-pipeline.live.test.ts +347 -0
  29. package/src/e2e-pipeline.test.ts +494 -0
  30. package/src/fitness.test.ts +103 -0
  31. package/src/fitness.ts +61 -0
  32. package/src/full-pipeline.live.test.ts +339 -0
  33. package/src/indicators.test.ts +224 -0
  34. package/src/indicators.ts +238 -0
  35. package/src/stats.test.ts +215 -0
  36. package/src/stats.ts +115 -0
  37. package/src/strategy-registry.test.ts +235 -0
  38. package/src/strategy-registry.ts +183 -0
  39. package/src/types.ts +19 -0
  40. package/src/walk-forward.test.ts +185 -0
  41. 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
+ });