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
@@ -19,6 +19,73 @@ function stitchEquitySeries(target, source) {
19
19
  target.push(...nextPoints);
20
20
  }
21
21
 
22
+ function canonicalParams(params) {
23
+ const entries = Object.entries(params || {}).sort(([left], [right]) => left.localeCompare(right));
24
+ return JSON.stringify(Object.fromEntries(entries));
25
+ }
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
+
33
+ function buildWindowRanges(length, trainBars, testBars, stepBars, mode) {
34
+ const ranges = [];
35
+ for (let start = 0; start + trainBars + testBars <= length; start += stepBars) {
36
+ const trainStart = mode === "anchored" ? 0 : start;
37
+ const trainEnd = mode === "anchored" ? trainBars + start : start + trainBars;
38
+ const testStart = trainEnd;
39
+ const testEnd = testStart + testBars;
40
+ if (testEnd > length) break;
41
+ ranges.push({ trainStart, trainEnd, testStart, testEnd });
42
+ }
43
+ return ranges;
44
+ }
45
+
46
+ function summarizeBestParams(windows) {
47
+ const summaryBySignature = new Map();
48
+ let adjacentRepeats = 0;
49
+
50
+ windows.forEach((window, index) => {
51
+ const signature = window.bestParamsSignature ?? canonicalParams(window.bestParams);
52
+ const current = summaryBySignature.get(signature) || {
53
+ params: window.bestParams,
54
+ wins: 0,
55
+ profitableWindows: 0,
56
+ oosTrades: 0,
57
+ };
58
+ current.wins += 1;
59
+ current.profitableWindows += window.profitable ? 1 : 0;
60
+ current.oosTrades += window.oosTrades;
61
+ summaryBySignature.set(signature, current);
62
+
63
+ if (
64
+ index > 0 &&
65
+ (windows[index - 1].bestParamsSignature ?? canonicalParams(windows[index - 1].bestParams)) ===
66
+ signature
67
+ ) {
68
+ adjacentRepeats += 1;
69
+ }
70
+ });
71
+
72
+ const byFrequency = [...summaryBySignature.values()].sort((left, right) => {
73
+ if (right.wins !== left.wins) return right.wins - left.wins;
74
+ return right.profitableWindows - left.profitableWindows;
75
+ });
76
+ const adjacentPairs = Math.max(0, windows.length - 1);
77
+
78
+ return {
79
+ winners: windows.map((window) => window.bestParams),
80
+ stability: {
81
+ adjacentRepeatRate: adjacentPairs ? adjacentRepeats / adjacentPairs : 0,
82
+ uniqueWinnerCount: summaryBySignature.size,
83
+ dominant: byFrequency[0] || null,
84
+ leaderboard: byFrequency,
85
+ },
86
+ };
87
+ }
88
+
22
89
  /**
23
90
  * Run rolling walk-forward optimization over a single candle series.
24
91
  *
@@ -32,40 +99,66 @@ export function walkForwardOptimize({
32
99
  trainBars,
33
100
  testBars,
34
101
  stepBars = testBars,
102
+ mode = "rolling",
35
103
  scoreBy = "profitFactor",
36
104
  backtestOptions = {},
37
105
  } = {}) {
38
106
  if (!Array.isArray(candles) || candles.length === 0) {
39
- 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
+ );
40
110
  }
41
111
  if (typeof signalFactory !== "function") {
42
- throw new Error("walkForwardOptimize() requires a signalFactory function");
112
+ throw new Error(
113
+ `walkForwardOptimize() requires a signalFactory function, got ${describeValue(signalFactory)}`
114
+ );
43
115
  }
44
116
  if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
45
- throw new Error("walkForwardOptimize() requires parameterSets");
117
+ throw new Error(
118
+ `walkForwardOptimize() requires parameterSets, got ${describeValue(parameterSets)}`
119
+ );
46
120
  }
47
121
  if (!(trainBars > 0) || !(testBars > 0) || !(stepBars > 0)) {
48
122
  throw new Error("walkForwardOptimize() requires positive trainBars, testBars, and stepBars");
49
123
  }
124
+ if (mode !== "rolling" && mode !== "anchored") {
125
+ throw new Error('walkForwardOptimize() mode must be "rolling" or "anchored"');
126
+ }
50
127
 
51
128
  const windows = [];
52
129
  const allTrades = [];
53
130
  const allPositions = [];
54
131
  const eqSeries = [];
55
132
  let rollingEquity = backtestOptions.equity ?? 10_000;
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
+ }
140
+ const trainBacktestOptions = {
141
+ ...backtestOptions,
142
+ collectEqSeries: false,
143
+ collectReplay: false,
144
+ };
145
+ const testBacktestOptions = { ...backtestOptions };
56
146
 
57
- for (
58
- let start = 0;
59
- start + trainBars + testBars <= candles.length;
60
- start += stepBars
61
- ) {
62
- const trainSlice = candles.slice(start, start + trainBars);
63
- const testSlice = candles.slice(start + trainBars, start + trainBars + testBars);
147
+ for (const range of ranges) {
148
+ const trainSlice = candles.slice(range.trainStart, range.trainEnd);
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
+ }
64
157
 
65
158
  let best = null;
66
159
  for (const params of parameterSets) {
67
160
  const trainResult = backtest({
68
- ...backtestOptions,
161
+ ...trainBacktestOptions,
69
162
  candles: trainSlice,
70
163
  equity: rollingEquity,
71
164
  signal: signalFactory(params),
@@ -77,11 +170,12 @@ export function walkForwardOptimize({
77
170
  }
78
171
 
79
172
  const testResult = backtest({
80
- ...backtestOptions,
173
+ ...testBacktestOptions,
81
174
  candles: testSlice,
82
175
  equity: rollingEquity,
83
176
  signal: signalFactory(best.params),
84
177
  });
178
+ const bestParamsSignature = canonicalParams(best.params);
85
179
 
86
180
  rollingEquity = testResult.metrics.finalEquity;
87
181
  allTrades.push(...testResult.trades);
@@ -101,10 +195,29 @@ export function walkForwardOptimize({
101
195
  trainScore: best.score,
102
196
  trainMetrics: best.metrics,
103
197
  testMetrics: testResult.metrics,
198
+ oosTrades: testResult.metrics.trades,
199
+ profitable: testResult.metrics.totalPnL > 0,
200
+ stabilityScore: 0,
201
+ bestParamsSignature,
104
202
  result: testResult,
105
203
  });
106
204
  }
107
205
 
206
+ for (let index = 0; index < windows.length; index += 1) {
207
+ const currentSignature = windows[index].bestParamsSignature;
208
+ const adjacent = [];
209
+ if (index > 0) {
210
+ adjacent.push(windows[index - 1].bestParamsSignature === currentSignature ? 1 : 0);
211
+ }
212
+ if (index + 1 < windows.length) {
213
+ adjacent.push(windows[index + 1].bestParamsSignature === currentSignature ? 1 : 0);
214
+ }
215
+ windows[index].stabilityScore = adjacent.length
216
+ ? adjacent.reduce((total, value) => total + value, 0) / adjacent.length
217
+ : 1;
218
+ delete windows[index].bestParamsSignature;
219
+ }
220
+
108
221
  const metrics = buildMetrics({
109
222
  closed: allTrades,
110
223
  equityStart: backtestOptions.equity ?? 10_000,
@@ -113,14 +226,20 @@ export function walkForwardOptimize({
113
226
  estBarMs: estimateBarMs(candles),
114
227
  eqSeries,
115
228
  });
229
+ const bestParamsSummary = summarizeBestParams(windows);
116
230
 
117
231
  return {
118
232
  windows,
119
233
  trades: allTrades,
120
234
  positions: allPositions,
235
+ openPositions: [],
121
236
  metrics,
122
237
  eqSeries,
123
238
  replay: { frames: [], events: [] },
124
- bestParams: windows.map((window) => window.bestParams),
239
+ bestParams: Object.assign(
240
+ windows.map((window) => window.bestParams),
241
+ bestParamsSummary
242
+ ),
243
+ bestParamsSummary: bestParamsSummary.stability,
125
244
  };
126
245
  }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { backtest } from "./engine/backtest.js";
2
+ export { backtestTicks } from "./engine/backtestTicks.js";
2
3
  export { backtestPortfolio } from "./engine/portfolio.js";
3
4
  export { walkForwardOptimize } from "./engine/walkForward.js";
4
5
 
@@ -17,10 +18,7 @@ export {
17
18
  saveCandlesToCache,
18
19
  } from "./data/index.js";
19
20
 
20
- export {
21
- renderHtmlReport,
22
- exportHtmlReport,
23
- } from "./reporting/renderHtmlReport.js";
21
+ export { renderHtmlReport, exportHtmlReport } from "./reporting/renderHtmlReport.js";
24
22
  export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
25
23
  export { exportMetricsJSON } from "./reporting/exportMetricsJson.js";
26
24
  export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
@@ -37,10 +35,4 @@ export {
37
35
  pct,
38
36
  } from "./utils/indicators.js";
39
37
  export { calculatePositionSize } from "./utils/positionSizing.js";
40
- export {
41
- offsetET,
42
- minutesET,
43
- isSession,
44
- parseWindowsCSV,
45
- inWindowsET,
46
- } 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
+ }