@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,235 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { StrategyRegistry } from "./strategy-registry.js";
|
|
6
|
+
import type { StrategyDefinition, BacktestResult, WalkForwardResult } from "./types.js";
|
|
7
|
+
|
|
8
|
+
function mockDefinition(id = "test-strat"): StrategyDefinition {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
name: "Test Strategy",
|
|
12
|
+
version: "1.0",
|
|
13
|
+
markets: ["crypto"],
|
|
14
|
+
symbols: ["BTC/USDT"],
|
|
15
|
+
timeframes: ["1d"],
|
|
16
|
+
parameters: { fast: 10, slow: 30 },
|
|
17
|
+
async onBar() {
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mockBacktestResult(strategyId: string): BacktestResult {
|
|
24
|
+
return {
|
|
25
|
+
strategyId,
|
|
26
|
+
startDate: 1000000,
|
|
27
|
+
endDate: 2000000,
|
|
28
|
+
initialCapital: 10000,
|
|
29
|
+
finalEquity: 12000,
|
|
30
|
+
totalReturn: 20,
|
|
31
|
+
sharpe: 1.5,
|
|
32
|
+
sortino: 2.0,
|
|
33
|
+
maxDrawdown: -5,
|
|
34
|
+
calmar: 4,
|
|
35
|
+
winRate: 60,
|
|
36
|
+
profitFactor: 1.8,
|
|
37
|
+
totalTrades: 10,
|
|
38
|
+
trades: [],
|
|
39
|
+
equityCurve: [10000, 12000],
|
|
40
|
+
dailyReturns: [0.2],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("StrategyRegistry", () => {
|
|
45
|
+
let tempDir: string;
|
|
46
|
+
let filePath: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tempDir = mkdtempSync(join(tmpdir(), "fin-registry-test-"));
|
|
50
|
+
filePath = join(tempDir, "state", "fin-strategies.json");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("creates and lists strategies", () => {
|
|
58
|
+
const reg = new StrategyRegistry(filePath);
|
|
59
|
+
const record = reg.create(mockDefinition("s1"));
|
|
60
|
+
|
|
61
|
+
expect(record.id).toBe("s1");
|
|
62
|
+
expect(record.level).toBe("L0_INCUBATE");
|
|
63
|
+
|
|
64
|
+
const all = reg.list();
|
|
65
|
+
expect(all.length).toBe(1);
|
|
66
|
+
expect(all[0]!.id).toBe("s1");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("gets a strategy by ID", () => {
|
|
70
|
+
const reg = new StrategyRegistry(filePath);
|
|
71
|
+
reg.create(mockDefinition("s1"));
|
|
72
|
+
|
|
73
|
+
const found = reg.get("s1");
|
|
74
|
+
expect(found).toBeDefined();
|
|
75
|
+
expect(found!.name).toBe("Test Strategy");
|
|
76
|
+
|
|
77
|
+
const notFound = reg.get("nonexistent");
|
|
78
|
+
expect(notFound).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("filters by level", () => {
|
|
82
|
+
const reg = new StrategyRegistry(filePath);
|
|
83
|
+
reg.create(mockDefinition("s1"));
|
|
84
|
+
reg.create(mockDefinition("s2"));
|
|
85
|
+
reg.updateLevel("s2", "L1_BACKTEST");
|
|
86
|
+
|
|
87
|
+
const incubating = reg.list({ level: "L0_INCUBATE" });
|
|
88
|
+
expect(incubating.length).toBe(1);
|
|
89
|
+
expect(incubating[0]!.id).toBe("s1");
|
|
90
|
+
|
|
91
|
+
const backtested = reg.list({ level: "L1_BACKTEST" });
|
|
92
|
+
expect(backtested.length).toBe(1);
|
|
93
|
+
expect(backtested[0]!.id).toBe("s2");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("updates level and persists", () => {
|
|
97
|
+
const reg = new StrategyRegistry(filePath);
|
|
98
|
+
reg.create(mockDefinition("s1"));
|
|
99
|
+
reg.updateLevel("s1", "L2_PAPER");
|
|
100
|
+
|
|
101
|
+
// Reload from disk
|
|
102
|
+
const reg2 = new StrategyRegistry(filePath);
|
|
103
|
+
const record = reg2.get("s1");
|
|
104
|
+
expect(record).toBeDefined();
|
|
105
|
+
expect(record!.level).toBe("L2_PAPER");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("stores backtest result", () => {
|
|
109
|
+
const reg = new StrategyRegistry(filePath);
|
|
110
|
+
reg.create(mockDefinition("s1"));
|
|
111
|
+
|
|
112
|
+
const bt = mockBacktestResult("s1");
|
|
113
|
+
reg.updateBacktest("s1", bt);
|
|
114
|
+
|
|
115
|
+
const record = reg.get("s1");
|
|
116
|
+
expect(record!.lastBacktest).toBeDefined();
|
|
117
|
+
expect(record!.lastBacktest!.sharpe).toBe(1.5);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("stores walk-forward result", () => {
|
|
121
|
+
const reg = new StrategyRegistry(filePath);
|
|
122
|
+
reg.create(mockDefinition("s1"));
|
|
123
|
+
|
|
124
|
+
const wf: WalkForwardResult = {
|
|
125
|
+
passed: true,
|
|
126
|
+
windows: [],
|
|
127
|
+
combinedTestSharpe: 1.2,
|
|
128
|
+
avgTrainSharpe: 1.5,
|
|
129
|
+
ratio: 0.8,
|
|
130
|
+
threshold: 0.6,
|
|
131
|
+
};
|
|
132
|
+
reg.updateWalkForward("s1", wf);
|
|
133
|
+
|
|
134
|
+
const record = reg.get("s1");
|
|
135
|
+
expect(record!.lastWalkForward).toBeDefined();
|
|
136
|
+
expect(record!.lastWalkForward!.passed).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws when updating nonexistent strategy", () => {
|
|
140
|
+
const reg = new StrategyRegistry(filePath);
|
|
141
|
+
expect(() => reg.updateLevel("nope", "L1_BACKTEST")).toThrow("not found");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles missing file gracefully", () => {
|
|
145
|
+
const reg = new StrategyRegistry(join(tempDir, "nonexistent", "file.json"));
|
|
146
|
+
expect(reg.list()).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("persists across instances", () => {
|
|
150
|
+
const reg1 = new StrategyRegistry(filePath);
|
|
151
|
+
reg1.create(mockDefinition("s1"));
|
|
152
|
+
reg1.create(mockDefinition("s2"));
|
|
153
|
+
|
|
154
|
+
const reg2 = new StrategyRegistry(filePath);
|
|
155
|
+
expect(reg2.list().length).toBe(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("increments version when definition changes", () => {
|
|
159
|
+
const reg = new StrategyRegistry(filePath);
|
|
160
|
+
const def = mockDefinition("s1");
|
|
161
|
+
reg.create(def);
|
|
162
|
+
|
|
163
|
+
const updatedDef: StrategyDefinition = {
|
|
164
|
+
id: "s1",
|
|
165
|
+
name: "Test Strategy",
|
|
166
|
+
version: "1.0",
|
|
167
|
+
markets: ["crypto"],
|
|
168
|
+
symbols: ["BTC/USDT"],
|
|
169
|
+
timeframes: ["1d"],
|
|
170
|
+
parameters: { fast: 15, slow: 30 },
|
|
171
|
+
async onBar() {
|
|
172
|
+
return null;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
reg.updateDefinition("s1", updatedDef);
|
|
176
|
+
|
|
177
|
+
const record = reg.get("s1");
|
|
178
|
+
expect(record!.version).toBe("1.1");
|
|
179
|
+
expect(record!.definition.parameters.fast).toBe(15);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not increment version when definition is identical", () => {
|
|
183
|
+
const reg = new StrategyRegistry(filePath);
|
|
184
|
+
reg.create(mockDefinition("s1"));
|
|
185
|
+
|
|
186
|
+
const sameDef: StrategyDefinition = {
|
|
187
|
+
...mockDefinition("s1"),
|
|
188
|
+
async onBar() {
|
|
189
|
+
return null;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
reg.updateDefinition("s1", sameDef);
|
|
193
|
+
|
|
194
|
+
const record = reg.get("s1");
|
|
195
|
+
expect(record!.version).toBe("1.0");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("increments semver version correctly", () => {
|
|
199
|
+
const reg = new StrategyRegistry(filePath);
|
|
200
|
+
reg.create({
|
|
201
|
+
id: "s1",
|
|
202
|
+
name: "Test Strategy",
|
|
203
|
+
version: "1.0.0",
|
|
204
|
+
markets: ["crypto"],
|
|
205
|
+
symbols: ["BTC/USDT"],
|
|
206
|
+
timeframes: ["1d"],
|
|
207
|
+
parameters: { fast: 10, slow: 30 },
|
|
208
|
+
async onBar() {
|
|
209
|
+
return null;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const updatedDef: StrategyDefinition = {
|
|
214
|
+
id: "s1",
|
|
215
|
+
name: "Test Strategy",
|
|
216
|
+
version: "1.0.0",
|
|
217
|
+
markets: ["crypto"],
|
|
218
|
+
symbols: ["BTC/USDT"],
|
|
219
|
+
timeframes: ["1d"],
|
|
220
|
+
parameters: { fast: 15, slow: 30 },
|
|
221
|
+
async onBar() {
|
|
222
|
+
return null;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
reg.updateDefinition("s1", updatedDef);
|
|
226
|
+
|
|
227
|
+
const record = reg.get("s1");
|
|
228
|
+
expect(record!.version).toBe("1.0.1");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("throws when updating definition of nonexistent strategy", () => {
|
|
232
|
+
const reg = new StrategyRegistry(filePath);
|
|
233
|
+
expect(() => reg.updateDefinition("nonexistent", mockDefinition())).toThrow("not found");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
BacktestResult,
|
|
5
|
+
StrategyDefinition,
|
|
6
|
+
StrategyLevel,
|
|
7
|
+
StrategyRecord,
|
|
8
|
+
StrategyStatus,
|
|
9
|
+
WalkForwardResult,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Increment version string. Supports semver (x.y.z) and simple numeric versions.
|
|
14
|
+
*/
|
|
15
|
+
function incrementVersion(version: string): string {
|
|
16
|
+
const semverMatch = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
17
|
+
if (semverMatch) {
|
|
18
|
+
const [, major, minor, patch] = semverMatch;
|
|
19
|
+
return `${major}.${minor}.${Number(patch) + 1}`;
|
|
20
|
+
}
|
|
21
|
+
const numericMatch = version.match(/^(\d+)$/);
|
|
22
|
+
if (numericMatch) {
|
|
23
|
+
return String(Number(numericMatch[1]) + 1);
|
|
24
|
+
}
|
|
25
|
+
const doubleMatch = version.match(/^(\d+)\.(\d+)$/);
|
|
26
|
+
if (doubleMatch) {
|
|
27
|
+
return `${doubleMatch[1]}.${Number(doubleMatch[2]) + 1}`;
|
|
28
|
+
}
|
|
29
|
+
return version + ".1";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep compare two objects to detect changes.
|
|
34
|
+
* Only compares serializable fields (ignores functions like onBar/init/onDayEnd).
|
|
35
|
+
*/
|
|
36
|
+
function hasDefinitionChangeded(oldDef: StrategyDefinition, newDef: StrategyDefinition): boolean {
|
|
37
|
+
const oldSerializable = {
|
|
38
|
+
id: oldDef.id,
|
|
39
|
+
name: oldDef.name,
|
|
40
|
+
version: oldDef.version,
|
|
41
|
+
markets: oldDef.markets,
|
|
42
|
+
symbols: oldDef.symbols,
|
|
43
|
+
timeframes: oldDef.timeframes,
|
|
44
|
+
parameters: oldDef.parameters,
|
|
45
|
+
parameterRanges: oldDef.parameterRanges,
|
|
46
|
+
};
|
|
47
|
+
const newSerializable = {
|
|
48
|
+
id: newDef.id,
|
|
49
|
+
name: newDef.name,
|
|
50
|
+
version: newDef.version,
|
|
51
|
+
markets: newDef.markets,
|
|
52
|
+
symbols: newDef.symbols,
|
|
53
|
+
timeframes: newDef.timeframes,
|
|
54
|
+
parameters: newDef.parameters,
|
|
55
|
+
parameterRanges: newDef.parameterRanges,
|
|
56
|
+
};
|
|
57
|
+
return JSON.stringify(oldSerializable) !== JSON.stringify(newSerializable);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Persistent strategy registry backed by a JSON file.
|
|
62
|
+
* Stores strategy metadata, backtest results, and walk-forward results.
|
|
63
|
+
*/
|
|
64
|
+
export class StrategyRegistry {
|
|
65
|
+
private records: Map<string, StrategyRecord> = new Map();
|
|
66
|
+
private definitionSnapshots: Map<string, string> = new Map();
|
|
67
|
+
|
|
68
|
+
constructor(private filePath: string) {
|
|
69
|
+
this.load();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Create a new strategy record. Returns the created record. */
|
|
73
|
+
create(definition: StrategyDefinition): StrategyRecord {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const record: StrategyRecord = {
|
|
76
|
+
id: definition.id,
|
|
77
|
+
name: definition.name,
|
|
78
|
+
version: definition.version,
|
|
79
|
+
level: "L0_INCUBATE",
|
|
80
|
+
definition,
|
|
81
|
+
createdAt: now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
};
|
|
84
|
+
this.records.set(definition.id, record);
|
|
85
|
+
this.definitionSnapshots.set(definition.id, JSON.stringify(definition));
|
|
86
|
+
this.save();
|
|
87
|
+
return record;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Get a strategy record by ID. */
|
|
91
|
+
get(id: string): StrategyRecord | undefined {
|
|
92
|
+
return this.records.get(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** List all strategies, optionally filtered by level. */
|
|
96
|
+
list(filter?: { level?: StrategyLevel }): StrategyRecord[] {
|
|
97
|
+
const all = [...this.records.values()];
|
|
98
|
+
if (filter?.level) {
|
|
99
|
+
return all.filter((r) => r.level === filter.level);
|
|
100
|
+
}
|
|
101
|
+
return all;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Update the promotion level of a strategy. */
|
|
105
|
+
updateLevel(id: string, level: StrategyLevel): void {
|
|
106
|
+
const record = this.records.get(id);
|
|
107
|
+
if (!record) throw new Error(`Strategy ${id} not found`);
|
|
108
|
+
record.level = level;
|
|
109
|
+
record.updatedAt = Date.now();
|
|
110
|
+
this.save();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Store a backtest result for a strategy. */
|
|
114
|
+
updateBacktest(id: string, result: BacktestResult): void {
|
|
115
|
+
const record = this.records.get(id);
|
|
116
|
+
if (!record) throw new Error(`Strategy ${id} not found`);
|
|
117
|
+
record.lastBacktest = result;
|
|
118
|
+
record.updatedAt = Date.now();
|
|
119
|
+
this.save();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Store a walk-forward result for a strategy. */
|
|
123
|
+
updateWalkForward(id: string, result: WalkForwardResult): void {
|
|
124
|
+
const record = this.records.get(id);
|
|
125
|
+
if (!record) throw new Error(`Strategy ${id} not found`);
|
|
126
|
+
record.lastWalkForward = result;
|
|
127
|
+
record.updatedAt = Date.now();
|
|
128
|
+
this.save();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Update the running status of a strategy. */
|
|
132
|
+
updateStatus(id: string, status: StrategyStatus): void {
|
|
133
|
+
const record = this.records.get(id);
|
|
134
|
+
if (!record) throw new Error(`Strategy ${id} not found`);
|
|
135
|
+
record.status = status;
|
|
136
|
+
record.updatedAt = Date.now();
|
|
137
|
+
this.save();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Update the definition of a strategy. Increments version if definition changed. */
|
|
141
|
+
updateDefinition(id: string, definition: StrategyDefinition): void {
|
|
142
|
+
const record = this.records.get(id);
|
|
143
|
+
if (!record) throw new Error(`Strategy ${id} not found`);
|
|
144
|
+
|
|
145
|
+
const oldDefinition = record.definition;
|
|
146
|
+
if (hasDefinitionChangeded(oldDefinition, definition)) {
|
|
147
|
+
record.definition = definition;
|
|
148
|
+
record.version = incrementVersion(record.version);
|
|
149
|
+
record.updatedAt = Date.now();
|
|
150
|
+
this.definitionSnapshots.set(id, JSON.stringify(definition));
|
|
151
|
+
this.save();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Persist current state to disk. */
|
|
156
|
+
save(): void {
|
|
157
|
+
const dir = dirname(this.filePath);
|
|
158
|
+
if (!existsSync(dir)) {
|
|
159
|
+
mkdirSync(dir, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
const data = [...this.records.values()];
|
|
162
|
+
writeFileSync(this.filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Load state from disk. */
|
|
166
|
+
private load(): void {
|
|
167
|
+
if (!existsSync(this.filePath)) return;
|
|
168
|
+
try {
|
|
169
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
170
|
+
const data = JSON.parse(raw) as StrategyRecord[];
|
|
171
|
+
this.records.clear();
|
|
172
|
+
this.definitionSnapshots.clear();
|
|
173
|
+
for (const record of data) {
|
|
174
|
+
this.records.set(record.id, record);
|
|
175
|
+
this.definitionSnapshots.set(record.id, JSON.stringify(record.definition));
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// Corrupted file — start fresh
|
|
179
|
+
this.records.clear();
|
|
180
|
+
this.definitionSnapshots.clear();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Canonical definitions live in @openfinclaw/fin-shared-types.
|
|
2
|
+
// Re-exported here for backward compatibility within fin-strategy-engine.
|
|
3
|
+
export type {
|
|
4
|
+
OHLCV,
|
|
5
|
+
MarketRegime,
|
|
6
|
+
MarketType,
|
|
7
|
+
StrategyLevel,
|
|
8
|
+
StrategyStatus,
|
|
9
|
+
Signal,
|
|
10
|
+
Position,
|
|
11
|
+
IndicatorLib,
|
|
12
|
+
StrategyContext,
|
|
13
|
+
StrategyDefinition,
|
|
14
|
+
BacktestConfig,
|
|
15
|
+
TradeRecord,
|
|
16
|
+
BacktestResult,
|
|
17
|
+
WalkForwardResult,
|
|
18
|
+
StrategyRecord,
|
|
19
|
+
} from "../../fin-shared-types/src/types.js";
|
|
@@ -0,0 +1,185 @@
|
|
|
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, Signal, StrategyContext, StrategyDefinition } from "./types.js";
|
|
5
|
+
import { WalkForward } from "./walk-forward.js";
|
|
6
|
+
|
|
7
|
+
function linearData(bars: number, startPrice: number, endPrice: number): OHLCV[] {
|
|
8
|
+
const data: OHLCV[] = [];
|
|
9
|
+
for (let i = 0; i < bars; i++) {
|
|
10
|
+
const price = startPrice + ((endPrice - startPrice) * i) / (bars - 1);
|
|
11
|
+
data.push({
|
|
12
|
+
timestamp: 1000000 + i * 86400000,
|
|
13
|
+
open: price,
|
|
14
|
+
high: price * 1.01,
|
|
15
|
+
low: price * 0.99,
|
|
16
|
+
close: price,
|
|
17
|
+
volume: 1000,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Strategy that consistently buys and holds → stable returns. Stateless so it works across walk-forward windows. */
|
|
24
|
+
function stableStrategy(): StrategyDefinition {
|
|
25
|
+
return {
|
|
26
|
+
id: "stable",
|
|
27
|
+
name: "Stable",
|
|
28
|
+
version: "1.0",
|
|
29
|
+
markets: ["crypto"],
|
|
30
|
+
symbols: ["TEST"],
|
|
31
|
+
timeframes: ["1d"],
|
|
32
|
+
parameters: {},
|
|
33
|
+
async onBar(_bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
|
|
34
|
+
// Buy if no position; fully stateless so walk-forward can reuse across windows
|
|
35
|
+
if (ctx.portfolio.positions.length === 0) {
|
|
36
|
+
return {
|
|
37
|
+
action: "buy",
|
|
38
|
+
symbol: "TEST",
|
|
39
|
+
sizePct: 100,
|
|
40
|
+
orderType: "market",
|
|
41
|
+
reason: "enter",
|
|
42
|
+
confidence: 1,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Strategy that only works on bars with indices < 30 (overfitting).
|
|
52
|
+
* Buys at bar 0, sells at bar 10, but does nothing on later bars.
|
|
53
|
+
*/
|
|
54
|
+
function overfitStrategy(): StrategyDefinition {
|
|
55
|
+
let barCount = 0;
|
|
56
|
+
let bought = false;
|
|
57
|
+
return {
|
|
58
|
+
id: "overfit",
|
|
59
|
+
name: "Overfit",
|
|
60
|
+
version: "1.0",
|
|
61
|
+
markets: ["crypto"],
|
|
62
|
+
symbols: ["TEST"],
|
|
63
|
+
timeframes: ["1d"],
|
|
64
|
+
parameters: {},
|
|
65
|
+
async onBar(_bar: OHLCV, ctx: StrategyContext): Promise<Signal | null> {
|
|
66
|
+
barCount++;
|
|
67
|
+
if (barCount === 1 && !bought) {
|
|
68
|
+
bought = true;
|
|
69
|
+
return {
|
|
70
|
+
action: "buy",
|
|
71
|
+
symbol: "TEST",
|
|
72
|
+
sizePct: 100,
|
|
73
|
+
orderType: "market",
|
|
74
|
+
reason: "overfit-buy",
|
|
75
|
+
confidence: 1,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const engine = new BacktestEngine();
|
|
84
|
+
const wf = new WalkForward(engine);
|
|
85
|
+
|
|
86
|
+
describe("WalkForward", () => {
|
|
87
|
+
it("validates a stable strategy on rising data → passed=true", async () => {
|
|
88
|
+
// 500 bars of rising data — consistent returns across all windows
|
|
89
|
+
const data = linearData(500, 100, 300);
|
|
90
|
+
const config: BacktestConfig = {
|
|
91
|
+
capital: 10000,
|
|
92
|
+
commissionRate: 0,
|
|
93
|
+
slippageBps: 0,
|
|
94
|
+
market: "crypto",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const result = await wf.validate(stableStrategy(), data, config, { windows: 5 });
|
|
98
|
+
|
|
99
|
+
expect(result.windows.length).toBe(5);
|
|
100
|
+
expect(result.passed).toBe(true);
|
|
101
|
+
expect(result.ratio).toBeGreaterThanOrEqual(0.6);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("window boundaries do not overlap", async () => {
|
|
105
|
+
const data = linearData(500, 100, 300);
|
|
106
|
+
const config: BacktestConfig = {
|
|
107
|
+
capital: 10000,
|
|
108
|
+
commissionRate: 0,
|
|
109
|
+
slippageBps: 0,
|
|
110
|
+
market: "crypto",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const result = await wf.validate(stableStrategy(), data, config, { windows: 5 });
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < result.windows.length; i++) {
|
|
116
|
+
const w = result.windows[i]!;
|
|
117
|
+
// Train ends before test starts
|
|
118
|
+
expect(w.trainEnd).toBeLessThanOrEqual(w.testStart);
|
|
119
|
+
|
|
120
|
+
// No overlap with next window
|
|
121
|
+
if (i < result.windows.length - 1) {
|
|
122
|
+
const next = result.windows[i + 1]!;
|
|
123
|
+
expect(w.testEnd).toBeLessThanOrEqual(next.trainStart);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns all window metrics", async () => {
|
|
129
|
+
const data = linearData(200, 100, 200);
|
|
130
|
+
const config: BacktestConfig = {
|
|
131
|
+
capital: 10000,
|
|
132
|
+
commissionRate: 0,
|
|
133
|
+
slippageBps: 0,
|
|
134
|
+
market: "crypto",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await wf.validate(stableStrategy(), data, config, { windows: 4 });
|
|
138
|
+
|
|
139
|
+
expect(result.windows.length).toBe(4);
|
|
140
|
+
for (const w of result.windows) {
|
|
141
|
+
expect(w.trainStart).toBeDefined();
|
|
142
|
+
expect(w.trainEnd).toBeDefined();
|
|
143
|
+
expect(w.testStart).toBeDefined();
|
|
144
|
+
expect(w.testEnd).toBeDefined();
|
|
145
|
+
expect(typeof w.trainSharpe).toBe("number");
|
|
146
|
+
expect(typeof w.testSharpe).toBe("number");
|
|
147
|
+
}
|
|
148
|
+
expect(typeof result.combinedTestSharpe).toBe("number");
|
|
149
|
+
expect(typeof result.avgTrainSharpe).toBe("number");
|
|
150
|
+
expect(typeof result.ratio).toBe("number");
|
|
151
|
+
expect(result.threshold).toBe(0.6);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("respects custom threshold", async () => {
|
|
155
|
+
const data = linearData(200, 100, 200);
|
|
156
|
+
const config: BacktestConfig = {
|
|
157
|
+
capital: 10000,
|
|
158
|
+
commissionRate: 0,
|
|
159
|
+
slippageBps: 0,
|
|
160
|
+
market: "crypto",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const result = await wf.validate(stableStrategy(), data, config, {
|
|
164
|
+
windows: 3,
|
|
165
|
+
threshold: 0.9,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.threshold).toBe(0.9);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("handles too-small data gracefully", async () => {
|
|
172
|
+
const data = linearData(5, 100, 110);
|
|
173
|
+
const config: BacktestConfig = {
|
|
174
|
+
capital: 10000,
|
|
175
|
+
commissionRate: 0,
|
|
176
|
+
slippageBps: 0,
|
|
177
|
+
market: "crypto",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const result = await wf.validate(stableStrategy(), data, config, { windows: 10 });
|
|
181
|
+
|
|
182
|
+
expect(result.passed).toBe(false);
|
|
183
|
+
expect(result.windows.length).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
});
|