@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,295 @@
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 { createRiskParityTripleScreen } from "./risk-parity-triple-screen.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("Risk-Parity Triple Screen strategy", () => {
27
+ it("creates strategy with default parameters", () => {
28
+ const strategy = createRiskParityTripleScreen();
29
+ expect(strategy.id).toBe("risk-parity-triple-screen");
30
+ expect(strategy.name).toBe("Risk-Parity Triple Screen");
31
+ expect(strategy.version).toBe("1.0.0");
32
+ expect(strategy.markets).toEqual(["crypto", "equity"]);
33
+ expect(strategy.symbols).toEqual(["BTC/USDT"]);
34
+ expect(strategy.timeframes).toEqual(["1d"]);
35
+
36
+ // Verify all default parameter values
37
+ expect(strategy.parameters.tideFastEma).toBe(13);
38
+ expect(strategy.parameters.tideSlowEma).toBe(48);
39
+ expect(strategy.parameters.tideMacdFast).toBe(12);
40
+ expect(strategy.parameters.tideMacdSlow).toBe(26);
41
+ expect(strategy.parameters.tideMacdSignal).toBe(9);
42
+ expect(strategy.parameters.tideSma).toBe(50);
43
+ expect(strategy.parameters.tideSlopeLookback).toBe(5);
44
+ expect(strategy.parameters.waveRsiPeriod).toBe(14);
45
+ expect(strategy.parameters.waveRsiOversold).toBe(40);
46
+ expect(strategy.parameters.waveRsiEntry).toBe(50);
47
+ expect(strategy.parameters.rippleBbPeriod).toBe(20);
48
+ expect(strategy.parameters.rippleBbStdDev).toBe(2.0);
49
+ expect(strategy.parameters.atrPeriod).toBe(14);
50
+ expect(strategy.parameters.riskPctPerTrade).toBe(2.0);
51
+ expect(strategy.parameters.atrStopMultiplier).toBe(2.0);
52
+ expect(strategy.parameters.atrProfitMultiplier).toBe(3.5);
53
+ expect(strategy.parameters.maxSizePct).toBe(80);
54
+
55
+ // Verify parameterRanges exist
56
+ expect(strategy.parameterRanges).toBeDefined();
57
+ expect(strategy.parameterRanges!.tideFastEma).toEqual({ min: 5, max: 30, step: 1 });
58
+ expect(strategy.parameterRanges!.atrStopMultiplier).toEqual({ min: 1.0, max: 4.0, step: 0.5 });
59
+ expect(strategy.parameterRanges!.riskPctPerTrade).toEqual({ min: 0.5, max: 5.0, step: 0.5 });
60
+ });
61
+
62
+ it("creates strategy with custom parameters", () => {
63
+ const strategy = createRiskParityTripleScreen({
64
+ tideFastEma: 8,
65
+ tideSlowEma: 21,
66
+ tideMacdFast: 6,
67
+ tideMacdSlow: 18,
68
+ tideMacdSignal: 5,
69
+ tideSma: 30,
70
+ tideSlopeLookback: 3,
71
+ waveRsiPeriod: 10,
72
+ waveRsiOversold: 30,
73
+ waveRsiEntry: 45,
74
+ rippleBbPeriod: 15,
75
+ rippleBbStdDev: 1.5,
76
+ atrPeriod: 10,
77
+ riskPctPerTrade: 1.5,
78
+ atrStopMultiplier: 1.5,
79
+ atrProfitMultiplier: 4.0,
80
+ maxSizePct: 60,
81
+ symbol: "ETH/USDT",
82
+ });
83
+
84
+ expect(strategy.parameters.tideFastEma).toBe(8);
85
+ expect(strategy.parameters.tideSlowEma).toBe(21);
86
+ expect(strategy.parameters.tideMacdFast).toBe(6);
87
+ expect(strategy.parameters.tideMacdSlow).toBe(18);
88
+ expect(strategy.parameters.tideMacdSignal).toBe(5);
89
+ expect(strategy.parameters.tideSma).toBe(30);
90
+ expect(strategy.parameters.tideSlopeLookback).toBe(3);
91
+ expect(strategy.parameters.waveRsiPeriod).toBe(10);
92
+ expect(strategy.parameters.waveRsiOversold).toBe(30);
93
+ expect(strategy.parameters.waveRsiEntry).toBe(45);
94
+ expect(strategy.parameters.rippleBbPeriod).toBe(15);
95
+ expect(strategy.parameters.rippleBbStdDev).toBe(1.5);
96
+ expect(strategy.parameters.atrPeriod).toBe(10);
97
+ expect(strategy.parameters.riskPctPerTrade).toBe(1.5);
98
+ expect(strategy.parameters.atrStopMultiplier).toBe(1.5);
99
+ expect(strategy.parameters.atrProfitMultiplier).toBe(4.0);
100
+ expect(strategy.parameters.maxSizePct).toBe(60);
101
+ expect(strategy.symbols).toEqual(["ETH/USDT"]);
102
+ });
103
+
104
+ it("returns null during warm-up period", async () => {
105
+ // Only 10 bars — insufficient for SMA(50), EMA(48), MACD(12,26,9), RSI(14), BB(20), ATR(14)
106
+ const data: OHLCV[] = [];
107
+ for (let i = 0; i < 10; i++) {
108
+ data.push(makeBar(i, 100 + i));
109
+ }
110
+ const strategy = createRiskParityTripleScreen();
111
+
112
+ const engine = new BacktestEngine();
113
+ const result = await engine.run(strategy, data, config);
114
+
115
+ expect(result.totalTrades).toBe(0);
116
+ });
117
+
118
+ it("generates buy when all three screens pass", async () => {
119
+ // Test triple screen logic with mock IndicatorLib.
120
+ // Screen 1 (Tide): EMA(3) > EMA(5) + MACD hist > 0 + SMA(5) rising → score 3/3
121
+ // Screen 2 (Wave): previously oversold, now RSI > entry → passes
122
+ // Screen 3 (Ripple): close <= BBLower * 1.01 → passes
123
+ const strategy = createRiskParityTripleScreen({
124
+ tideFastEma: 3,
125
+ tideSlowEma: 5,
126
+ tideMacdFast: 3,
127
+ tideMacdSlow: 5,
128
+ tideMacdSignal: 2,
129
+ tideSma: 5,
130
+ tideSlopeLookback: 2,
131
+ waveRsiPeriod: 3,
132
+ waveRsiOversold: 35,
133
+ waveRsiEntry: 45,
134
+ rippleBbPeriod: 5,
135
+ rippleBbStdDev: 2,
136
+ atrPeriod: 3,
137
+ maxSizePct: 80,
138
+ });
139
+
140
+ const bar = makeBar(20, 100);
141
+ const memory = new Map<string, unknown>();
142
+ // Pre-set wave state: RSI was oversold on a previous bar
143
+ memory.set("waveWasOversold", true);
144
+
145
+ // Mock indicators satisfying all three screens:
146
+ const mockIndicators = {
147
+ ema: (period: number) => {
148
+ // tideFastEma(3): current = 102 (above tideSlowEma)
149
+ if (period === 3) return Array(20).fill(NaN).concat([101, 102]);
150
+ // tideSlowEma(5): current = 100
151
+ return Array(20).fill(NaN).concat([99, 100]);
152
+ },
153
+ macd: () => ({
154
+ macd: Array(21).fill(NaN).concat([0.5]),
155
+ signal: Array(21).fill(NaN).concat([0.2]),
156
+ histogram: Array(21).fill(NaN).concat([0.3]), // positive → tide point
157
+ }),
158
+ sma: () => {
159
+ // SMA(5): current at index 21 = 99, past (index 19) = 97 → rising
160
+ const arr = Array(19).fill(NaN);
161
+ arr.push(97, 98, 99); // indices 19, 20, 21
162
+ return arr;
163
+ },
164
+ rsi: () => Array(20).fill(NaN).concat([40, 50]), // prev=40, curr=50 > entry(45)
165
+ bollingerBands: () => ({
166
+ upper: Array(21).fill(NaN).concat([110]),
167
+ middle: Array(21).fill(NaN).concat([102]),
168
+ lower: Array(21).fill(NaN).concat([100]), // close(100) <= 100*1.01=101 → ripple passes
169
+ }),
170
+ atr: () => Array(20).fill(NaN).concat([1.5, 2]),
171
+ };
172
+
173
+ const ctx = {
174
+ portfolio: {
175
+ equity: 10000,
176
+ cash: 10000,
177
+ positions: [] as {
178
+ side: "long";
179
+ symbol: string;
180
+ quantity: number;
181
+ entryPrice: number;
182
+ currentPrice: number;
183
+ unrealizedPnl: number;
184
+ }[],
185
+ },
186
+ history: Array(22).fill(bar),
187
+ indicators: mockIndicators,
188
+ regime: "sideways" as const,
189
+ memory,
190
+ log: () => {},
191
+ };
192
+
193
+ const signal = await strategy.onBar(bar, ctx);
194
+
195
+ expect(signal).not.toBeNull();
196
+ expect(signal!.action).toBe("buy");
197
+ expect(signal!.stopLoss).toBeDefined();
198
+ expect(signal!.takeProfit).toBeDefined();
199
+ expect(signal!.confidence).toBeGreaterThanOrEqual(0.3);
200
+ expect(signal!.confidence).toBeLessThanOrEqual(0.95);
201
+ expect(signal!.sizePct).toBeGreaterThan(0);
202
+ expect(signal!.sizePct).toBeLessThanOrEqual(80);
203
+ // Wave consumed after buy
204
+ expect(memory.get("waveWasOversold")).toBe(false);
205
+ // Entry metadata stored
206
+ expect(memory.get("entryBar")).toBeDefined();
207
+ expect(memory.get("entryPrice")).toBe(100);
208
+ });
209
+
210
+ it("does not buy when tide is bearish", async () => {
211
+ // 20 bars falling steadily from 120 to 80 — EMA fast < slow, MACD negative,
212
+ // SMA declining. tideScore should stay < 2, blocking all buys.
213
+ const prices: number[] = [];
214
+ for (let i = 0; i < 20; i++) {
215
+ prices.push(120 - i * 2);
216
+ }
217
+
218
+ const data = prices.map((p, i) => makeBar(i, p));
219
+ const strategy = createRiskParityTripleScreen({
220
+ tideFastEma: 3,
221
+ tideSlowEma: 5,
222
+ tideMacdFast: 3,
223
+ tideMacdSlow: 5,
224
+ tideMacdSignal: 2,
225
+ tideSma: 5,
226
+ tideSlopeLookback: 2,
227
+ waveRsiPeriod: 3,
228
+ waveRsiOversold: 35,
229
+ waveRsiEntry: 45,
230
+ rippleBbPeriod: 5,
231
+ rippleBbStdDev: 2,
232
+ atrPeriod: 3,
233
+ maxSizePct: 80,
234
+ });
235
+
236
+ const engine = new BacktestEngine();
237
+ const result = await engine.run(strategy, data, config);
238
+
239
+ expect(result.totalTrades).toBe(0);
240
+ });
241
+
242
+ it("uses risk-parity sizing", async () => {
243
+ // Verify that the strategy uses ATR-based risk-parity sizing rather than
244
+ // always allocating maxSizePct. The sizePct should vary based on ATR/price.
245
+ const prices: number[] = [];
246
+
247
+ // Phase 1: uptrend to establish tide
248
+ for (let i = 0; i < 15; i++) {
249
+ prices.push(100 + i);
250
+ }
251
+
252
+ // Phase 2: pullback for wave oversold
253
+ const pullback = [113, 111, 109, 107, 106, 105, 104, 104];
254
+ prices.push(...pullback);
255
+
256
+ // Phase 3: near BB lower + wave recovery
257
+ prices.push(103, 104);
258
+
259
+ // Phase 4: recovery and exit
260
+ prices.push(106, 108, 110, 112, 114, 116, 118, 120);
261
+
262
+ const data = prices.map((p, i) => makeBar(i, p));
263
+ const strategy = createRiskParityTripleScreen({
264
+ tideFastEma: 3,
265
+ tideSlowEma: 5,
266
+ tideMacdFast: 3,
267
+ tideMacdSlow: 5,
268
+ tideMacdSignal: 2,
269
+ tideSma: 5,
270
+ tideSlopeLookback: 2,
271
+ waveRsiPeriod: 3,
272
+ waveRsiOversold: 35,
273
+ waveRsiEntry: 45,
274
+ rippleBbPeriod: 5,
275
+ rippleBbStdDev: 2,
276
+ atrPeriod: 3,
277
+ riskPctPerTrade: 2,
278
+ maxSizePct: 80,
279
+ });
280
+
281
+ const engine = new BacktestEngine();
282
+ const result = await engine.run(strategy, data, config);
283
+
284
+ // There should be at least one trade
285
+ if (result.trades.length > 0) {
286
+ const firstTrade = result.trades[0]!;
287
+ // Quantity should exist and be reasonable (not zero, not absurdly large)
288
+ expect(firstTrade.quantity).toBeGreaterThan(0);
289
+ // The position value should not consume the entire capital (risk-parity limits it)
290
+ const positionValue = firstTrade.quantity * firstTrade.entryPrice;
291
+ expect(positionValue).toBeLessThan(config.capital);
292
+ expect(positionValue).toBeGreaterThan(0);
293
+ }
294
+ });
295
+ });
@@ -0,0 +1,295 @@
1
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
2
+ import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
3
+
4
+ /**
5
+ * Risk-Parity Triple Screen composite strategy.
6
+ *
7
+ * Applies Alexander Elder's Triple Screen method with risk-parity position sizing:
8
+ *
9
+ * Screen 1 (Tide): Long-term trend scored 0-3 via EMA crossover, MACD histogram,
10
+ * and SMA slope. Requires score >= 2 to pass.
11
+ * Screen 2 (Wave): Medium-term pullback state machine — RSI must first dip below
12
+ * oversold threshold, then recover above entry level to confirm a reversal.
13
+ * Screen 3 (Ripple): Short-term entry timing — price must be near the lower
14
+ * Bollinger Band (within 1% tolerance).
15
+ *
16
+ * Position sizing uses ATR-based risk parity: risk a fixed percentage of equity
17
+ * per trade, with stop distance derived from ATR.
18
+ *
19
+ * Sell when: tide flips bearish, price touches upper BB, RSI overbought & reversing,
20
+ * or time stop (20 bars) is hit.
21
+ */
22
+ export function createRiskParityTripleScreen(params?: {
23
+ tideFastEma?: number;
24
+ tideSlowEma?: number;
25
+ tideMacdFast?: number;
26
+ tideMacdSlow?: number;
27
+ tideMacdSignal?: number;
28
+ tideSma?: number;
29
+ tideSlopeLookback?: number;
30
+ waveRsiPeriod?: number;
31
+ waveRsiOversold?: number;
32
+ waveRsiEntry?: number;
33
+ rippleBbPeriod?: number;
34
+ rippleBbStdDev?: number;
35
+ atrPeriod?: number;
36
+ riskPctPerTrade?: number;
37
+ atrStopMultiplier?: number;
38
+ atrProfitMultiplier?: number;
39
+ maxSizePct?: number;
40
+ symbol?: string;
41
+ }): StrategyDefinition {
42
+ const tideFastEma = params?.tideFastEma ?? 13;
43
+ const tideSlowEma = params?.tideSlowEma ?? 48;
44
+ const tideMacdFast = params?.tideMacdFast ?? 12;
45
+ const tideMacdSlow = params?.tideMacdSlow ?? 26;
46
+ const tideMacdSignal = params?.tideMacdSignal ?? 9;
47
+ const tideSma = params?.tideSma ?? 50;
48
+ const tideSlopeLookback = params?.tideSlopeLookback ?? 5;
49
+ const waveRsiPeriod = params?.waveRsiPeriod ?? 14;
50
+ const waveRsiOversold = params?.waveRsiOversold ?? 40;
51
+ const waveRsiEntry = params?.waveRsiEntry ?? 50;
52
+ const rippleBbPeriod = params?.rippleBbPeriod ?? 20;
53
+ const rippleBbStdDev = params?.rippleBbStdDev ?? 2.0;
54
+ const atrPeriod = params?.atrPeriod ?? 14;
55
+ const riskPctPerTrade = params?.riskPctPerTrade ?? 2.0;
56
+ const atrStopMultiplier = params?.atrStopMultiplier ?? 2.0;
57
+ const atrProfitMultiplier = params?.atrProfitMultiplier ?? 3.5;
58
+ const maxSizePct = params?.maxSizePct ?? 80;
59
+ const symbol = params?.symbol ?? "BTC/USDT";
60
+
61
+ return {
62
+ id: "risk-parity-triple-screen",
63
+ name: "Risk-Parity Triple Screen",
64
+ version: "1.0.0",
65
+ markets: ["crypto", "equity"],
66
+ symbols: [symbol],
67
+ timeframes: ["1d"],
68
+ parameters: {
69
+ tideFastEma,
70
+ tideSlowEma,
71
+ tideMacdFast,
72
+ tideMacdSlow,
73
+ tideMacdSignal,
74
+ tideSma,
75
+ tideSlopeLookback,
76
+ waveRsiPeriod,
77
+ waveRsiOversold,
78
+ waveRsiEntry,
79
+ rippleBbPeriod,
80
+ rippleBbStdDev,
81
+ atrPeriod,
82
+ riskPctPerTrade,
83
+ atrStopMultiplier,
84
+ atrProfitMultiplier,
85
+ maxSizePct,
86
+ },
87
+ parameterRanges: {
88
+ tideFastEma: { min: 5, max: 30, step: 1 },
89
+ tideSlowEma: { min: 20, max: 100, step: 2 },
90
+ tideMacdFast: { min: 8, max: 20, step: 2 },
91
+ tideMacdSlow: { min: 20, max: 40, step: 2 },
92
+ tideMacdSignal: { min: 5, max: 15, step: 1 },
93
+ tideSma: { min: 20, max: 100, step: 5 },
94
+ tideSlopeLookback: { min: 3, max: 10, step: 1 },
95
+ waveRsiPeriod: { min: 5, max: 28, step: 1 },
96
+ waveRsiOversold: { min: 25, max: 45, step: 5 },
97
+ waveRsiEntry: { min: 40, max: 60, step: 5 },
98
+ rippleBbPeriod: { min: 10, max: 40, step: 5 },
99
+ rippleBbStdDev: { min: 1.0, max: 3.0, step: 0.25 },
100
+ atrPeriod: { min: 7, max: 28, step: 1 },
101
+ riskPctPerTrade: { min: 0.5, max: 5.0, step: 0.5 },
102
+ atrStopMultiplier: { min: 1.0, max: 4.0, step: 0.5 },
103
+ atrProfitMultiplier: { min: 2.0, max: 6.0, step: 0.5 },
104
+ maxSizePct: { min: 20, max: 100, step: 10 },
105
+ },
106
+
107
+ async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
108
+ // 1. Compute all indicators
109
+ const tideFastEmaArr = ctx.indicators.ema(tideFastEma);
110
+ const tideSlowEmaArr = ctx.indicators.ema(tideSlowEma);
111
+ const { histogram: macdHistogram } = ctx.indicators.macd(
112
+ tideMacdFast,
113
+ tideMacdSlow,
114
+ tideMacdSignal,
115
+ );
116
+ const sma50Arr = ctx.indicators.sma(tideSma);
117
+ const rsiArr = ctx.indicators.rsi(waveRsiPeriod);
118
+ const bb = ctx.indicators.bollingerBands(rippleBbPeriod, rippleBbStdDev);
119
+ const atrArr = ctx.indicators.atr(atrPeriod);
120
+
121
+ const len = tideFastEmaArr.length;
122
+ if (len < 2) return null;
123
+
124
+ const currTideFastEma = tideFastEmaArr[len - 1]!;
125
+ const currTideSlowEma = tideSlowEmaArr[len - 1]!;
126
+ const currMacdHistogram = macdHistogram[len - 1]!;
127
+ const currSma50 = sma50Arr[len - 1]!;
128
+ const currRsi = rsiArr[len - 1]!;
129
+ const prevRsi = rsiArr[len - 2]!;
130
+ const currBBUpper = bb.upper[len - 1]!;
131
+ const currBBLower = bb.lower[len - 1]!;
132
+ const currAtr = atrArr[len - 1]!;
133
+
134
+ // SMA value from tideSlopeLookback bars ago
135
+ const slopeLookbackIdx = len - 1 - tideSlopeLookback;
136
+ const pastSma50 = slopeLookbackIdx >= 0 ? sma50Arr[slopeLookbackIdx]! : NaN;
137
+
138
+ // 2. NaN guard
139
+ if (
140
+ Number.isNaN(currTideFastEma) ||
141
+ Number.isNaN(currTideSlowEma) ||
142
+ Number.isNaN(currMacdHistogram) ||
143
+ Number.isNaN(currSma50) ||
144
+ Number.isNaN(pastSma50) ||
145
+ Number.isNaN(currRsi) ||
146
+ Number.isNaN(prevRsi) ||
147
+ Number.isNaN(currBBUpper) ||
148
+ Number.isNaN(currBBLower) ||
149
+ Number.isNaN(currAtr)
150
+ ) {
151
+ return null;
152
+ }
153
+
154
+ // 3. Regime filter
155
+ if (ctx.regime === "crisis") return null;
156
+
157
+ // 4. Position check
158
+ const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
159
+
160
+ // 5. Screen 1: Tide (long-term trend, scored 0-3)
161
+ const emaBullish = currTideFastEma > currTideSlowEma;
162
+ const macdPositive = currMacdHistogram > 0;
163
+ const smaSloping = currSma50 > pastSma50;
164
+
165
+ const tideScore = (emaBullish ? 1 : 0) + (macdPositive ? 1 : 0) + (smaSloping ? 1 : 0);
166
+ const tidePassed = tideScore >= 2;
167
+
168
+ // 6. Screen 2: Wave (medium-term pullback state machine)
169
+ let waveWasOversold = (ctx.memory.get("waveWasOversold") as boolean) ?? false;
170
+
171
+ if (currRsi < waveRsiOversold) {
172
+ waveWasOversold = true;
173
+ }
174
+ ctx.memory.set("waveWasOversold", waveWasOversold);
175
+
176
+ const wavePassed = waveWasOversold && currRsi > waveRsiEntry;
177
+
178
+ // If wave passed, consume the signal (reset state)
179
+ if (wavePassed) {
180
+ ctx.memory.set("waveWasOversold", false);
181
+ }
182
+
183
+ // 7. Screen 3: Ripple (short-term entry timing — near lower BB)
184
+ const ripplePassed = bar.close <= currBBLower * 1.01;
185
+
186
+ // 8. BUY conditions
187
+ if (!hasLong && tidePassed && wavePassed && ripplePassed) {
188
+ // Risk-parity position sizing
189
+ const stopDistance = atrStopMultiplier * currAtr;
190
+ const riskAmount = ctx.portfolio.equity * (riskPctPerTrade / 100);
191
+ const sharesFromRisk = riskAmount / stopDistance;
192
+ const positionValue = sharesFromRisk * bar.close;
193
+ const sizePct = Math.min(
194
+ maxSizePct,
195
+ Math.max(10, (positionValue / ctx.portfolio.equity) * 100),
196
+ );
197
+
198
+ // Confidence: base 0.4, plus component bonuses
199
+ const confidence = Math.min(
200
+ 0.95,
201
+ Math.max(0.3, 0.4 + 0.1 * tideScore + (wavePassed ? 0.15 : 0) + (ripplePassed ? 0.1 : 0)),
202
+ );
203
+
204
+ const stopLoss = bar.close - stopDistance;
205
+ const takeProfit = bar.close + atrProfitMultiplier * currAtr;
206
+
207
+ // Store entry metadata in memory
208
+ ctx.memory.set("screens", {
209
+ tide: tideScore,
210
+ wave: true,
211
+ ripple: true,
212
+ });
213
+ ctx.memory.set("entryBar", ctx.history.length);
214
+ ctx.memory.set("entryPrice", bar.close);
215
+
216
+ return {
217
+ action: "buy",
218
+ symbol,
219
+ sizePct,
220
+ orderType: "market",
221
+ reason: `Triple Screen buy: tide=${tideScore}/3, wave=oversold-recovery, ripple=near-BB-lower (close=${bar.close.toFixed(2)}, BBL=${currBBLower.toFixed(2)})`,
222
+ confidence,
223
+ stopLoss,
224
+ takeProfit,
225
+ };
226
+ }
227
+
228
+ // 9. SELL conditions
229
+ if (hasLong) {
230
+ // Tide flips bearish
231
+ if (tideScore < 2) {
232
+ ctx.memory.delete("screens");
233
+ ctx.memory.delete("entryBar");
234
+ ctx.memory.delete("entryPrice");
235
+ return {
236
+ action: "sell",
237
+ symbol,
238
+ sizePct: 100,
239
+ orderType: "market",
240
+ reason: `Tide bearish: tideScore=${tideScore} < 2`,
241
+ confidence: 0.7,
242
+ };
243
+ }
244
+
245
+ // BB upper touch
246
+ if (bar.close >= currBBUpper) {
247
+ ctx.memory.delete("screens");
248
+ ctx.memory.delete("entryBar");
249
+ ctx.memory.delete("entryPrice");
250
+ return {
251
+ action: "sell",
252
+ symbol,
253
+ sizePct: 100,
254
+ orderType: "market",
255
+ reason: `BB upper touch: close=${bar.close.toFixed(2)} >= BBU=${currBBUpper.toFixed(2)}`,
256
+ confidence: 0.75,
257
+ };
258
+ }
259
+
260
+ // RSI extreme + reversal
261
+ if (currRsi > 80 && currRsi < prevRsi) {
262
+ ctx.memory.delete("screens");
263
+ ctx.memory.delete("entryBar");
264
+ ctx.memory.delete("entryPrice");
265
+ return {
266
+ action: "sell",
267
+ symbol,
268
+ sizePct: 100,
269
+ orderType: "market",
270
+ reason: `RSI overbought reversal: RSI=${currRsi.toFixed(1)} > 80 and declining`,
271
+ confidence: 0.7,
272
+ };
273
+ }
274
+
275
+ // Time stop
276
+ const entryBar = ctx.memory.get("entryBar") as number | undefined;
277
+ if (entryBar !== undefined && ctx.history.length - entryBar > 20) {
278
+ ctx.memory.delete("screens");
279
+ ctx.memory.delete("entryBar");
280
+ ctx.memory.delete("entryPrice");
281
+ return {
282
+ action: "sell",
283
+ symbol,
284
+ sizePct: 100,
285
+ orderType: "market",
286
+ reason: `Time stop: held ${ctx.history.length - entryBar} bars > 20 limit`,
287
+ confidence: 0.6,
288
+ };
289
+ }
290
+ }
291
+
292
+ return null;
293
+ },
294
+ };
295
+ }