@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,238 @@
1
+ /**
2
+ * Pure technical indicator functions. Zero external dependencies.
3
+ * All functions return arrays the same length as the input, with NaN
4
+ * for indices where the indicator cannot yet be computed (warm-up period).
5
+ */
6
+
7
+ /** Simple Moving Average. */
8
+ export function sma(data: number[], period: number): number[] {
9
+ const len = data.length;
10
+ const result = new Array<number>(len);
11
+ if (len === 0) return [];
12
+
13
+ let sum = 0;
14
+ for (let i = 0; i < len; i++) {
15
+ sum += data[i];
16
+ if (i < period - 1) {
17
+ result[i] = NaN;
18
+ } else {
19
+ if (i >= period) sum -= data[i - period];
20
+ result[i] = sum / period;
21
+ }
22
+ }
23
+ return result;
24
+ }
25
+
26
+ /** Exponential Moving Average. Seeded with SMA of the first `period` values. */
27
+ export function ema(data: number[], period: number): number[] {
28
+ const len = data.length;
29
+ if (len === 0) return [];
30
+
31
+ const result = new Array<number>(len);
32
+ if (period > len) {
33
+ result.fill(NaN);
34
+ return result;
35
+ }
36
+
37
+ const k = 2 / (period + 1);
38
+
39
+ // Seed: SMA of first `period` values
40
+ let sum = 0;
41
+ for (let i = 0; i < period - 1; i++) {
42
+ sum += data[i];
43
+ result[i] = NaN;
44
+ }
45
+ sum += data[period - 1];
46
+ result[period - 1] = sum / period;
47
+
48
+ // Subsequent values use the EMA formula
49
+ for (let i = period; i < len; i++) {
50
+ result[i] = data[i] * k + result[i - 1] * (1 - k);
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Relative Strength Index (Wilder's smoothing).
57
+ * Returns values in [0, 100]. NaN during warm-up.
58
+ */
59
+ export function rsi(data: number[], period: number): number[] {
60
+ const len = data.length;
61
+ if (len === 0) return [];
62
+
63
+ const result = new Array<number>(len).fill(NaN);
64
+
65
+ // Need at least period+1 data points to compute first RSI
66
+ if (len < period + 1) return result;
67
+
68
+ // Compute initial average gain/loss over first `period` changes
69
+ let avgGain = 0;
70
+ let avgLoss = 0;
71
+ for (let i = 1; i <= period; i++) {
72
+ const change = data[i] - data[i - 1];
73
+ if (change > 0) avgGain += change;
74
+ else avgLoss += Math.abs(change);
75
+ }
76
+ avgGain /= period;
77
+ avgLoss /= period;
78
+
79
+ if (avgLoss === 0) {
80
+ result[period] = 100;
81
+ } else {
82
+ const rs = avgGain / avgLoss;
83
+ result[period] = 100 - 100 / (1 + rs);
84
+ }
85
+
86
+ // Wilder's smoothing for subsequent values
87
+ for (let i = period + 1; i < len; i++) {
88
+ const change = data[i] - data[i - 1];
89
+ const gain = change > 0 ? change : 0;
90
+ const loss = change < 0 ? Math.abs(change) : 0;
91
+
92
+ avgGain = (avgGain * (period - 1) + gain) / period;
93
+ avgLoss = (avgLoss * (period - 1) + loss) / period;
94
+
95
+ if (avgLoss === 0) {
96
+ result[i] = 100;
97
+ } else {
98
+ const rs = avgGain / avgLoss;
99
+ result[i] = 100 - 100 / (1 + rs);
100
+ }
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Moving Average Convergence Divergence.
108
+ * Returns MACD line, signal line, and histogram.
109
+ */
110
+ export function macd(
111
+ data: number[],
112
+ fast = 12,
113
+ slow = 26,
114
+ signal = 9,
115
+ ): { macd: number[]; signal: number[]; histogram: number[] } {
116
+ const len = data.length;
117
+ if (len === 0) {
118
+ return { macd: [], signal: [], histogram: [] };
119
+ }
120
+
121
+ const fastEma = ema(data, fast);
122
+ const slowEma = ema(data, slow);
123
+
124
+ // MACD line = fast EMA - slow EMA
125
+ const macdLine = new Array<number>(len);
126
+ for (let i = 0; i < len; i++) {
127
+ if (Number.isNaN(fastEma[i]) || Number.isNaN(slowEma[i])) {
128
+ macdLine[i] = NaN;
129
+ } else {
130
+ macdLine[i] = fastEma[i] - slowEma[i];
131
+ }
132
+ }
133
+
134
+ // Signal line = EMA of MACD line (only over valid MACD values)
135
+ // Find first valid MACD index
136
+ let firstValid = -1;
137
+ for (let i = 0; i < len; i++) {
138
+ if (!Number.isNaN(macdLine[i])) {
139
+ firstValid = i;
140
+ break;
141
+ }
142
+ }
143
+
144
+ const signalLine = new Array<number>(len).fill(NaN);
145
+ const histogram = new Array<number>(len).fill(NaN);
146
+
147
+ if (firstValid === -1 || len - firstValid < signal) {
148
+ return { macd: macdLine, signal: signalLine, histogram };
149
+ }
150
+
151
+ // Compute EMA of the valid portion of MACD line
152
+ const validMacd = macdLine.slice(firstValid);
153
+ const signalEma = ema(validMacd, signal);
154
+
155
+ for (let i = 0; i < signalEma.length; i++) {
156
+ signalLine[firstValid + i] = signalEma[i];
157
+ if (!Number.isNaN(signalEma[i]) && !Number.isNaN(macdLine[firstValid + i])) {
158
+ histogram[firstValid + i] = macdLine[firstValid + i] - signalEma[i];
159
+ }
160
+ }
161
+
162
+ return { macd: macdLine, signal: signalLine, histogram };
163
+ }
164
+
165
+ /**
166
+ * Bollinger Bands: middle = SMA, upper/lower = middle +/- stdDev * multiplier.
167
+ */
168
+ export function bollingerBands(
169
+ data: number[],
170
+ period = 20,
171
+ stdDevMultiplier = 2,
172
+ ): { upper: number[]; middle: number[]; lower: number[] } {
173
+ const len = data.length;
174
+ if (len === 0) {
175
+ return { upper: [], middle: [], lower: [] };
176
+ }
177
+
178
+ const middle = sma(data, period);
179
+ const upper = new Array<number>(len);
180
+ const lower = new Array<number>(len);
181
+
182
+ for (let i = 0; i < len; i++) {
183
+ if (Number.isNaN(middle[i])) {
184
+ upper[i] = NaN;
185
+ lower[i] = NaN;
186
+ } else {
187
+ // Compute standard deviation over the window
188
+ let sumSq = 0;
189
+ for (let j = i - period + 1; j <= i; j++) {
190
+ const diff = data[j] - middle[i];
191
+ sumSq += diff * diff;
192
+ }
193
+ const sd = Math.sqrt(sumSq / period);
194
+ upper[i] = middle[i] + stdDevMultiplier * sd;
195
+ lower[i] = middle[i] - stdDevMultiplier * sd;
196
+ }
197
+ }
198
+
199
+ return { upper, middle, lower };
200
+ }
201
+
202
+ /**
203
+ * Average True Range (Wilder's smoothing).
204
+ * True Range = max(H-L, |H-prevClose|, |L-prevClose|).
205
+ */
206
+ export function atr(highs: number[], lows: number[], closes: number[], period = 14): number[] {
207
+ const len = highs.length;
208
+ if (len === 0) return [];
209
+
210
+ const result = new Array<number>(len).fill(NaN);
211
+
212
+ // Compute True Range array (first bar has no previous close, so TR = H - L)
213
+ const tr = new Array<number>(len);
214
+ tr[0] = highs[0] - lows[0];
215
+ for (let i = 1; i < len; i++) {
216
+ const hl = highs[i] - lows[i];
217
+ const hpc = Math.abs(highs[i] - closes[i - 1]);
218
+ const lpc = Math.abs(lows[i] - closes[i - 1]);
219
+ tr[i] = Math.max(hl, hpc, lpc);
220
+ }
221
+
222
+ // Need at least `period` TRs to compute first ATR
223
+ if (len < period + 1) return result;
224
+
225
+ // First ATR = simple average of first `period` TRs (starting from index 1)
226
+ let sum = 0;
227
+ for (let i = 1; i <= period; i++) {
228
+ sum += tr[i];
229
+ }
230
+ result[period] = sum / period;
231
+
232
+ // Wilder's smoothing
233
+ for (let i = period + 1; i < len; i++) {
234
+ result[i] = (result[i - 1] * (period - 1) + tr[i]) / period;
235
+ }
236
+
237
+ return result;
238
+ }
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ mean,
4
+ stdDev,
5
+ sharpeRatio,
6
+ sortinoRatio,
7
+ maxDrawdown,
8
+ calmarRatio,
9
+ profitFactor,
10
+ winRate,
11
+ } from "./stats.js";
12
+
13
+ function expectClose(actual: number, expected: number, tolerance = 0.0001) {
14
+ if (expected === 0) {
15
+ expect(Math.abs(actual)).toBeLessThan(tolerance);
16
+ } else {
17
+ expect(Math.abs((actual - expected) / expected)).toBeLessThan(tolerance);
18
+ }
19
+ }
20
+
21
+ describe("mean", () => {
22
+ it("computes arithmetic mean", () => {
23
+ expectClose(mean([1, 2, 3, 4, 5]), 3);
24
+ });
25
+
26
+ it("returns NaN for empty array", () => {
27
+ expect(mean([])).toBeNaN();
28
+ });
29
+
30
+ it("handles single value", () => {
31
+ expectClose(mean([42]), 42);
32
+ });
33
+
34
+ it("handles negative values", () => {
35
+ expectClose(mean([-10, 10]), 0);
36
+ });
37
+ });
38
+
39
+ describe("stdDev", () => {
40
+ it("computes sample standard deviation by default", () => {
41
+ // [2, 4, 4, 4, 5, 5, 7, 9] → mean=5, sample var=4.571..., sample sd=2.138...
42
+ expectClose(stdDev([2, 4, 4, 4, 5, 5, 7, 9]), 2.13809, 0.001);
43
+ });
44
+
45
+ it("computes population standard deviation when specified", () => {
46
+ // Population var = 4, pop sd = 2.0
47
+ expectClose(stdDev([2, 4, 4, 4, 5, 5, 7, 9], true), 2.0, 0.001);
48
+ });
49
+
50
+ it("returns 0 for constant values", () => {
51
+ expectClose(stdDev([5, 5, 5, 5]), 0);
52
+ });
53
+
54
+ it("returns NaN for empty array", () => {
55
+ expect(stdDev([])).toBeNaN();
56
+ });
57
+
58
+ it("returns 0 for single value (population)", () => {
59
+ expectClose(stdDev([5], true), 0);
60
+ });
61
+ });
62
+
63
+ describe("sharpeRatio", () => {
64
+ it("positive returns → Sharpe > 0", () => {
65
+ const returns = [0.01, 0.02, 0.015, 0.01, 0.025, 0.02, 0.01, 0.015, 0.02, 0.01, 0.015, 0.02];
66
+ const sr = sharpeRatio(returns);
67
+ expect(sr).toBeGreaterThan(0);
68
+ });
69
+
70
+ it("constant returns → Infinity (zero stddev)", () => {
71
+ const returns = [0.01, 0.01, 0.01, 0.01];
72
+ const sr = sharpeRatio(returns);
73
+ expect(sr).toBe(Infinity);
74
+ });
75
+
76
+ it("negative excess returns → Sharpe < 0", () => {
77
+ const returns = [-0.01, -0.02, -0.015, -0.01];
78
+ const sr = sharpeRatio(returns, 0);
79
+ expect(sr).toBeLessThan(0);
80
+ });
81
+
82
+ it("annualizes by default (sqrt(252) factor)", () => {
83
+ const returns = [0.01, 0.02, 0.015, 0.01, 0.025];
84
+ const annualized = sharpeRatio(returns, 0, true);
85
+ const nonAnnualized = sharpeRatio(returns, 0, false);
86
+ expectClose(annualized, nonAnnualized * Math.sqrt(252), 0.001);
87
+ });
88
+
89
+ it("respects custom risk-free rate", () => {
90
+ const returns = [0.05, 0.06, 0.04, 0.05, 0.07];
91
+ const sr1 = sharpeRatio(returns, 0, false);
92
+ const sr2 = sharpeRatio(returns, 0.03, false);
93
+ expect(sr1).toBeGreaterThan(sr2);
94
+ });
95
+ });
96
+
97
+ describe("sortinoRatio", () => {
98
+ it("no downside deviation → Infinity", () => {
99
+ const returns = [0.01, 0.02, 0.03, 0.04];
100
+ const sr = sortinoRatio(returns, 0);
101
+ expect(sr).toBe(Infinity);
102
+ });
103
+
104
+ it("all negative → negative Sortino", () => {
105
+ const returns = [-0.01, -0.02, -0.03, -0.04];
106
+ const sr = sortinoRatio(returns, 0);
107
+ expect(sr).toBeLessThan(0);
108
+ });
109
+
110
+ it("mixed returns produce finite ratio", () => {
111
+ const returns = [0.02, -0.01, 0.03, -0.02, 0.01];
112
+ const sr = sortinoRatio(returns, 0);
113
+ expect(Number.isFinite(sr)).toBe(true);
114
+ expect(sr).toBeGreaterThan(0);
115
+ });
116
+ });
117
+
118
+ describe("maxDrawdown", () => {
119
+ it("computes drawdown from peak to trough", () => {
120
+ const equity = [100, 110, 90, 95];
121
+ const result = maxDrawdown(equity);
122
+ // Max drawdown: from 110 to 90 = -18.18%
123
+ expectClose(result.maxDD, -18.1818, 0.01);
124
+ expect(result.peak).toBe(110);
125
+ expect(result.trough).toBe(90);
126
+ expect(result.peakIndex).toBe(1);
127
+ expect(result.troughIndex).toBe(2);
128
+ });
129
+
130
+ it("monotonic up → maxDD = 0", () => {
131
+ const equity = [100, 110, 120, 130];
132
+ const result = maxDrawdown(equity);
133
+ expectClose(result.maxDD, 0);
134
+ });
135
+
136
+ it("monotonic down → full drawdown", () => {
137
+ const equity = [100, 80, 60, 40];
138
+ const result = maxDrawdown(equity);
139
+ // From 100 to 40 = -60%
140
+ expectClose(result.maxDD, -60, 0.01);
141
+ expect(result.peakIndex).toBe(0);
142
+ expect(result.troughIndex).toBe(3);
143
+ });
144
+
145
+ it("finds the worst drawdown among multiple dips", () => {
146
+ const equity = [100, 95, 110, 80, 120, 100];
147
+ const result = maxDrawdown(equity);
148
+ // Worst: 110 → 80 = -27.27%
149
+ expectClose(result.maxDD, -27.2727, 0.01);
150
+ expect(result.peak).toBe(110);
151
+ expect(result.trough).toBe(80);
152
+ });
153
+
154
+ it("handles single value", () => {
155
+ const result = maxDrawdown([100]);
156
+ expectClose(result.maxDD, 0);
157
+ });
158
+ });
159
+
160
+ describe("calmarRatio", () => {
161
+ it("computes annualized return / abs(maxDrawdown)", () => {
162
+ // 20% return, -10% drawdown → Calmar = 2.0
163
+ expectClose(calmarRatio(0.2, -0.1), 2.0);
164
+ });
165
+
166
+ it("zero drawdown → Infinity", () => {
167
+ expect(calmarRatio(0.15, 0)).toBe(Infinity);
168
+ });
169
+
170
+ it("negative return with drawdown → negative ratio", () => {
171
+ expect(calmarRatio(-0.1, -0.2)).toBeLessThan(0);
172
+ });
173
+ });
174
+
175
+ describe("profitFactor", () => {
176
+ it("no losses → Infinity", () => {
177
+ expect(profitFactor([100, 200, 50], [])).toBe(Infinity);
178
+ });
179
+
180
+ it("no wins → 0", () => {
181
+ expect(profitFactor([], [100, 200])).toBe(0);
182
+ });
183
+
184
+ it("equal wins and losses → 1", () => {
185
+ expectClose(profitFactor([100], [100]), 1);
186
+ });
187
+
188
+ it("computes sum(wins) / sum(abs(losses))", () => {
189
+ // Wins: 100+200=300, Losses: -50+-100=-150 → abs=150 → PF=2
190
+ expectClose(profitFactor([100, 200], [-50, -100]), 2);
191
+ });
192
+ });
193
+
194
+ describe("winRate", () => {
195
+ it("3 wins, 2 losses → 60%", () => {
196
+ const trades = [{ pnl: 10 }, { pnl: -5 }, { pnl: 20 }, { pnl: 15 }, { pnl: -8 }];
197
+ expectClose(winRate(trades), 60);
198
+ });
199
+
200
+ it("all wins → 100%", () => {
201
+ expectClose(winRate([{ pnl: 10 }, { pnl: 5 }]), 100);
202
+ });
203
+
204
+ it("all losses → 0%", () => {
205
+ expectClose(winRate([{ pnl: -10 }, { pnl: -5 }]), 0);
206
+ });
207
+
208
+ it("empty trades → NaN", () => {
209
+ expect(winRate([])).toBeNaN();
210
+ });
211
+
212
+ it("zero pnl counts as non-win", () => {
213
+ expectClose(winRate([{ pnl: 0 }, { pnl: 10 }]), 50);
214
+ });
215
+ });
package/src/stats.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pure statistical functions for strategy performance analysis.
3
+ * Zero external dependencies.
4
+ *
5
+ * mean, stdDev, and sharpeRatio are canonical in @openfinclaw/fin-shared-types
6
+ * and re-exported here for backward compatibility within fin-strategy-engine.
7
+ */
8
+
9
+ export { mean, stdDev, sharpeRatio } from "../../fin-shared-types/src/stats.js";
10
+
11
+ import { mean } from "../../fin-shared-types/src/stats.js";
12
+
13
+ /**
14
+ * Sortino Ratio: like Sharpe but penalizes only downside deviation.
15
+ * Annualized by sqrt(252).
16
+ */
17
+ export function sortinoRatio(returns: number[], riskFreeRate = 0): number {
18
+ const excess = returns.map((r) => r - riskFreeRate);
19
+ const m = mean(excess);
20
+
21
+ // Downside deviation: stddev of negative excess returns only
22
+ const downsideSquares = excess.filter((r) => r < 0).map((r) => r * r);
23
+ if (downsideSquares.length === 0) {
24
+ return m >= 0 ? Infinity : -Infinity;
25
+ }
26
+
27
+ let sumSq = 0;
28
+ for (const sq of downsideSquares) sumSq += sq;
29
+ const downsideDev = Math.sqrt(sumSq / returns.length);
30
+
31
+ if (downsideDev === 0) {
32
+ return m >= 0 ? Infinity : -Infinity;
33
+ }
34
+
35
+ return (m / downsideDev) * Math.sqrt(252);
36
+ }
37
+
38
+ /**
39
+ * Maximum drawdown from an equity curve.
40
+ * Returns the worst peak-to-trough decline as a percentage.
41
+ */
42
+ export function maxDrawdown(equityCurve: number[]): {
43
+ maxDD: number;
44
+ peak: number;
45
+ trough: number;
46
+ peakIndex: number;
47
+ troughIndex: number;
48
+ } {
49
+ if (equityCurve.length <= 1) {
50
+ return {
51
+ maxDD: 0,
52
+ peak: equityCurve[0] ?? 0,
53
+ trough: equityCurve[0] ?? 0,
54
+ peakIndex: 0,
55
+ troughIndex: 0,
56
+ };
57
+ }
58
+
59
+ let peak = equityCurve[0];
60
+ let peakIdx = 0;
61
+ let worstDD = 0;
62
+ let worstPeak = peak;
63
+ let worstTrough = peak;
64
+ let worstPeakIdx = 0;
65
+ let worstTroughIdx = 0;
66
+
67
+ for (let i = 1; i < equityCurve.length; i++) {
68
+ const val = equityCurve[i];
69
+ if (val > peak) {
70
+ peak = val;
71
+ peakIdx = i;
72
+ }
73
+ const dd = ((val - peak) / peak) * 100;
74
+ if (dd < worstDD) {
75
+ worstDD = dd;
76
+ worstPeak = peak;
77
+ worstTrough = val;
78
+ worstPeakIdx = peakIdx;
79
+ worstTroughIdx = i;
80
+ }
81
+ }
82
+
83
+ return {
84
+ maxDD: worstDD,
85
+ peak: worstPeak,
86
+ trough: worstTrough,
87
+ peakIndex: worstPeakIdx,
88
+ troughIndex: worstTroughIdx,
89
+ };
90
+ }
91
+
92
+ /** Calmar Ratio: annualized return / |maxDrawdown|. */
93
+ export function calmarRatio(annualizedReturn: number, maxDD: number): number {
94
+ if (maxDD === 0) return annualizedReturn >= 0 ? Infinity : -Infinity;
95
+ return annualizedReturn / Math.abs(maxDD);
96
+ }
97
+
98
+ /**
99
+ * Profit Factor: sum(wins) / sum(|losses|).
100
+ * Wins are positive values, losses are negative or passed as-is (abs taken).
101
+ */
102
+ export function profitFactor(wins: number[], losses: number[]): number {
103
+ const totalWins = wins.reduce((s, v) => s + v, 0);
104
+ const totalLosses = losses.reduce((s, v) => s + Math.abs(v), 0);
105
+ if (totalLosses === 0) return totalWins > 0 ? Infinity : 0;
106
+ if (totalWins === 0) return 0;
107
+ return totalWins / totalLosses;
108
+ }
109
+
110
+ /** Win rate as a percentage (0-100). Wins = trades with pnl > 0. */
111
+ export function winRate(trades: Array<{ pnl: number }>): number {
112
+ if (trades.length === 0) return NaN;
113
+ const wins = trades.filter((t) => t.pnl > 0).length;
114
+ return (wins / trades.length) * 100;
115
+ }