@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,313 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ /**
5
+ * E2E test: fin-strategy-engine × Binance Testnet — Multi-Pair Full Pipeline
6
+ *
7
+ * Pipeline per symbol:
8
+ * Fetch real OHLCV → Create strategy → Backtest → Verify metrics
9
+ * Plus cross-symbol:
10
+ * Walk-Forward validation → Strategy Registry round-trip → Cost impact
11
+ *
12
+ * Requires env vars:
13
+ * BINANCE_TESTNET_API_KEY
14
+ * BINANCE_TESTNET_SECRET
15
+ *
16
+ * Run:
17
+ * LIVE=1 BINANCE_TESTNET_API_KEY=xxx BINANCE_TESTNET_SECRET=xxx \
18
+ * npx vitest run extensions/fin-strategy-engine/src/backtest-engine.live.test.ts
19
+ */
20
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
21
+ import { ExchangeRegistry } from "../../fin-core/src/exchange-registry.js";
22
+ import type { OHLCV } from "../../fin-shared-types/src/types.js";
23
+ import { BacktestEngine } from "./backtest-engine.js";
24
+ import { createRsiMeanReversion } from "./builtin-strategies/rsi-mean-reversion.js";
25
+ import { createSmaCrossover } from "./builtin-strategies/sma-crossover.js";
26
+ import { StrategyRegistry } from "./strategy-registry.js";
27
+ import type { BacktestConfig, BacktestResult } from "./types.js";
28
+ import { WalkForward } from "./walk-forward.js";
29
+
30
+ const LIVE = process.env.LIVE === "1" || process.env.BINANCE_E2E === "1";
31
+ const API_KEY = process.env.BINANCE_TESTNET_API_KEY ?? "";
32
+ const SECRET = process.env.BINANCE_TESTNET_SECRET ?? "";
33
+
34
+ const SYMBOLS = ["BTC/USDT", "ETH/USDT", "SOL/USDT"] as const;
35
+
36
+ type CcxtExchange = {
37
+ fetchOHLCV: (
38
+ symbol: string,
39
+ timeframe: string,
40
+ since?: number,
41
+ limit?: number,
42
+ ) => Promise<Array<[number, number, number, number, number, number]>>;
43
+ };
44
+
45
+ /** Convert raw CCXT OHLCV tuples to our OHLCV type. */
46
+ function toOHLCV(raw: Array<[number, number, number, number, number, number]>): OHLCV[] {
47
+ return raw.map(([ts, open, high, low, close, volume]) => ({
48
+ timestamp: ts,
49
+ open,
50
+ high,
51
+ low,
52
+ close,
53
+ volume,
54
+ }));
55
+ }
56
+
57
+ /** Common metric assertions applied to every backtest result. */
58
+ function assertValidMetrics(result: BacktestResult, symbol: string) {
59
+ expect(result.equityCurve.length).toBeGreaterThan(0);
60
+ expect(result.initialCapital).toBe(10000);
61
+ expect(result.finalEquity).toBeGreaterThan(0);
62
+ expect(typeof result.sharpe).toBe("number");
63
+ expect(Number.isNaN(result.sharpe)).toBe(false);
64
+ expect(typeof result.sortino).toBe("number");
65
+ expect(typeof result.maxDrawdown).toBe("number");
66
+ expect(result.maxDrawdown).toBeLessThanOrEqual(0);
67
+ expect(typeof result.winRate).toBe("number");
68
+ expect(typeof result.profitFactor).toBe("number");
69
+ expect(result.dailyReturns.length).toBe(result.equityCurve.length - 1);
70
+
71
+ // Equity curve should never go negative
72
+ for (const eq of result.equityCurve) {
73
+ expect(eq).toBeGreaterThanOrEqual(0);
74
+ }
75
+
76
+ // If there are trades, each trade must have valid fields
77
+ for (const trade of result.trades) {
78
+ expect(trade.symbol).toBe(symbol);
79
+ expect(trade.entryPrice).toBeGreaterThan(0);
80
+ expect(trade.exitPrice).toBeGreaterThan(0);
81
+ expect(trade.quantity).toBeGreaterThan(0);
82
+ }
83
+ }
84
+
85
+ function logResult(label: string, result: BacktestResult) {
86
+ console.log(` ${label}:`);
87
+ console.log(
88
+ ` Trades: ${result.totalTrades} | Return: ${result.totalReturn.toFixed(2)}% | Sharpe: ${result.sharpe.toFixed(3)}`,
89
+ );
90
+ console.log(
91
+ ` MaxDD: ${result.maxDrawdown.toFixed(2)}% | WinRate: ${result.winRate.toFixed(1)}% | PF: ${result.profitFactor.toFixed(2)}`,
92
+ );
93
+ console.log(
94
+ ` Equity: $${result.initialCapital.toFixed(0)} → $${result.finalEquity.toFixed(2)}`,
95
+ );
96
+ }
97
+
98
+ describe.skipIf(!LIVE || !API_KEY || !SECRET)("Backtest E2E — Multi-Pair Binance Testnet", () => {
99
+ let registry: ExchangeRegistry;
100
+ const dataBySymbol = new Map<string, OHLCV[]>();
101
+ const engine = new BacktestEngine();
102
+ const config: BacktestConfig = {
103
+ capital: 10000,
104
+ commissionRate: 0.001,
105
+ slippageBps: 5,
106
+ market: "crypto",
107
+ };
108
+ let tempDir: string;
109
+
110
+ // ---------------------------------------------------------------
111
+ // Setup: connect to testnet, fetch OHLCV for all 3 symbols
112
+ // ---------------------------------------------------------------
113
+ beforeAll(async () => {
114
+ tempDir = mkdtempSync(join(tmpdir(), "fin-backtest-live-"));
115
+
116
+ registry = new ExchangeRegistry();
117
+ registry.addExchange("binance-testnet", {
118
+ exchange: "binance",
119
+ apiKey: API_KEY,
120
+ secret: SECRET,
121
+ testnet: true,
122
+ defaultType: "spot",
123
+ });
124
+
125
+ const instance = await registry.getInstance("binance-testnet");
126
+ const ccxt = instance as CcxtExchange;
127
+
128
+ for (const symbol of SYMBOLS) {
129
+ const raw = await ccxt.fetchOHLCV(symbol, "1h", undefined, 500);
130
+ const ohlcv = toOHLCV(raw);
131
+ dataBySymbol.set(symbol, ohlcv);
132
+
133
+ const first = ohlcv[0]!;
134
+ const last = ohlcv[ohlcv.length - 1]!;
135
+ console.log(
136
+ ` ${symbol}: ${ohlcv.length} bars | ` +
137
+ `${new Date(first.timestamp).toISOString().slice(0, 10)} → ${new Date(last.timestamp).toISOString().slice(0, 10)} | ` +
138
+ `$${Math.min(...ohlcv.map((d) => d.low)).toFixed(2)} – $${Math.max(...ohlcv.map((d) => d.high)).toFixed(2)}`,
139
+ );
140
+ }
141
+ }, 60_000);
142
+
143
+ afterAll(async () => {
144
+ await registry.closeAll();
145
+ rmSync(tempDir, { recursive: true, force: true });
146
+ });
147
+
148
+ // ---------------------------------------------------------------
149
+ // 1. SMA Crossover backtest × 3 symbols
150
+ // ---------------------------------------------------------------
151
+ for (const symbol of SYMBOLS) {
152
+ it(`SMA Crossover backtest on ${symbol}`, async () => {
153
+ const data = dataBySymbol.get(symbol)!;
154
+ expect(data.length).toBeGreaterThanOrEqual(500);
155
+
156
+ const strategy = createSmaCrossover({
157
+ fastPeriod: 10,
158
+ slowPeriod: 30,
159
+ symbol,
160
+ });
161
+ const result = await engine.run(strategy, data, config);
162
+
163
+ expect(result.strategyId).toBe("sma-crossover");
164
+ expect(result.equityCurve.length).toBe(data.length);
165
+ assertValidMetrics(result, symbol);
166
+ logResult(`SMA Crossover [${symbol}]`, result);
167
+ });
168
+ }
169
+
170
+ // ---------------------------------------------------------------
171
+ // 2. RSI Mean Reversion backtest × 3 symbols
172
+ // ---------------------------------------------------------------
173
+ for (const symbol of SYMBOLS) {
174
+ it(`RSI Mean Reversion backtest on ${symbol}`, async () => {
175
+ const data = dataBySymbol.get(symbol)!;
176
+
177
+ const strategy = createRsiMeanReversion({
178
+ period: 14,
179
+ oversold: 30,
180
+ overbought: 70,
181
+ symbol,
182
+ });
183
+ const result = await engine.run(strategy, data, config);
184
+
185
+ expect(result.strategyId).toBe("rsi-mean-reversion");
186
+ expect(result.equityCurve.length).toBe(data.length);
187
+ assertValidMetrics(result, symbol);
188
+ logResult(`RSI MeanRev [${symbol}]`, result);
189
+ });
190
+ }
191
+
192
+ // ---------------------------------------------------------------
193
+ // 3. Walk-Forward validation on BTC/USDT (SMA Crossover)
194
+ // ---------------------------------------------------------------
195
+ it("Walk-Forward validation on BTC/USDT", async () => {
196
+ const data = dataBySymbol.get("BTC/USDT")!;
197
+ const strategy = createSmaCrossover({ fastPeriod: 10, slowPeriod: 30, symbol: "BTC/USDT" });
198
+ const wf = new WalkForward(engine);
199
+
200
+ const result = await wf.validate(strategy, data, config, {
201
+ windows: 3,
202
+ threshold: 0.5,
203
+ });
204
+
205
+ expect(result.windows.length).toBe(3);
206
+ expect(typeof result.combinedTestSharpe).toBe("number");
207
+ expect(Number.isNaN(result.combinedTestSharpe)).toBe(false);
208
+ expect(typeof result.avgTrainSharpe).toBe("number");
209
+ expect(typeof result.ratio).toBe("number");
210
+ expect(result.threshold).toBe(0.5);
211
+
212
+ // Window boundaries must not overlap
213
+ for (let i = 0; i < result.windows.length; i++) {
214
+ const w = result.windows[i]!;
215
+ expect(w.trainEnd).toBeLessThanOrEqual(w.testStart);
216
+ if (i > 0) {
217
+ const prev = result.windows[i - 1]!;
218
+ expect(prev.testEnd).toBeLessThanOrEqual(w.trainStart);
219
+ }
220
+ }
221
+
222
+ console.log(` Walk-Forward BTC/USDT:`);
223
+ console.log(
224
+ ` Passed: ${result.passed} | Ratio: ${result.ratio.toFixed(3)} (threshold: ${result.threshold})`,
225
+ );
226
+ console.log(
227
+ ` AvgTrainSharpe: ${result.avgTrainSharpe.toFixed(3)} | CombinedTestSharpe: ${result.combinedTestSharpe.toFixed(3)}`,
228
+ );
229
+ for (let i = 0; i < result.windows.length; i++) {
230
+ const w = result.windows[i]!;
231
+ console.log(
232
+ ` Window ${i + 1}: train=${w.trainSharpe.toFixed(3)}, test=${w.testSharpe.toFixed(3)}`,
233
+ );
234
+ }
235
+ });
236
+
237
+ // ---------------------------------------------------------------
238
+ // 4. Strategy Registry round-trip: create → backtest → store → read
239
+ // ---------------------------------------------------------------
240
+ it("Strategy Registry: create → backtest → persist → reload", async () => {
241
+ const regPath = join(tempDir, "fin-strategies.json");
242
+ const reg = new StrategyRegistry(regPath);
243
+
244
+ // Create strategies for each symbol
245
+ for (const symbol of SYMBOLS) {
246
+ const def = createSmaCrossover({ fastPeriod: 10, slowPeriod: 30, symbol });
247
+ def.id = `sma-${symbol.replace("/", "-").toLowerCase()}`;
248
+ def.name = `SMA Crossover ${symbol}`;
249
+ reg.create(def);
250
+ }
251
+
252
+ expect(reg.list().length).toBe(3);
253
+
254
+ // Backtest and store results for each
255
+ for (const symbol of SYMBOLS) {
256
+ const id = `sma-${symbol.replace("/", "-").toLowerCase()}`;
257
+ const record = reg.get(id)!;
258
+ expect(record).toBeDefined();
259
+
260
+ const data = dataBySymbol.get(symbol)!;
261
+ const result = await engine.run(record.definition, data, config);
262
+ reg.updateBacktest(id, result);
263
+ reg.updateLevel(id, "L1_BACKTEST");
264
+ }
265
+
266
+ // Reload from disk and verify
267
+ const reg2 = new StrategyRegistry(regPath);
268
+ expect(reg2.list().length).toBe(3);
269
+
270
+ const backtested = reg2.list({ level: "L1_BACKTEST" });
271
+ expect(backtested.length).toBe(3);
272
+
273
+ for (const record of backtested) {
274
+ expect(record.lastBacktest).toBeDefined();
275
+ expect(record.lastBacktest!.totalTrades).toBeGreaterThanOrEqual(0);
276
+ expect(typeof record.lastBacktest!.sharpe).toBe("number");
277
+ console.log(
278
+ ` Registry [${record.name}]: level=${record.level} | ` +
279
+ `return=${record.lastBacktest!.totalReturn.toFixed(2)}% | ` +
280
+ `sharpe=${record.lastBacktest!.sharpe.toFixed(3)}`,
281
+ );
282
+ }
283
+ });
284
+
285
+ // ---------------------------------------------------------------
286
+ // 5. Cost impact: with vs without commission/slippage per symbol
287
+ // ---------------------------------------------------------------
288
+ it("cost drag comparison across all symbols", async () => {
289
+ const zeroCostConfig: BacktestConfig = {
290
+ capital: 10000,
291
+ commissionRate: 0,
292
+ slippageBps: 0,
293
+ market: "crypto",
294
+ };
295
+
296
+ for (const symbol of SYMBOLS) {
297
+ const data = dataBySymbol.get(symbol)!;
298
+ const strategy = createSmaCrossover({ fastPeriod: 10, slowPeriod: 30, symbol });
299
+
300
+ const withCosts = await engine.run(strategy, data, config);
301
+ const noCosts = await engine.run(strategy, data, zeroCostConfig);
302
+
303
+ // Costs can only reduce or maintain returns, never improve them
304
+ expect(noCosts.finalEquity).toBeGreaterThanOrEqual(withCosts.finalEquity - 0.01);
305
+
306
+ const drag = noCosts.totalReturn - withCosts.totalReturn;
307
+ console.log(
308
+ ` ${symbol}: noCost=${noCosts.totalReturn.toFixed(2)}% | ` +
309
+ `withCost=${withCosts.totalReturn.toFixed(2)}% | drag=${drag.toFixed(2)}%`,
310
+ );
311
+ }
312
+ });
313
+ });
@@ -0,0 +1,368 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyConstantSlippage } from "../../fin-shared-types/src/fill-simulation.js";
3
+ import type { OHLCV } from "../../fin-shared-types/src/types.js";
4
+ import { BacktestEngine } from "./backtest-engine.js";
5
+ import { sma } from "./indicators.js";
6
+ import type { BacktestConfig, Signal, StrategyContext, StrategyDefinition } from "./types.js";
7
+
8
+ /** Helper: generate linear OHLCV data from startPrice to endPrice. */
9
+ function linearData(bars: number, startPrice: number, endPrice: number): OHLCV[] {
10
+ const data: OHLCV[] = [];
11
+ for (let i = 0; i < bars; i++) {
12
+ const price = startPrice + ((endPrice - startPrice) * i) / (bars - 1);
13
+ data.push({
14
+ timestamp: 1000000 + i * 86400000,
15
+ open: price,
16
+ high: price * 1.001,
17
+ low: price * 0.999,
18
+ close: price,
19
+ volume: 1000,
20
+ });
21
+ }
22
+ return data;
23
+ }
24
+
25
+ /** Helper: create a strategy that always buys on bar 0 and sells on the last bar. */
26
+ function buyAndHoldStrategy(): StrategyDefinition {
27
+ let bought = false;
28
+ return {
29
+ id: "buy-and-hold",
30
+ name: "Buy and Hold",
31
+ version: "1.0",
32
+ markets: ["crypto"],
33
+ symbols: ["TEST"],
34
+ timeframes: ["1d"],
35
+ parameters: {},
36
+ async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
37
+ if (!bought && ctx.portfolio.positions.length === 0) {
38
+ bought = true;
39
+ return {
40
+ action: "buy",
41
+ symbol: "TEST",
42
+ sizePct: 100,
43
+ orderType: "market",
44
+ reason: "enter",
45
+ confidence: 1,
46
+ };
47
+ }
48
+ return null;
49
+ },
50
+ };
51
+ }
52
+
53
+ const engine = new BacktestEngine();
54
+
55
+ describe("BacktestEngine", () => {
56
+ describe("empty data", () => {
57
+ it("returns empty result for no data", async () => {
58
+ const result = await engine.run(buyAndHoldStrategy(), [], {
59
+ capital: 10000,
60
+ commissionRate: 0,
61
+ slippageBps: 0,
62
+ market: "crypto",
63
+ });
64
+ expect(result.totalTrades).toBe(0);
65
+ expect(result.finalEquity).toBe(10000);
66
+ });
67
+ });
68
+
69
+ describe("buy-and-hold baseline", () => {
70
+ it("captures ~100% return on 100→200 linear data (zero costs)", async () => {
71
+ const data = linearData(100, 100, 200);
72
+ const config: BacktestConfig = {
73
+ capital: 10000,
74
+ commissionRate: 0,
75
+ slippageBps: 0,
76
+ market: "crypto",
77
+ };
78
+
79
+ const result = await engine.run(buyAndHoldStrategy(), data, config);
80
+
81
+ // Buy at 100, auto-close at 200 → ~100% return
82
+ expect(result.totalTrades).toBe(1);
83
+ expect(result.totalReturn).toBeCloseTo(100, 0);
84
+ expect(result.finalEquity).toBeCloseTo(20000, -1);
85
+ });
86
+ });
87
+
88
+ describe("commission verification", () => {
89
+ it("deducts commissions correctly", async () => {
90
+ const data = linearData(100, 100, 200);
91
+ const commissionRate = 0.001; // 0.1%
92
+ const config: BacktestConfig = {
93
+ capital: 10000,
94
+ commissionRate,
95
+ slippageBps: 0,
96
+ market: "crypto",
97
+ };
98
+
99
+ const result = await engine.run(buyAndHoldStrategy(), data, config);
100
+
101
+ // With commission-adjusted allocation:
102
+ // qty = 10000 / (100 * 1.001) ≈ 99.9 units
103
+ // Entry commission ≈ 99.9 * 100 * 0.001 ≈ 9.99
104
+ // Exit at 200: exit commission ≈ 99.9 * 200 * 0.001 ≈ 19.98
105
+ // Total commission ≈ 30
106
+ expect(result.totalTrades).toBe(1);
107
+ const trade = result.trades[0]!;
108
+ expect(trade.commission).toBeGreaterThan(0);
109
+ expect(result.totalReturn).toBeLessThan(100);
110
+ // Return should be close to but less than 100% due to commissions
111
+ expect(result.totalReturn).toBeGreaterThan(99);
112
+ // Total commission (entry + exit) should be approximately 30
113
+ expect(trade.commission).toBeCloseTo(30, -1);
114
+ });
115
+ });
116
+
117
+ describe("slippage verification", () => {
118
+ it("buy fills at higher price, sell fills at lower price", async () => {
119
+ const data = linearData(100, 100, 200);
120
+ const slippageBps = 5;
121
+ const config: BacktestConfig = {
122
+ capital: 10000,
123
+ commissionRate: 0,
124
+ slippageBps,
125
+ market: "crypto",
126
+ };
127
+
128
+ const result = await engine.run(buyAndHoldStrategy(), data, config);
129
+ const trade = result.trades[0]!;
130
+
131
+ // Entry: buy at close=100 with 5bps slippage → fillPrice = 100.05
132
+ const expectedEntry = applyConstantSlippage(100, "buy", slippageBps);
133
+ expect(trade.entryPrice).toBeCloseTo(expectedEntry.fillPrice, 4);
134
+
135
+ // Exit: sell at close=200 with 5bps slippage → fillPrice = 199.9
136
+ const expectedExit = applyConstantSlippage(200, "sell", slippageBps);
137
+ expect(trade.exitPrice).toBeCloseTo(expectedExit.fillPrice, 4);
138
+
139
+ // Slippage reduces return from 100%
140
+ expect(result.totalReturn).toBeLessThan(100);
141
+ });
142
+ });
143
+
144
+ describe("multi-trade with SMA crossover", () => {
145
+ it("generates correct trades at known crossover points", async () => {
146
+ // Create data with a clear pattern:
147
+ // Bars 0-9: price at 100 (flat)
148
+ // Bars 10-14: price rises to 120 (triggers golden cross)
149
+ // Bars 15-19: price drops to 80 (triggers death cross)
150
+ // Bars 20-24: price rises to 110
151
+ const data: OHLCV[] = [];
152
+ const prices = [
153
+ // Bars 0-9: stable at 100
154
+ ...Array(10).fill(100),
155
+ // Bars 10-14: rise
156
+ 105,
157
+ 110,
158
+ 115,
159
+ 118,
160
+ 120,
161
+ // Bars 15-19: drop
162
+ 110,
163
+ 100,
164
+ 90,
165
+ 85,
166
+ 80,
167
+ // Bars 20-24: recovery
168
+ 85,
169
+ 90,
170
+ 95,
171
+ 100,
172
+ 110,
173
+ ] as number[];
174
+
175
+ for (let i = 0; i < prices.length; i++) {
176
+ data.push({
177
+ timestamp: 1000000 + i * 86400000,
178
+ open: prices[i]!,
179
+ high: prices[i]! * 1.01,
180
+ low: prices[i]! * 0.99,
181
+ close: prices[i]!,
182
+ volume: 1000,
183
+ });
184
+ }
185
+
186
+ // Use fast=3, slow=5 so crossovers happen quickly
187
+ const fastPeriod = 3;
188
+ const slowPeriod = 5;
189
+
190
+ // Pre-compute expected crossover points
191
+ const closes = data.map((d) => d.close);
192
+ const fastSma = sma(closes, fastPeriod);
193
+ const slowSma = sma(closes, slowPeriod);
194
+
195
+ // Find golden and death crosses
196
+ const goldenCrosses: number[] = [];
197
+ const deathCrosses: number[] = [];
198
+ for (let i = 1; i < closes.length; i++) {
199
+ if (
200
+ !Number.isNaN(fastSma[i]!) &&
201
+ !Number.isNaN(slowSma[i]!) &&
202
+ !Number.isNaN(fastSma[i - 1]!) &&
203
+ !Number.isNaN(slowSma[i - 1]!)
204
+ ) {
205
+ if (fastSma[i - 1]! <= slowSma[i - 1]! && fastSma[i]! > slowSma[i]!) {
206
+ goldenCrosses.push(i);
207
+ }
208
+ if (fastSma[i - 1]! >= slowSma[i - 1]! && fastSma[i]! < slowSma[i]!) {
209
+ deathCrosses.push(i);
210
+ }
211
+ }
212
+ }
213
+
214
+ // Create an SMA crossover strategy
215
+ let inPosition = false;
216
+ const strategy: StrategyDefinition = {
217
+ id: "test-sma",
218
+ name: "Test SMA",
219
+ version: "1.0",
220
+ markets: ["crypto"],
221
+ symbols: ["TEST"],
222
+ timeframes: ["1d"],
223
+ parameters: { fastPeriod, slowPeriod },
224
+ async onBar(_bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
225
+ const fast = ctx.indicators.sma(fastPeriod);
226
+ const slow = ctx.indicators.sma(slowPeriod);
227
+ const len = fast.length;
228
+ if (len < 2) return null;
229
+
230
+ const cf = fast[len - 1]!;
231
+ const cs = slow[len - 1]!;
232
+ const pf = fast[len - 2]!;
233
+ const ps = slow[len - 2]!;
234
+
235
+ if (Number.isNaN(cf) || Number.isNaN(cs) || Number.isNaN(pf) || Number.isNaN(ps)) {
236
+ return null;
237
+ }
238
+
239
+ if (pf <= ps && cf > cs && !inPosition) {
240
+ inPosition = true;
241
+ return {
242
+ action: "buy",
243
+ symbol: "TEST",
244
+ sizePct: 100,
245
+ orderType: "market",
246
+ reason: "golden-cross",
247
+ confidence: 0.7,
248
+ };
249
+ }
250
+ if (pf >= ps && cf < cs && inPosition) {
251
+ inPosition = false;
252
+ return {
253
+ action: "sell",
254
+ symbol: "TEST",
255
+ sizePct: 100,
256
+ orderType: "market",
257
+ reason: "death-cross",
258
+ confidence: 0.7,
259
+ };
260
+ }
261
+ return null;
262
+ },
263
+ };
264
+
265
+ const config: BacktestConfig = {
266
+ capital: 10000,
267
+ commissionRate: 0,
268
+ slippageBps: 0,
269
+ market: "crypto",
270
+ };
271
+ const result = await engine.run(strategy, data, config);
272
+
273
+ // We should have trades (golden cross buys, death cross sells)
274
+ expect(result.totalTrades).toBeGreaterThanOrEqual(1);
275
+
276
+ // Verify each trade has valid entry/exit
277
+ for (const trade of result.trades) {
278
+ expect(trade.entryPrice).toBeGreaterThan(0);
279
+ expect(trade.exitPrice).toBeGreaterThan(0);
280
+ expect(trade.quantity).toBeGreaterThan(0);
281
+ }
282
+
283
+ // All trades' P&L should sum to totalReturn
284
+ const totalPnl = result.trades.reduce((s, t) => s + t.pnl, 0);
285
+ expect(result.finalEquity).toBeCloseTo(config.capital + totalPnl, 2);
286
+ });
287
+ });
288
+
289
+ describe("metric verification", () => {
290
+ it("equity curve and daily returns are consistent", async () => {
291
+ const data = linearData(50, 100, 150);
292
+ const config: BacktestConfig = {
293
+ capital: 10000,
294
+ commissionRate: 0,
295
+ slippageBps: 0,
296
+ market: "crypto",
297
+ };
298
+ const result = await engine.run(buyAndHoldStrategy(), data, config);
299
+
300
+ expect(result.equityCurve.length).toBe(data.length);
301
+ expect(result.dailyReturns.length).toBe(data.length - 1);
302
+
303
+ // Equity should not drop to zero (position value should be tracked)
304
+ for (const eq of result.equityCurve) {
305
+ expect(eq).toBeGreaterThan(0);
306
+ }
307
+
308
+ // Verify daily returns match equity curve changes
309
+ for (let i = 0; i < result.dailyReturns.length; i++) {
310
+ const prev = result.equityCurve[i]!;
311
+ const next = result.equityCurve[i + 1]!;
312
+ if (prev === 0) continue; // skip division by zero edge case
313
+ const expected = (next - prev) / prev;
314
+ expect(result.dailyReturns[i]).toBeCloseTo(expected, 8);
315
+ }
316
+
317
+ // Equity curve should be monotonically increasing for linear rising data
318
+ for (let i = 1; i < result.equityCurve.length; i++) {
319
+ expect(result.equityCurve[i]!).toBeGreaterThanOrEqual(result.equityCurve[i - 1]! - 0.01);
320
+ }
321
+ });
322
+
323
+ it("Sharpe ratio is positive for profitable strategy", async () => {
324
+ const data = linearData(100, 100, 200);
325
+ const config: BacktestConfig = {
326
+ capital: 10000,
327
+ commissionRate: 0,
328
+ slippageBps: 0,
329
+ market: "crypto",
330
+ };
331
+ const result = await engine.run(buyAndHoldStrategy(), data, config);
332
+
333
+ expect(result.sharpe).toBeGreaterThan(0);
334
+ expect(result.winRate).toBe(100);
335
+ expect(result.maxDrawdown).toBeLessThanOrEqual(0);
336
+ });
337
+ });
338
+
339
+ describe("no-signal strategy", () => {
340
+ it("returns zero trades and original capital when strategy never signals", async () => {
341
+ const data = linearData(20, 100, 200);
342
+ const strategy: StrategyDefinition = {
343
+ id: "no-op",
344
+ name: "No-Op",
345
+ version: "1.0",
346
+ markets: ["crypto"],
347
+ symbols: ["TEST"],
348
+ timeframes: ["1d"],
349
+ parameters: {},
350
+ async onBar(): Promise<Signal | null> {
351
+ return null;
352
+ },
353
+ };
354
+
355
+ const config: BacktestConfig = {
356
+ capital: 10000,
357
+ commissionRate: 0,
358
+ slippageBps: 0,
359
+ market: "crypto",
360
+ };
361
+ const result = await engine.run(strategy, data, config);
362
+
363
+ expect(result.totalTrades).toBe(0);
364
+ expect(result.finalEquity).toBe(10000);
365
+ expect(result.totalReturn).toBe(0);
366
+ });
367
+ });
368
+ });