tradelab 0.5.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 +89 -41
  2. package/bin/tradelab.js +276 -30
  3. package/dist/cjs/data.cjs +134 -104
  4. package/dist/cjs/index.cjs +378 -177
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +21 -9
  7. package/docs/api-reference.md +87 -29
  8. package/docs/backtest-engine.md +37 -53
  9. package/docs/data-reporting-cli.md +60 -34
  10. package/docs/examples.md +6 -12
  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 +89 -37
  19. package/src/engine/barSystemRunner.js +182 -118
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +54 -6
  22. package/src/engine/walkForward.js +37 -14
  23. package/src/index.js +2 -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 +18 -41
  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 +21 -3
  54. package/types/live.d.ts +382 -0
@@ -6,6 +6,12 @@ function asWeight(value) {
6
6
  return Number.isFinite(value) && value > 0 ? value : 0;
7
7
  }
8
8
 
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
+ }
14
+
9
15
  function buildPortfolioPoint(time, equity, lockedCapital, availableCapital) {
10
16
  return {
11
17
  time,
@@ -20,6 +26,27 @@ function stableSystemOrder(left, right) {
20
26
  return left.index - right.index;
21
27
  }
22
28
 
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
+ }
41
+
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);
47
+ });
48
+ }
49
+
23
50
  function combineReplay(systemResults, eqSeries, collectReplay) {
24
51
  if (!collectReplay) {
25
52
  return { frames: [], events: [] };
@@ -115,7 +142,8 @@ function forceExitAll(runners, time) {
115
142
  *
116
143
  * Existing allocation weights are preserved as default per-system capital caps,
117
144
  * but capital is only locked when a fill actually occurs. Systems therefore
118
- * compete for the same remaining capital at fill time.
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.
119
147
  */
120
148
  export function backtestPortfolio({
121
149
  systems = [],
@@ -124,9 +152,18 @@ export function backtestPortfolio({
124
152
  collectEqSeries = true,
125
153
  collectReplay = false,
126
154
  maxDailyLossPct = 0,
155
+ processingOrder = "sequential",
156
+ shuffleSeed = 0,
127
157
  } = {}) {
128
158
  if (!Array.isArray(systems) || systems.length === 0) {
129
- 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
+ );
130
167
  }
131
168
 
132
169
  const weights =
@@ -136,7 +173,9 @@ export function backtestPortfolio({
136
173
  const totalWeight = weights.reduce((sumValue, weight) => sumValue + weight, 0);
137
174
 
138
175
  if (!(totalWeight > 0)) {
139
- throw new Error("backtestPortfolio() requires positive allocation weights");
176
+ throw new Error(
177
+ `backtestPortfolio() requires positive allocation weights, got allocation=${allocation}`
178
+ );
140
179
  }
141
180
 
142
181
  const runners = systems.map((system, index) => {
@@ -178,7 +217,7 @@ export function backtestPortfolio({
178
217
  while (true) {
179
218
  const { nextTime, active } = findNextTimeAndActive(runners);
180
219
  if (!Number.isFinite(nextTime)) break;
181
- active.sort(stableSystemOrder);
220
+ orderActiveSystems(active, nextTime, processingOrder, shuffleSeed);
182
221
 
183
222
  const dayKey = dayKeyET(nextTime);
184
223
  if (currentDay === null || dayKey !== currentDay) {
@@ -201,8 +240,10 @@ export function backtestPortfolio({
201
240
  canTrade: !portfolioHalted,
202
241
  resolveEntrySize({ desiredSize, entryPrice }) {
203
242
  const maxLeverage = Math.max(1, systemEntry.runner.options.maxLeverage || 1);
204
- const byAvailable = (availableCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
205
- const bySystemCap = (systemRemainingCapital * maxLeverage) / Math.max(1e-12, Math.abs(entryPrice));
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));
206
247
  return Math.min(desiredSize, byAvailable, bySystemCap);
207
248
  },
208
249
  });
@@ -257,6 +298,12 @@ export function backtestPortfolio({
257
298
  }))
258
299
  )
259
300
  .sort((left, right) => left.exit.time - right.exit.time);
301
+ const openPositions = systemResults.flatMap((run) =>
302
+ (run.result.openPositions || []).map((position) => ({
303
+ ...position,
304
+ symbol: position.symbol || run.symbol,
305
+ }))
306
+ );
260
307
  const replay = combineReplay(systemResults, eqSeries, collectReplay);
261
308
  const allCandles = systems.flatMap((system) => system.candles || []);
262
309
  const orderedCandles = [...allCandles].sort((left, right) => left.time - right.time);
@@ -275,6 +322,7 @@ export function backtestPortfolio({
275
322
  range: undefined,
276
323
  trades,
277
324
  positions,
325
+ openPositions,
278
326
  metrics,
279
327
  eqSeries,
280
328
  replay,
@@ -20,19 +20,19 @@ function stitchEquitySeries(target, source) {
20
20
  }
21
21
 
22
22
  function canonicalParams(params) {
23
- const entries = Object.entries(params || {}).sort(([left], [right]) =>
24
- left.localeCompare(right)
25
- );
23
+ const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
26
24
  return JSON.stringify(Object.fromEntries(entries));
27
25
  }
28
26
 
27
+ function describeValue(value) {
28
+ if (Array.isArray(value)) return `array(length=${value.length})`;
29
+ if (value === null) return "null";
30
+ return typeof value;
31
+ }
32
+
29
33
  function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
30
34
  const ranges = [];
31
- for (
32
- let start = 0;
33
- start + trainBars + testBars <= length;
34
- start += stepBars
35
- ) {
35
+ for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
36
36
  const trainStart = mode === "anchored" ? 0 : start;
37
37
  const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
38
38
  const testStart = trainEnd;
@@ -62,8 +62,8 @@ function summarizeBestParams(windows) {
62
62
 
63
63
  if (
64
64
  index > 0 &&
65
- (windows[index - 1].bestParamsSignature ??
66
- canonicalParams(windows[index - 1].bestParams)) === signature
65
+ (windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) ===
66
+ signature
67
67
  ) {
68
68
  adjacentRepeats += 1;
69
69
  }
@@ -104,13 +104,19 @@ export function walkForwardOptimize({
104
104
  backtestOptions = {},
105
105
  } = {}) {
106
106
  if (!Array.isArray(candles) || candles.length === 0) {
107
- throw new Error("walkForwardOptimize() requires a non-empty candles array");
107
+ throw new Error(
108
+ `walkForwardOptimize() requires a non-empty candles array, got ${describeValue(candles)}`
109
+ );
108
110
  }
109
111
  if (typeof signalFactory !== "function") {
110
- throw new Error("walkForwardOptimize() requires a signalFactory function");
112
+ throw new Error(
113
+ `walkForwardOptimize() requires a signalFactory function, got ${describeValue(signalFactory)}`
114
+ );
111
115
  }
112
116
  if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
113
- throw new Error("walkForwardOptimize() requires parameterSets");
117
+ throw new Error(
118
+ `walkForwardOptimize() requires parameterSets, got ${describeValue(parameterSets)}`
119
+ );
114
120
  }
115
121
  if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
116
122
  throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
@@ -125,6 +131,12 @@ export function walkForwardOptimize({
125
131
  const eqSeries = [];
126
132
  let rollingEquity = backtestOptions.equity ?? 10_000;
127
133
  const ranges = buildWindowRanges(candles.length, trainBars, testBars, stepBars, mode);
134
+ if (!ranges.length) {
135
+ const required = trainBars + testBars;
136
+ throw new Error(
137
+ `walkForwardOptimize() produced zero windows: need at least ${required} candles (trainBars=${trainBars} + testBars=${testBars}) but got ${candles.length}. Try reducing trainBars/testBars or adding more historical data.`
138
+ );
139
+ }
128
140
  const trainBacktestOptions = {
129
141
  ...backtestOptions,
130
142
  collectEqSeries: false,
@@ -135,6 +147,13 @@ export function walkForwardOptimize({
135
147
  for (const range of ranges) {
136
148
  const trainSlice = candles.slice(range.trainStart, range.trainEnd);
137
149
  const testSlice = candles.slice(range.testStart, range.testEnd);
150
+ if (!trainSlice.length || !testSlice.length) {
151
+ throw new Error(
152
+ `walkForwardOptimize() generated an empty window (train=${trainSlice.length}, test=${testSlice.length}, range=${JSON.stringify(
153
+ range
154
+ )})`
155
+ );
156
+ }
138
157
 
139
158
  let best = null;
140
159
  for (const params of parameterSets) {
@@ -213,10 +232,14 @@ export function walkForwardOptimize({
213
232
  windows,
214
233
  trades: allTrades,
215
234
  positions: allPositions,
235
+ openPositions: [],
216
236
  metrics,
217
237
  eqSeries,
218
238
  replay: { frames: [], events: [] },
219
- bestParams: Object.assign(windows.map((window) => window.bestParams), bestParamsSummary),
239
+ bestParams: Object.assign(
240
+ windows.map((window) => window.bestParams),
241
+ bestParamsSummary
242
+ ),
220
243
  bestParamsSummary: bestParamsSummary.stability,
221
244
  };
222
245
  }
package/src/index.js CHANGED
@@ -18,10 +18,7 @@ export {
18
18
  saveCandlesToCache,
19
19
  } from "./data/index.js";
20
20
 
21
- export {
22
- renderHtmlReport,
23
- exportHtmlReport,
24
- } from "./reporting/renderHtmlReport.js";
21
+ export { renderHtmlReport, exportHtmlReport } from "./reporting/renderHtmlReport.js";
25
22
  export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
26
23
  export { exportMetricsJSON } from "./reporting/exportMetricsJson.js";
27
24
  export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
@@ -38,10 +35,4 @@ export {
38
35
  pct,
39
36
  } from "./utils/indicators.js";
40
37
  export { calculatePositionSize } from "./utils/positionSizing.js";
41
- export {
42
- offsetET,
43
- minutesET,
44
- isSession,
45
- parseWindowsCSV,
46
- inWindowsET,
47
- } from "./utils/time.js";
38
+ export { offsetET, minutesET, isSession, parseWindowsCSV, inWindowsET } from "./utils/time.js";
@@ -0,0 +1,254 @@
1
+ import { URL } from "node:url";
2
+
3
+ import { normalizeCandles } from "../../data/csv.js";
4
+ import { BrokerAdapter } from "./interface.js";
5
+
6
+ function withQuery(url, query = {}) {
7
+ const target = new URL(url);
8
+ for (const [key, value] of Object.entries(query)) {
9
+ if (value === undefined || value === null) continue;
10
+ target.searchParams.set(key, String(value));
11
+ }
12
+ return target.toString();
13
+ }
14
+
15
+ function mapOrderStatus(status) {
16
+ const normalized = String(status || "").toLowerCase();
17
+ if (normalized === "partially_filled") return "partially_filled";
18
+ if (normalized === "filled") return "filled";
19
+ if (normalized === "canceled" || normalized === "cancelled") return "canceled";
20
+ if (normalized === "rejected") return "rejected";
21
+ if (normalized === "expired") return "expired";
22
+ return "new";
23
+ }
24
+
25
+ function mapOrderReceipt(order) {
26
+ return {
27
+ orderId: String(order.id),
28
+ clientOrderId: order.client_order_id,
29
+ status: mapOrderStatus(order.status),
30
+ filledQty: Number(order.filled_qty || 0),
31
+ avgFillPrice: Number.isFinite(Number(order.filled_avg_price))
32
+ ? Number(order.filled_avg_price)
33
+ : undefined,
34
+ filledAt: order.filled_at ? Date.parse(order.filled_at) : undefined,
35
+ symbol: order.symbol,
36
+ side: order.side,
37
+ type: String(order.type || "").toLowerCase(),
38
+ qty: Number(order.qty || 0),
39
+ rejectReason: order.reject_reason,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Alpaca Markets broker adapter.
45
+ */
46
+ export class AlpacaBroker extends BrokerAdapter {
47
+ constructor({ fetchImpl = globalThis.fetch } = {}) {
48
+ super();
49
+ this.fetch = fetchImpl;
50
+ this.connected = false;
51
+ this.config = {};
52
+ this.subscriptions = {
53
+ bars: new Map(),
54
+ quotes: new Map(),
55
+ trades: new Map(),
56
+ };
57
+ }
58
+
59
+ async connect(config = {}) {
60
+ this.config = { ...config };
61
+ this.baseUrl =
62
+ config.baseUrl ||
63
+ (config.paper ? "https://paper-api.alpaca.markets" : "https://api.alpaca.markets");
64
+ this.dataUrl = config.dataUrl || "https://data.alpaca.markets";
65
+ this.connected = true;
66
+ }
67
+
68
+ async disconnect() {
69
+ this.connected = false;
70
+ this.subscriptions.bars.clear();
71
+ this.subscriptions.quotes.clear();
72
+ this.subscriptions.trades.clear();
73
+ }
74
+
75
+ isConnected() {
76
+ return this.connected;
77
+ }
78
+
79
+ supportsPaperNative() {
80
+ return true;
81
+ }
82
+
83
+ _headers(extra = {}) {
84
+ return {
85
+ "content-type": "application/json",
86
+ "APCA-API-KEY-ID": this.config.apiKey || "",
87
+ "APCA-API-SECRET-KEY": this.config.apiSecret || "",
88
+ ...extra,
89
+ };
90
+ }
91
+
92
+ async _request(method, path, { query = null, body = null, dataApi = false } = {}) {
93
+ if (!this.fetch) throw new Error("global fetch is unavailable");
94
+ const base = dataApi ? this.dataUrl : this.baseUrl;
95
+ const url = withQuery(`${base}${path}`, query || {});
96
+ const response = await this.fetch(url, {
97
+ method,
98
+ headers: this._headers(),
99
+ body: body ? JSON.stringify(body) : undefined,
100
+ });
101
+ const text = await response.text();
102
+ const payload = text ? JSON.parse(text) : {};
103
+ if (!response.ok) {
104
+ const message =
105
+ payload?.message || payload?.error || `alpaca request failed (${response.status})`;
106
+ throw new Error(message);
107
+ }
108
+ return payload;
109
+ }
110
+
111
+ async getAccount() {
112
+ const account = await this._request("GET", "/v2/account");
113
+ return {
114
+ equity: Number(account.equity || 0),
115
+ buyingPower: Number(account.buying_power || 0),
116
+ cash: Number(account.cash || 0),
117
+ currency: account.currency || "USD",
118
+ marginUsed: Number(account.initial_margin || 0),
119
+ };
120
+ }
121
+
122
+ async getPositions() {
123
+ const positions = await this._request("GET", "/v2/positions");
124
+ return positions.map((position) => ({
125
+ symbol: position.symbol,
126
+ side: String(position.side || "long").toLowerCase(),
127
+ qty: Number(position.qty || 0),
128
+ avgEntry: Number(position.avg_entry_price || 0),
129
+ marketValue: Number(position.market_value || 0),
130
+ unrealizedPnl: Number(position.unrealized_pl || 0),
131
+ }));
132
+ }
133
+
134
+ async getServerTime() {
135
+ const clock = await this._request("GET", "/v2/clock");
136
+ return clock.timestamp ? Date.parse(clock.timestamp) : Date.now();
137
+ }
138
+
139
+ async submitOrder(order) {
140
+ const payload = {
141
+ symbol: order.symbol,
142
+ side: order.side,
143
+ type: order.type,
144
+ qty: String(order.qty),
145
+ time_in_force: order.timeInForce || "day",
146
+ client_order_id: order.clientOrderId,
147
+ };
148
+ if (order.limitPrice !== undefined) payload.limit_price = String(order.limitPrice);
149
+ if (order.stopPrice !== undefined) payload.stop_price = String(order.stopPrice);
150
+ const response = await this._request("POST", "/v2/orders", { body: payload });
151
+ const receipt = mapOrderReceipt(response);
152
+ this.emit("order:submitted", receipt);
153
+ return receipt;
154
+ }
155
+
156
+ async cancelOrder(orderId) {
157
+ await this._request("DELETE", `/v2/orders/${orderId}`);
158
+ this.emit("order:canceled", { orderId });
159
+ }
160
+
161
+ async modifyOrder(orderId, changes) {
162
+ const payload = {};
163
+ if (changes.qty !== undefined) payload.qty = String(changes.qty);
164
+ if (changes.limitPrice !== undefined) payload.limit_price = String(changes.limitPrice);
165
+ if (changes.stopPrice !== undefined) payload.stop_price = String(changes.stopPrice);
166
+ const response = await this._request("PATCH", `/v2/orders/${orderId}`, { body: payload });
167
+ const receipt = mapOrderReceipt(response);
168
+ this.emit("order:modified", receipt);
169
+ return receipt;
170
+ }
171
+
172
+ async getOpenOrders() {
173
+ const orders = await this._request("GET", "/v2/orders", { query: { status: "open" } });
174
+ return orders.map(mapOrderReceipt);
175
+ }
176
+
177
+ async getOrderStatus(orderId) {
178
+ const order = await this._request("GET", `/v2/orders/${orderId}`);
179
+ return mapOrderReceipt(order);
180
+ }
181
+
182
+ async subscribeQuotes(symbol, handler) {
183
+ const key = symbol;
184
+ const list = this.subscriptions.quotes.get(key) || [];
185
+ list.push(handler);
186
+ this.subscriptions.quotes.set(key, list);
187
+ return {
188
+ unsubscribe: () => {
189
+ const current = this.subscriptions.quotes.get(key) || [];
190
+ this.subscriptions.quotes.set(
191
+ key,
192
+ current.filter((candidate) => candidate !== handler)
193
+ );
194
+ },
195
+ };
196
+ }
197
+
198
+ async subscribeTrades(symbol, handler) {
199
+ const key = symbol;
200
+ const list = this.subscriptions.trades.get(key) || [];
201
+ list.push(handler);
202
+ this.subscriptions.trades.set(key, list);
203
+ return {
204
+ unsubscribe: () => {
205
+ const current = this.subscriptions.trades.get(key) || [];
206
+ this.subscriptions.trades.set(
207
+ key,
208
+ current.filter((candidate) => candidate !== handler)
209
+ );
210
+ },
211
+ };
212
+ }
213
+
214
+ async subscribeBars(symbol, interval, handler) {
215
+ const key = `${symbol}::${interval}`;
216
+ const list = this.subscriptions.bars.get(key) || [];
217
+ list.push(handler);
218
+ this.subscriptions.bars.set(key, list);
219
+ return {
220
+ unsubscribe: () => {
221
+ const current = this.subscriptions.bars.get(key) || [];
222
+ this.subscriptions.bars.set(
223
+ key,
224
+ current.filter((candidate) => candidate !== handler)
225
+ );
226
+ },
227
+ };
228
+ }
229
+
230
+ async getHistoricalBars(symbol, interval, limit = 200) {
231
+ const response = await this._request("GET", `/v2/stocks/${symbol}/bars`, {
232
+ dataApi: true,
233
+ query: {
234
+ timeframe: interval,
235
+ limit,
236
+ },
237
+ });
238
+ const bars = Array.isArray(response?.bars)
239
+ ? response.bars.map((bar) => ({
240
+ time: Date.parse(bar.t),
241
+ open: Number(bar.o),
242
+ high: Number(bar.h),
243
+ low: Number(bar.l),
244
+ close: Number(bar.c),
245
+ volume: Number(bar.v ?? 0),
246
+ }))
247
+ : [];
248
+ return normalizeCandles(bars);
249
+ }
250
+ }
251
+
252
+ export function createAlpacaBroker(options) {
253
+ return new AlpacaBroker(options);
254
+ }