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