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