tradelab 0.1.2 → 0.3.0

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/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "tradelab",
3
- "version": "0.1.2",
4
- "description": "Reusable trading and backtesting engine for candle-based strategies",
3
+ "version": "0.3.0",
4
+ "description": "Backtesting toolkit for Node.js with strategy simulation, historical data loading, and report generation",
5
5
  "type": "module",
6
- "main": "./src/index.js",
6
+ "main": "./dist/cjs/index.cjs",
7
+ "module": "./src/index.js",
8
+ "types": "./types/index.d.ts",
9
+ "bin": {
10
+ "tradelab": "./bin/tradelab.js"
11
+ },
7
12
  "license": "MIT",
8
13
  "repository": {
9
14
  "type": "git",
@@ -18,18 +23,32 @@
18
23
  },
19
24
  "sideEffects": false,
20
25
  "exports": {
21
- ".": "./src/index.js",
22
- "./data": "./src/data/index.js",
26
+ ".": {
27
+ "types": "./types/index.d.ts",
28
+ "import": "./src/index.js",
29
+ "require": "./dist/cjs/index.cjs"
30
+ },
31
+ "./data": {
32
+ "types": "./types/data.d.ts",
33
+ "import": "./src/data/index.js",
34
+ "require": "./dist/cjs/data.cjs"
35
+ },
23
36
  "./package.json": "./package.json"
24
37
  },
25
38
  "files": [
39
+ "bin",
40
+ "dist",
26
41
  "src",
42
+ "types",
27
43
  "templates",
28
44
  "examples/*.js",
29
45
  "README.md",
30
46
  "LICENSE"
31
47
  ],
32
48
  "scripts": {
49
+ "build": "node scripts/build-cjs.mjs",
50
+ "prepare": "npm run build",
51
+ "prepack": "npm run build",
33
52
  "test": "node --test"
34
53
  },
35
54
  "keywords": [
@@ -37,9 +56,16 @@
37
56
  "backtesting",
38
57
  "algorithmic-trading",
39
58
  "ohlcv",
40
- "quant"
59
+ "quant",
60
+ "quantitative-finance",
61
+ "yahoo-finance",
62
+ "trading-strategy",
63
+ "market-data"
41
64
  ],
42
65
  "publishConfig": {
43
66
  "access": "public"
67
+ },
68
+ "devDependencies": {
69
+ "esbuild": "^0.27.3"
44
70
  }
45
71
  }
