tradelab 0.4.0 → 1.0.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.
Files changed (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +103 -100
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +98 -4
  54. package/types/live.d.ts +382 -0
@@ -17,20 +17,10 @@ export function applyFill(
17
17
  { slippageBps = 0, feeBps = 0, kind = "market", qty = 0, costs = {} } = {}
18
18
  ) {
19
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;
20
+ const modelSlippageBps = Number.isFinite(model.slippageBps) ? model.slippageBps : slippageBps;
21
+ const modelFeeBps = Number.isFinite(model.commissionBps) ? model.commissionBps : feeBps;
22
+ const effectiveSlippageBps = resolveSlippageBps(kind, modelSlippageBps, model.slippageByKind);
23
+ const halfSpreadBps = Number.isFinite(model.spreadBps) ? model.spreadBps / 2 : 0;
34
24
 
35
25
  const slippage = ((effectiveSlippageBps + halfSpreadBps) / 10000) * price;
36
26
  const filledPrice = side === "long" ? price + slippage : price - slippage;
@@ -38,16 +28,13 @@ export function applyFill(
38
28
  ((modelFeeBps || 0) / 10000) * Math.abs(filledPrice) +
39
29
  (Number.isFinite(model.commissionPerUnit) ? model.commissionPerUnit : 0);
40
30
  const variableFeeTotal = variableFeePerUnit * Math.max(0, qty);
41
- const fixedFeeTotal = Number.isFinite(model.commissionPerOrder)
42
- ? model.commissionPerOrder
43
- : 0;
31
+ const fixedFeeTotal = Number.isFinite(model.commissionPerOrder) ? model.commissionPerOrder : 0;
44
32
  const grossFeeTotal = variableFeeTotal + fixedFeeTotal;
45
33
  const feeTotal = Math.max(
46
34
  Number.isFinite(model.minCommission) ? model.minCommission : 0,
47
35
  grossFeeTotal
48
36
  );
49
- const feePerUnit =
50
- qty > 0 ? feeTotal / qty : variableFeePerUnit;
37
+ const feePerUnit = qty > 0 ? feeTotal / qty : variableFeePerUnit;
51
38
 
52
39
  return { price: filledPrice, fee: feePerUnit, feeTotal };
53
40
  }
@@ -63,21 +50,12 @@ export function clampStop(marketPrice, proposedStop, side, oco) {
63
50
  export function touchedLimit(side, limitPrice, bar, mode = "intrabar") {
64
51
  if (!bar || limitPrice === undefined || limitPrice === null) return false;
65
52
  if (mode === "close") {
66
- return side === "long"
67
- ? bar.close <= limitPrice
68
- : bar.close >= limitPrice;
53
+ return side === "long" ? bar.close <= limitPrice : bar.close >= limitPrice;
69
54
  }
70
55
  return side === "long" ? bar.low <= limitPrice : bar.high >= limitPrice;
71
56
  }
72
57
 
73
- export function ocoExitCheck({
74
- side,
75
- stop,
76
- tp,
77
- bar,
78
- mode = "intrabar",
79
- tieBreak = "pessimistic",
80
- }) {
58
+ export function ocoExitCheck({ side, stop, tp, bar, mode = "intrabar", tieBreak = "pessimistic" }) {
81
59
  if (mode === "close") {
82
60
  const close = bar.close;
83
61
  if (side === "long") {
@@ -94,9 +72,7 @@ export function ocoExitCheck({
94
72
  const hitTarget = side === "long" ? bar.high >= tp : bar.low <= tp;
95
73
 
96
74
  if (hitStop && hitTarget) {
97
- return tieBreak === "optimistic"
98
- ? { hit: "TP", px: tp }
99
- : { hit: "SL", px: stop };
75
+ return tieBreak === "optimistic" ? { hit: "TP", px: tp } : { hit: "SL", px: stop };
100
76
  }
101
77
 
102
78
  if (hitStop) return { hit: "SL", px: stop };
@@ -123,10 +99,7 @@ export function estimateBarMs(candles) {
123
99
  if (deltas.length) {
124
100
  deltas.sort((a, b) => a - b);
125
101
  const middle = Math.floor(deltas.length / 2);
126
- const median =
127
- deltas.length % 2
128
- ? deltas[middle]
129
- : (deltas[middle - 1] + deltas[middle]) / 2;
102
+ const median = deltas.length % 2 ? deltas[middle] : (deltas[middle - 1] + deltas[middle]) / 2;
130
103
  return Math.max(60e3, Math.min(median, 60 * 60e3));
131
104
  }
132
105
  }
@@ -151,7 +124,6 @@ export function dayKeyET(timeMs) {
151
124
  const anchor = new Date(
152
125
  Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)
153
126
  );
154
- const pseudoEtTime =
155
- anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
127
+ const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
156
128
  return dayKeyUTC(pseudoEtTime);
157
129
  }
@@ -1,57 +1,62 @@
1
1
  import { buildMetrics } from "../metrics/buildMetrics.js";
2
- import { backtest } from "./backtest.js";
3
- import { estimateBarMs } from "./execution.js";
2
+ import { estimateBarMs, dayKeyET } from "./execution.js";
3
+ import { BarSystemRunner, defaultSystemCap } from "./barSystemRunner.js";
4
4
 
5
5
  function asWeight(value) {
6
6
  return Number.isFinite(value) && value > 0 ? value : 0;
7
7
  }
8
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
- }
9
+ function describeValue(value) {
10
+ if (Array.isArray(value)) return `array(length=${value.length})`;
11
+ if (value === null) return "null";
12
+ return typeof value;
13
+ }
16
14
 
17
- const times = [...timeline].sort((left, right) => left - right);
18
- if (!times.length) {
19
- return [{ time: 0, timestamp: 0, equity: totalEquity }];
20
- }
15
+ function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
16
+ return {
17
+ time,
18
+ timestamp: time,
19
+ equity,
20
+ lockedCapital,
21
+ availableCapital,
22
+ };
23
+ }
21
24
 
22
- const states = systemRuns.map((run) => ({
23
- points: run.result.eqSeries || [],
24
- index: 0,
25
- lastEquity: run.allocationEquity,
26
- }));
25
+ function stableSystemOrder(left, right) {
26
+ return left.index - right.index;
27
+ }
27
28
 
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
- });
29
+ function hashedOrderScore(index, time, seed) {
30
+ let value = (Number(time) ^ Math.imul(index + 1, 0x9e3779b1) ^ (seed | 0)) >>> 0;
31
+ value = Math.imul(value ^ (value >>> 16), 0x85ebca6b) >>> 0;
32
+ value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35) >>> 0;
33
+ return (value ^ (value >>> 16)) >>> 0;
34
+ }
35
+
36
+ function orderActiveSystems(active, nextTime, processingOrder, shuffleSeed) {
37
+ if (processingOrder !== "shuffle") {
38
+ active.sort(stableSystemOrder);
39
+ return;
40
+ }
40
41
 
41
- return { time, timestamp: time, equity };
42
+ active.sort((left, right) => {
43
+ const leftScore = hashedOrderScore(left.index, nextTime, shuffleSeed);
44
+ const rightScore = hashedOrderScore(right.index, nextTime, shuffleSeed);
45
+ if (leftScore !== rightScore) return leftScore - rightScore;
46
+ return stableSystemOrder(left, right);
42
47
  });
43
48
  }
44
49
 
45
- function combineReplay(systemRuns, eqSeries, collectReplay) {
50
+ function combineReplay(systemResults, eqSeries, collectReplay) {
46
51
  if (!collectReplay) {
47
52
  return { frames: [], events: [] };
48
53
  }
49
54
 
50
- const events = systemRuns
51
- .flatMap((run) =>
52
- (run.result.replay?.events || []).map((event) => ({
55
+ const events = systemResults
56
+ .flatMap((entry) =>
57
+ (entry.result.replay?.events || []).map((event) => ({
53
58
  ...event,
54
- symbol: event.symbol || run.symbol,
59
+ symbol: event.symbol || entry.symbol,
55
60
  }))
56
61
  )
57
62
  .sort((left, right) => new Date(left.t).getTime() - new Date(right.t).getTime());
@@ -62,17 +67,83 @@ function combineReplay(systemRuns, eqSeries, collectReplay) {
62
67
  equity: point.equity,
63
68
  posSide: null,
64
69
  posSize: 0,
70
+ lockedCapital: point.lockedCapital,
71
+ availableCapital: point.availableCapital,
65
72
  }));
66
73
 
67
74
  return { frames, events };
68
75
  }
69
76
 
77
+ function portfolioState(runners, initialEquity) {
78
+ let markedEquity = initialEquity;
79
+ let lockedCapital = 0;
80
+
81
+ for (const { runner, initialReferenceEquity } of runners) {
82
+ markedEquity += runner.getMarkedEquity() - initialReferenceEquity;
83
+ lockedCapital += runner.getLockedCapital();
84
+ }
85
+
86
+ return {
87
+ markedEquity,
88
+ lockedCapital,
89
+ availableCapital: markedEquity - lockedCapital,
90
+ };
91
+ }
92
+
93
+ function findNextTimeAndActive(runners) {
94
+ let nextTime = Infinity;
95
+ const active = [];
96
+
97
+ for (const entry of runners) {
98
+ const time = entry.runner.peekTime();
99
+ if (time < nextTime) {
100
+ nextTime = time;
101
+ active.length = 0;
102
+ active.push(entry);
103
+ continue;
104
+ }
105
+ if (time === nextTime) {
106
+ active.push(entry);
107
+ }
108
+ }
109
+
110
+ return { nextTime, active };
111
+ }
112
+
113
+ function initialPortfolioTime(runners) {
114
+ let time = Infinity;
115
+ for (const { runner } of runners) {
116
+ const next = runner.candles[0]?.time ?? Infinity;
117
+ if (next < time) time = next;
118
+ }
119
+ return Number.isFinite(time) ? time : 0;
120
+ }
121
+
122
+ function resolveSystemCap(systemEntry, totalEquity) {
123
+ return defaultSystemCap(
124
+ Math.max(0, totalEquity),
125
+ systemEntry.defaultCapPct,
126
+ systemEntry.system.maxAllocation,
127
+ systemEntry.system.maxAllocationPct
128
+ );
129
+ }
130
+
131
+ function forceExitAll(runners, time) {
132
+ for (const { runner } of runners) {
133
+ if (!runner.open) continue;
134
+ const price = runner.getMarkPrice();
135
+ if (!Number.isFinite(price)) continue;
136
+ runner.forceExit("PORTFOLIO_DAILY_LOSS", { time, close: price }, price);
137
+ }
138
+ }
139
+
70
140
  /**
71
- * Run multiple symbol/system backtests and aggregate them into a portfolio view.
141
+ * Run multiple systems against a shared capital pool.
72
142
  *
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.
143
+ * Existing allocation weights are preserved as default per-system capital caps,
144
+ * but capital is only locked when a fill actually occurs. Systems therefore
145
+ * compete for the same remaining capital at fill time. `processingOrder` can be
146
+ * set to `"shuffle"` for fairness testing when multiple systems act on the same bar.
76
147
  */
77
148
  export function backtestPortfolio({
78
149
  systems = [],
@@ -80,39 +151,138 @@ export function backtestPortfolio({
80
151
  allocation = "equal",
81
152
  collectEqSeries = true,
82
153
  collectReplay = false,
154
+ maxDailyLossPct = 0,
155
+ processingOrder = "sequential",
156
+ shuffleSeed = 0,
83
157
  } = {}) {
84
158
  if (!Array.isArray(systems) || systems.length === 0) {
85
- throw new Error("backtestPortfolio() requires a non-empty systems array");
159
+ throw new Error(
160
+ `backtestPortfolio() requires a non-empty systems array, got ${describeValue(systems)}`
161
+ );
162
+ }
163
+ if (processingOrder !== "sequential" && processingOrder !== "shuffle") {
164
+ throw new Error(
165
+ `backtestPortfolio() processingOrder must be "sequential" or "shuffle", got ${processingOrder}`
166
+ );
86
167
  }
87
168
 
88
169
  const weights =
89
170
  allocation === "equal"
90
171
  ? systems.map(() => 1)
91
172
  : systems.map((system) => asWeight(system.weight || 0));
92
- const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
173
+ const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
93
174
 
94
175
  if (!(totalWeight > 0)) {
95
- throw new Error("backtestPortfolio() requires positive allocation weights");
176
+ throw new Error(
177
+ `backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
178
+ );
96
179
  }
97
180
 
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
-
181
+ const runners = systems.map((system, index) => {
182
+ const defaultCapPct = weights[index] / totalWeight;
183
+ const initialReferenceEquity = equity * defaultCapPct;
107
184
  return {
108
- symbol: system.symbol ?? result.symbol ?? `system-${index + 1}`,
109
- weight: weights[index],
110
- allocationEquity,
111
- result,
185
+ index,
186
+ symbol: system.symbol ?? `system-${index + 1}`,
187
+ system,
188
+ defaultCapPct,
189
+ initialReferenceEquity,
190
+ runner: new BarSystemRunner({
191
+ ...system,
192
+ symbol: system.symbol ?? `system-${index + 1}`,
193
+ equity: initialReferenceEquity,
194
+ collectEqSeries,
195
+ collectReplay,
196
+ }),
112
197
  };
113
198
  });
114
199
 
115
- const trades = systemRuns
200
+ const eqSeries = collectEqSeries ? [] : [];
201
+ let state = portfolioState(runners, equity);
202
+ if (collectEqSeries) {
203
+ eqSeries.push(
204
+ buildPortfolioPoint(
205
+ initialPortfolioTime(runners),
206
+ state.markedEquity,
207
+ state.lockedCapital,
208
+ state.availableCapital
209
+ )
210
+ );
211
+ }
212
+
213
+ let currentDay = null;
214
+ let dayStartEquity = equity;
215
+ let portfolioHalted = false;
216
+
217
+ while (true) {
218
+ const { nextTime, active } = findNextTimeAndActive(runners);
219
+ if (!Number.isFinite(nextTime)) break;
220
+ orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
221
+
222
+ const dayKey = dayKeyET(nextTime);
223
+ if (currentDay === null || dayKey !== currentDay) {
224
+ currentDay = dayKey;
225
+ state = portfolioState(runners, equity);
226
+ dayStartEquity = state.markedEquity;
227
+ portfolioHalted = false;
228
+ }
229
+
230
+ for (const systemEntry of active) {
231
+ state = portfolioState(runners, equity);
232
+ const totalEquity = state.markedEquity;
233
+ const availableCapital = Math.max(0, state.availableCapital);
234
+ const systemLocked = systemEntry.runner.getLockedCapital();
235
+ const systemCap = resolveSystemCap(systemEntry, totalEquity);
236
+ const systemRemainingCapital = Math.max(0, systemCap - systemLocked);
237
+
238
+ systemEntry.runner.step({
239
+ signalEquity: totalEquity,
240
+ canTrade: !portfolioHalted,
241
+ resolveEntrySize({ desiredSize, entryPrice }) {
242
+ const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
243
+ const byAvailable =
244
+ (availableCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
245
+ const bySystemCap =
246
+ (systemRemainingCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
247
+ return Math.min(desiredSize, byAvailable, bySystemCap);
248
+ },
249
+ });
250
+
251
+ state = portfolioState(runners, equity);
252
+ if (
253
+ !portfolioHalted &&
254
+ maxDailyLossPct > 0 &&
255
+ state.markedEquity <= dayStartEquity * (1 - Math.abs(maxDailyLossPct) / 100)
256
+ ) {
257
+ portfolioHalted = true;
258
+ for (const { runner } of runners) runner.cancelPending();
259
+ forceExitAll(runners, nextTime);
260
+ state = portfolioState(runners, equity);
261
+ }
262
+ }
263
+
264
+ if (collectEqSeries) {
265
+ eqSeries.push(
266
+ buildPortfolioPoint(
267
+ nextTime,
268
+ state.markedEquity,
269
+ state.lockedCapital,
270
+ state.availableCapital
271
+ )
272
+ );
273
+ }
274
+ }
275
+
276
+ const systemResults = runners.map((entry) => ({
277
+ symbol: entry.symbol,
278
+ weight: entry.defaultCapPct,
279
+ equity: entry.initialReferenceEquity,
280
+ allocationCapPct: entry.defaultCapPct,
281
+ allocationCap: resolveSystemCap(entry, equity),
282
+ result: entry.runner.buildResult(),
283
+ }));
284
+
285
+ const trades = systemResults
116
286
  .flatMap((run) =>
117
287
  run.result.trades.map((trade) => ({
118
288
  ...trade,
@@ -120,7 +290,7 @@ export function backtestPortfolio({
120
290
  }))
121
291
  )
122
292
  .sort((left, right) => left.exit.time - right.exit.time);
123
- const positions = systemRuns
293
+ const positions = systemResults
124
294
  .flatMap((run) =>
125
295
  run.result.positions.map((trade) => ({
126
296
  ...trade,
@@ -128,8 +298,13 @@ export function backtestPortfolio({
128
298
  }))
129
299
  )
130
300
  .sort((left, right) => left.exit.time - right.exit.time);
131
- const eqSeries = collectEqSeries ? combineEquitySeries(systemRuns, equity) : [];
132
- const replay = combineReplay(systemRuns, eqSeries, collectReplay);
301
+ const openPositions = systemResults.flatMap((run) =>
302
+ (run.result.openPositions || []).map((position) => ({
303
+ ...position,
304
+ symbol: position.symbol || run.symbol,
305
+ }))
306
+ );
307
+ const replay = combineReplay(systemResults, eqSeries, collectReplay);
133
308
  const allCandles = systems.flatMap((system) => system.candles || []);
134
309
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
135
310
  const metrics = buildMetrics({
@@ -147,14 +322,10 @@ export function backtestPortfolio({
147
322
  range: undefined,
148
323
  trades,
149
324
  positions,
325
+ openPositions,
150
326
  metrics,
151
327
  eqSeries,
152
328
  replay,
153
- systems: systemRuns.map((run) => ({
154
- symbol: run.symbol,
155
- weight: run.weight / totalWeight,
156
- equity: run.allocationEquity,
157
- result: run.result,
158
- })),
329
+ systems: systemResults,
159
330
  };
160
331
  }