@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,143 @@
|
|
|
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 { rsi } from "../indicators.js";
|
|
5
|
+
import type { BacktestConfig } from "../types.js";
|
|
6
|
+
import { createRsiMeanReversion } from "./rsi-mean-reversion.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("RSI Mean Reversion strategy", () => {
|
|
20
|
+
it("creates strategy with default parameters", () => {
|
|
21
|
+
const strategy = createRsiMeanReversion();
|
|
22
|
+
expect(strategy.id).toBe("rsi-mean-reversion");
|
|
23
|
+
expect(strategy.parameters.period).toBe(14);
|
|
24
|
+
expect(strategy.parameters.oversold).toBe(30);
|
|
25
|
+
expect(strategy.parameters.overbought).toBe(70);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("creates strategy with custom parameters", () => {
|
|
29
|
+
const strategy = createRsiMeanReversion({
|
|
30
|
+
period: 7,
|
|
31
|
+
oversold: 25,
|
|
32
|
+
overbought: 75,
|
|
33
|
+
sizePct: 50,
|
|
34
|
+
});
|
|
35
|
+
expect(strategy.parameters.period).toBe(7);
|
|
36
|
+
expect(strategy.parameters.oversold).toBe(25);
|
|
37
|
+
expect(strategy.parameters.overbought).toBe(75);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("buys on oversold RSI and sells on overbought RSI", async () => {
|
|
41
|
+
// Create price data that produces known RSI values:
|
|
42
|
+
// Start flat, then sharp drop (RSI < 30), then sharp rise (RSI > 70)
|
|
43
|
+
const prices: number[] = [];
|
|
44
|
+
// 20 bars flat at 100 (RSI neutral)
|
|
45
|
+
for (let i = 0; i < 20; i++) prices.push(100);
|
|
46
|
+
// 5 bars dropping sharply (RSI drops toward oversold)
|
|
47
|
+
prices.push(97, 93, 88, 82, 75);
|
|
48
|
+
// 10 bars rising sharply (RSI rises toward overbought)
|
|
49
|
+
prices.push(80, 88, 96, 105, 115, 125, 135, 145, 155, 165);
|
|
50
|
+
|
|
51
|
+
const data = prices.map((p, i) => makeBar(i, p));
|
|
52
|
+
const strategy = createRsiMeanReversion({
|
|
53
|
+
period: 14,
|
|
54
|
+
oversold: 30,
|
|
55
|
+
overbought: 70,
|
|
56
|
+
sizePct: 100,
|
|
57
|
+
});
|
|
58
|
+
const config: BacktestConfig = {
|
|
59
|
+
capital: 10000,
|
|
60
|
+
commissionRate: 0,
|
|
61
|
+
slippageBps: 0,
|
|
62
|
+
market: "crypto",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const engine = new BacktestEngine();
|
|
66
|
+
const result = await engine.run(strategy, data, config);
|
|
67
|
+
|
|
68
|
+
// Verify RSI actually crosses thresholds
|
|
69
|
+
const closes = data.map((d) => d.close);
|
|
70
|
+
const rsiValues = rsi(closes, 14);
|
|
71
|
+
|
|
72
|
+
// Find first oversold bar
|
|
73
|
+
let oversoldBar = -1;
|
|
74
|
+
for (let i = 0; i < rsiValues.length; i++) {
|
|
75
|
+
if (!Number.isNaN(rsiValues[i]!) && rsiValues[i]! < 30) {
|
|
76
|
+
oversoldBar = i;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Find first overbought bar after oversold
|
|
82
|
+
let overboughtBar = -1;
|
|
83
|
+
for (let i = oversoldBar + 1; i < rsiValues.length; i++) {
|
|
84
|
+
if (!Number.isNaN(rsiValues[i]!) && rsiValues[i]! > 70) {
|
|
85
|
+
overboughtBar = i;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// RSI should have crossed both thresholds
|
|
91
|
+
expect(oversoldBar).toBeGreaterThan(-1);
|
|
92
|
+
expect(overboughtBar).toBeGreaterThan(oversoldBar);
|
|
93
|
+
|
|
94
|
+
// Strategy should have at least 1 trade
|
|
95
|
+
expect(result.totalTrades).toBeGreaterThanOrEqual(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns no trades during warm-up period", async () => {
|
|
99
|
+
// Only 10 bars — not enough for RSI(14)
|
|
100
|
+
const data: OHLCV[] = [];
|
|
101
|
+
for (let i = 0; i < 10; i++) {
|
|
102
|
+
data.push(makeBar(i, 100 + i));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const strategy = createRsiMeanReversion({ period: 14 });
|
|
106
|
+
const config: BacktestConfig = {
|
|
107
|
+
capital: 10000,
|
|
108
|
+
commissionRate: 0,
|
|
109
|
+
slippageBps: 0,
|
|
110
|
+
market: "crypto",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const engine = new BacktestEngine();
|
|
114
|
+
const result = await engine.run(strategy, data, config);
|
|
115
|
+
|
|
116
|
+
expect(result.totalTrades).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not double-buy when already in position", async () => {
|
|
120
|
+
// Multiple oversold readings — should only buy once
|
|
121
|
+
const prices: number[] = [];
|
|
122
|
+
for (let i = 0; i < 20; i++) prices.push(100);
|
|
123
|
+
// Keep dropping to stay oversold
|
|
124
|
+
for (let i = 0; i < 10; i++) prices.push(75 - i);
|
|
125
|
+
|
|
126
|
+
const data = prices.map((p, i) => makeBar(i, p));
|
|
127
|
+
const strategy = createRsiMeanReversion({ period: 14, oversold: 30 });
|
|
128
|
+
const config: BacktestConfig = {
|
|
129
|
+
capital: 10000,
|
|
130
|
+
commissionRate: 0,
|
|
131
|
+
slippageBps: 0,
|
|
132
|
+
market: "crypto",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const engine = new BacktestEngine();
|
|
136
|
+
const result = await engine.run(strategy, data, config);
|
|
137
|
+
|
|
138
|
+
// Should have at most 1 open position worth of trades
|
|
139
|
+
// (the auto-close at end counts as the exit)
|
|
140
|
+
const buySignals = result.trades.filter((t) => t.reason.includes("oversold"));
|
|
141
|
+
expect(buySignals.length).toBeLessThanOrEqual(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { OHLCV } from "../../../fin-shared-types/src/types.js";
|
|
2
|
+
import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RSI Mean Reversion strategy.
|
|
6
|
+
* Buy when RSI drops below oversold threshold.
|
|
7
|
+
* Sell when RSI rises above overbought threshold.
|
|
8
|
+
*/
|
|
9
|
+
export function createRsiMeanReversion(params?: {
|
|
10
|
+
period?: number;
|
|
11
|
+
oversold?: number;
|
|
12
|
+
overbought?: number;
|
|
13
|
+
sizePct?: number;
|
|
14
|
+
symbol?: string;
|
|
15
|
+
}): StrategyDefinition {
|
|
16
|
+
const period = params?.period ?? 14;
|
|
17
|
+
const oversold = params?.oversold ?? 30;
|
|
18
|
+
const overbought = params?.overbought ?? 70;
|
|
19
|
+
const sizePct = params?.sizePct ?? 100;
|
|
20
|
+
const symbol = params?.symbol ?? "BTC/USDT";
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
id: "rsi-mean-reversion",
|
|
24
|
+
name: "RSI Mean Reversion",
|
|
25
|
+
version: "1.0.0",
|
|
26
|
+
markets: ["crypto", "equity"],
|
|
27
|
+
symbols: [symbol],
|
|
28
|
+
timeframes: ["1d"],
|
|
29
|
+
parameters: { period, oversold, overbought, sizePct },
|
|
30
|
+
parameterRanges: {
|
|
31
|
+
period: { min: 7, max: 28, step: 7 },
|
|
32
|
+
oversold: { min: 20, max: 40, step: 5 },
|
|
33
|
+
overbought: { min: 60, max: 80, step: 5 },
|
|
34
|
+
sizePct: { min: 10, max: 100, step: 10 },
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
|
|
38
|
+
const rsiValues = ctx.indicators.rsi(period);
|
|
39
|
+
const len = rsiValues.length;
|
|
40
|
+
if (len < 1) return null;
|
|
41
|
+
|
|
42
|
+
const currentRsi = rsiValues[len - 1]!;
|
|
43
|
+
if (Number.isNaN(currentRsi)) return null;
|
|
44
|
+
|
|
45
|
+
const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
|
|
46
|
+
|
|
47
|
+
// RSI below oversold → buy
|
|
48
|
+
if (currentRsi < oversold && !hasLong) {
|
|
49
|
+
return {
|
|
50
|
+
action: "buy",
|
|
51
|
+
symbol,
|
|
52
|
+
sizePct,
|
|
53
|
+
orderType: "market",
|
|
54
|
+
reason: `RSI oversold: ${currentRsi.toFixed(1)} < ${oversold}`,
|
|
55
|
+
confidence: 0.6,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// RSI above overbought → sell
|
|
60
|
+
if (currentRsi > overbought && hasLong) {
|
|
61
|
+
return {
|
|
62
|
+
action: "sell",
|
|
63
|
+
symbol,
|
|
64
|
+
sizePct: 100,
|
|
65
|
+
orderType: "market",
|
|
66
|
+
reason: `RSI overbought: ${currentRsi.toFixed(1)} > ${overbought}`,
|
|
67
|
+
confidence: 0.6,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { sma } from "../indicators.js";
|
|
5
|
+
import type { BacktestConfig } from "../types.js";
|
|
6
|
+
import { createSmaCrossover } from "./sma-crossover.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("SMA Crossover strategy", () => {
|
|
20
|
+
it("creates strategy with default parameters", () => {
|
|
21
|
+
const strategy = createSmaCrossover();
|
|
22
|
+
expect(strategy.id).toBe("sma-crossover");
|
|
23
|
+
expect(strategy.parameters.fastPeriod).toBe(10);
|
|
24
|
+
expect(strategy.parameters.slowPeriod).toBe(30);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates strategy with custom parameters", () => {
|
|
28
|
+
const strategy = createSmaCrossover({ fastPeriod: 5, slowPeriod: 20, sizePct: 50 });
|
|
29
|
+
expect(strategy.parameters.fastPeriod).toBe(5);
|
|
30
|
+
expect(strategy.parameters.slowPeriod).toBe(20);
|
|
31
|
+
expect(strategy.parameters.sizePct).toBe(50);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("buys on golden cross and sells on death cross", async () => {
|
|
35
|
+
// Design prices so that SMA(3) crosses above SMA(5) clearly.
|
|
36
|
+
// Then later SMA(3) crosses below SMA(5).
|
|
37
|
+
const prices = [
|
|
38
|
+
100,
|
|
39
|
+
100,
|
|
40
|
+
100,
|
|
41
|
+
100,
|
|
42
|
+
100, // bars 0-4: flat
|
|
43
|
+
102,
|
|
44
|
+
105,
|
|
45
|
+
110,
|
|
46
|
+
115,
|
|
47
|
+
120, // bars 5-9: rise (golden cross)
|
|
48
|
+
115,
|
|
49
|
+
108,
|
|
50
|
+
100,
|
|
51
|
+
95,
|
|
52
|
+
90, // bars 10-14: drop (death cross)
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const data = prices.map((p, i) => makeBar(i, p));
|
|
56
|
+
const strategy = createSmaCrossover({ fastPeriod: 3, slowPeriod: 5, sizePct: 100 });
|
|
57
|
+
const config: BacktestConfig = {
|
|
58
|
+
capital: 10000,
|
|
59
|
+
commissionRate: 0,
|
|
60
|
+
slippageBps: 0,
|
|
61
|
+
market: "crypto",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const engine = new BacktestEngine();
|
|
65
|
+
const result = await engine.run(strategy, data, config);
|
|
66
|
+
|
|
67
|
+
// Verify crossover happened by checking indicators
|
|
68
|
+
const closes = data.map((d) => d.close);
|
|
69
|
+
const fast = sma(closes, 3);
|
|
70
|
+
const slow = sma(closes, 5);
|
|
71
|
+
|
|
72
|
+
// Find golden cross
|
|
73
|
+
let goldenBar = -1;
|
|
74
|
+
for (let i = 1; i < closes.length; i++) {
|
|
75
|
+
if (
|
|
76
|
+
!Number.isNaN(fast[i]!) &&
|
|
77
|
+
!Number.isNaN(slow[i]!) &&
|
|
78
|
+
!Number.isNaN(fast[i - 1]!) &&
|
|
79
|
+
!Number.isNaN(slow[i - 1]!) &&
|
|
80
|
+
fast[i - 1]! <= slow[i - 1]! &&
|
|
81
|
+
fast[i]! > slow[i]!
|
|
82
|
+
) {
|
|
83
|
+
goldenBar = i;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(goldenBar).toBeGreaterThan(-1);
|
|
89
|
+
// Strategy should have at least 1 trade
|
|
90
|
+
expect(result.totalTrades).toBeGreaterThanOrEqual(1);
|
|
91
|
+
// Entry should be at the golden cross bar's close price
|
|
92
|
+
if (result.trades.length > 0) {
|
|
93
|
+
expect(result.trades[0]!.entryPrice).toBeCloseTo(closes[goldenBar]!, 0);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null during warm-up period", async () => {
|
|
98
|
+
// Only 3 bars — not enough for SMA(10)
|
|
99
|
+
const data = [makeBar(0, 100), makeBar(1, 101), makeBar(2, 102)];
|
|
100
|
+
const strategy = createSmaCrossover({ fastPeriod: 10, slowPeriod: 30 });
|
|
101
|
+
const config: BacktestConfig = {
|
|
102
|
+
capital: 10000,
|
|
103
|
+
commissionRate: 0,
|
|
104
|
+
slippageBps: 0,
|
|
105
|
+
market: "crypto",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const engine = new BacktestEngine();
|
|
109
|
+
const result = await engine.run(strategy, data, config);
|
|
110
|
+
|
|
111
|
+
expect(result.totalTrades).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { OHLCV } from "../../../fin-shared-types/src/types.js";
|
|
2
|
+
import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SMA Crossover strategy.
|
|
6
|
+
* Buy when SMA(fast) crosses above SMA(slow).
|
|
7
|
+
* Sell when SMA(fast) crosses below SMA(slow).
|
|
8
|
+
*/
|
|
9
|
+
export function createSmaCrossover(params?: {
|
|
10
|
+
fastPeriod?: number;
|
|
11
|
+
slowPeriod?: number;
|
|
12
|
+
sizePct?: number;
|
|
13
|
+
symbol?: string;
|
|
14
|
+
}): StrategyDefinition {
|
|
15
|
+
const fastPeriod = params?.fastPeriod ?? 10;
|
|
16
|
+
const slowPeriod = params?.slowPeriod ?? 30;
|
|
17
|
+
const sizePct = params?.sizePct ?? 100;
|
|
18
|
+
const symbol = params?.symbol ?? "BTC/USDT";
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
id: "sma-crossover",
|
|
22
|
+
name: "SMA Crossover",
|
|
23
|
+
version: "1.0.0",
|
|
24
|
+
markets: ["crypto", "equity"],
|
|
25
|
+
symbols: [symbol],
|
|
26
|
+
timeframes: ["1d"],
|
|
27
|
+
parameters: { fastPeriod, slowPeriod, sizePct },
|
|
28
|
+
parameterRanges: {
|
|
29
|
+
fastPeriod: { min: 5, max: 50, step: 5 },
|
|
30
|
+
slowPeriod: { min: 20, max: 200, step: 10 },
|
|
31
|
+
sizePct: { min: 10, max: 100, step: 10 },
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async onBar(_bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
|
|
35
|
+
const fastSma = ctx.indicators.sma(fastPeriod);
|
|
36
|
+
const slowSma = ctx.indicators.sma(slowPeriod);
|
|
37
|
+
|
|
38
|
+
const len = fastSma.length;
|
|
39
|
+
if (len < 2) return null;
|
|
40
|
+
|
|
41
|
+
const currFast = fastSma[len - 1]!;
|
|
42
|
+
const currSlow = slowSma[len - 1]!;
|
|
43
|
+
const prevFast = fastSma[len - 2]!;
|
|
44
|
+
const prevSlow = slowSma[len - 2]!;
|
|
45
|
+
|
|
46
|
+
// Skip if any value is NaN (warm-up period)
|
|
47
|
+
if (
|
|
48
|
+
Number.isNaN(currFast) ||
|
|
49
|
+
Number.isNaN(currSlow) ||
|
|
50
|
+
Number.isNaN(prevFast) ||
|
|
51
|
+
Number.isNaN(prevSlow)
|
|
52
|
+
) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
|
|
57
|
+
|
|
58
|
+
// Golden cross: fast crosses above slow → buy
|
|
59
|
+
if (prevFast <= prevSlow && currFast > currSlow && !hasLong) {
|
|
60
|
+
return {
|
|
61
|
+
action: "buy",
|
|
62
|
+
symbol,
|
|
63
|
+
sizePct,
|
|
64
|
+
orderType: "market",
|
|
65
|
+
reason: `SMA golden cross: fast(${fastPeriod})=${currFast.toFixed(2)} > slow(${slowPeriod})=${currSlow.toFixed(2)}`,
|
|
66
|
+
confidence: 0.7,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Death cross: fast crosses below slow → sell
|
|
71
|
+
if (prevFast >= prevSlow && currFast < currSlow && hasLong) {
|
|
72
|
+
return {
|
|
73
|
+
action: "sell",
|
|
74
|
+
symbol,
|
|
75
|
+
sizePct: 100,
|
|
76
|
+
orderType: "market",
|
|
77
|
+
reason: `SMA death cross: fast(${fastPeriod})=${currFast.toFixed(2)} < slow(${slowPeriod})=${currSlow.toFixed(2)}`,
|
|
78
|
+
confidence: 0.7,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
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 type { BacktestConfig } from "../types.js";
|
|
5
|
+
import { createTrendFollowingMomentum } from "./trend-following-momentum.js";
|
|
6
|
+
|
|
7
|
+
function makeBar(index: number, close: number, overrides?: Partial<OHLCV>): OHLCV {
|
|
8
|
+
return {
|
|
9
|
+
timestamp: 1000000 + index * 86400000,
|
|
10
|
+
open: close,
|
|
11
|
+
high: close * 1.01,
|
|
12
|
+
low: close * 0.99,
|
|
13
|
+
close,
|
|
14
|
+
volume: 1000,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config: BacktestConfig = {
|
|
20
|
+
capital: 10000,
|
|
21
|
+
commissionRate: 0,
|
|
22
|
+
slippageBps: 0,
|
|
23
|
+
market: "crypto",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("Trend-Following Momentum strategy", () => {
|
|
27
|
+
it("creates strategy with default parameters", () => {
|
|
28
|
+
const strategy = createTrendFollowingMomentum();
|
|
29
|
+
expect(strategy.id).toBe("trend-following-momentum");
|
|
30
|
+
expect(strategy.name).toBe("Trend-Following Momentum");
|
|
31
|
+
expect(strategy.parameters.fastEma).toBe(12);
|
|
32
|
+
expect(strategy.parameters.slowEma).toBe(26);
|
|
33
|
+
expect(strategy.parameters.macdFast).toBe(12);
|
|
34
|
+
expect(strategy.parameters.macdSlow).toBe(26);
|
|
35
|
+
expect(strategy.parameters.macdSignal).toBe(9);
|
|
36
|
+
expect(strategy.parameters.rsiPeriod).toBe(14);
|
|
37
|
+
expect(strategy.parameters.rsiOverbought).toBe(75);
|
|
38
|
+
expect(strategy.parameters.atrPeriod).toBe(14);
|
|
39
|
+
expect(strategy.parameters.atrStopMultiplier).toBe(2.0);
|
|
40
|
+
expect(strategy.parameters.atrProfitMultiplier).toBe(3.0);
|
|
41
|
+
expect(strategy.parameters.maxSizePct).toBe(80);
|
|
42
|
+
|
|
43
|
+
// Verify parameterRanges exist for all numeric params
|
|
44
|
+
expect(strategy.parameterRanges).toBeDefined();
|
|
45
|
+
expect(strategy.parameterRanges!.fastEma).toEqual({ min: 5, max: 50, step: 1 });
|
|
46
|
+
expect(strategy.parameterRanges!.slowEma).toEqual({ min: 10, max: 100, step: 2 });
|
|
47
|
+
expect(strategy.parameterRanges!.atrStopMultiplier).toEqual({ min: 1.0, max: 4.0, step: 0.5 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("creates strategy with custom parameters", () => {
|
|
51
|
+
const strategy = createTrendFollowingMomentum({
|
|
52
|
+
fastEma: 8,
|
|
53
|
+
slowEma: 21,
|
|
54
|
+
macdFast: 8,
|
|
55
|
+
macdSlow: 21,
|
|
56
|
+
macdSignal: 5,
|
|
57
|
+
rsiPeriod: 10,
|
|
58
|
+
rsiOverbought: 70,
|
|
59
|
+
atrPeriod: 10,
|
|
60
|
+
atrStopMultiplier: 1.5,
|
|
61
|
+
atrProfitMultiplier: 4.0,
|
|
62
|
+
maxSizePct: 60,
|
|
63
|
+
symbol: "ETH/USDT",
|
|
64
|
+
});
|
|
65
|
+
expect(strategy.parameters.fastEma).toBe(8);
|
|
66
|
+
expect(strategy.parameters.slowEma).toBe(21);
|
|
67
|
+
expect(strategy.parameters.macdFast).toBe(8);
|
|
68
|
+
expect(strategy.parameters.macdSlow).toBe(21);
|
|
69
|
+
expect(strategy.parameters.macdSignal).toBe(5);
|
|
70
|
+
expect(strategy.parameters.rsiPeriod).toBe(10);
|
|
71
|
+
expect(strategy.parameters.rsiOverbought).toBe(70);
|
|
72
|
+
expect(strategy.parameters.atrPeriod).toBe(10);
|
|
73
|
+
expect(strategy.parameters.atrStopMultiplier).toBe(1.5);
|
|
74
|
+
expect(strategy.parameters.atrProfitMultiplier).toBe(4.0);
|
|
75
|
+
expect(strategy.parameters.maxSizePct).toBe(60);
|
|
76
|
+
expect(strategy.symbols).toEqual(["ETH/USDT"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null during warm-up period", async () => {
|
|
80
|
+
// Only 5 bars — insufficient for EMA(26), MACD(12,26,9), RSI(14), ATR(14)
|
|
81
|
+
const data = [
|
|
82
|
+
makeBar(0, 100),
|
|
83
|
+
makeBar(1, 101),
|
|
84
|
+
makeBar(2, 102),
|
|
85
|
+
makeBar(3, 103),
|
|
86
|
+
makeBar(4, 104),
|
|
87
|
+
];
|
|
88
|
+
const strategy = createTrendFollowingMomentum();
|
|
89
|
+
|
|
90
|
+
const engine = new BacktestEngine();
|
|
91
|
+
const result = await engine.run(strategy, data, config);
|
|
92
|
+
|
|
93
|
+
expect(result.totalTrades).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("generates buy signal on EMA golden cross with MACD confirmation", async () => {
|
|
97
|
+
// Use small indicator periods to trigger signals with fewer bars.
|
|
98
|
+
// Phase 1: gentle decline (brings RSI low, fast EMA below slow EMA)
|
|
99
|
+
// Phase 2: choppy base (RSI normalizes near 50)
|
|
100
|
+
// Phase 3: gentle rise (triggers golden cross while RSI is still moderate)
|
|
101
|
+
const prices: number[] = [];
|
|
102
|
+
// Phase 1: decline from 120 to 106 over 15 bars
|
|
103
|
+
for (let i = 0; i < 15; i++) prices.push(120 - i);
|
|
104
|
+
// Phase 2: choppy base around 105
|
|
105
|
+
prices.push(105, 104, 106, 103, 105, 104, 106, 104, 105, 104);
|
|
106
|
+
// Phase 3: gentle rise
|
|
107
|
+
for (let i = 1; i <= 15; i++) prices.push(104 + i * 0.8);
|
|
108
|
+
|
|
109
|
+
const data = prices.map((p, i) => makeBar(i, p));
|
|
110
|
+
const strategy = createTrendFollowingMomentum({
|
|
111
|
+
fastEma: 3,
|
|
112
|
+
slowEma: 5,
|
|
113
|
+
macdFast: 3,
|
|
114
|
+
macdSlow: 5,
|
|
115
|
+
macdSignal: 2,
|
|
116
|
+
rsiPeriod: 3,
|
|
117
|
+
atrPeriod: 3,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const engine = new BacktestEngine();
|
|
121
|
+
const result = await engine.run(strategy, data, config);
|
|
122
|
+
|
|
123
|
+
// The decline→chop→rise pattern should trigger at least one buy entry
|
|
124
|
+
expect(result.totalTrades).toBeGreaterThanOrEqual(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("does not buy when already holding long position", async () => {
|
|
128
|
+
// Create prices that produce two potential golden cross signals
|
|
129
|
+
// but the second should be ignored because we're already long
|
|
130
|
+
const prices: number[] = [];
|
|
131
|
+
// Flat baseline
|
|
132
|
+
for (let i = 0; i < 15; i++) prices.push(100);
|
|
133
|
+
// First rise → golden cross + buy
|
|
134
|
+
for (let i = 1; i <= 10; i++) prices.push(100 + i * 3);
|
|
135
|
+
// Small dip (not enough for death cross or MACD negative with small periods)
|
|
136
|
+
for (let i = 0; i < 3; i++) prices.push(128);
|
|
137
|
+
// Second rise → would be another buy if not already long
|
|
138
|
+
for (let i = 1; i <= 10; i++) prices.push(128 + i * 3);
|
|
139
|
+
|
|
140
|
+
const data = prices.map((p, i) => makeBar(i, p));
|
|
141
|
+
const strategy = createTrendFollowingMomentum({
|
|
142
|
+
fastEma: 3,
|
|
143
|
+
slowEma: 5,
|
|
144
|
+
macdFast: 3,
|
|
145
|
+
macdSlow: 5,
|
|
146
|
+
macdSignal: 2,
|
|
147
|
+
rsiPeriod: 3,
|
|
148
|
+
atrPeriod: 3,
|
|
149
|
+
rsiOverbought: 90, // High threshold so RSI doesn't block
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const engine = new BacktestEngine();
|
|
153
|
+
const result = await engine.run(strategy, data, config);
|
|
154
|
+
|
|
155
|
+
// Count buy entries — there should be at most a few (not double entries)
|
|
156
|
+
const buyEntries = result.trades.filter((t) => t.reason.includes("Trend-following buy"));
|
|
157
|
+
// If multiple trades exist, each buy must follow a sell (no double buys)
|
|
158
|
+
for (let i = 1; i < buyEntries.length; i++) {
|
|
159
|
+
expect(buyEntries[i]!.entryTime).toBeGreaterThan(buyEntries[i - 1]!.exitTime);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("sets dynamic stopLoss and takeProfit on buy signals", async () => {
|
|
164
|
+
// Same decline→chop→rise data that triggers a buy
|
|
165
|
+
const prices: number[] = [];
|
|
166
|
+
for (let i = 0; i < 15; i++) prices.push(120 - i);
|
|
167
|
+
prices.push(105, 104, 106, 103, 105, 104, 106, 104, 105, 104);
|
|
168
|
+
for (let i = 1; i <= 15; i++) prices.push(104 + i * 0.8);
|
|
169
|
+
|
|
170
|
+
const data = prices.map((p, i) => makeBar(i, p));
|
|
171
|
+
const strategy = createTrendFollowingMomentum({
|
|
172
|
+
fastEma: 3,
|
|
173
|
+
slowEma: 5,
|
|
174
|
+
macdFast: 3,
|
|
175
|
+
macdSlow: 5,
|
|
176
|
+
macdSignal: 2,
|
|
177
|
+
rsiPeriod: 3,
|
|
178
|
+
atrPeriod: 3,
|
|
179
|
+
atrStopMultiplier: 2.0,
|
|
180
|
+
atrProfitMultiplier: 3.0,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Run onBar manually to capture the signal with stopLoss/takeProfit
|
|
184
|
+
// (BacktestEngine doesn't store these on TradeRecord)
|
|
185
|
+
const memory = new Map<string, unknown>();
|
|
186
|
+
let buySignal: { stopLoss?: number; takeProfit?: number; confidence: number } | null = null;
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < data.length; i++) {
|
|
189
|
+
const bar = data[i]!;
|
|
190
|
+
const history = data.slice(0, i + 1);
|
|
191
|
+
const closes = history.map((b) => b.close);
|
|
192
|
+
const highs = history.map((b) => b.high);
|
|
193
|
+
const lows = history.map((b) => b.low);
|
|
194
|
+
|
|
195
|
+
// Import indicators inline for manual context building
|
|
196
|
+
const { ema, rsi, macd, atr } = await import("../indicators.js");
|
|
197
|
+
|
|
198
|
+
const ctx = {
|
|
199
|
+
portfolio: { equity: 10000, cash: 10000, positions: [] as { side: string }[] },
|
|
200
|
+
history,
|
|
201
|
+
indicators: {
|
|
202
|
+
sma: () => [],
|
|
203
|
+
ema: (period: number) => ema(closes, period),
|
|
204
|
+
rsi: (period: number) => rsi(closes, period),
|
|
205
|
+
macd: (fast?: number, slow?: number, signal?: number) => macd(closes, fast, slow, signal),
|
|
206
|
+
bollingerBands: () => ({ upper: [], middle: [], lower: [] }),
|
|
207
|
+
atr: (period?: number) => atr(highs, lows, closes, period),
|
|
208
|
+
},
|
|
209
|
+
regime: "sideways" as const,
|
|
210
|
+
memory,
|
|
211
|
+
log: () => {},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const signal = await strategy.onBar(bar, ctx);
|
|
215
|
+
if (signal && signal.action === "buy") {
|
|
216
|
+
buySignal = signal;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
expect(buySignal).not.toBeNull();
|
|
222
|
+
expect(buySignal!.stopLoss).toBeDefined();
|
|
223
|
+
expect(buySignal!.takeProfit).toBeDefined();
|
|
224
|
+
expect(buySignal!.stopLoss!).toBeLessThan(buySignal!.takeProfit!);
|
|
225
|
+
expect(buySignal!.confidence).toBeGreaterThanOrEqual(0.3);
|
|
226
|
+
expect(buySignal!.confidence).toBeLessThanOrEqual(0.95);
|
|
227
|
+
});
|
|
228
|
+
});
|