package/src/data/yahoo.js CHANGED
@@ -174,7 +174,16 @@ async function fetchYahooChart(symbol, { period1, period2, interval, includePreP
174
174
  return candles;
175
175
  }
176
176
 
177
- async function fetchYahooChartWithRetry(symbol, params, maxRetries = 5) {
177
+ function formatYahooFailureMessage(symbol, interval, period, error, attempts) {
178
+ const detail = String(error?.message || error || "unknown error");
179
+ return [
180
+ `Unable to reach Yahoo Finance for ${symbol} ${interval} ${period} after ${attempts} attempts.`,
181
+ `Last error: ${detail}`,
182
+ "Try again later, or fall back to a local CSV/cache workflow with getHistoricalCandles({ source: \"csv\", ... }) or loadCandlesFromCache(...).",
183
+ ].join(" ");
184
+ }
185
+
186
+ async function fetchYahooChartWithRetry(symbol, params, period, maxRetries = 3) {
178
187
  let lastError = null;
179
188
 
180
189
  for (let attempt = 0; attempt < maxRetries; attempt += 1) {
@@ -188,14 +197,20 @@ async function fetchYahooChartWithRetry(symbol, params, maxRetries = 5) {
188
197
 
189
198
  if (!isRetryable || attempt === maxRetries - 1) break;
190
199
 
191
- const delay = isRateLimited
192
- ? Math.min(30_000, 2_000 * 2 ** attempt)
193
- : Math.min(10_000, 750 * (attempt + 1));
200
+ const delay = Math.min(12_000, 500 * 2 ** attempt);
194
201
  await sleep(delay);
195
202
  }
196
203
  }
197
204
 
198
- throw lastError ?? new Error(`Failed to fetch Yahoo data for ${symbol}`);
205
+ throw new Error(
206
+ formatYahooFailureMessage(
207
+ symbol,
208
+ params.interval,
209
+ period,
210
+ lastError,
211
+ maxRetries
212
+ )
213
+ );
199
214
  }
200
215
 
201
216
  export async function fetchHistorical(symbol, interval = "5m", period = "60d", options = {}) {
@@ -207,12 +222,16 @@ export async function fetchHistorical(symbol, interval = "5m", period = "60d", o
207
222
  if (spanMs <= maxSpanMs) {
208
223
  const endSec = nowSec();
209
224
  const startSec = Math.max(0, endSec - msToSec(spanMs));
210
- const candles = await fetchYahooChartWithRetry(symbol, {
211
- period1: startSec,
212
- period2: endSec,
213
- interval: normalizedInterval,
214
- includePrePost,
215
- });
225
+ const candles = await fetchYahooChartWithRetry(
226
+ symbol,
227
+ {
228
+ period1: startSec,
229
+ period2: endSec,
230
+ interval: normalizedInterval,
231
+ includePrePost,
232
+ },
233
+ period
234
+ );
216
235
  return sanitizeBars(candles);
217
236
  }
218
237
 
@@ -223,12 +242,16 @@ export async function fetchHistorical(symbol, interval = "5m", period = "60d", o
223
242
  while (remainingMs > 0) {
224
243
  const takeMs = Math.min(remainingMs, maxSpanMs);
225
244
  const chunkStartMs = chunkEndMs - takeMs;
226
- const candles = await fetchYahooChartWithRetry(symbol, {
227
- period1: msToSec(chunkStartMs),
228
- period2: msToSec(chunkEndMs),
229
- interval: normalizedInterval,
230
- includePrePost,
231
- });
245
+ const candles = await fetchYahooChartWithRetry(
246
+ symbol,
247
+ {
248
+ period1: msToSec(chunkStartMs),
249
+ period2: msToSec(chunkEndMs),
250
+ interval: normalizedInterval,
251
+ includePrePost,
252
+ },
253
+ period
254
+ );
232
255
  chunks.push(...candles);
233
256
  chunkEndMs = chunkStartMs - 1000;
234
257
  remainingMs -= takeMs;
@@ -19,6 +19,29 @@ function asNumber(value) {
19
19
  return Number.isFinite(numeric) ? numeric : null;
20
20
  }
21
21
 
22
+ function equityPoint(time, equity) {
23
+ return { time, timestamp: time, equity };
24
+ }
25
+
26
+ function isArrayIndexKey(property) {
27
+ if (typeof property !== "string") return false;
28
+ const numeric = Number(property);
29
+ return Number.isInteger(numeric) && numeric >= 0;
30
+ }
31
+
32
+ function strictHistoryView(candles, currentIndex) {
33
+ return new Proxy(candles, {
34
+ get(target, property, receiver) {
35
+ if (isArrayIndexKey(property) && Number(property) >= target.length) {
36
+ throw new Error(
37
+ `strict mode: signal() tried to access candles[${property}] beyond current index ${currentIndex}`
38
+ );
39
+ }
40
+ return Reflect.get(target, property, receiver);
41
+ },
42
+ });
43
+ }
44
+
22
45
  function mergeOptions(options) {
23
46
  const normalizedRiskPct = Number.isFinite(options.riskFraction)
24
47
  ? options.riskFraction * 100
@@ -35,6 +58,7 @@ function mergeOptions(options) {
35
58
  warmupBars: options.warmupBars ?? 200,
36
59
  slippageBps: options.slippageBps ?? 1,
37
60
  feeBps: options.feeBps ?? 0,
61
+ costs: options.costs ?? null,
38
62
  scaleOutAtR: options.scaleOutAtR ?? 1,
39
63
  scaleOutFrac: options.scaleOutFrac ?? 0.5,
40
64
  finalTP_R: options.finalTP_R ?? 3,
@@ -88,6 +112,7 @@ function mergeOptions(options) {
88
112
  maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
89
113
  collectEqSeries: options.collectEqSeries ?? true,
90
114
  collectReplay: options.collectReplay ?? true,
115
+ strict: options.strict ?? false,
91
116
  };
92
117
  }
93
118
 
@@ -135,6 +160,16 @@ function normalizeSignal(signal, bar, fallbackR) {
135
160
  };
136
161
  }
137
162
 
163
+ /**
164
+ * Run a candle-based backtest.
165
+ *
166
+ * Returns raw realized trade legs in `trades`, completed positions in `positions`,
167
+ * aggregate `metrics`, realized equity points in `eqSeries`, and chart-ready
168
+ * replay data in `replay`.
169
+ *
170
+ * When `strict: true` is enabled, the `candles` array passed to `signal()` throws
171
+ * if the strategy tries to access bars beyond the current index.
172
+ */
138
173
  export function backtest(rawOptions) {
139
174
  const options = mergeOptions(rawOptions || {});
140
175
  const {
@@ -145,6 +180,7 @@ export function backtest(rawOptions) {
145
180
  signal,
146
181
  slippageBps,
147
182
  feeBps,
183
+ costs,
148
184
  scaleOutAtR,
149
185
  scaleOutFrac,
150
186
  finalTP_R,
@@ -168,6 +204,7 @@ export function backtest(rawOptions) {
168
204
  collectEqSeries,
169
205
  collectReplay,
170
206
  warmupBars,
207
+ strict,
171
208
  } = options;
172
209
 
173
210
  if (!Array.isArray(candles) || candles.length === 0) {
@@ -196,7 +233,7 @@ export function backtest(rawOptions) {
196
233
  const needAtr = atrTrailMult > 0 || volScale.enabled;
197
234
  const atrValues = needAtr ? atr(candles, atrSourcePeriod) : null;
198
235
 
199
- const eqSeries = wantEqSeries ? [{ time: candles[0].time, equity: currentEquity }] : [];
236
+ const eqSeries = wantEqSeries ? [equityPoint(candles[0].time, currentEquity)] : [];
200
237
  const replayFrames = wantReplay ? [] : [];
201
238
  const replayEvents = wantReplay ? [] : [];
202
239
  let tradeIdCounter = 0;
@@ -209,7 +246,7 @@ export function backtest(rawOptions) {
209
246
 
210
247
  function recordFrame(bar) {
211
248
  if (wantEqSeries) {
212
- eqSeries.push({ time: bar.time, equity: currentEquity });
249
+ eqSeries.push(equityPoint(bar.time, currentEquity));
213
250
  }
214
251
 
215
252
  if (wantReplay) {
@@ -223,20 +260,19 @@ export function backtest(rawOptions) {
223
260
  }
224
261
  }
225
262
 
226
- function closeLeg({ openPos, qty, exitPx, exitFeePerUnit, time, reason }) {
263
+ function closeLeg({ openPos, qty, exitPx, exitFeeTotal = 0, time, reason }) {
227
264
  const direction = openPos.side === "long" ? 1 : -1;
228
265
  const entryFill = openPos.entryFill;
229
266
  const grossPnl = (exitPx - entryFill) * direction * qty;
230
267
  const entryFeePortion =
231
268
  (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
232
- const exitFeeTotal = exitFeePerUnit * qty;
233
269
  const pnl = grossPnl - entryFeePortion - exitFeeTotal;
234
270
 
235
271
  currentEquity += pnl;
236
272
  dayPnl += pnl;
237
273
 
238
274
  if (wantEqSeries) {
239
- eqSeries.push({ time, equity: currentEquity });
275
+ eqSeries.push(equityPoint(time, currentEquity));
240
276
  }
241
277
 
242
278
  const remaining = openPos.size - qty;
@@ -313,17 +349,19 @@ export function backtest(rawOptions) {
313
349
  if (!open) return;
314
350
 
315
351
  const exitSide = open.side === "long" ? "short" : "long";
316
- const { price: filled, fee: exitFee } = applyFill(bar.close, exitSide, {
352
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(bar.close, exitSide, {
317
353
  slippageBps,
318
354
  feeBps,
319
355
  kind: "market",
356
+ qty: open.size,
357
+ costs,
320
358
  });
321
359
 
322
360
  closeLeg({
323
361
  openPos: open,
324
362
  qty: open.size,
325
363
  exitPx: filled,
326
- exitFeePerUnit: exitFee,
364
+ exitFeeTotal,
327
365
  time: bar.time,
328
366
  reason,
329
367
  });
@@ -386,13 +424,15 @@ export function backtest(rawOptions) {
386
424
  const size = roundStep(rawSize, qtyStep);
387
425
  if (size < minQty) return false;
388
426
 
389
- const { price: entryFill, fee: entryFee } = applyFill(
427
+ const { price: entryFill, feeTotal: entryFeeTotal } = applyFill(
390
428
  entryPrice,
391
429
  pending.side,
392
430
  {
393
431
  slippageBps,
394
432
  feeBps,
395
433
  kind: fillKind,
434
+ qty: size,
435
+ costs,
396
436
  }
397
437
  );
398
438
 
@@ -407,7 +447,7 @@ export function backtest(rawOptions) {
407
447
  size,
408
448
  openTime: bar.time,
409
449
  entryFill,
410
- entryFeeTotal: entryFee * size,
450
+ entryFeeTotal,
411
451
  initSize: size,
412
452
  baseSize: size,
413
453
  _mfeR: 0,
@@ -570,16 +610,16 @@ export function backtest(rawOptions) {
570
610
  const cutQty = roundStep(open.size * volScale.cutFrac, qtyStep);
571
611
  if (cutQty >= minQty && cutQty < open.size) {
572
612
  const exitSide = open.side === "long" ? "short" : "long";
573
- const { price: filled, fee: exitFee } = applyFill(
613
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(
574
614
  bar.close,
575
615
  exitSide,
576
- { slippageBps, feeBps, kind: "market" }
616
+ { slippageBps, feeBps, kind: "market", qty: cutQty, costs }
577
617
  );
578
618
  closeLeg({
579
619
  openPos: open,
580
620
  qty: cutQty,
581
621
  exitPx: filled,
582
- exitFeePerUnit: exitFee,
622
+ exitFeeTotal,
583
623
  time: bar.time,
584
624
  reason: "SCALE",
585
625
  });
@@ -614,13 +654,13 @@ export function backtest(rawOptions) {
614
654
  const baseSize = open.baseSize || open.initSize;
615
655
  const addQty = roundStep(baseSize * pyramiding.addFrac, qtyStep);
616
656
  if (addQty >= minQty) {
617
- const { price: addFill, fee: addFee } = applyFill(
657
+ const { price: addFill, feeTotal: addFeeTotal } = applyFill(
618
658
  triggerPrice,
619
659
  open.side,
620
- { slippageBps, feeBps, kind: "limit" }
660
+ { slippageBps, feeBps, kind: "limit", qty: addQty, costs }
621
661
  );
622
662
  const newSize = open.size + addQty;
623
- open.entryFeeTotal += addFee * addQty;
663
+ open.entryFeeTotal += addFeeTotal;
624
664
  open.entryFill =
625
665
  (open.entryFill * open.size + addFill * addQty) / newSize;
626
666
  open.size = newSize;
@@ -648,18 +688,20 @@ export function backtest(rawOptions) {
648
688
 
649
689
  if (touched) {
650
690
  const exitSide = open.side === "long" ? "short" : "long";
651
- const { price: filled, fee: exitFee } = applyFill(triggerPrice, exitSide, {
652
- slippageBps,
653
- feeBps,
654
- kind: "limit",
655
- });
656
691
  const qty = roundStep(open.size * scaleOutFrac, qtyStep);
657
692
  if (qty >= minQty && qty < open.size) {
693
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(triggerPrice, exitSide, {
694
+ slippageBps,
695
+ feeBps,
696
+ kind: "limit",
697
+ qty,
698
+ costs,
699
+ });
658
700
  closeLeg({
659
701
  openPos: open,
660
702
  qty,
661
703
  exitPx: filled,
662
- exitFeePerUnit: exitFee,
704
+ exitFeeTotal,
663
705
  time: bar.time,
664
706
  reason: "SCALE",
665
707
  });
@@ -686,17 +728,19 @@ export function backtest(rawOptions) {
686
728
 
687
729
  if (hit) {
688
730
  const exitKind = hit === "TP" ? "limit" : "stop";
689
- const { price: filled, fee: exitFee } = applyFill(px, exitSide, {
731
+ const { price: filled, feeTotal: exitFeeTotal } = applyFill(px, exitSide, {
690
732
  slippageBps,
691
733
  feeBps,
692
734
  kind: exitKind,
735
+ qty: open.size,
736
+ costs,
693
737
  });
694
738
  const localCooldown = open._cooldownBars || 0;
695
739
  closeLeg({
696
740
  openPos: open,
697
741
  qty: open.size,
698
742
  exitPx: filled,
699
- exitFeePerUnit: exitFee,
743
+ exitFeeTotal,
700
744
  time: bar.time,
701
745
  reason: hit,
702
746
  });
@@ -783,8 +827,15 @@ export function backtest(rawOptions) {
783
827
  }
784
828
 
785
829
  if (!pending) {
830
+ if (strict && history.length !== index + 1) {
831
+ throw new Error(
832
+ `strict mode: signal() received ${history.length} candles at index ${index}`
833
+ );
834
+ }
835
+
836
+ const signalCandles = strict ? strictHistoryView(history, index) : history;
786
837
  const rawSignal = signal({
787
- candles: history,
838
+ candles: signalCandles,
788
839
  index,
789
840
  bar,
790
841
  equity: currentEquity,
@@ -1,18 +1,55 @@
1
1
  import { minutesET } from "../utils/time.js";
2
2
 
3
+ function resolveSlippageBps(kind, slippageBps, slippageByKind) {
4
+ if (Number.isFinite(slippageByKind?.[kind])) {
5
+ return slippageByKind[kind];
6
+ }
7
+
8
+ let effectiveSlippageBps = slippageBps;
9
+ if (kind === "limit") effectiveSlippageBps *= 0.25;
10
+ if (kind === "stop") effectiveSlippageBps *= 1.25;
11
+ return effectiveSlippageBps;
12
+ }
13
+
3
14
  export function applyFill(
4
15
  price,
5
16
  side,
6
- { slippageBps = 0, feeBps = 0, kind = "market" } = {}
17
+ { slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}
7
18
  ) {
8
- let effectiveSlippageBps = slippageBps;
9
- if (kind === "limit") effectiveSlippageBps *= 0.25;
10
- if (kind === "stop") effectiveSlippageBps *= 1.25;
19
+ const model = costs || {};
20
+ const modelSlippageBps = Number.isFinite(model.slippageBps)
21
+ ? model.slippageBps
22
+ : slippageBps;
23
+ const modelFeeBps = Number.isFinite(model.commissionBps)
24
+ ? model.commissionBps
25
+ : feeBps;
26
+ const effectiveSlippageBps = resolveSlippageBps(
27
+ kind,
28
+ modelSlippageBps,
29
+ model.slippageByKind
30
+ );
31
+ const halfSpreadBps = Number.isFinite(model.spreadBps)
32
+ ? model.spreadBps / 2
33
+ : 0;
11
34
 
12
- const slippage = (effectiveSlippageBps / 10000) * price;
35
+ const slippage = ((effectiveSlippageBps + halfSpreadBps) / 10000) * price;
13
36
  const filledPrice = side === "long" ? price + slippage : price - slippage;
14
- const feePerUnit = (feeBps / 10000) * Math.abs(filledPrice);
15
- return { price: filledPrice, fee: feePerUnit };
37
+ const variableFeePerUnit =
38
+ ((modelFeeBps || 0) / 10000) * Math.abs(filledPrice) +
39
+ (Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
40
+ const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
41
+ const fixedFeeTotal = Number.isFinite(model.commissionPerOrder)
42
+ ? model.commissionPerOrder
43
+ : 0;
44
+ const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
45
+ const feeTotal = Math.max(
46
+ Number.isFinite(model.minCommission) ? model.minCommission : 0,
47
+ grossFeeTotal
48
+ );
49
+ const feePerUnit =
50
+ qty > 0 ? feeTotal / qty : variableFeePerUnit;
51
+
52
+ return { price: filledPrice, fee: feePerUnit, feeTotal };
16
53
  }
17
54
 
18
55
  export function clampStop(marketPrice, proposedStop, side, oco) {
@@ -0,0 +1,160 @@
1
+ import { buildMetrics } from "../metrics/buildMetrics.js";
2
+ import { backtest } from "./backtest.js";
3
+ import { estimateBarMs } from "./execution.js";
4
+
5
+ function asWeight(value) {
6
+ return Number.isFinite(value) && value > 0 ? value : 0;
7
+ }
8
+
9
+ function combineEquitySeries(systemRuns, totalEquity) {
10
+ const timeline = new Set();
11
+ for (const run of systemRuns) {
12
+ for (const point of run.result.eqSeries || []) {
13
+ timeline.add(point.time);
14
+ }
15
+ }
16
+
17
+ const times = [...timeline].sort((left, right) => left - right);
18
+ if (!times.length) {
19
+ return [{ time: 0, timestamp: 0, equity: totalEquity }];
20
+ }
21
+
22
+ const states = systemRuns.map((run) => ({
23
+ points: run.result.eqSeries || [],
24
+ index: 0,
25
+ lastEquity: run.allocationEquity,
26
+ }));
27
+
28
+ return times.map((time) => {
29
+ let equity = 0;
30
+ states.forEach((state) => {
31
+ while (
32
+ state.index < state.points.length &&
33
+ state.points[state.index].time <= time
34
+ ) {
35
+ state.lastEquity = state.points[state.index].equity;
36
+ state.index += 1;
37
+ }
38
+ equity += state.lastEquity;
39
+ });
40
+
41
+ return { time, timestamp: time, equity };
42
+ });
43
+ }
44
+
45
+ function combineReplay(systemRuns, eqSeries, collectReplay) {
46
+ if (!collectReplay) {
47
+ return { frames: [], events: [] };
48
+ }
49
+
50
+ const events = systemRuns
51
+ .flatMap((run) =>
52
+ (run.result.replay?.events || []).map((event) => ({
53
+ ...event,
54
+ symbol: event.symbol || run.symbol,
55
+ }))
56
+ )
57
+ .sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
58
+
59
+ const frames = eqSeries.map((point) => ({
60
+ t: new Date(point.time).toISOString(),
61
+ price: 0,
62
+ equity: point.equity,
63
+ posSide: null,
64
+ posSize: 0,
65
+ }));
66
+
67
+ return { frames, events };
68
+ }
69
+
70
+ /**
71
+ * Run multiple symbol/system backtests and aggregate them into a portfolio view.
72
+ *
73
+ * Capital is allocated up front per system using weights. Each system then runs
74
+ * through the normal single-symbol backtest engine and the portfolio result
75
+ * aggregates trades, positions, equity, replay events, and metrics.
76
+ */
77
+ export function backtestPortfolio({
78
+ systems = [],
79
+ equity = 10_000,
80
+ allocation = "equal",
81
+ collectEqSeries = true,
82
+ collectReplay = false,
83
+ } = {}) {
84
+ if (!Array.isArray(systems) || systems.length === 0) {
85
+ throw new Error("backtestPortfolio() requires a non-empty systems array");
86
+ }
87
+
88
+ const weights =
89
+ allocation === "equal"
90
+ ? systems.map(() => 1)
91
+ : systems.map((system) => asWeight(system.weight || 0));
92
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
93
+
94
+ if (!(totalWeight > 0)) {
95
+ throw new Error("backtestPortfolio() requires positive allocation weights");
96
+ }
97
+
98
+ const systemRuns = systems.map((system, index) => {
99
+ const allocationEquity = equity * (weights[index] / totalWeight);
100
+ const result = backtest({
101
+ ...system,
102
+ equity: allocationEquity,
103
+ collectEqSeries,
104
+ collectReplay,
105
+ });
106
+
107
+ return {
108
+ symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
109
+ weight: weights[index],
110
+ allocationEquity,
111
+ result,
112
+ };
113
+ });
114
+
115
+ const trades = systemRuns
116
+ .flatMap((run) =>
117
+ run.result.trades.map((trade) => ({
118
+ ...trade,
119
+ symbol: trade.symbol || run.symbol,
120
+ }))
121
+ )
122
+ .sort((left, right) => left.exit.time - right.exit.time);
123
+ const positions = systemRuns
124
+ .flatMap((run) =>
125
+ run.result.positions.map((trade) => ({
126
+ ...trade,
127
+ symbol: trade.symbol || run.symbol,
128
+ }))
129
+ )
130
+ .sort((left, right) => left.exit.time - right.exit.time);
131
+ const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
132
+ const replay = combineReplay(systemRuns, eqSeries, collectReplay);
133
+ const allCandles = systems.flatMap((system) => system.candles || []);
134
+ const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
135
+ const metrics = buildMetrics({
136
+ closed: trades,
137
+ equityStart: equity,
138
+ equityFinal: eqSeries.length ? eqSeries[eqSeries.length - 1].equity : equity,
139
+ candles: orderedCandles,
140
+ estBarMs: estimateBarMs(orderedCandles),
141
+ eqSeries,
142
+ });
143
+
144
+ return {
145
+ symbol: "PORTFOLIO",
146
+ interval: undefined,
147
+ range: undefined,
148
+ trades,
149
+ positions,
150
+ metrics,
151
+ eqSeries,
152
+ replay,
153
+ systems: systemRuns.map((run) => ({
154
+ symbol: run.symbol,
155
+ weight: run.weight / totalWeight,
156
+ equity: run.allocationEquity,
157
+ result: run.result,
158
+ })),
159
+ };
160
+ }