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
@@ -0,0 +1,481 @@
1
+ import { buildMetrics } from "../metrics/buildMetrics.js";
2
+ import { calculatePositionSize } from "../utils/positionSizing.js";
3
+ import { applyFill, dayKeyUTC, ocoExitCheck, roundStep } from "./execution.js";
4
+
5
+ function asNumber(value) {
6
+ const numeric = Number(value);
7
+ return Number.isFinite(numeric) ? numeric : null;
8
+ }
9
+
10
+ function describeValue(value) {
11
+ if (Array.isArray(value)) return `array(length=${value.length})`;
12
+ if (value === null) return "null";
13
+ return typeof value;
14
+ }
15
+
16
+ function formatIsoTime(time) {
17
+ return Number.isFinite(time) ? new Date(time).toISOString() : "invalid-time";
18
+ }
19
+
20
+ function callSignalWithContext({ signal, context, index, bar, symbol }) {
21
+ try {
22
+ return signal(context);
23
+ } catch (error) {
24
+ const cause = error instanceof Error ? error.message : String(error);
25
+ throw new Error(
26
+ `signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
27
+ );
28
+ }
29
+ }
30
+
31
+ function normalizeSide(value) {
32
+ if (value === "long" || value === "buy") return "long";
33
+ if (value === "short" || value === "sell") return "short";
34
+ return null;
35
+ }
36
+
37
+ function normalizeTick(tick) {
38
+ const time = Number(tick?.time);
39
+ const bid = asNumber(tick?.bid);
40
+ const ask = asNumber(tick?.ask);
41
+ const last = asNumber(tick?.price ?? tick?.last ?? tick?.close);
42
+ const mid = bid !== null && ask !== null ? (bid + ask) / 2 : (last ?? bid ?? ask);
43
+ if (!Number.isFinite(time) || !Number.isFinite(mid)) return null;
44
+
45
+ const prices = [asNumber(tick?.low), asNumber(tick?.high), bid, ask, last, mid].filter(
46
+ Number.isFinite
47
+ );
48
+ const low = prices.length ? Math.min(...prices) : mid;
49
+ const high = prices.length ? Math.max(...prices) : mid;
50
+
51
+ return {
52
+ ...tick,
53
+ time,
54
+ open: mid,
55
+ high,
56
+ low,
57
+ close: mid,
58
+ volume: asNumber(tick?.size ?? tick?.volume) ?? undefined,
59
+ };
60
+ }
61
+
62
+ function normalizeSignal(signal, bar, fallbackR) {
63
+ if (!signal) return null;
64
+ const side = normalizeSide(signal.side ?? signal.direction ?? signal.action);
65
+ if (!side) return null;
66
+
67
+ const hasExplicitEntry =
68
+ signal.entry !== undefined || signal.limit !== undefined || signal.price !== undefined;
69
+ const entry = asNumber(signal.entry ?? signal.limit ?? signal.price) ?? asNumber(bar?.close);
70
+ const stop = asNumber(signal.stop ?? signal.stopLoss ?? signal.sl);
71
+ if (entry === null || stop === null) return null;
72
+
73
+ const risk = Math.abs(entry - stop);
74
+ if (!(risk > 0)) return null;
75
+
76
+ let takeProfit = asNumber(signal.takeProfit ?? signal.target ?? signal.tp);
77
+ const rrHint = asNumber(signal._rr ?? signal.rr);
78
+ const targetR = rrHint ?? fallbackR;
79
+
80
+ if (takeProfit === null && Number.isFinite(targetR) && targetR > 0) {
81
+ takeProfit = side === "long" ? entry + risk * targetR : entry - risk * targetR;
82
+ }
83
+ if (takeProfit === null) return null;
84
+
85
+ return {
86
+ ...signal,
87
+ side,
88
+ entry,
89
+ stop,
90
+ takeProfit,
91
+ qty: asNumber(signal.qty ?? signal.size),
92
+ riskPct: asNumber(signal.riskPct),
93
+ riskFraction: asNumber(signal.riskFraction),
94
+ orderType: hasExplicitEntry ? "limit" : "market",
95
+ };
96
+ }
97
+
98
+ function equityPoint(time, equity) {
99
+ return { time, timestamp: time, equity };
100
+ }
101
+
102
+ function xmur3(seed) {
103
+ let hash = 1779033703 ^ seed.length;
104
+ for (let index = 0; index < seed.length; index += 1) {
105
+ hash = Math.imul(hash ^ seed.charCodeAt(index), 3432918353);
106
+ hash = (hash << 13) | (hash >>> 19);
107
+ }
108
+ return () => {
109
+ hash = Math.imul(hash ^ (hash >>> 16), 2246822507);
110
+ hash = Math.imul(hash ^ (hash >>> 13), 3266489909);
111
+ return (hash ^= hash >>> 16) >>> 0;
112
+ };
113
+ }
114
+
115
+ function mulberry32(seed) {
116
+ let state = seed >>> 0;
117
+ return () => {
118
+ state = (state + 0x6d2b79f5) >>> 0;
119
+ let value = Math.imul(state ^ (state >>> 15), state | 1);
120
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
121
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
122
+ };
123
+ }
124
+
125
+ function seededUnitInterval(seedParts) {
126
+ const seed = seedParts.map((part) => String(part)).join("|");
127
+ const seedFn = xmur3(seed);
128
+ return mulberry32(seedFn())();
129
+ }
130
+
131
+ function deterministicFill(probability, seedParts) {
132
+ if (probability >= 1) return true;
133
+ if (probability <= 0) return false;
134
+ const normalized = seededUnitInterval(seedParts);
135
+ return normalized <= probability;
136
+ }
137
+
138
+ /**
139
+ * Run a tick-level backtest with event-driven fills.
140
+ *
141
+ * Tick data is normalized into `{ time, open, high, low, close }` shape, then
142
+ * the strategy `signal()` callback is evaluated sequentially. Returned payload
143
+ * matches the candle engine: `trades`, `positions`, `metrics`, `eqSeries`, and
144
+ * chart-friendly `replay` frames/events.
145
+ */
146
+ export function backtestTicks({
147
+ ticks = [],
148
+ symbol = "UNKNOWN",
149
+ equity = 10_000,
150
+ riskPct = 1,
151
+ signal,
152
+ interval,
153
+ range,
154
+ slippageBps = 1,
155
+ feeBps = 0,
156
+ costs = null,
157
+ finalTP_R = 3,
158
+ maxDailyLossPct = 0,
159
+ dailyMaxTrades = 0,
160
+ qtyStep = 0.001,
161
+ minQty = 0.001,
162
+ maxLeverage = 2,
163
+ collectEqSeries = true,
164
+ collectReplay = true,
165
+ queueFillProbability = 1,
166
+ oco = {},
167
+ } = {}) {
168
+ if (!Array.isArray(ticks) || ticks.length === 0) {
169
+ throw new Error(
170
+ `backtestTicks() requires a non-empty ticks array, got ${describeValue(ticks)}`
171
+ );
172
+ }
173
+ if (typeof signal !== "function") {
174
+ throw new Error(`backtestTicks() requires a signal function, got ${describeValue(signal)}`);
175
+ }
176
+
177
+ const normalizedTicks = ticks.map(normalizeTick).filter(Boolean);
178
+ if (!normalizedTicks.length) {
179
+ throw new Error(
180
+ `backtestTicks() could not normalize any ticks from ${ticks.length} input rows`
181
+ );
182
+ }
183
+
184
+ const ocoOptions = {
185
+ mode: "intrabar",
186
+ tieBreak: "pessimistic",
187
+ ...oco,
188
+ };
189
+
190
+ const trades = [];
191
+ const eqSeries = collectEqSeries ? [equityPoint(normalizedTicks[0].time, equity)] : [];
192
+ const replayFrames = collectReplay ? [] : [];
193
+ const replayEvents = collectReplay ? [] : [];
194
+ const history = [];
195
+ let open = null;
196
+ let pending = null;
197
+ let currentEquity = equity;
198
+ let dayKey = null;
199
+ let dayStartEquity = equity;
200
+ let dayPnl = 0;
201
+ let dayTrades = 0;
202
+ let tradeIdCounter = 0;
203
+
204
+ function markedEquity(tick) {
205
+ if (!open) return currentEquity;
206
+ const direction = open.side === "long" ? 1 : -1;
207
+ return currentEquity + (tick.close - open.entryFill) * direction * open.size;
208
+ }
209
+
210
+ function recordFrame(tick) {
211
+ const equityNow = markedEquity(tick);
212
+ if (collectEqSeries) {
213
+ eqSeries.push(equityPoint(tick.time, equityNow));
214
+ }
215
+ if (collectReplay) {
216
+ replayFrames.push({
217
+ t: new Date(tick.time).toISOString(),
218
+ price: tick.close,
219
+ equity: equityNow,
220
+ posSide: open?.side ?? null,
221
+ posSize: open?.size ?? 0,
222
+ });
223
+ }
224
+ }
225
+
226
+ function closePosition(tick, reason, rawPrice, fillKind) {
227
+ if (!open) return;
228
+ const exitSide = open.side === "long" ? "short" : "long";
229
+ const { price, feeTotal } = applyFill(rawPrice, exitSide, {
230
+ slippageBps,
231
+ feeBps,
232
+ kind: fillKind,
233
+ qty: open.size,
234
+ costs,
235
+ });
236
+ const direction = open.side === "long" ? 1 : -1;
237
+ const grossPnl = (price - open.entryFill) * direction * open.size;
238
+ const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal;
239
+ currentEquity += pnl;
240
+ dayPnl += pnl;
241
+ const trade = {
242
+ ...open,
243
+ exit: {
244
+ price,
245
+ time: tick.time,
246
+ reason,
247
+ pnl,
248
+ },
249
+ };
250
+ trades.push(trade);
251
+ if (collectReplay) {
252
+ replayEvents.push({
253
+ t: new Date(tick.time).toISOString(),
254
+ price,
255
+ type: reason === "TP" ? "tp" : reason === "SL" ? "sl" : "exit",
256
+ side: open.side,
257
+ size: open.size,
258
+ tradeId: open.id,
259
+ reason,
260
+ pnl,
261
+ });
262
+ }
263
+ open = null;
264
+ }
265
+
266
+ for (let index = 0; index < normalizedTicks.length; index += 1) {
267
+ const tick = normalizedTicks[index];
268
+ history.push(tick);
269
+
270
+ const currentDayKey = dayKeyUTC(tick.time);
271
+ if (dayKey === null || currentDayKey !== dayKey) {
272
+ dayKey = currentDayKey;
273
+ dayStartEquity = currentEquity;
274
+ dayPnl = 0;
275
+ dayTrades = 0;
276
+ }
277
+
278
+ if (open) {
279
+ const { hit, px } = ocoExitCheck({
280
+ side: open.side,
281
+ stop: open.stop,
282
+ tp: open.takeProfit,
283
+ bar: tick,
284
+ mode: "intrabar",
285
+ tieBreak: ocoOptions.tieBreak,
286
+ });
287
+ if (hit) {
288
+ closePosition(tick, hit, px, hit === "TP" ? "limit" : "stop");
289
+ }
290
+ }
291
+
292
+ if (!open && pending && index > pending.createdAtIndex) {
293
+ if (pending.orderType === "market") {
294
+ const rawSize =
295
+ pending.fixedQty ??
296
+ calculatePositionSize({
297
+ equity: currentEquity,
298
+ entry: tick.close,
299
+ stop: pending.stop,
300
+ riskFraction: pending.riskFrac,
301
+ qtyStep,
302
+ minQty,
303
+ maxLeverage,
304
+ });
305
+ const size = roundStep(rawSize, qtyStep);
306
+ if (size >= minQty) {
307
+ const { price, feeTotal } = applyFill(tick.close, pending.side, {
308
+ slippageBps,
309
+ feeBps,
310
+ kind: "market",
311
+ qty: size,
312
+ costs,
313
+ });
314
+ open = {
315
+ symbol,
316
+ id: ++tradeIdCounter,
317
+ side: pending.side,
318
+ entry: tick.close,
319
+ stop: pending.stop,
320
+ takeProfit: pending.takeProfit,
321
+ size,
322
+ openTime: tick.time,
323
+ entryFill: price,
324
+ entryFeeTotal: feeTotal,
325
+ _initRisk: Math.abs(tick.close - pending.stop),
326
+ };
327
+ dayTrades += 1;
328
+ if (collectReplay) {
329
+ replayEvents.push({
330
+ t: new Date(tick.time).toISOString(),
331
+ price,
332
+ type: "entry",
333
+ side: open.side,
334
+ size,
335
+ tradeId: open.id,
336
+ });
337
+ }
338
+ }
339
+ pending = null;
340
+ } else {
341
+ const touched =
342
+ pending.side === "long" ? tick.low <= pending.entry : tick.high >= pending.entry;
343
+ if (
344
+ touched &&
345
+ deterministicFill(queueFillProbability, [
346
+ symbol,
347
+ tick.time,
348
+ pending.entry,
349
+ pending.stop,
350
+ pending.side,
351
+ ])
352
+ ) {
353
+ const rawSize =
354
+ pending.fixedQty ??
355
+ calculatePositionSize({
356
+ equity: currentEquity,
357
+ entry: pending.entry,
358
+ stop: pending.stop,
359
+ riskFraction: pending.riskFrac,
360
+ qtyStep,
361
+ minQty,
362
+ maxLeverage,
363
+ });
364
+ const size = roundStep(rawSize, qtyStep);
365
+ if (size >= minQty) {
366
+ const { price, feeTotal } = applyFill(pending.entry, pending.side, {
367
+ slippageBps,
368
+ feeBps,
369
+ kind: "limit",
370
+ qty: size,
371
+ costs,
372
+ });
373
+ open = {
374
+ symbol,
375
+ id: ++tradeIdCounter,
376
+ side: pending.side,
377
+ entry: pending.entry,
378
+ stop: pending.stop,
379
+ takeProfit: pending.takeProfit,
380
+ size,
381
+ openTime: tick.time,
382
+ entryFill: price,
383
+ entryFeeTotal: feeTotal,
384
+ _initRisk: Math.abs(pending.entry - pending.stop),
385
+ };
386
+ dayTrades += 1;
387
+ if (collectReplay) {
388
+ replayEvents.push({
389
+ t: new Date(tick.time).toISOString(),
390
+ price,
391
+ type: "entry",
392
+ side: open.side,
393
+ size,
394
+ tradeId: open.id,
395
+ });
396
+ }
397
+ }
398
+ pending = null;
399
+ }
400
+ }
401
+ }
402
+
403
+ const maxLossDollars = (Math.abs(maxDailyLossPct) / 100) * dayStartEquity;
404
+ const dailyLossHit = maxDailyLossPct > 0 && dayPnl <= -maxLossDollars;
405
+ const dailyTradeCapHit = dailyMaxTrades > 0 && dayTrades >= dailyMaxTrades;
406
+
407
+ if (!open && !pending && !dailyLossHit && !dailyTradeCapHit) {
408
+ const nextSignal = normalizeSignal(
409
+ callSignalWithContext({
410
+ signal,
411
+ context: {
412
+ candles: history,
413
+ index,
414
+ bar: tick,
415
+ equity: markedEquity(tick),
416
+ openPosition: open,
417
+ pendingOrder: pending,
418
+ },
419
+ index,
420
+ bar: tick,
421
+ symbol,
422
+ }),
423
+ tick,
424
+ finalTP_R
425
+ );
426
+
427
+ if (nextSignal) {
428
+ pending = {
429
+ side: nextSignal.side,
430
+ entry: nextSignal.entry,
431
+ stop: nextSignal.stop,
432
+ takeProfit: nextSignal.takeProfit,
433
+ fixedQty: nextSignal.qty,
434
+ riskFrac: Number.isFinite(nextSignal.riskFraction)
435
+ ? nextSignal.riskFraction
436
+ : Number.isFinite(nextSignal.riskPct)
437
+ ? nextSignal.riskPct / 100
438
+ : riskPct / 100,
439
+ orderType: nextSignal.orderType,
440
+ createdAtIndex: index,
441
+ };
442
+ }
443
+ }
444
+
445
+ recordFrame(tick);
446
+ }
447
+
448
+ if (open) {
449
+ const lastTick = normalizedTicks[normalizedTicks.length - 1];
450
+ closePosition(lastTick, "EOT", lastTick.close, "market");
451
+ recordFrame(lastTick);
452
+ }
453
+
454
+ const positions = trades;
455
+ const metrics = buildMetrics({
456
+ closed: trades,
457
+ equityStart: equity,
458
+ equityFinal: currentEquity,
459
+ candles: normalizedTicks,
460
+ estBarMs:
461
+ normalizedTicks.length > 1
462
+ ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time)
463
+ : 1,
464
+ eqSeries,
465
+ });
466
+
467
+ return {
468
+ symbol,
469
+ interval,
470
+ range,
471
+ trades,
472
+ positions,
473
+ openPositions: [],
474
+ metrics,
475
+ eqSeries,
476
+ replay: {
477
+ frames: replayFrames,
478
+ events: replayEvents,
479
+ },
480
+ };
481
+ }