@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,209 @@
1
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
2
+ import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
3
+
4
+ /**
5
+ * Trend-Following Momentum composite strategy.
6
+ * Combines EMA crossover, MACD histogram confirmation, RSI filter,
7
+ * and ATR-based dynamic stop-loss / take-profit with trailing stop.
8
+ *
9
+ * Buy when: EMA golden cross + MACD histogram rising & positive + RSI not overbought.
10
+ * Sell when: EMA death cross OR MACD histogram turns negative OR trailing stop hit.
11
+ */
12
+ export function createTrendFollowingMomentum(params?: {
13
+ fastEma?: number;
14
+ slowEma?: number;
15
+ macdFast?: number;
16
+ macdSlow?: number;
17
+ macdSignal?: number;
18
+ rsiPeriod?: number;
19
+ rsiOverbought?: number;
20
+ atrPeriod?: number;
21
+ atrStopMultiplier?: number;
22
+ atrProfitMultiplier?: number;
23
+ maxSizePct?: number;
24
+ symbol?: string;
25
+ }): StrategyDefinition {
26
+ const fastEma = params?.fastEma ?? 12;
27
+ const slowEma = params?.slowEma ?? 26;
28
+ const macdFast = params?.macdFast ?? 12;
29
+ const macdSlow = params?.macdSlow ?? 26;
30
+ const macdSignal = params?.macdSignal ?? 9;
31
+ const rsiPeriod = params?.rsiPeriod ?? 14;
32
+ const rsiOverbought = params?.rsiOverbought ?? 75;
33
+ const atrPeriod = params?.atrPeriod ?? 14;
34
+ const atrStopMultiplier = params?.atrStopMultiplier ?? 2.0;
35
+ const atrProfitMultiplier = params?.atrProfitMultiplier ?? 3.0;
36
+ const maxSizePct = params?.maxSizePct ?? 80;
37
+ const symbol = params?.symbol ?? "BTC/USDT";
38
+
39
+ return {
40
+ id: "trend-following-momentum",
41
+ name: "Trend-Following Momentum",
42
+ version: "1.0.0",
43
+ markets: ["crypto", "equity"],
44
+ symbols: [symbol],
45
+ timeframes: ["1d"],
46
+ parameters: {
47
+ fastEma,
48
+ slowEma,
49
+ macdFast,
50
+ macdSlow,
51
+ macdSignal,
52
+ rsiPeriod,
53
+ rsiOverbought,
54
+ atrPeriod,
55
+ atrStopMultiplier,
56
+ atrProfitMultiplier,
57
+ maxSizePct,
58
+ },
59
+ parameterRanges: {
60
+ fastEma: { min: 5, max: 50, step: 1 },
61
+ slowEma: { min: 10, max: 100, step: 2 },
62
+ macdFast: { min: 8, max: 20, step: 2 },
63
+ macdSlow: { min: 20, max: 40, step: 2 },
64
+ macdSignal: { min: 5, max: 15, step: 1 },
65
+ rsiPeriod: { min: 7, max: 28, step: 1 },
66
+ rsiOverbought: { min: 65, max: 85, step: 5 },
67
+ atrPeriod: { min: 7, max: 28, step: 1 },
68
+ atrStopMultiplier: { min: 1.0, max: 4.0, step: 0.5 },
69
+ atrProfitMultiplier: { min: 1.5, max: 6.0, step: 0.5 },
70
+ maxSizePct: { min: 20, max: 100, step: 10 },
71
+ },
72
+
73
+ async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
74
+ // Compute indicators
75
+ const fastEmaArr = ctx.indicators.ema(fastEma);
76
+ const slowEmaArr = ctx.indicators.ema(slowEma);
77
+ const { histogram } = ctx.indicators.macd(macdFast, macdSlow, macdSignal);
78
+ const rsiArr = ctx.indicators.rsi(rsiPeriod);
79
+ const atrArr = ctx.indicators.atr(atrPeriod);
80
+
81
+ const len = fastEmaArr.length;
82
+ if (len < 2) return null;
83
+
84
+ const currFast = fastEmaArr[len - 1]!;
85
+ const currSlow = slowEmaArr[len - 1]!;
86
+ const prevFast = fastEmaArr[len - 2]!;
87
+ const prevSlow = slowEmaArr[len - 2]!;
88
+ const currHist = histogram[len - 1]!;
89
+ const prevHist = histogram[len - 2]!;
90
+ const currRsi = rsiArr[len - 1]!;
91
+ const currAtr = atrArr[len - 1]!;
92
+ const prevClose =
93
+ ctx.history.length >= 2 ? ctx.history[ctx.history.length - 2]!.close : bar.close;
94
+
95
+ // Skip if any indicator value is NaN (warm-up period)
96
+ if (
97
+ Number.isNaN(currFast) ||
98
+ Number.isNaN(currSlow) ||
99
+ Number.isNaN(prevFast) ||
100
+ Number.isNaN(prevSlow) ||
101
+ Number.isNaN(currHist) ||
102
+ Number.isNaN(prevHist) ||
103
+ Number.isNaN(currRsi) ||
104
+ Number.isNaN(currAtr)
105
+ ) {
106
+ return null;
107
+ }
108
+
109
+ // Skip during crisis regime
110
+ if (ctx.regime === "crisis") return null;
111
+
112
+ const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
113
+
114
+ // --- SELL conditions (check first so trailing stop updates every bar) ---
115
+ if (hasLong) {
116
+ // Update trailing stop
117
+ const storedStop = ctx.memory.get("trailingStop") as number | undefined;
118
+ if (storedStop !== undefined) {
119
+ const newStop = Math.max(storedStop, bar.close - atrStopMultiplier * currAtr);
120
+ ctx.memory.set("trailingStop", newStop);
121
+
122
+ // Trailing stop hit
123
+ if (bar.close < newStop) {
124
+ ctx.memory.delete("trailingStop");
125
+ ctx.memory.delete("entryAtr");
126
+ return {
127
+ action: "sell",
128
+ symbol,
129
+ sizePct: 100,
130
+ orderType: "market",
131
+ reason: `Trailing stop hit: close=${bar.close.toFixed(2)} < stop=${newStop.toFixed(2)}`,
132
+ confidence: 0.8,
133
+ };
134
+ }
135
+ }
136
+
137
+ // EMA death cross
138
+ if (prevFast >= prevSlow && currFast < currSlow) {
139
+ ctx.memory.delete("trailingStop");
140
+ ctx.memory.delete("entryAtr");
141
+ return {
142
+ action: "sell",
143
+ symbol,
144
+ sizePct: 100,
145
+ orderType: "market",
146
+ reason: `EMA death cross: fast(${fastEma})=${currFast.toFixed(2)} < slow(${slowEma})=${currSlow.toFixed(2)}`,
147
+ confidence: 0.7,
148
+ };
149
+ }
150
+
151
+ // MACD histogram turns negative
152
+ if (currHist < 0) {
153
+ ctx.memory.delete("trailingStop");
154
+ ctx.memory.delete("entryAtr");
155
+ return {
156
+ action: "sell",
157
+ symbol,
158
+ sizePct: 100,
159
+ orderType: "market",
160
+ reason: `MACD histogram negative: ${currHist.toFixed(4)}`,
161
+ confidence: 0.6,
162
+ };
163
+ }
164
+ }
165
+
166
+ // --- BUY conditions (all must be true) ---
167
+ if (!hasLong) {
168
+ const goldenCross = prevFast <= prevSlow && currFast > currSlow;
169
+ const macdRising = currHist > 0 && currHist > prevHist;
170
+ const rsiNotOverbought = currRsi < rsiOverbought;
171
+
172
+ if (goldenCross && macdRising && rsiNotOverbought) {
173
+ // Compute confidence components
174
+ const histStrength = Math.min((Math.abs(currHist) / prevClose) * 1000, 1);
175
+ const rsiScore = 1 - currRsi / 100;
176
+ const emaSepScore = Math.min((Math.abs(currFast - currSlow) / prevClose) * 100, 1);
177
+ const confidence = Math.max(
178
+ 0.3,
179
+ Math.min(0.95, 0.5 + 0.15 * histStrength + 0.1 * rsiScore + 0.1 * emaSepScore),
180
+ );
181
+
182
+ // ATR-based stop-loss and take-profit
183
+ const stopLoss = bar.close - atrStopMultiplier * currAtr;
184
+ const takeProfit = bar.close + atrProfitMultiplier * currAtr;
185
+
186
+ // Store trailing stop and entry ATR in memory
187
+ ctx.memory.set("trailingStop", stopLoss);
188
+ ctx.memory.set("entryAtr", currAtr);
189
+
190
+ // Dynamic position sizing
191
+ const sizePct = Math.min(maxSizePct, Math.max(20, confidence * 100));
192
+
193
+ return {
194
+ action: "buy",
195
+ symbol,
196
+ sizePct,
197
+ orderType: "market",
198
+ reason: `Trend-following buy: EMA golden cross + MACD histogram rising (${currHist.toFixed(4)}) + RSI=${currRsi.toFixed(1)}`,
199
+ confidence,
200
+ stopLoss,
201
+ takeProfit,
202
+ };
203
+ }
204
+ }
205
+
206
+ return null;
207
+ },
208
+ };
209
+ }
@@ -0,0 +1,193 @@
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 { createVolatilityMeanReversion } from "./volatility-mean-reversion.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
+ /**
27
+ * Helper: generates a base oscillation of 25 bars around 100 using sin wave,
28
+ * which establishes BB width with bbPeriod=20. The resulting BB lower is ~95.
29
+ * A sudden crash below 88 will breach the lower band. After a 2-bar crash
30
+ * and a micro-bounce, RSI(3) turns up while still oversold and price stays
31
+ * below the slowly-adapting BB lower band.
32
+ */
33
+ function generateOscillatingBase(): number[] {
34
+ const prices: number[] = [];
35
+ for (let i = 0; i < 25; i++) {
36
+ prices.push(100 + 3 * Math.sin(i * 0.5));
37
+ }
38
+ return prices;
39
+ }
40
+
41
+ /** Strategy params tuned for test data: bbPeriod=20 for slow BB adaptation. */
42
+ const testParams = {
43
+ bbPeriod: 20,
44
+ rsiPeriod: 3,
45
+ atrPeriod: 3,
46
+ trendFilterPeriod: 5,
47
+ useTrendFilter: 0,
48
+ maxAtrPctFilter: 10.0,
49
+ };
50
+
51
+ describe("Volatility Mean Reversion strategy", () => {
52
+ it("creates strategy with default parameters", () => {
53
+ const strategy = createVolatilityMeanReversion();
54
+ expect(strategy.id).toBe("volatility-mean-reversion");
55
+ expect(strategy.name).toBe("Volatility Mean Reversion");
56
+ expect(strategy.version).toBe("1.0.0");
57
+ expect(strategy.parameters.bbPeriod).toBe(20);
58
+ expect(strategy.parameters.bbStdDev).toBe(2.0);
59
+ expect(strategy.parameters.rsiPeriod).toBe(7);
60
+ expect(strategy.parameters.rsiOversold).toBe(25);
61
+ expect(strategy.parameters.rsiOverbought).toBe(75);
62
+ expect(strategy.parameters.atrPeriod).toBe(14);
63
+ expect(strategy.parameters.atrStopMultiplier).toBe(1.5);
64
+ expect(strategy.parameters.trendFilterPeriod).toBe(200);
65
+ expect(strategy.parameters.useTrendFilter).toBe(1);
66
+ expect(strategy.parameters.maxSizePct).toBe(60);
67
+ expect(strategy.parameters.maxAtrPctFilter).toBe(5.0);
68
+ });
69
+
70
+ it("creates strategy with custom parameters", () => {
71
+ const strategy = createVolatilityMeanReversion({
72
+ bbPeriod: 15,
73
+ rsiPeriod: 5,
74
+ rsiOversold: 20,
75
+ rsiOverbought: 80,
76
+ atrPeriod: 10,
77
+ atrStopMultiplier: 2.0,
78
+ trendFilterPeriod: 100,
79
+ useTrendFilter: 0,
80
+ maxSizePct: 40,
81
+ maxAtrPctFilter: 3.0,
82
+ symbol: "ETH/USDT",
83
+ });
84
+ expect(strategy.parameters.bbPeriod).toBe(15);
85
+ expect(strategy.parameters.rsiPeriod).toBe(5);
86
+ expect(strategy.parameters.rsiOversold).toBe(20);
87
+ expect(strategy.parameters.rsiOverbought).toBe(80);
88
+ expect(strategy.parameters.atrPeriod).toBe(10);
89
+ expect(strategy.parameters.atrStopMultiplier).toBe(2.0);
90
+ expect(strategy.parameters.trendFilterPeriod).toBe(100);
91
+ expect(strategy.parameters.useTrendFilter).toBe(0);
92
+ expect(strategy.parameters.maxSizePct).toBe(40);
93
+ expect(strategy.parameters.maxAtrPctFilter).toBe(3.0);
94
+ expect(strategy.symbols).toEqual(["ETH/USDT"]);
95
+ });
96
+
97
+ it("returns null during warm-up period", async () => {
98
+ // Only 5 bars — not enough for BB(20) or RSI(3)+ATR(3) to all converge
99
+ const data = [makeBar(0, 100), makeBar(1, 101), makeBar(2, 99), makeBar(3, 98), makeBar(4, 97)];
100
+ const strategy = createVolatilityMeanReversion(testParams);
101
+
102
+ const engine = new BacktestEngine();
103
+ const result = await engine.run(strategy, data, config);
104
+
105
+ expect(result.totalTrades).toBe(0);
106
+ });
107
+
108
+ it("buys on BB lower touch with RSI oversold confirmation", async () => {
109
+ // 25-bar sin-wave oscillation establishes BB width (~95-105 bands with bbPeriod=20).
110
+ // 2-bar crash (88, 84) breaches the lower band. Micro-bounce to 85 triggers RSI
111
+ // turning up while still oversold and price still below the slowly-adapting BB lower.
112
+ const prices = generateOscillatingBase();
113
+ // Bars 25-26: sharp crash below BB lower
114
+ prices.push(88, 84);
115
+ // Bar 27: micro-bounce — RSI turns up, still oversold, still below BB lower
116
+ prices.push(85);
117
+ // Bars 28-37: recovery through BB middle and beyond
118
+ prices.push(88, 92, 96, 99, 101, 103, 105, 106, 107, 108);
119
+
120
+ const data = prices.map((p, i) => makeBar(i, p));
121
+ const strategy = createVolatilityMeanReversion(testParams);
122
+
123
+ const engine = new BacktestEngine();
124
+ const result = await engine.run(strategy, data, config);
125
+
126
+ // The crash + bounce should trigger at least one buy
127
+ expect(result.totalTrades).toBeGreaterThanOrEqual(1);
128
+ // Entry should be during the crash phase (price < 90)
129
+ if (result.trades.length > 0) {
130
+ expect(result.trades[0]!.entryPrice).toBeLessThan(90);
131
+ }
132
+ });
133
+
134
+ it("sells when price reverts to BB middle", async () => {
135
+ // Same crash setup, but focus on verifying the exit at BB middle
136
+ const prices = generateOscillatingBase();
137
+ // Crash + bounce to trigger entry
138
+ prices.push(88, 84, 85);
139
+ // Recovery that crosses BB middle (around 96-97 after crash)
140
+ prices.push(88, 92, 96, 99, 101, 103, 105, 106, 107, 108);
141
+
142
+ const data = prices.map((p, i) => makeBar(i, p));
143
+ const strategy = createVolatilityMeanReversion(testParams);
144
+
145
+ const engine = new BacktestEngine();
146
+ const result = await engine.run(strategy, data, config);
147
+
148
+ // Should have at least one completed trade
149
+ expect(result.totalTrades).toBeGreaterThanOrEqual(1);
150
+
151
+ if (result.trades.length > 0) {
152
+ const firstTrade = result.trades[0]!;
153
+ // Exit should be due to mean reversion (price reaching BB middle)
154
+ // or end-of-backtest or time stop (acceptable alternative exits)
155
+ const validExitReason =
156
+ firstTrade.exitReason.includes("Mean reversion target") ||
157
+ firstTrade.exitReason.includes("RSI overbought") ||
158
+ firstTrade.exitReason === "end-of-backtest" ||
159
+ firstTrade.exitReason.includes("Time stop");
160
+ expect(validExitReason).toBe(true);
161
+ // Entry was during the dip
162
+ expect(firstTrade.entryPrice).toBeLessThan(90);
163
+ }
164
+ });
165
+
166
+ it("respects time stop after 10 bars holding", async () => {
167
+ // Same crash setup to trigger entry, but then price stays flat below BB middle
168
+ // for > 10 bars so the time stop fires.
169
+ const prices = generateOscillatingBase();
170
+ // Crash + bounce to trigger entry
171
+ prices.push(88, 84, 85);
172
+ // Stay flat well below BB middle (~96) for 20 bars — never reaches BB middle
173
+ for (let i = 0; i < 20; i++) {
174
+ // Oscillate between 85-86 to avoid RSI overbought exit
175
+ prices.push(85 + (i % 2));
176
+ }
177
+
178
+ const data = prices.map((p, i) => makeBar(i, p));
179
+ const strategy = createVolatilityMeanReversion(testParams);
180
+
181
+ const engine = new BacktestEngine();
182
+ const result = await engine.run(strategy, data, config);
183
+
184
+ // Should have at least one trade
185
+ expect(result.totalTrades).toBeGreaterThanOrEqual(1);
186
+
187
+ // At least one trade should have exited via time stop or end-of-backtest
188
+ const hasTimeStopOrForceExit = result.trades.some(
189
+ (t) => t.exitReason.includes("Time stop") || t.exitReason === "end-of-backtest",
190
+ );
191
+ expect(hasTimeStopOrForceExit).toBe(true);
192
+ });
193
+ });
@@ -0,0 +1,212 @@
1
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
2
+ import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
3
+
4
+ /**
5
+ * Volatility Mean Reversion strategy.
6
+ * Enters long when price drops below Bollinger Band lower with RSI oversold confirmation
7
+ * and RSI turning up. Exits when price reverts to BB middle, RSI becomes overbought,
8
+ * or a time stop (10 bars) is triggered.
9
+ */
10
+ export function createVolatilityMeanReversion(params?: {
11
+ bbPeriod?: number;
12
+ bbStdDev?: number;
13
+ rsiPeriod?: number;
14
+ rsiOversold?: number;
15
+ rsiOverbought?: number;
16
+ atrPeriod?: number;
17
+ atrStopMultiplier?: number;
18
+ trendFilterPeriod?: number;
19
+ useTrendFilter?: number;
20
+ maxSizePct?: number;
21
+ maxAtrPctFilter?: number;
22
+ symbol?: string;
23
+ }): StrategyDefinition {
24
+ const bbPeriod = params?.bbPeriod ?? 20;
25
+ const bbStdDev = params?.bbStdDev ?? 2.0;
26
+ const rsiPeriod = params?.rsiPeriod ?? 7;
27
+ const rsiOversold = params?.rsiOversold ?? 25;
28
+ const rsiOverbought = params?.rsiOverbought ?? 75;
29
+ const atrPeriod = params?.atrPeriod ?? 14;
30
+ const atrStopMultiplier = params?.atrStopMultiplier ?? 1.5;
31
+ const trendFilterPeriod = params?.trendFilterPeriod ?? 200;
32
+ const useTrendFilter = params?.useTrendFilter ?? 1;
33
+ const maxSizePct = params?.maxSizePct ?? 60;
34
+ const maxAtrPctFilter = params?.maxAtrPctFilter ?? 5.0;
35
+ const symbol = params?.symbol ?? "BTC/USDT";
36
+
37
+ return {
38
+ id: "volatility-mean-reversion",
39
+ name: "Volatility Mean Reversion",
40
+ version: "1.0.0",
41
+ markets: ["crypto", "equity"],
42
+ symbols: [symbol],
43
+ timeframes: ["1d"],
44
+ parameters: {
45
+ bbPeriod,
46
+ bbStdDev,
47
+ rsiPeriod,
48
+ rsiOversold,
49
+ rsiOverbought,
50
+ atrPeriod,
51
+ atrStopMultiplier,
52
+ trendFilterPeriod,
53
+ useTrendFilter,
54
+ maxSizePct,
55
+ maxAtrPctFilter,
56
+ },
57
+ parameterRanges: {
58
+ bbPeriod: { min: 10, max: 50, step: 5 },
59
+ bbStdDev: { min: 1.0, max: 3.0, step: 0.25 },
60
+ rsiPeriod: { min: 3, max: 21, step: 2 },
61
+ rsiOversold: { min: 15, max: 35, step: 5 },
62
+ rsiOverbought: { min: 65, max: 85, step: 5 },
63
+ atrPeriod: { min: 7, max: 28, step: 7 },
64
+ atrStopMultiplier: { min: 1.0, max: 3.0, step: 0.5 },
65
+ trendFilterPeriod: { min: 50, max: 200, step: 50 },
66
+ useTrendFilter: { min: 0, max: 1, step: 1 },
67
+ maxSizePct: { min: 20, max: 100, step: 10 },
68
+ maxAtrPctFilter: { min: 2.0, max: 10.0, step: 1.0 },
69
+ },
70
+
71
+ async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
72
+ const bb = ctx.indicators.bollingerBands(bbPeriod, bbStdDev);
73
+ const rsiValues = ctx.indicators.rsi(rsiPeriod);
74
+ const atrValues = ctx.indicators.atr(atrPeriod);
75
+ const sma200 = ctx.indicators.sma(trendFilterPeriod);
76
+
77
+ const len = rsiValues.length;
78
+ if (len < 2) return null;
79
+
80
+ const currRsi = rsiValues[len - 1]!;
81
+ const prevRsi = rsiValues[len - 2]!;
82
+ const upper = bb.upper[len - 1]!;
83
+ const lower = bb.lower[len - 1]!;
84
+ const middle = bb.middle[len - 1]!;
85
+ const currAtr = atrValues[len - 1]!;
86
+ const currSma200 = sma200[len - 1]!;
87
+
88
+ // NaN check on current values
89
+ if (
90
+ Number.isNaN(currRsi) ||
91
+ Number.isNaN(prevRsi) ||
92
+ Number.isNaN(upper) ||
93
+ Number.isNaN(lower) ||
94
+ Number.isNaN(middle) ||
95
+ Number.isNaN(currAtr)
96
+ ) {
97
+ return null;
98
+ }
99
+
100
+ // Trend filter NaN check (only when enabled)
101
+ if (useTrendFilter && Number.isNaN(currSma200)) {
102
+ return null;
103
+ }
104
+
105
+ // Regime filter: skip during crisis
106
+ if (ctx.regime === "crisis") {
107
+ return null;
108
+ }
109
+
110
+ // Compute %B = (close - lower) / (upper - lower)
111
+ const bbWidth = upper - lower;
112
+ const percentB = bbWidth === 0 ? 0.5 : (bar.close - lower) / bbWidth;
113
+
114
+ // ATR as percentage of price
115
+ const atrPct = (currAtr / bar.close) * 100;
116
+
117
+ const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
118
+
119
+ // --- BUY logic ---
120
+ if (!hasLong) {
121
+ const belowLower = bar.close < lower;
122
+ const rsiOversoldCondition = currRsi < rsiOversold;
123
+ const rsiTurningUp = currRsi > prevRsi;
124
+ const trendOk = useTrendFilter === 0 || bar.close > currSma200;
125
+ const atrOk = atrPct < maxAtrPctFilter;
126
+
127
+ if (belowLower && rsiOversoldCondition && rsiTurningUp && trendOk && atrOk) {
128
+ // Confidence components
129
+ const percentBComponent = 0.2 * (1 - Math.max(percentB, 0));
130
+ const rsiReversal = Math.min(
131
+ Math.max((rsiOversold - currRsi) / 20 + (currRsi - prevRsi) / 10, 0),
132
+ 1,
133
+ );
134
+ const rsiComponent = 0.15 * rsiReversal;
135
+ const trendAlignment = useTrendFilter && bar.close > currSma200 ? 1 : 0;
136
+ const trendComponent = 0.1 * trendAlignment;
137
+ const confidence = Math.min(0.4 + percentBComponent + rsiComponent + trendComponent, 1);
138
+
139
+ const stopLoss = bar.close - atrStopMultiplier * currAtr;
140
+ const takeProfit = middle;
141
+ const sizePct = Math.min(maxSizePct, Math.max(20, confidence * 80));
142
+
143
+ // Store tracking state in memory
144
+ ctx.memory.set("holdBars", 0);
145
+ ctx.memory.set("entryPercentB", percentB);
146
+
147
+ return {
148
+ action: "buy",
149
+ symbol,
150
+ sizePct,
151
+ orderType: "market",
152
+ reason: `BB lower touch (percentB=${percentB.toFixed(3)}) + RSI oversold (${currRsi.toFixed(1)}) turning up`,
153
+ confidence,
154
+ stopLoss,
155
+ takeProfit,
156
+ };
157
+ }
158
+ }
159
+
160
+ // --- SELL logic ---
161
+ if (hasLong) {
162
+ // Increment hold bar counter
163
+ const holdBars = ((ctx.memory.get("holdBars") as number) ?? 0) + 1;
164
+ ctx.memory.set("holdBars", holdBars);
165
+
166
+ // Mean reversion target reached
167
+ if (bar.close >= middle) {
168
+ ctx.memory.delete("holdBars");
169
+ ctx.memory.delete("entryPercentB");
170
+ return {
171
+ action: "sell",
172
+ symbol,
173
+ sizePct: 100,
174
+ orderType: "market",
175
+ reason: `Mean reversion target: price ${bar.close.toFixed(2)} >= BB middle ${middle.toFixed(2)}`,
176
+ confidence: 0.8,
177
+ };
178
+ }
179
+
180
+ // Overbought exit
181
+ if (currRsi > rsiOverbought) {
182
+ ctx.memory.delete("holdBars");
183
+ ctx.memory.delete("entryPercentB");
184
+ return {
185
+ action: "sell",
186
+ symbol,
187
+ sizePct: 100,
188
+ orderType: "market",
189
+ reason: `RSI overbought exit: RSI=${currRsi.toFixed(1)} > ${rsiOverbought}`,
190
+ confidence: 0.75,
191
+ };
192
+ }
193
+
194
+ // Time stop
195
+ if (holdBars > 10) {
196
+ ctx.memory.delete("holdBars");
197
+ ctx.memory.delete("entryPercentB");
198
+ return {
199
+ action: "sell",
200
+ symbol,
201
+ sizePct: 100,
202
+ orderType: "market",
203
+ reason: `Time stop: held ${holdBars} bars > 10 bar limit`,
204
+ confidence: 0.6,
205
+ };
206
+ }
207
+ }
208
+
209
+ return null;
210
+ },
211
+ };
212
+ }