@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,274 @@
1
+ /**
2
+ * Declarative rule engine for custom trading strategies.
3
+ * Parses simple expressions (AND, OR, <, >, <=, >=, ==) and generates
4
+ * a StrategyDefinition with an onBar function.
5
+ *
6
+ * Supported indicators: rsi, sma, ema, macd.histogram, bb.upper, bb.lower, atr
7
+ * Supported price fields: close, open, high, low, volume
8
+ * Supports user-defined parameter references.
9
+ */
10
+
11
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
12
+ import type { StrategyDefinition, StrategyContext, Signal } from "../types.js";
13
+
14
+ type Operator = "<" | ">" | "<=" | ">=" | "==";
15
+ type LogicalOp = "AND" | "OR";
16
+
17
+ interface Comparison {
18
+ left: string;
19
+ op: Operator;
20
+ right: string;
21
+ }
22
+
23
+ interface RuleNode {
24
+ type: "comparison" | "logical";
25
+ comparison?: Comparison;
26
+ logicalOp?: LogicalOp;
27
+ children?: RuleNode[];
28
+ }
29
+
30
+ const OPERATORS: Operator[] = ["<=", ">=", "==", "<", ">"];
31
+
32
+ function tokenize(expr: string): string[] {
33
+ // Normalize whitespace and split while preserving operators
34
+ const normalized = expr
35
+ .replace(/\bAND\b/gi, " AND ")
36
+ .replace(/\bOR\b/gi, " OR ")
37
+ .replace(/<=/g, " <= ")
38
+ .replace(/>=/g, " >= ")
39
+ .replace(/==/g, " == ")
40
+ .replace(/(?<!=)(?<!<)(?<!>)<(?!=)/g, " < ")
41
+ .replace(/(?<!=)(?<!<)(?<!>)>(?!=)/g, " > ");
42
+
43
+ return normalized.split(/\s+/).filter((t) => t.length > 0);
44
+ }
45
+
46
+ function parseRule(expr: string): RuleNode {
47
+ const tokens = tokenize(expr);
48
+ if (tokens.length === 0) {
49
+ throw new Error("Empty rule expression");
50
+ }
51
+
52
+ // Split by OR first (lower precedence), then AND
53
+ const orParts = splitByLogical(tokens, "OR");
54
+ if (orParts.length > 1) {
55
+ return {
56
+ type: "logical",
57
+ logicalOp: "OR",
58
+ children: orParts.map((part) => parseRule(part.join(" "))),
59
+ };
60
+ }
61
+
62
+ const andParts = splitByLogical(tokens, "AND");
63
+ if (andParts.length > 1) {
64
+ return {
65
+ type: "logical",
66
+ logicalOp: "AND",
67
+ children: andParts.map((part) => parseRule(part.join(" "))),
68
+ };
69
+ }
70
+
71
+ // Single comparison: left op right
72
+ if (tokens.length < 3) {
73
+ throw new Error(`Invalid comparison: "${tokens.join(" ")}". Expected: <left> <op> <right>`);
74
+ }
75
+
76
+ const op = tokens.find((t) => OPERATORS.includes(t as Operator)) as Operator | undefined;
77
+ if (!op) {
78
+ throw new Error(`No operator found in: "${tokens.join(" ")}". Use <, >, <=, >=, or ==`);
79
+ }
80
+
81
+ const opIdx = tokens.indexOf(op);
82
+ const left = tokens.slice(0, opIdx).join(".");
83
+ const right = tokens.slice(opIdx + 1).join(".");
84
+
85
+ return { type: "comparison", comparison: { left, op, right } };
86
+ }
87
+
88
+ function splitByLogical(tokens: string[], keyword: string): string[][] {
89
+ const parts: string[][] = [];
90
+ let current: string[] = [];
91
+ for (const t of tokens) {
92
+ if (t.toUpperCase() === keyword) {
93
+ if (current.length > 0) parts.push(current);
94
+ current = [];
95
+ } else {
96
+ current.push(t);
97
+ }
98
+ }
99
+ if (current.length > 0) parts.push(current);
100
+ return parts;
101
+ }
102
+
103
+ function resolveValue(
104
+ name: string,
105
+ bar: OHLCV,
106
+ ctx: StrategyContext,
107
+ params: Record<string, number>,
108
+ ): number {
109
+ // Try as a number literal first
110
+ const num = Number(name);
111
+ if (!Number.isNaN(num)) return num;
112
+
113
+ // Price fields
114
+ switch (name) {
115
+ case "close":
116
+ return bar.close;
117
+ case "open":
118
+ return bar.open;
119
+ case "high":
120
+ return bar.high;
121
+ case "low":
122
+ return bar.low;
123
+ case "volume":
124
+ return bar.volume;
125
+ }
126
+
127
+ // User parameters
128
+ if (name in params) return params[name]!;
129
+
130
+ // Indicators (take latest value)
131
+ const latest = (arr: number[]) => (arr.length > 0 ? arr[arr.length - 1]! : 0);
132
+
133
+ if (name === "rsi") return latest(ctx.indicators.rsi(params.rsiPeriod ?? 14));
134
+ if (name === "sma") return latest(ctx.indicators.sma(params.smaPeriod ?? 20));
135
+ if (name === "ema") return latest(ctx.indicators.ema(params.emaPeriod ?? 20));
136
+ if (name === "atr") return latest(ctx.indicators.atr(params.atrPeriod ?? 14));
137
+
138
+ // MACD sub-fields
139
+ if (name === "macd.histogram" || name === "macd.hist") {
140
+ const m = ctx.indicators.macd(
141
+ params.macdFast ?? 12,
142
+ params.macdSlow ?? 26,
143
+ params.macdSignal ?? 9,
144
+ );
145
+ return latest(m.histogram);
146
+ }
147
+ if (name === "macd.signal") {
148
+ const m = ctx.indicators.macd(
149
+ params.macdFast ?? 12,
150
+ params.macdSlow ?? 26,
151
+ params.macdSignal ?? 9,
152
+ );
153
+ return latest(m.signal);
154
+ }
155
+ if (name === "macd.macd" || name === "macd.line") {
156
+ const m = ctx.indicators.macd(
157
+ params.macdFast ?? 12,
158
+ params.macdSlow ?? 26,
159
+ params.macdSignal ?? 9,
160
+ );
161
+ return latest(m.macd);
162
+ }
163
+
164
+ // Bollinger Bands
165
+ if (name === "bb.upper") {
166
+ const bb = ctx.indicators.bollingerBands(params.bbPeriod ?? 20, params.bbStdDev ?? 2);
167
+ return latest(bb.upper);
168
+ }
169
+ if (name === "bb.lower") {
170
+ const bb = ctx.indicators.bollingerBands(params.bbPeriod ?? 20, params.bbStdDev ?? 2);
171
+ return latest(bb.lower);
172
+ }
173
+ if (name === "bb.middle") {
174
+ const bb = ctx.indicators.bollingerBands(params.bbPeriod ?? 20, params.bbStdDev ?? 2);
175
+ return latest(bb.middle);
176
+ }
177
+
178
+ throw new Error(`Unknown variable: "${name}"`);
179
+ }
180
+
181
+ function evaluateNode(
182
+ node: RuleNode,
183
+ bar: OHLCV,
184
+ ctx: StrategyContext,
185
+ params: Record<string, number>,
186
+ ): boolean {
187
+ if (node.type === "comparison" && node.comparison) {
188
+ const left = resolveValue(node.comparison.left, bar, ctx, params);
189
+ const right = resolveValue(node.comparison.right, bar, ctx, params);
190
+
191
+ switch (node.comparison.op) {
192
+ case "<":
193
+ return left < right;
194
+ case ">":
195
+ return left > right;
196
+ case "<=":
197
+ return left <= right;
198
+ case ">=":
199
+ return left >= right;
200
+ case "==":
201
+ return Math.abs(left - right) < 1e-10;
202
+ }
203
+ }
204
+
205
+ if (node.type === "logical" && node.children) {
206
+ if (node.logicalOp === "AND") {
207
+ return node.children.every((c) => evaluateNode(c, bar, ctx, params));
208
+ }
209
+ if (node.logicalOp === "OR") {
210
+ return node.children.some((c) => evaluateNode(c, bar, ctx, params));
211
+ }
212
+ }
213
+
214
+ return false;
215
+ }
216
+
217
+ export interface CustomRules {
218
+ buy: string;
219
+ sell: string;
220
+ }
221
+
222
+ export function buildCustomStrategy(
223
+ name: string,
224
+ rules: CustomRules,
225
+ params: Record<string, number>,
226
+ symbols?: string[],
227
+ timeframes?: string[],
228
+ ): StrategyDefinition {
229
+ // Parse rules upfront to catch errors early
230
+ const buyRule = parseRule(rules.buy);
231
+ const sellRule = parseRule(rules.sell);
232
+
233
+ return {
234
+ id: `custom-${Date.now()}`,
235
+ name,
236
+ version: "1.0.0",
237
+ markets: ["crypto"],
238
+ symbols: symbols ?? ["BTC/USDT"],
239
+ timeframes: timeframes ?? ["1d"],
240
+ parameters: { ...params },
241
+ async onBar(bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
242
+ const hasPosition = ctx.portfolio.positions.some(
243
+ (p) => p.symbol === (symbols?.[0] ?? "BTC/USDT"),
244
+ );
245
+
246
+ if (!hasPosition && evaluateNode(buyRule, bar, ctx, params)) {
247
+ return {
248
+ action: "buy",
249
+ symbol: symbols?.[0] ?? "BTC/USDT",
250
+ sizePct: 10,
251
+ orderType: "market",
252
+ reason: `Custom rule: ${rules.buy}`,
253
+ confidence: 0.7,
254
+ };
255
+ }
256
+
257
+ if (hasPosition && evaluateNode(sellRule, bar, ctx, params)) {
258
+ return {
259
+ action: "sell",
260
+ symbol: symbols?.[0] ?? "BTC/USDT",
261
+ sizePct: 100,
262
+ orderType: "market",
263
+ reason: `Custom rule: ${rules.sell}`,
264
+ confidence: 0.7,
265
+ };
266
+ }
267
+
268
+ return null;
269
+ },
270
+ };
271
+ }
272
+
273
+ // Re-export for testing
274
+ export { parseRule, evaluateNode, resolveValue };
@@ -0,0 +1,122 @@
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 { macd } from "../indicators.js";
5
+ import type { BacktestConfig } from "../types.js";
6
+ import { createMacdDivergence } from "./macd-divergence.js";
7
+
8
+ function makeBar(index: number, close: number): OHLCV {
9
+ return {
10
+ timestamp: 1000000 + index * 86400000,
11
+ open: close,
12
+ high: close * 1.01,
13
+ low: close * 0.99,
14
+ close,
15
+ volume: 1000,
16
+ };
17
+ }
18
+
19
+ describe("MACD Divergence strategy", () => {
20
+ it("creates strategy with default parameters", () => {
21
+ const strategy = createMacdDivergence();
22
+ expect(strategy.id).toBe("macd-divergence");
23
+ expect(strategy.parameters.fastPeriod).toBe(12);
24
+ expect(strategy.parameters.slowPeriod).toBe(26);
25
+ expect(strategy.parameters.signalPeriod).toBe(9);
26
+ expect(strategy.parameters.sizePct).toBe(100);
27
+ });
28
+
29
+ it("creates strategy with custom parameters", () => {
30
+ const strategy = createMacdDivergence({
31
+ fastPeriod: 8,
32
+ slowPeriod: 21,
33
+ signalPeriod: 5,
34
+ sizePct: 50,
35
+ });
36
+ expect(strategy.parameters.fastPeriod).toBe(8);
37
+ expect(strategy.parameters.slowPeriod).toBe(21);
38
+ expect(strategy.parameters.signalPeriod).toBe(5);
39
+ expect(strategy.parameters.sizePct).toBe(50);
40
+ });
41
+
42
+ it("buys on bullish histogram cross and sells on bearish cross", async () => {
43
+ // Need: uptrend (positive histogram) → decline (negative) → recovery (positive) → decline (negative)
44
+ // Non-linear moves so MACD actually reacts (linear = flat MACD)
45
+ const prices: number[] = [];
46
+ // Phase 1: accelerating rise → positive histogram
47
+ for (let i = 0; i < 25; i++) prices.push(100 + i * i * 0.2);
48
+ // Phase 2: sharp decline → histogram crosses to negative
49
+ for (let i = 0; i < 15; i++) prices.push(prices[prices.length - 1] - 8 - i * 0.5);
50
+ // Phase 3: sharp recovery → histogram crosses back to positive
51
+ for (let i = 0; i < 15; i++) prices.push(prices[prices.length - 1] + 10 + i * 0.5);
52
+ // Phase 4: decline again → histogram crosses back to negative
53
+ for (let i = 0; i < 10; i++) prices.push(prices[prices.length - 1] - 10);
54
+
55
+ const data = prices.map((p, i) => makeBar(i, p));
56
+ const strategy = createMacdDivergence({
57
+ fastPeriod: 8,
58
+ slowPeriod: 17,
59
+ signalPeriod: 5,
60
+ sizePct: 100,
61
+ });
62
+ const config: BacktestConfig = {
63
+ capital: 10000,
64
+ commissionRate: 0,
65
+ slippageBps: 0,
66
+ market: "crypto",
67
+ };
68
+
69
+ const engine = new BacktestEngine();
70
+ const result = await engine.run(strategy, data, config);
71
+
72
+ // Verify histogram actually crosses zero in both directions
73
+ const closes = data.map((d) => d.close);
74
+ const { histogram } = macd(closes, 8, 17, 5);
75
+
76
+ let bullishCross = false;
77
+ let bearishCross = false;
78
+ for (let i = 1; i < histogram.length; i++) {
79
+ if (
80
+ !Number.isNaN(histogram[i]) &&
81
+ !Number.isNaN(histogram[i - 1]) &&
82
+ histogram[i - 1] < 0 &&
83
+ histogram[i] >= 0
84
+ ) {
85
+ bullishCross = true;
86
+ }
87
+ if (
88
+ !Number.isNaN(histogram[i]) &&
89
+ !Number.isNaN(histogram[i - 1]) &&
90
+ histogram[i - 1] >= 0 &&
91
+ histogram[i] < 0
92
+ ) {
93
+ bearishCross = true;
94
+ }
95
+ }
96
+
97
+ expect(bullishCross).toBe(true);
98
+ expect(bearishCross).toBe(true);
99
+ expect(result.totalTrades).toBeGreaterThanOrEqual(1);
100
+ });
101
+
102
+ it("returns no trades during warm-up period", async () => {
103
+ // Only 20 bars — not enough for MACD(12,26,9)
104
+ const data: OHLCV[] = [];
105
+ for (let i = 0; i < 20; i++) {
106
+ data.push(makeBar(i, 100 + i));
107
+ }
108
+
109
+ const strategy = createMacdDivergence({ fastPeriod: 12, slowPeriod: 26, signalPeriod: 9 });
110
+ const config: BacktestConfig = {
111
+ capital: 10000,
112
+ commissionRate: 0,
113
+ slippageBps: 0,
114
+ market: "crypto",
115
+ };
116
+
117
+ const engine = new BacktestEngine();
118
+ const result = await engine.run(strategy, data, config);
119
+
120
+ expect(result.totalTrades).toBe(0);
121
+ });
122
+ });
@@ -0,0 +1,77 @@
1
+ import type { OHLCV } from "../../../fin-shared-types/src/types.js";
2
+ import type { Signal, StrategyContext, StrategyDefinition } from "../types.js";
3
+
4
+ /**
5
+ * MACD Divergence strategy.
6
+ * Buy when MACD histogram crosses from negative to positive.
7
+ * Sell when MACD histogram crosses from positive to negative.
8
+ */
9
+ export function createMacdDivergence(params?: {
10
+ fastPeriod?: number;
11
+ slowPeriod?: number;
12
+ signalPeriod?: number;
13
+ sizePct?: number;
14
+ symbol?: string;
15
+ }): StrategyDefinition {
16
+ const fastPeriod = params?.fastPeriod ?? 12;
17
+ const slowPeriod = params?.slowPeriod ?? 26;
18
+ const signalPeriod = params?.signalPeriod ?? 9;
19
+ const sizePct = params?.sizePct ?? 100;
20
+ const symbol = params?.symbol ?? "BTC/USDT";
21
+
22
+ return {
23
+ id: "macd-divergence",
24
+ name: "MACD Divergence",
25
+ version: "1.0.0",
26
+ markets: ["crypto", "equity"],
27
+ symbols: [symbol],
28
+ timeframes: ["1d"],
29
+ parameters: { fastPeriod, slowPeriod, signalPeriod, sizePct },
30
+ parameterRanges: {
31
+ fastPeriod: { min: 8, max: 20, step: 2 },
32
+ slowPeriod: { min: 20, max: 40, step: 2 },
33
+ signalPeriod: { min: 5, max: 15, step: 2 },
34
+ sizePct: { min: 10, max: 100, step: 10 },
35
+ },
36
+
37
+ async onBar(_bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
38
+ const { histogram } = ctx.indicators.macd(fastPeriod, slowPeriod, signalPeriod);
39
+
40
+ const len = histogram.length;
41
+ if (len < 2) return null;
42
+
43
+ const curr = histogram[len - 1]!;
44
+ const prev = histogram[len - 2]!;
45
+
46
+ if (Number.isNaN(curr) || Number.isNaN(prev)) return null;
47
+
48
+ const hasLong = ctx.portfolio.positions.some((p) => p.side === "long");
49
+
50
+ // Histogram crosses from negative to positive → bullish, buy
51
+ if (prev < 0 && curr >= 0 && !hasLong) {
52
+ return {
53
+ action: "buy",
54
+ symbol,
55
+ sizePct,
56
+ orderType: "market",
57
+ reason: `MACD bullish cross: histogram ${prev.toFixed(4)} → ${curr.toFixed(4)}`,
58
+ confidence: 0.6,
59
+ };
60
+ }
61
+
62
+ // Histogram crosses from positive to negative → bearish, sell
63
+ if (prev >= 0 && curr < 0 && hasLong) {
64
+ return {
65
+ action: "sell",
66
+ symbol,
67
+ sizePct: 100,
68
+ orderType: "market",
69
+ reason: `MACD bearish cross: histogram ${prev.toFixed(4)} → ${curr.toFixed(4)}`,
70
+ confidence: 0.6,
71
+ };
72
+ }
73
+
74
+ return null;
75
+ },
76
+ };
77
+ }