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