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