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