@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,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E: Full Trading Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Validates the complete lifecycle:
|
|
5
|
+
* Create Strategy → Backtest → Walk-Forward → L2 Paper → Tick → Leaderboard → Promotion Check → Rebalance with/without confirmation → L3 Live Tick
|
|
6
|
+
*
|
|
7
|
+
* All data is mock (deterministic OHLCV). No external services needed.
|
|
8
|
+
* For live Binance testnet tests, set LIVE=1.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
14
|
+
import { FundManager } from "../../fin-fund-manager/src/fund-manager.js";
|
|
15
|
+
import { PromotionPipeline } from "../../fin-fund-manager/src/promotion-pipeline.js";
|
|
16
|
+
import type { PromotionCheck, StrategyProfile } from "../../fin-fund-manager/src/types.js";
|
|
17
|
+
import type { OHLCV, DecayState } from "../../fin-shared-types/src/types.js";
|
|
18
|
+
import { BacktestEngine, buildIndicatorLib } from "./backtest-engine.js";
|
|
19
|
+
import { createSmaCrossover } from "./builtin-strategies/sma-crossover.js";
|
|
20
|
+
import { StrategyRegistry } from "./strategy-registry.js";
|
|
21
|
+
import { WalkForward } from "./walk-forward.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Mock OHLCV generator — deterministic with clear trends for SMA crossovers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function generateMockOHLCV(count: number, startPrice = 40000): OHLCV[] {
|
|
28
|
+
const bars: OHLCV[] = [];
|
|
29
|
+
let price = startPrice;
|
|
30
|
+
const baseTimestamp = Date.now() - count * 3600_000;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < count; i++) {
|
|
33
|
+
// Phase design for SMA(10)/SMA(30) crossovers:
|
|
34
|
+
// 0-99: Uptrend 40000 → ~48000 (+0.18% per bar)
|
|
35
|
+
// 100-199: Downtrend 48000 → ~38000 (-0.23% per bar)
|
|
36
|
+
// 200-299: Rebound 38000 → ~45000 (+0.17% per bar)
|
|
37
|
+
let drift: number;
|
|
38
|
+
if (i < 100) {
|
|
39
|
+
drift = 0.0018;
|
|
40
|
+
} else if (i < 200) {
|
|
41
|
+
drift = -0.0023;
|
|
42
|
+
} else {
|
|
43
|
+
drift = 0.0017;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Small deterministic noise based on index
|
|
47
|
+
const noise = Math.sin(i * 0.7) * 0.003 + Math.cos(i * 1.3) * 0.002;
|
|
48
|
+
price = price * (1 + drift + noise);
|
|
49
|
+
|
|
50
|
+
const open = price * (1 + Math.sin(i * 0.5) * 0.002);
|
|
51
|
+
const high = Math.max(open, price) * (1 + Math.abs(Math.sin(i * 0.3)) * 0.005);
|
|
52
|
+
const low = Math.min(open, price) * (1 - Math.abs(Math.cos(i * 0.4)) * 0.005);
|
|
53
|
+
|
|
54
|
+
bars.push({
|
|
55
|
+
timestamp: baseTimestamp + i * 3600_000,
|
|
56
|
+
open: Math.round(open * 100) / 100,
|
|
57
|
+
high: Math.round(high * 100) / 100,
|
|
58
|
+
low: Math.round(low * 100) / 100,
|
|
59
|
+
close: Math.round(price * 100) / 100,
|
|
60
|
+
volume: 100 + Math.abs(Math.sin(i * 0.2)) * 500,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return bars;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Test suite
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe("E2E: Full Trading Pipeline", () => {
|
|
72
|
+
let tmpDir: string;
|
|
73
|
+
let registry: StrategyRegistry;
|
|
74
|
+
let engine: BacktestEngine;
|
|
75
|
+
let walkForward: WalkForward;
|
|
76
|
+
let manager: FundManager;
|
|
77
|
+
let pipeline: PromotionPipeline;
|
|
78
|
+
let strategyId: string;
|
|
79
|
+
const mockOHLCV = generateMockOHLCV(300);
|
|
80
|
+
|
|
81
|
+
beforeAll(() => {
|
|
82
|
+
tmpDir = join(tmpdir(), `e2e-pipeline-${Date.now()}`);
|
|
83
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
registry = new StrategyRegistry(join(tmpDir, "strategies.json"));
|
|
86
|
+
engine = new BacktestEngine();
|
|
87
|
+
walkForward = new WalkForward(engine);
|
|
88
|
+
pipeline = new PromotionPipeline();
|
|
89
|
+
manager = new FundManager(join(tmpDir, "fund-state.json"), {
|
|
90
|
+
cashReservePct: 30,
|
|
91
|
+
maxSingleStrategyPct: 50,
|
|
92
|
+
maxTotalExposurePct: 70,
|
|
93
|
+
rebalanceFrequency: "daily",
|
|
94
|
+
totalCapital: 100000,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterAll(() => {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
// Cleanup best-effort
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Step 1: Create SMA crossover strategy → L0 ──
|
|
107
|
+
|
|
108
|
+
test("Step 1: Create SMA crossover strategy → L0_INCUBATE", () => {
|
|
109
|
+
const definition = createSmaCrossover({
|
|
110
|
+
fastPeriod: 10,
|
|
111
|
+
slowPeriod: 30,
|
|
112
|
+
sizePct: 50,
|
|
113
|
+
});
|
|
114
|
+
definition.id = `sma-crossover-e2e-${Date.now()}`;
|
|
115
|
+
definition.name = "E2E SMA Crossover";
|
|
116
|
+
|
|
117
|
+
const record = registry.create(definition);
|
|
118
|
+
strategyId = record.id;
|
|
119
|
+
|
|
120
|
+
expect(record.level).toBe("L0_INCUBATE");
|
|
121
|
+
expect(record.name).toBe("E2E SMA Crossover");
|
|
122
|
+
expect(registry.get(strategyId)).toBeDefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Step 2: Backtest with mock OHLCV → update registry ──
|
|
126
|
+
|
|
127
|
+
test("Step 2: Backtest with mock OHLCV → trades generated", async () => {
|
|
128
|
+
const record = registry.get(strategyId)!;
|
|
129
|
+
|
|
130
|
+
const result = await engine.run(record.definition, mockOHLCV, {
|
|
131
|
+
capital: 10000,
|
|
132
|
+
commissionRate: 0.001,
|
|
133
|
+
slippageBps: 5,
|
|
134
|
+
market: "crypto",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
registry.updateBacktest(strategyId, result);
|
|
138
|
+
|
|
139
|
+
expect(result.totalTrades).toBeGreaterThanOrEqual(1);
|
|
140
|
+
expect(result.equityCurve.length).toBe(300);
|
|
141
|
+
|
|
142
|
+
const updated = registry.get(strategyId)!;
|
|
143
|
+
expect(updated.lastBacktest).toBeDefined();
|
|
144
|
+
expect(updated.lastBacktest!.totalTrades).toBe(result.totalTrades);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Step 3: Walk-forward → promote to L2_PAPER ──
|
|
148
|
+
|
|
149
|
+
test("Step 3: Walk-forward + promote L0→L1→L2", async () => {
|
|
150
|
+
const record = registry.get(strategyId)!;
|
|
151
|
+
|
|
152
|
+
// L0 → L1: auto-promote (valid definition)
|
|
153
|
+
const l0Profile: StrategyProfile = {
|
|
154
|
+
id: record.id,
|
|
155
|
+
name: record.name,
|
|
156
|
+
level: "L0_INCUBATE",
|
|
157
|
+
backtest: record.lastBacktest,
|
|
158
|
+
fitness: 0.5,
|
|
159
|
+
};
|
|
160
|
+
const l0Check = pipeline.checkPromotion(l0Profile);
|
|
161
|
+
expect(l0Check.eligible).toBe(true);
|
|
162
|
+
expect(l0Check.targetLevel).toBe("L1_BACKTEST");
|
|
163
|
+
registry.updateLevel(strategyId, "L1_BACKTEST");
|
|
164
|
+
|
|
165
|
+
// Run walk-forward
|
|
166
|
+
const wfResult = await walkForward.validate(
|
|
167
|
+
record.definition,
|
|
168
|
+
mockOHLCV,
|
|
169
|
+
{ capital: 10000, commissionRate: 0.001, slippageBps: 5, market: "crypto" },
|
|
170
|
+
{ windows: 3, threshold: 0.3 }, // lower threshold for mock data
|
|
171
|
+
);
|
|
172
|
+
registry.updateWalkForward(strategyId, wfResult);
|
|
173
|
+
|
|
174
|
+
// For L1→L2 promotion, we need: WF passed, Sharpe≥1.0, DD≤25%, trades≥100
|
|
175
|
+
// With mock data these may or may not pass, so we manually ensure
|
|
176
|
+
// the strategy meets criteria by patching backtest if needed
|
|
177
|
+
const bt = registry.get(strategyId)!.lastBacktest!;
|
|
178
|
+
const meetsL1Criteria =
|
|
179
|
+
wfResult.passed &&
|
|
180
|
+
bt.sharpe >= 1.0 &&
|
|
181
|
+
Math.abs(bt.maxDrawdown) <= 25 &&
|
|
182
|
+
bt.totalTrades >= 100;
|
|
183
|
+
|
|
184
|
+
if (!meetsL1Criteria) {
|
|
185
|
+
// Patch backtest to meet L1→L2 criteria for E2E progression
|
|
186
|
+
registry.updateBacktest(strategyId, {
|
|
187
|
+
...bt,
|
|
188
|
+
sharpe: 1.5,
|
|
189
|
+
maxDrawdown: -12,
|
|
190
|
+
totalTrades: 150,
|
|
191
|
+
});
|
|
192
|
+
registry.updateWalkForward(strategyId, {
|
|
193
|
+
...wfResult,
|
|
194
|
+
passed: true,
|
|
195
|
+
ratio: 0.8,
|
|
196
|
+
threshold: 0.6,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check L1→L2 promotion
|
|
201
|
+
const updatedRecord = registry.get(strategyId)!;
|
|
202
|
+
const l1Profile: StrategyProfile = {
|
|
203
|
+
id: updatedRecord.id,
|
|
204
|
+
name: updatedRecord.name,
|
|
205
|
+
level: "L1_BACKTEST",
|
|
206
|
+
backtest: updatedRecord.lastBacktest,
|
|
207
|
+
walkForward: updatedRecord.lastWalkForward,
|
|
208
|
+
fitness: 0.7,
|
|
209
|
+
};
|
|
210
|
+
const l1Check = pipeline.checkPromotion(l1Profile);
|
|
211
|
+
expect(l1Check.eligible).toBe(true);
|
|
212
|
+
expect(l1Check.targetLevel).toBe("L2_PAPER");
|
|
213
|
+
|
|
214
|
+
registry.updateLevel(strategyId, "L2_PAPER");
|
|
215
|
+
expect(registry.get(strategyId)!.level).toBe("L2_PAPER");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── Step 4: Tick strategy at L2 with mock data → signal + order ──
|
|
219
|
+
|
|
220
|
+
test("Step 4: Tick strategy at L2_PAPER → generates signals", async () => {
|
|
221
|
+
const record = registry.get(strategyId)!;
|
|
222
|
+
expect(record.level).toBe("L2_PAPER");
|
|
223
|
+
|
|
224
|
+
// Simulate ticking through bars and collecting signals
|
|
225
|
+
const signals: Array<{ action: string; bar: number }> = [];
|
|
226
|
+
const memory = new Map<string, unknown>();
|
|
227
|
+
|
|
228
|
+
for (let i = 30; i < mockOHLCV.length; i++) {
|
|
229
|
+
const history = mockOHLCV.slice(0, i + 1);
|
|
230
|
+
const bar = mockOHLCV[i]!;
|
|
231
|
+
const indicators = buildIndicatorLib(history);
|
|
232
|
+
|
|
233
|
+
const ctx = {
|
|
234
|
+
portfolio: { equity: 10000, cash: 10000, positions: [] as never[] },
|
|
235
|
+
history,
|
|
236
|
+
indicators,
|
|
237
|
+
regime: "sideways" as const,
|
|
238
|
+
memory,
|
|
239
|
+
log: () => {},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const signal = await record.definition.onBar(bar, ctx);
|
|
243
|
+
if (signal) {
|
|
244
|
+
signals.push({ action: signal.action, bar: i });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// With our trend data, SMA(10)/SMA(30) should produce crossovers
|
|
249
|
+
expect(signals.length).toBeGreaterThanOrEqual(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── Step 5: Build profile meeting L2→L3 criteria ──
|
|
253
|
+
|
|
254
|
+
test("Step 5: Profile meets L2→L3 promotion criteria", () => {
|
|
255
|
+
const record = registry.get(strategyId)!;
|
|
256
|
+
|
|
257
|
+
// Construct a profile that meets all L2→L3 thresholds
|
|
258
|
+
const paperMetrics: DecayState = {
|
|
259
|
+
rollingSharpe7d: 0.8,
|
|
260
|
+
rollingSharpe30d: 1.2,
|
|
261
|
+
sharpeMomentum: 0.1,
|
|
262
|
+
consecutiveLossDays: 0,
|
|
263
|
+
currentDrawdown: -15,
|
|
264
|
+
peakEquity: 11500,
|
|
265
|
+
decayLevel: "healthy",
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const profile: StrategyProfile = {
|
|
269
|
+
id: record.id,
|
|
270
|
+
name: record.name,
|
|
271
|
+
level: "L2_PAPER",
|
|
272
|
+
backtest: record.lastBacktest,
|
|
273
|
+
walkForward: record.lastWalkForward,
|
|
274
|
+
paperMetrics,
|
|
275
|
+
paperEquity: 11000,
|
|
276
|
+
paperInitialCapital: 10000,
|
|
277
|
+
paperDaysActive: 31,
|
|
278
|
+
paperTradeCount: 35,
|
|
279
|
+
fitness: 0.8,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const check = pipeline.checkPromotion(profile);
|
|
283
|
+
expect(check.eligible).toBe(true);
|
|
284
|
+
expect(check.targetLevel).toBe("L3_LIVE");
|
|
285
|
+
expect(check.blockers).toHaveLength(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── Step 6: fin_list_promotions_ready → needsUserConfirmation ──
|
|
289
|
+
|
|
290
|
+
test("Step 6: L2→L3 promotion shows needsUserConfirmation", () => {
|
|
291
|
+
const record = registry.get(strategyId)!;
|
|
292
|
+
|
|
293
|
+
const paperMetrics: DecayState = {
|
|
294
|
+
rollingSharpe7d: 0.8,
|
|
295
|
+
rollingSharpe30d: 1.2,
|
|
296
|
+
sharpeMomentum: 0.1,
|
|
297
|
+
consecutiveLossDays: 0,
|
|
298
|
+
currentDrawdown: -15,
|
|
299
|
+
peakEquity: 11500,
|
|
300
|
+
decayLevel: "healthy",
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const profile: StrategyProfile = {
|
|
304
|
+
id: record.id,
|
|
305
|
+
name: record.name,
|
|
306
|
+
level: "L2_PAPER",
|
|
307
|
+
backtest: record.lastBacktest,
|
|
308
|
+
walkForward: record.lastWalkForward,
|
|
309
|
+
paperMetrics,
|
|
310
|
+
paperEquity: 11000,
|
|
311
|
+
paperInitialCapital: 10000,
|
|
312
|
+
paperDaysActive: 31,
|
|
313
|
+
paperTradeCount: 35,
|
|
314
|
+
fitness: 0.8,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const check = manager.checkPromotion(profile);
|
|
318
|
+
expect(check.eligible).toBe(true);
|
|
319
|
+
expect(check.needsUserConfirmation).toBe(true);
|
|
320
|
+
expect(check.targetLevel).toBe("L3_LIVE");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ── Step 7: Rebalance WITHOUT confirmation → L3 NOT promoted ──
|
|
324
|
+
|
|
325
|
+
test("Step 7: Rebalance without confirmation → L3 NOT promoted", () => {
|
|
326
|
+
const record = registry.get(strategyId)!;
|
|
327
|
+
expect(record.level).toBe("L2_PAPER");
|
|
328
|
+
|
|
329
|
+
// Build a rebalance result with eligible L2→L3 promotion
|
|
330
|
+
const paperMetrics: DecayState = {
|
|
331
|
+
rollingSharpe7d: 0.8,
|
|
332
|
+
rollingSharpe30d: 1.2,
|
|
333
|
+
sharpeMomentum: 0.1,
|
|
334
|
+
consecutiveLossDays: 0,
|
|
335
|
+
currentDrawdown: -15,
|
|
336
|
+
peakEquity: 11500,
|
|
337
|
+
decayLevel: "healthy",
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Manually construct profiles with paper data
|
|
341
|
+
const paperData = new Map<
|
|
342
|
+
string,
|
|
343
|
+
{
|
|
344
|
+
metrics?: DecayState;
|
|
345
|
+
equity?: number;
|
|
346
|
+
initialCapital?: number;
|
|
347
|
+
daysActive?: number;
|
|
348
|
+
tradeCount?: number;
|
|
349
|
+
}
|
|
350
|
+
>();
|
|
351
|
+
paperData.set(strategyId, {
|
|
352
|
+
metrics: paperMetrics,
|
|
353
|
+
equity: 11000,
|
|
354
|
+
initialCapital: 10000,
|
|
355
|
+
daysActive: 31,
|
|
356
|
+
tradeCount: 35,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const records = registry.list() as Parameters<typeof manager.buildProfiles>[0];
|
|
360
|
+
const result = manager.rebalance(records, paperData);
|
|
361
|
+
|
|
362
|
+
// There should be an eligible L2→L3 promotion
|
|
363
|
+
const l3Promo = result.promotions.find(
|
|
364
|
+
(p) => p.strategyId === strategyId && p.targetLevel === "L3_LIVE",
|
|
365
|
+
);
|
|
366
|
+
expect(l3Promo).toBeDefined();
|
|
367
|
+
|
|
368
|
+
// Simulate rebalance WITHOUT confirmed_promotions
|
|
369
|
+
// The promotion should NOT be applied (L2→L3 needs confirmation)
|
|
370
|
+
const confirmedSet = new Set<string>(); // empty — no confirmation
|
|
371
|
+
|
|
372
|
+
for (const promo of result.promotions) {
|
|
373
|
+
if (promo.targetLevel === "L3_LIVE" && !confirmedSet.has(promo.strategyId)) {
|
|
374
|
+
continue; // Skip — needs confirmation
|
|
375
|
+
}
|
|
376
|
+
if (promo.targetLevel) {
|
|
377
|
+
try {
|
|
378
|
+
registry.updateLevel(promo.strategyId, promo.targetLevel);
|
|
379
|
+
} catch {
|
|
380
|
+
// ignore
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Strategy should still be L2_PAPER
|
|
386
|
+
expect(registry.get(strategyId)!.level).toBe("L2_PAPER");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ── Step 8: Rebalance WITH confirmation → L3 promoted ──
|
|
390
|
+
|
|
391
|
+
test("Step 8: Rebalance with confirmation → L3 promoted", () => {
|
|
392
|
+
expect(registry.get(strategyId)!.level).toBe("L2_PAPER");
|
|
393
|
+
|
|
394
|
+
const paperMetrics: DecayState = {
|
|
395
|
+
rollingSharpe7d: 0.8,
|
|
396
|
+
rollingSharpe30d: 1.2,
|
|
397
|
+
sharpeMomentum: 0.1,
|
|
398
|
+
consecutiveLossDays: 0,
|
|
399
|
+
currentDrawdown: -15,
|
|
400
|
+
peakEquity: 11500,
|
|
401
|
+
decayLevel: "healthy",
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const paperData = new Map<
|
|
405
|
+
string,
|
|
406
|
+
{
|
|
407
|
+
metrics?: DecayState;
|
|
408
|
+
equity?: number;
|
|
409
|
+
initialCapital?: number;
|
|
410
|
+
daysActive?: number;
|
|
411
|
+
tradeCount?: number;
|
|
412
|
+
}
|
|
413
|
+
>();
|
|
414
|
+
paperData.set(strategyId, {
|
|
415
|
+
metrics: paperMetrics,
|
|
416
|
+
equity: 11000,
|
|
417
|
+
initialCapital: 10000,
|
|
418
|
+
daysActive: 31,
|
|
419
|
+
tradeCount: 35,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const records = registry.list() as Parameters<typeof manager.buildProfiles>[0];
|
|
423
|
+
const result = manager.rebalance(records, paperData);
|
|
424
|
+
|
|
425
|
+
// Now WITH confirmed_promotions
|
|
426
|
+
const confirmedSet = new Set([strategyId]);
|
|
427
|
+
|
|
428
|
+
for (const promo of result.promotions) {
|
|
429
|
+
if (promo.targetLevel === "L3_LIVE" && !confirmedSet.has(promo.strategyId)) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (promo.targetLevel) {
|
|
433
|
+
try {
|
|
434
|
+
registry.updateLevel(promo.strategyId, promo.targetLevel);
|
|
435
|
+
} catch {
|
|
436
|
+
// ignore
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Strategy should now be L3_LIVE
|
|
442
|
+
expect(registry.get(strategyId)!.level).toBe("L3_LIVE");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ── Step 9: Tick at L3 → routes to live engine (mock) ──
|
|
446
|
+
|
|
447
|
+
test("Step 9: Tick at L3_LIVE → routes to live exchange (mock)", async () => {
|
|
448
|
+
const record = registry.get(strategyId)!;
|
|
449
|
+
expect(record.level).toBe("L3_LIVE");
|
|
450
|
+
|
|
451
|
+
// Simulate a tick that produces a signal
|
|
452
|
+
// Use a slice of data where we know a crossover happens
|
|
453
|
+
const memory = new Map<string, unknown>();
|
|
454
|
+
let signalFound = false;
|
|
455
|
+
const mockLiveOrders: Array<{ symbol: string; side: string; quantity: number }> = [];
|
|
456
|
+
|
|
457
|
+
for (let i = 30; i < mockOHLCV.length; i++) {
|
|
458
|
+
const history = mockOHLCV.slice(0, i + 1);
|
|
459
|
+
const bar = mockOHLCV[i]!;
|
|
460
|
+
const indicators = buildIndicatorLib(history);
|
|
461
|
+
|
|
462
|
+
const ctx = {
|
|
463
|
+
portfolio: { equity: 10000, cash: 10000, positions: [] as never[] },
|
|
464
|
+
history,
|
|
465
|
+
indicators,
|
|
466
|
+
regime: "sideways" as const,
|
|
467
|
+
memory,
|
|
468
|
+
log: () => {},
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const signal = await record.definition.onBar(bar, ctx);
|
|
472
|
+
|
|
473
|
+
if (signal) {
|
|
474
|
+
signalFound = true;
|
|
475
|
+
|
|
476
|
+
// At L3_LIVE, order should route to live engine
|
|
477
|
+
// Simulate what fin_strategy_tick does: route to fin-exchange-registry
|
|
478
|
+
const quantity = ((signal.sizePct / 100) * ctx.portfolio.equity) / bar.close;
|
|
479
|
+
mockLiveOrders.push({
|
|
480
|
+
symbol: signal.symbol,
|
|
481
|
+
side: signal.action === "buy" ? "buy" : "sell",
|
|
482
|
+
quantity,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// We only need to verify one signal routes correctly
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
expect(signalFound).toBe(true);
|
|
491
|
+
expect(mockLiveOrders.length).toBe(1);
|
|
492
|
+
expect(mockLiveOrders[0]!.quantity).toBeGreaterThan(0);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { calculateFitness } from "./fitness.js";
|
|
3
|
+
import type { FitnessInput } from "./fitness.js";
|
|
4
|
+
|
|
5
|
+
describe("calculateFitness", () => {
|
|
6
|
+
it("good strategy with all windows positive → fitness > 0", () => {
|
|
7
|
+
const input: FitnessInput = {
|
|
8
|
+
longTerm: { sharpe: 1.5, maxDD: -0.1, trades: 200 },
|
|
9
|
+
recent: { sharpe: 1.8, maxDD: -0.08, trades: 50 },
|
|
10
|
+
paper: { sharpe: 1.6, maxDD: -0.09, trades: 30 },
|
|
11
|
+
};
|
|
12
|
+
const fitness = calculateFitness(input);
|
|
13
|
+
expect(fitness).toBeGreaterThan(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("decaying strategy (longTerm good, recent bad) → lower fitness", () => {
|
|
17
|
+
const good: FitnessInput = {
|
|
18
|
+
longTerm: { sharpe: 2.0, maxDD: -0.1, trades: 200 },
|
|
19
|
+
recent: { sharpe: 2.0, maxDD: -0.1, trades: 50 },
|
|
20
|
+
paper: { sharpe: 2.0, maxDD: -0.1, trades: 30 },
|
|
21
|
+
};
|
|
22
|
+
const decaying: FitnessInput = {
|
|
23
|
+
longTerm: { sharpe: 2.0, maxDD: -0.1, trades: 200 },
|
|
24
|
+
recent: { sharpe: 0.5, maxDD: -0.2, trades: 50 },
|
|
25
|
+
paper: { sharpe: 0.5, maxDD: -0.2, trades: 30 },
|
|
26
|
+
};
|
|
27
|
+
expect(calculateFitness(decaying)).toBeLessThan(calculateFitness(good));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("overfitting strategy (backtest good, paper bad) → penalized vs non-overfit", () => {
|
|
31
|
+
// Same base inputs, but paper sharpe much lower than recent sharpe → overfit penalty
|
|
32
|
+
const noOverfit: FitnessInput = {
|
|
33
|
+
longTerm: { sharpe: 1.5, maxDD: -0.1, trades: 200 },
|
|
34
|
+
recent: { sharpe: 1.5, maxDD: -0.1, trades: 50 },
|
|
35
|
+
paper: { sharpe: 1.5, maxDD: -0.1, trades: 30 },
|
|
36
|
+
};
|
|
37
|
+
const overfitting: FitnessInput = {
|
|
38
|
+
longTerm: { sharpe: 1.5, maxDD: -0.1, trades: 200 },
|
|
39
|
+
recent: { sharpe: 1.5, maxDD: -0.1, trades: 50 },
|
|
40
|
+
paper: { sharpe: 0.3, maxDD: -0.3, trades: 30 },
|
|
41
|
+
};
|
|
42
|
+
expect(calculateFitness(overfitting)).toBeLessThan(calculateFitness(noOverfit));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("no paper data → uses recent 70% + longTerm 30%", () => {
|
|
46
|
+
const input: FitnessInput = {
|
|
47
|
+
longTerm: { sharpe: 1.0, maxDD: -0.15, trades: 200 },
|
|
48
|
+
recent: { sharpe: 1.5, maxDD: -0.1, trades: 50 },
|
|
49
|
+
};
|
|
50
|
+
const fitness = calculateFitness(input);
|
|
51
|
+
expect(fitness).toBeGreaterThan(0);
|
|
52
|
+
expect(Number.isFinite(fitness)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("applies correlation penalty", () => {
|
|
56
|
+
const base: FitnessInput = {
|
|
57
|
+
longTerm: { sharpe: 1.5, maxDD: -0.1, trades: 200 },
|
|
58
|
+
recent: { sharpe: 1.5, maxDD: -0.1, trades: 50 },
|
|
59
|
+
correlationWithPortfolio: 0,
|
|
60
|
+
};
|
|
61
|
+
const correlated: FitnessInput = {
|
|
62
|
+
...base,
|
|
63
|
+
correlationWithPortfolio: 0.9,
|
|
64
|
+
};
|
|
65
|
+
expect(calculateFitness(correlated)).toBeLessThan(calculateFitness(base));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("applies half-life penalty after 180 days", () => {
|
|
69
|
+
const fresh: FitnessInput = {
|
|
70
|
+
longTerm: { sharpe: 1.5, maxDD: -0.1, trades: 200 },
|
|
71
|
+
recent: { sharpe: 1.5, maxDD: -0.1, trades: 50 },
|
|
72
|
+
daysSinceLaunch: 90,
|
|
73
|
+
};
|
|
74
|
+
const stale: FitnessInput = {
|
|
75
|
+
...fresh,
|
|
76
|
+
daysSinceLaunch: 365,
|
|
77
|
+
};
|
|
78
|
+
expect(calculateFitness(stale)).toBeLessThan(calculateFitness(fresh));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("no half-life penalty at exactly 180 days", () => {
|
|
82
|
+
const at180: FitnessInput = {
|
|
83
|
+
longTerm: { sharpe: 1.5, maxDD: -0.1, trades: 200 },
|
|
84
|
+
recent: { sharpe: 1.5, maxDD: -0.1, trades: 50 },
|
|
85
|
+
daysSinceLaunch: 180,
|
|
86
|
+
};
|
|
87
|
+
const at90: FitnessInput = {
|
|
88
|
+
...at180,
|
|
89
|
+
daysSinceLaunch: 90,
|
|
90
|
+
};
|
|
91
|
+
// At exactly 180 days, penalty = 0.1 * (180-180)/365 = 0
|
|
92
|
+
expect(calculateFitness(at180)).toBe(calculateFitness(at90));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("handles all-negative sharpe values", () => {
|
|
96
|
+
const input: FitnessInput = {
|
|
97
|
+
longTerm: { sharpe: -0.5, maxDD: -0.3, trades: 100 },
|
|
98
|
+
recent: { sharpe: -1.0, maxDD: -0.4, trades: 30 },
|
|
99
|
+
};
|
|
100
|
+
const fitness = calculateFitness(input);
|
|
101
|
+
expect(Number.isFinite(fitness)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/fitness.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-window fitness function for strategy evaluation.
|
|
3
|
+
* Combines long-term backtest, recent performance, and paper trading
|
|
4
|
+
* with decay, overfit, correlation, and half-life penalties.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Canonical definition lives in @openfinclaw/fin-shared-types.
|
|
8
|
+
// Re-exported here for backward compatibility within fin-strategy-engine.
|
|
9
|
+
export type { FitnessInput } from "../../fin-shared-types/src/types.js";
|
|
10
|
+
|
|
11
|
+
import type { FitnessInput } from "../../fin-shared-types/src/types.js";
|
|
12
|
+
|
|
13
|
+
/** Score a single window: sharpe adjusted for drawdown depth. */
|
|
14
|
+
function windowScore(window: { sharpe: number; maxDD: number; trades: number }): number {
|
|
15
|
+
// Sharpe is the primary signal; penalize deep drawdowns.
|
|
16
|
+
// maxDD is negative (e.g. -0.1 for 10%), so abs is used.
|
|
17
|
+
const ddPenalty = Math.abs(window.maxDD);
|
|
18
|
+
return window.sharpe - ddPenalty;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculate composite fitness score for a strategy.
|
|
23
|
+
*
|
|
24
|
+
* Weights:
|
|
25
|
+
* - With paper data: paper 50% + recent 35% + longTerm 15%
|
|
26
|
+
* - Without paper: recent 70% + longTerm 30%
|
|
27
|
+
*
|
|
28
|
+
* Penalties:
|
|
29
|
+
* - Decay: max(0, longTerm.sharpe - recent.sharpe) * 0.30
|
|
30
|
+
* - Overfit: max(0, recent.sharpe - paper.sharpe) * 0.50
|
|
31
|
+
* - Correlation: correlationWithPortfolio * 0.20
|
|
32
|
+
* - Half-life: if days > 180, 0.1 * (days - 180) / 365
|
|
33
|
+
*/
|
|
34
|
+
export function calculateFitness(input: FitnessInput): number {
|
|
35
|
+
const ltScore = windowScore(input.longTerm);
|
|
36
|
+
const recentScore = windowScore(input.recent);
|
|
37
|
+
|
|
38
|
+
let base: number;
|
|
39
|
+
if (input.paper) {
|
|
40
|
+
const paperScore = windowScore(input.paper);
|
|
41
|
+
base = paperScore * 0.5 + recentScore * 0.35 + ltScore * 0.15;
|
|
42
|
+
} else {
|
|
43
|
+
base = recentScore * 0.7 + ltScore * 0.3;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Decay penalty: strategy getting worse over time
|
|
47
|
+
const decayPenalty = Math.max(0, input.longTerm.sharpe - input.recent.sharpe) * 0.3;
|
|
48
|
+
|
|
49
|
+
// Overfit penalty: backtest looks great but paper trading is poor
|
|
50
|
+
const paperSharpe = input.paper?.sharpe ?? input.recent.sharpe;
|
|
51
|
+
const overfitPenalty = Math.max(0, input.recent.sharpe - paperSharpe) * 0.5;
|
|
52
|
+
|
|
53
|
+
// Correlation penalty: strategy too correlated with existing portfolio
|
|
54
|
+
const correlationPenalty = (input.correlationWithPortfolio ?? 0) * 0.2;
|
|
55
|
+
|
|
56
|
+
// Half-life penalty: strategy may be stale
|
|
57
|
+
const days = input.daysSinceLaunch ?? 0;
|
|
58
|
+
const halfLifePenalty = days > 180 ? (0.1 * (days - 180)) / 365 : 0;
|
|
59
|
+
|
|
60
|
+
return base - decayPenalty - overfitPenalty - correlationPenalty - halfLifePenalty;
|
|
61
|
+
}
|