@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,362 @@
1
+ import { applyConstantSlippage } from "../../fin-shared-types/src/fill-simulation.js";
2
+ import type { OHLCV } from "../../fin-shared-types/src/types.js";
3
+ import { sma, ema, rsi, macd, bollingerBands, atr } from "./indicators.js";
4
+ import {
5
+ sharpeRatio,
6
+ sortinoRatio,
7
+ maxDrawdown,
8
+ calmarRatio,
9
+ profitFactor,
10
+ winRate,
11
+ } from "./stats.js";
12
+ import type {
13
+ BacktestConfig,
14
+ BacktestResult,
15
+ IndicatorLib,
16
+ Position,
17
+ Signal,
18
+ StrategyContext,
19
+ StrategyDefinition,
20
+ TradeRecord,
21
+ } from "./types.js";
22
+
23
+ /** Internal mutable position used during simulation. */
24
+ interface InternalPosition {
25
+ symbol: string;
26
+ side: "long" | "short";
27
+ quantity: number;
28
+ entryPrice: number;
29
+ entryTime: number;
30
+ entryCommission: number;
31
+ reason: string;
32
+ }
33
+
34
+ /** Build an IndicatorLib over history close/high/low arrays. */
35
+ export function buildIndicatorLib(history: OHLCV[]): IndicatorLib {
36
+ const closes = history.map((b) => b.close);
37
+ const highs = history.map((b) => b.high);
38
+ const lows = history.map((b) => b.low);
39
+
40
+ return {
41
+ sma: (period: number) => sma(closes, period),
42
+ ema: (period: number) => ema(closes, period),
43
+ rsi: (period: number) => rsi(closes, period),
44
+ macd: (fast?: number, slow?: number, signal?: number) => macd(closes, fast, slow, signal),
45
+ bollingerBands: (period?: number, stdDev?: number) => bollingerBands(closes, period, stdDev),
46
+ atr: (period?: number) => atr(highs, lows, closes, period),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Event-driven bar-by-bar backtest engine.
52
+ * Cash accounting is maintained in real-time — no replay needed.
53
+ */
54
+ export class BacktestEngine {
55
+ async run(
56
+ strategy: StrategyDefinition,
57
+ data: OHLCV[],
58
+ config: BacktestConfig,
59
+ ): Promise<BacktestResult> {
60
+ if (data.length === 0) {
61
+ return emptyResult(strategy.id, config.capital);
62
+ }
63
+
64
+ let cash = config.capital;
65
+ const positions: InternalPosition[] = [];
66
+ const trades: TradeRecord[] = [];
67
+ const equityCurve: number[] = [];
68
+ const memory = new Map<string, unknown>();
69
+
70
+ const getEquity = (price: number) => {
71
+ // Equity = cash + market value of all open positions.
72
+ // For long positions: market value = quantity * currentPrice.
73
+ // Cash was reduced by (quantity * entryPrice + entryCommission) at entry.
74
+ const positionValue = positions.reduce((sum, p) => {
75
+ if (p.side === "long") return sum + p.quantity * price;
76
+ // For short: value = 2 * entryPrice * qty - qty * price (proceeds - current liability)
77
+ return sum + p.quantity * (2 * p.entryPrice - price);
78
+ }, 0);
79
+ return cash + positionValue;
80
+ };
81
+
82
+ const buildContext = (barIndex: number): StrategyContext => {
83
+ const history = data.slice(0, barIndex + 1);
84
+ const currentPrice = data[barIndex]!.close;
85
+
86
+ const positionSnapshots: Position[] = positions.map((p) => {
87
+ const move = p.side === "long" ? currentPrice - p.entryPrice : p.entryPrice - currentPrice;
88
+ return {
89
+ symbol: p.symbol,
90
+ side: p.side,
91
+ quantity: p.quantity,
92
+ entryPrice: p.entryPrice,
93
+ currentPrice,
94
+ unrealizedPnl: move * p.quantity,
95
+ };
96
+ });
97
+
98
+ return {
99
+ portfolio: {
100
+ equity: getEquity(currentPrice),
101
+ cash,
102
+ positions: positionSnapshots,
103
+ },
104
+ history,
105
+ indicators: buildIndicatorLib(history),
106
+ regime: "sideways",
107
+ memory,
108
+ log: () => {},
109
+ };
110
+ };
111
+
112
+ // Initialize strategy
113
+ if (strategy.init) {
114
+ await strategy.init(buildContext(0));
115
+ }
116
+
117
+ // Main simulation loop
118
+ for (let i = 0; i < data.length; i++) {
119
+ const bar = data[i]!;
120
+ const ctx = buildContext(i);
121
+ const signal = await strategy.onBar(bar, ctx);
122
+
123
+ if (signal) {
124
+ this.processSignal(signal, bar, positions, trades, config, {
125
+ get cash() {
126
+ return cash;
127
+ },
128
+ set cash(v: number) {
129
+ cash = v;
130
+ },
131
+ });
132
+ }
133
+
134
+ equityCurve.push(getEquity(bar.close));
135
+
136
+ if (strategy.onDayEnd) {
137
+ await strategy.onDayEnd(buildContext(i));
138
+ }
139
+ }
140
+
141
+ // Close all remaining positions at last bar's close
142
+ const lastBar = data[data.length - 1]!;
143
+ this.closeAllPositions(positions, trades, lastBar, config, {
144
+ get cash() {
145
+ return cash;
146
+ },
147
+ set cash(v: number) {
148
+ cash = v;
149
+ },
150
+ });
151
+
152
+ // Update final equity curve entry (now all positions are closed)
153
+ equityCurve[equityCurve.length - 1] = cash;
154
+
155
+ const dailyReturns = computeDailyReturns(equityCurve);
156
+ const totalReturn = ((cash - config.capital) / config.capital) * 100;
157
+ const sharpe = sharpeRatio(dailyReturns, 0, true);
158
+ const sortino = sortinoRatio(dailyReturns, 0);
159
+ const dd = maxDrawdown(equityCurve);
160
+ const annualizedReturn = totalReturn * (252 / Math.max(data.length, 1));
161
+ const calmar = calmarRatio(annualizedReturn, dd.maxDD);
162
+ const wins = trades.filter((t) => t.pnl > 0).map((t) => t.pnl);
163
+ const losses = trades.filter((t) => t.pnl <= 0).map((t) => t.pnl);
164
+ const pf = profitFactor(wins, losses);
165
+ const wr = winRate(trades);
166
+
167
+ return {
168
+ strategyId: strategy.id,
169
+ startDate: data[0]!.timestamp,
170
+ endDate: lastBar.timestamp,
171
+ initialCapital: config.capital,
172
+ finalEquity: cash,
173
+ totalReturn,
174
+ sharpe: Number.isFinite(sharpe) ? sharpe : 0,
175
+ sortino: Number.isFinite(sortino) ? sortino : 0,
176
+ maxDrawdown: dd.maxDD,
177
+ calmar: Number.isFinite(calmar) ? calmar : 0,
178
+ winRate: Number.isNaN(wr) ? 0 : wr,
179
+ profitFactor: Number.isFinite(pf) ? pf : 0,
180
+ totalTrades: trades.length,
181
+ trades,
182
+ equityCurve,
183
+ dailyReturns,
184
+ };
185
+ }
186
+
187
+ /** Process a signal: open or close positions with slippage and commission. */
188
+ private processSignal(
189
+ signal: Signal,
190
+ bar: OHLCV,
191
+ positions: InternalPosition[],
192
+ trades: TradeRecord[],
193
+ config: BacktestConfig,
194
+ wallet: { cash: number },
195
+ ): void {
196
+ if (signal.action === "close") {
197
+ const toClose = [...positions.filter((p) => p.symbol === signal.symbol)];
198
+ for (const pos of toClose) {
199
+ this.closeSinglePosition(pos, bar, positions, trades, config, wallet, "signal-close");
200
+ }
201
+ return;
202
+ }
203
+
204
+ if (signal.action === "buy") {
205
+ // Close any short position first
206
+ const shortPos = positions.find((p) => p.symbol === signal.symbol && p.side === "short");
207
+ if (shortPos) {
208
+ this.closeSinglePosition(
209
+ shortPos,
210
+ bar,
211
+ positions,
212
+ trades,
213
+ config,
214
+ wallet,
215
+ "reverse-to-long",
216
+ );
217
+ }
218
+
219
+ const { fillPrice } = applyConstantSlippage(bar.close, "buy", config.slippageBps);
220
+ const posValue = positions.reduce((s, p) => {
221
+ if (p.side === "long") return s + p.quantity * bar.close;
222
+ return s + p.quantity * (2 * p.entryPrice - bar.close);
223
+ }, 0);
224
+ const equity = wallet.cash + posValue;
225
+ const allocAmount = equity * (signal.sizePct / 100);
226
+ // Account for commission: allocAmount = quantity * fillPrice * (1 + commissionRate)
227
+ const quantity = allocAmount / (fillPrice * (1 + config.commissionRate));
228
+ if (quantity <= 0) return;
229
+
230
+ const notional = quantity * fillPrice;
231
+ const commission = notional * config.commissionRate;
232
+ if (wallet.cash < notional + commission) return;
233
+
234
+ wallet.cash -= notional + commission;
235
+
236
+ positions.push({
237
+ symbol: signal.symbol,
238
+ side: "long",
239
+ quantity,
240
+ entryPrice: fillPrice,
241
+ entryTime: bar.timestamp,
242
+ entryCommission: commission,
243
+ reason: signal.reason,
244
+ });
245
+ }
246
+
247
+ if (signal.action === "sell") {
248
+ const longPos = positions.find((p) => p.symbol === signal.symbol && p.side === "long");
249
+ if (longPos) {
250
+ this.closeSinglePosition(longPos, bar, positions, trades, config, wallet, signal.reason);
251
+ }
252
+ }
253
+ }
254
+
255
+ /** Close a single position and record the trade. */
256
+ private closeSinglePosition(
257
+ pos: InternalPosition,
258
+ bar: OHLCV,
259
+ positions: InternalPosition[],
260
+ trades: TradeRecord[],
261
+ config: BacktestConfig,
262
+ wallet: { cash: number },
263
+ exitReason: string,
264
+ ): void {
265
+ const exitSide = pos.side === "long" ? "sell" : "buy";
266
+ const { fillPrice, slippageCost } = applyConstantSlippage(
267
+ bar.close,
268
+ exitSide,
269
+ config.slippageBps,
270
+ );
271
+
272
+ const exitNotional = pos.quantity * fillPrice;
273
+ const exitCommission = exitNotional * config.commissionRate;
274
+
275
+ // Raw P&L from price movement
276
+ const rawPnl =
277
+ pos.side === "long"
278
+ ? (fillPrice - pos.entryPrice) * pos.quantity
279
+ : (pos.entryPrice - fillPrice) * pos.quantity;
280
+
281
+ // Net P&L after both entry and exit commissions
282
+ const netPnl = rawPnl - exitCommission;
283
+ // Note: entry commission was already deducted from cash at entry
284
+
285
+ wallet.cash += exitNotional - exitCommission;
286
+
287
+ const totalCommission = pos.entryCommission + exitCommission;
288
+ const pnlPct = ((rawPnl - exitCommission) / (pos.entryPrice * pos.quantity)) * 100;
289
+
290
+ trades.push({
291
+ entryTime: pos.entryTime,
292
+ exitTime: bar.timestamp,
293
+ symbol: pos.symbol,
294
+ side: pos.side,
295
+ entryPrice: pos.entryPrice,
296
+ exitPrice: fillPrice,
297
+ quantity: pos.quantity,
298
+ commission: totalCommission,
299
+ slippage: slippageCost * pos.quantity,
300
+ pnl: netPnl,
301
+ pnlPct,
302
+ reason: pos.reason,
303
+ exitReason,
304
+ });
305
+
306
+ const idx = positions.indexOf(pos);
307
+ if (idx !== -1) positions.splice(idx, 1);
308
+ }
309
+
310
+ /** Force-close all remaining open positions. */
311
+ private closeAllPositions(
312
+ positions: InternalPosition[],
313
+ trades: TradeRecord[],
314
+ lastBar: OHLCV,
315
+ config: BacktestConfig,
316
+ wallet: { cash: number },
317
+ ): void {
318
+ while (positions.length > 0) {
319
+ this.closeSinglePosition(
320
+ positions[0]!,
321
+ lastBar,
322
+ positions,
323
+ trades,
324
+ config,
325
+ wallet,
326
+ "end-of-backtest",
327
+ );
328
+ }
329
+ }
330
+ }
331
+
332
+ /** Compute daily returns from an equity curve. */
333
+ function computeDailyReturns(equityCurve: number[]): number[] {
334
+ if (equityCurve.length <= 1) return [];
335
+ const returns: number[] = [];
336
+ for (let i = 1; i < equityCurve.length; i++) {
337
+ const prev = equityCurve[i - 1]!;
338
+ returns.push(prev === 0 ? 0 : (equityCurve[i]! - prev) / prev);
339
+ }
340
+ return returns;
341
+ }
342
+
343
+ function emptyResult(strategyId: string, capital: number): BacktestResult {
344
+ return {
345
+ strategyId,
346
+ startDate: 0,
347
+ endDate: 0,
348
+ initialCapital: capital,
349
+ finalEquity: capital,
350
+ totalReturn: 0,
351
+ sharpe: 0,
352
+ sortino: 0,
353
+ maxDrawdown: 0,
354
+ calmar: 0,
355
+ winRate: 0,
356
+ profitFactor: 0,
357
+ totalTrades: 0,
358
+ trades: [],
359
+ equityCurve: [capital],
360
+ dailyReturns: [],
361
+ };
362
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
3
+ import { BacktestEngine } from "../backtest-engine.js";
4
+ import { bollingerBands } from "../indicators.js";
5
+ import type { BacktestConfig } from "../types.js";
6
+ import { createBollingerBands } from "./bollinger-bands.js";
7
+
8
+ function makeBar(index: number, close: number): OHLCV {
9
+ return {
10
+ timestamp: 1000000 + index * 86400000,
11
+ open: close,
12
+ high: close * 1.01,
13
+ low: close * 0.99,
14
+ close,
15
+ volume: 1000,
16
+ };
17
+ }
18
+
19
+ describe("Bollinger Bands strategy", () => {
20
+ it("creates strategy with default parameters", () => {
21
+ const strategy = createBollingerBands();
22
+ expect(strategy.id).toBe("bollinger-bands");
23
+ expect(strategy.parameters.period).toBe(20);
24
+ expect(strategy.parameters.stdDev).toBe(2);
25
+ expect(strategy.parameters.sizePct).toBe(100);
26
+ });
27
+
28
+ it("creates strategy with custom parameters", () => {
29
+ const strategy = createBollingerBands({ period: 15, stdDev: 1.5, sizePct: 50 });
30
+ expect(strategy.parameters.period).toBe(15);
31
+ expect(strategy.parameters.stdDev).toBe(1.5);
32
+ expect(strategy.parameters.sizePct).toBe(50);
33
+ });
34
+
35
+ it("buys below lower band and sells above upper band", async () => {
36
+ // Phase 1: stable at 100 (tight bands) → dip triggers buy below lower band
37
+ // Phase 2: stable at low price (bands contract) → spike triggers sell above upper band
38
+ const prices: number[] = [];
39
+ // 15 bars stable at 100 (warm-up, tight bands)
40
+ for (let i = 0; i < 15; i++) prices.push(100);
41
+ // Sharp dip: close drops below lower band (bands still tight from stability)
42
+ prices.push(90, 80, 70);
43
+ // 10 bars stable near 70 (bands contract around 70)
44
+ for (let i = 0; i < 10; i++) prices.push(70);
45
+ // Sharp spike: close jumps above upper band (bands tight around 70)
46
+ prices.push(85, 100, 115, 130);
47
+
48
+ const data = prices.map((p, i) => makeBar(i, p));
49
+ const strategy = createBollingerBands({ period: 10, stdDev: 2, sizePct: 100 });
50
+ const config: BacktestConfig = {
51
+ capital: 10000,
52
+ commissionRate: 0,
53
+ slippageBps: 0,
54
+ market: "crypto",
55
+ };
56
+
57
+ const engine = new BacktestEngine();
58
+ const result = await engine.run(strategy, data, config);
59
+
60
+ // Verify bands actually breach
61
+ const closes = data.map((d) => d.close);
62
+ const bands = bollingerBands(closes, 10, 2);
63
+
64
+ let belowLower = false;
65
+ let aboveUpper = false;
66
+ for (let i = 0; i < closes.length; i++) {
67
+ if (!Number.isNaN(bands.lower[i]) && closes[i] < bands.lower[i]) belowLower = true;
68
+ if (!Number.isNaN(bands.upper[i]) && closes[i] > bands.upper[i]) aboveUpper = true;
69
+ }
70
+
71
+ expect(belowLower).toBe(true);
72
+ expect(aboveUpper).toBe(true);
73
+ expect(result.totalTrades).toBeGreaterThanOrEqual(1);
74
+ });
75
+
76
+ it("returns no trades during warm-up period", async () => {
77
+ // Only 10 bars — not enough for BB(20)
78
+ const data: OHLCV[] = [];
79
+ for (let i = 0; i < 10; i++) {
80
+ data.push(makeBar(i, 100 + i));
81
+ }
82
+
83
+ const strategy = createBollingerBands({ period: 20 });
84
+ const config: BacktestConfig = {
85
+ capital: 10000,
86
+ commissionRate: 0,
87
+ slippageBps: 0,
88
+ market: "crypto",
89
+ };
90
+
91
+ const engine = new BacktestEngine();
92
+ const result = await engine.run(strategy, data, config);
93
+
94
+ expect(result.totalTrades).toBe(0);
95
+ });
96
+ });
@@ -0,0 +1,75 @@
1
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
2
+ import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
3
+
4
+ /**
5
+ * Bollinger Bands strategy.
6
+ * Buy when close < lower band (oversold reversion).
7
+ * Sell when close > upper band (overbought reversion).
8
+ */
9
+ export function createBollingerBands(params?: {
10
+ period?: number;
11
+ stdDev?: number;
12
+ sizePct?: number;
13
+ symbol?: string;
14
+ }): StrategyDefinition {
15
+ const period = params?.period ?? 20;
16
+ const stdDev = params?.stdDev ?? 2;
17
+ const sizePct = params?.sizePct ?? 100;
18
+ const symbol = params?.symbol ?? "BTC/USDT";
19
+
20
+ return {
21
+ id: "bollinger-bands",
22
+ name: "Bollinger Bands",
23
+ version: "1.0.0",
24
+ markets: ["crypto", "equity"],
25
+ symbols: [symbol],
26
+ timeframes: ["1d"],
27
+ parameters: { period, stdDev, sizePct },
28
+ parameterRanges: {
29
+ period: { min: 10, max: 50, step: 5 },
30
+ stdDev: { min: 1, max: 3, step: 0.5 },
31
+ sizePct: { min: 10, max: 100, step: 10 },
32
+ },
33
+
34
+ async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
35
+ const bands = ctx.indicators.bollingerBands(period, stdDev);
36
+
37
+ const len = bands.upper.length;
38
+ if (len < 1) return null;
39
+
40
+ const upper = bands.upper[len - 1]!;
41
+ const lower = bands.lower[len - 1]!;
42
+
43
+ if (Number.isNaN(upper) || Number.isNaN(lower)) return null;
44
+
45
+ const close = bar.close;
46
+ const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
47
+
48
+ // Close below lower band → oversold, buy
49
+ if (close < lower && !hasLong) {
50
+ return {
51
+ action: "buy",
52
+ symbol,
53
+ sizePct,
54
+ orderType: "market",
55
+ reason: `BB oversold: close=${close.toFixed(2)} < lower=${lower.toFixed(2)}`,
56
+ confidence: 0.65,
57
+ };
58
+ }
59
+
60
+ // Close above upper band → overbought, sell
61
+ if (close > upper && hasLong) {
62
+ return {
63
+ action: "sell",
64
+ symbol,
65
+ sizePct: 100,
66
+ orderType: "market",
67
+ reason: `BB overbought: close=${close.toFixed(2)} > upper=${upper.toFixed(2)}`,
68
+ confidence: 0.65,
69
+ };
70
+ }
71
+
72
+ return null;
73
+ },
74
+ };
75
+ }