@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,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
|
+
});
|