tradelab 1.0.1 → 1.2.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 (66) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +188 -328
  3. package/bin/tradelab-mcp.js +7 -0
  4. package/bin/tradelab.js +29 -0
  5. package/dist/cjs/data.cjs +149 -26
  6. package/dist/cjs/index.cjs +1917 -1005
  7. package/dist/cjs/live.cjs +536 -25
  8. package/dist/cjs/ta.cjs +339 -0
  9. package/docs/README.md +32 -66
  10. package/docs/api-reference.md +283 -112
  11. package/docs/backtest-engine.md +210 -252
  12. package/docs/data-reporting-cli.md +114 -156
  13. package/docs/examples.md +6 -6
  14. package/docs/live-trading.md +263 -92
  15. package/docs/mcp.md +285 -0
  16. package/docs/research.md +157 -0
  17. package/examples/liveDashboard.js +33 -0
  18. package/examples/llmSignal.js +33 -0
  19. package/examples/mcpLiveTrading.js +77 -0
  20. package/examples/optimize.js +25 -0
  21. package/package.json +26 -4
  22. package/src/engine/asyncSignal.js +28 -0
  23. package/src/engine/backtest.js +13 -1
  24. package/src/engine/backtestAsync.js +27 -0
  25. package/src/engine/backtestTicks.js +13 -2
  26. package/src/engine/barSystemRunner.js +96 -41
  27. package/src/engine/execution.js +39 -0
  28. package/src/engine/grid.js +15 -0
  29. package/src/engine/llmSignal.js +84 -0
  30. package/src/engine/optimize.js +110 -0
  31. package/src/engine/optimizeWorker.js +67 -0
  32. package/src/engine/portfolio.js +4 -1
  33. package/src/engine/walkForward.js +1 -0
  34. package/src/index.js +9 -0
  35. package/src/live/dashboard/server.js +179 -0
  36. package/src/live/engine/liveEngine.js +2 -2
  37. package/src/live/engine/paperEngine.js +5 -0
  38. package/src/live/index.js +3 -0
  39. package/src/live/session.js +402 -0
  40. package/src/mcp/liveTools.js +179 -0
  41. package/src/mcp/schemas.js +167 -0
  42. package/src/mcp/server.js +35 -0
  43. package/src/mcp/tools.js +265 -0
  44. package/src/metrics/annualize.js +32 -0
  45. package/src/metrics/benchmark.js +55 -0
  46. package/src/metrics/buildMetrics.js +34 -13
  47. package/src/metrics/finite.js +17 -0
  48. package/src/research/combinations.js +18 -0
  49. package/src/research/cpcv.js +47 -0
  50. package/src/research/deflatedSharpe.js +35 -0
  51. package/src/research/index.js +6 -0
  52. package/src/research/monteCarlo.js +88 -0
  53. package/src/research/pbo.js +69 -0
  54. package/src/research/stats.js +78 -0
  55. package/src/strategies/builtins.js +96 -0
  56. package/src/strategies/index.js +30 -0
  57. package/src/ta/channels.js +67 -0
  58. package/src/ta/index.js +16 -0
  59. package/src/ta/oscillators.js +70 -0
  60. package/src/ta/trend.js +78 -0
  61. package/src/utils/random.js +33 -0
  62. package/templates/dashboard.html +661 -0
  63. package/types/index.d.ts +179 -0
  64. package/types/live.d.ts +114 -0
  65. package/types/mcp.d.ts +17 -0
  66. package/types/ta.d.ts +45 -0
@@ -0,0 +1,28 @@
1
+ export class BudgetExceededError extends Error {
2
+ constructor(ms) {
3
+ super(`signal() exceeded its ${ms}ms per-bar budget`);
4
+ this.name = "BudgetExceededError";
5
+ this.budgetMs = ms;
6
+ }
7
+ }
8
+
9
+ /**
10
+ * Race a promise against a per-bar time budget. `budgetMs` of 0/undefined
11
+ * disables the timeout. Rejects with BudgetExceededError on overrun.
12
+ */
13
+ export function withBudget(promise, budgetMs) {
14
+ if (!budgetMs || budgetMs <= 0) return Promise.resolve(promise);
15
+ return new Promise((resolve, reject) => {
16
+ const timer = setTimeout(() => reject(new BudgetExceededError(budgetMs)), budgetMs);
17
+ Promise.resolve(promise).then(
18
+ (value) => {
19
+ clearTimeout(timer);
20
+ resolve(value);
21
+ },
22
+ (err) => {
23
+ clearTimeout(timer);
24
+ reject(err);
25
+ }
26
+ );
27
+ });
28
+ }
@@ -12,6 +12,7 @@ import {
12
12
  estimateBarMs,
13
13
  dayKeyUTC,
14
14
  dayKeyET,
15
+ financingCost,
15
16
  } from "./execution.js";
16
17
 
17
18
  function asNumber(value) {
@@ -154,6 +155,7 @@ function mergeOptions(options) {
154
155
  maxSlipROnFill: options.maxSlipROnFill ?? 0.4,
155
156
  collectEqSeries: options.collectEqSeries ?? true,
156
157
  collectReplay: options.collectReplay ?? true,
158
+ benchmarkReturns: Array.isArray(options.benchmarkReturns) ? options.benchmarkReturns : null,
157
159
  strict: options.strict ?? false,
158
160
  };
159
161
  }
@@ -305,7 +307,14 @@ export function backtest(rawOptions) {
305
307
  const entryFill = openPos.entryFill;
306
308
  const grossPnl = (exitPx - entryFill) * direction * qty;
307
309
  const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
308
- const pnl = grossPnl - entryFeePortion - exitFeeTotal;
310
+ const financing = financingCost({
311
+ side: openPos.side,
312
+ notional: entryFill * qty,
313
+ fromMs: openPos.openTime,
314
+ toMs: time,
315
+ costs,
316
+ });
317
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
309
318
 
310
319
  currentEquity += pnl;
311
320
  dayPnl += pnl;
@@ -349,6 +358,7 @@ export function backtest(rawOptions) {
349
358
  time,
350
359
  reason,
351
360
  pnl,
361
+ financing,
352
362
  exitATR: openPos._lastATR ?? undefined,
353
363
  },
354
364
  mfeR: openPos._mfeR ?? 0,
@@ -872,6 +882,8 @@ export function backtest(rawOptions) {
872
882
  candles,
873
883
  estBarMs: estimatedBarMs,
874
884
  eqSeries,
885
+ interval: options.interval,
886
+ benchmarkReturns: options.benchmarkReturns,
875
887
  });
876
888
  const positions = closed.filter((trade) => trade.exit.reason !== "SCALE");
877
889
  const lastPrice = asNumber(candles[candles.length - 1]?.close);
@@ -0,0 +1,27 @@
1
+ import { BarSystemRunner } from "./barSystemRunner.js";
2
+ import { withBudget } from "./asyncSignal.js";
3
+
4
+ /**
5
+ * Async sibling of backtest(). Identical result shape, but `signal()` may return
6
+ * a Promise. Each bar's signal is raced against `signalBudgetMs` (0 disables).
7
+ *
8
+ * Built on BarSystemRunner so position/pending/exit logic is shared with the
9
+ * sync engine and portfolio mode.
10
+ */
11
+ export async function backtestAsync(rawOptions = {}) {
12
+ const budgetMs = rawOptions.signalBudgetMs ?? 0;
13
+ const userSignal = rawOptions.signal;
14
+ const budgetedSignal = (context) =>
15
+ withBudget(
16
+ Promise.resolve().then(() => userSignal(context)),
17
+ budgetMs
18
+ );
19
+
20
+ const runner = new BarSystemRunner({ ...rawOptions, signal: budgetedSignal });
21
+
22
+ while (runner.hasNext()) {
23
+ await runner.stepAsync({ signalEquity: runner.getMarkedEquity() });
24
+ }
25
+
26
+ return runner.buildResult();
27
+ }
@@ -1,6 +1,6 @@
1
1
  import { buildMetrics } from "../metrics/buildMetrics.js";
2
2
  import { calculatePositionSize } from "../utils/positionSizing.js";
3
- import { applyFill, dayKeyUTC, ocoExitCheck, roundStep } from "./execution.js";
3
+ import { applyFill, dayKeyUTC, financingCost, ocoExitCheck, roundStep } from "./execution.js";
4
4
 
5
5
  function asNumber(value) {
6
6
  const numeric = Number(value);
@@ -163,6 +163,7 @@ export function backtestTicks({
163
163
  collectEqSeries = true,
164
164
  collectReplay = true,
165
165
  queueFillProbability = 1,
166
+ seed = "tradelab-ticks",
166
167
  oco = {},
167
168
  } = {}) {
168
169
  if (!Array.isArray(ticks) || ticks.length === 0) {
@@ -235,7 +236,14 @@ export function backtestTicks({
235
236
  });
236
237
  const direction = open.side === "long" ? 1 : -1;
237
238
  const grossPnl = (price - open.entryFill) * direction * open.size;
238
- const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal;
239
+ const financing = financingCost({
240
+ side: open.side,
241
+ notional: open.entryFill * open.size,
242
+ fromMs: open.openTime,
243
+ toMs: tick.time,
244
+ costs,
245
+ });
246
+ const pnl = grossPnl - (open.entryFeeTotal || 0) - feeTotal - financing;
239
247
  currentEquity += pnl;
240
248
  dayPnl += pnl;
241
249
  const trade = {
@@ -245,6 +253,7 @@ export function backtestTicks({
245
253
  time: tick.time,
246
254
  reason,
247
255
  pnl,
256
+ financing,
248
257
  },
249
258
  };
250
259
  trades.push(trade);
@@ -343,6 +352,7 @@ export function backtestTicks({
343
352
  if (
344
353
  touched &&
345
354
  deterministicFill(queueFillProbability, [
355
+ seed,
346
356
  symbol,
347
357
  tick.time,
348
358
  pending.entry,
@@ -462,6 +472,7 @@ export function backtestTicks({
462
472
  ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time)
463
473
  : 1,
464
474
  eqSeries,
475
+ interval,
465
476
  });
466
477
 
467
478
  return {
@@ -5,6 +5,7 @@ import { normalizeCandles } from "../data/csv.js";
5
5
  import {
6
6
  applyFill,
7
7
  clampStop,
8
+ financingCost,
8
9
  touchedLimit,
9
10
  ocoExitCheck,
10
11
  isEODBar,
@@ -63,6 +64,17 @@ export function callSignalWithContext({ signal, context, index, bar, symbol }) {
63
64
  }
64
65
  }
65
66
 
67
+ export async function callSignalWithContextAsync({ signal, context, index, bar, symbol }) {
68
+ try {
69
+ return await signal(context);
70
+ } catch (error) {
71
+ const cause = error instanceof Error ? error.message : String(error);
72
+ throw new Error(
73
+ `signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
74
+ );
75
+ }
76
+ }
77
+
66
78
  export function snapshotOpenPosition(open, markPrice) {
67
79
  if (!open) return null;
68
80
  const entryPrice = open.entryFill ?? open.entry;
@@ -305,7 +317,14 @@ export class BarSystemRunner {
305
317
  const entryFill = openPos.entryFill;
306
318
  const grossPnl = (exitPx - entryFill) * direction * qty;
307
319
  const entryFeePortion = (openPos.entryFeeTotal || 0) * (qty / openPos.initSize);
308
- const pnl = grossPnl - entryFeePortion - exitFeeTotal;
320
+ const financing = financingCost({
321
+ side: openPos.side,
322
+ notional: entryFill * qty,
323
+ fromMs: openPos.openTime,
324
+ toMs: time,
325
+ costs: this.options.costs,
326
+ });
327
+ const pnl = grossPnl - entryFeePortion - exitFeeTotal - financing;
309
328
 
310
329
  this.currentEquity += pnl;
311
330
  this.dayPnl += pnl;
@@ -350,6 +369,7 @@ export class BarSystemRunner {
350
369
  time,
351
370
  reason,
352
371
  pnl,
372
+ financing,
353
373
  exitATR: openPos._lastATR ?? undefined,
354
374
  },
355
375
  mfeR: openPos._mfeR ?? 0,
@@ -544,8 +564,8 @@ export class BarSystemRunner {
544
564
  };
545
565
  }
546
566
 
547
- step({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
548
- if (!this.hasNext()) return null;
567
+ _preSignal({ signalEquity, canTrade = true, resolveEntrySize } = {}) {
568
+ if (!this.hasNext()) return { handled: true, bar: null };
549
569
 
550
570
  const bar = this.candles[this.index];
551
571
  this.history.push(bar);
@@ -929,52 +949,60 @@ export class BarSystemRunner {
929
949
  if (this.cooldown > 0) this.cooldown -= 1;
930
950
  this.recordFrame(bar);
931
951
  this.index += 1;
932
- return bar;
952
+ return { handled: true, bar };
933
953
  }
934
954
 
935
955
  if (!canTrade || dailyLossHit || dailyTradeCapHit) {
936
956
  this.pending = null;
937
957
  this.recordFrame(bar);
938
958
  this.index += 1;
939
- return bar;
959
+ return { handled: true, bar };
940
960
  }
941
961
 
942
- if (!this.pending) {
943
- const rawSignal = callSignalWithContext({
944
- signal: this.options.signal,
945
- context: this.buildSignalContext(this.index, bar, signalEquity),
946
- index: this.index,
947
- bar,
948
- symbol: this.symbol,
949
- });
950
- const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
951
-
952
- if (nextSignal) {
953
- const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
954
- ? nextSignal.riskFraction
955
- : Number.isFinite(nextSignal.riskPct)
956
- ? nextSignal.riskPct / 100
957
- : this.options.riskPct / 100;
958
- const expiryBars = nextSignal._entryExpiryBars ?? 5;
959
- this.pending = {
960
- side: nextSignal.side,
961
- entry: nextSignal.entry,
962
- stop: nextSignal.stop,
963
- tp: nextSignal.takeProfit,
964
- riskFrac: signalRiskFraction,
965
- fixedQty: nextSignal.qty,
966
- expiresAt: this.index + Math.max(1, expiryBars),
967
- startedAtIndex: this.index,
968
- meta: nextSignal,
969
- plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
970
- };
971
-
972
- if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
973
- if (
974
- !this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
975
- ) {
976
- this.pending = null;
977
- }
962
+ if (this.pending) {
963
+ this.recordFrame(bar);
964
+ this.index += 1;
965
+ return { handled: true, bar };
966
+ }
967
+
968
+ return {
969
+ handled: false,
970
+ bar,
971
+ trigger,
972
+ signalEquity,
973
+ resolveEntrySize,
974
+ };
975
+ }
976
+
977
+ _applyRawSignal(rawSignal, pre) {
978
+ const { bar, trigger, signalEquity, resolveEntrySize } = pre;
979
+ const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
980
+
981
+ if (nextSignal) {
982
+ const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
983
+ ? nextSignal.riskFraction
984
+ : Number.isFinite(nextSignal.riskPct)
985
+ ? nextSignal.riskPct / 100
986
+ : this.options.riskPct / 100;
987
+ const expiryBars = nextSignal._entryExpiryBars ?? 5;
988
+ this.pending = {
989
+ side: nextSignal.side,
990
+ entry: nextSignal.entry,
991
+ stop: nextSignal.stop,
992
+ tp: nextSignal.takeProfit,
993
+ riskFrac: signalRiskFraction,
994
+ fixedQty: nextSignal.qty,
995
+ expiresAt: this.index + Math.max(1, expiryBars),
996
+ startedAtIndex: this.index,
997
+ meta: nextSignal,
998
+ plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
999
+ };
1000
+
1001
+ if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
1002
+ if (
1003
+ !this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
1004
+ ) {
1005
+ this.pending = null;
978
1006
  }
979
1007
  }
980
1008
  }
@@ -984,6 +1012,32 @@ export class BarSystemRunner {
984
1012
  return bar;
985
1013
  }
986
1014
 
1015
+ step(options = {}) {
1016
+ const pre = this._preSignal(options);
1017
+ if (pre.handled) return pre.bar;
1018
+ const rawSignal = callSignalWithContext({
1019
+ signal: this.options.signal,
1020
+ context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
1021
+ index: this.index,
1022
+ bar: pre.bar,
1023
+ symbol: this.symbol,
1024
+ });
1025
+ return this._applyRawSignal(rawSignal, pre);
1026
+ }
1027
+
1028
+ async stepAsync(options = {}) {
1029
+ const pre = this._preSignal(options);
1030
+ if (pre.handled) return pre.bar;
1031
+ const rawSignal = await callSignalWithContextAsync({
1032
+ signal: this.options.signal,
1033
+ context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
1034
+ index: this.index,
1035
+ bar: pre.bar,
1036
+ symbol: this.symbol,
1037
+ });
1038
+ return this._applyRawSignal(rawSignal, pre);
1039
+ }
1040
+
987
1041
  buildResult() {
988
1042
  const metrics = buildMetrics({
989
1043
  closed: this.closed,
@@ -992,6 +1046,7 @@ export class BarSystemRunner {
992
1046
  candles: this.candles,
993
1047
  estBarMs: this.estimatedBarMs,
994
1048
  eqSeries: this.eqSeries,
1049
+ interval: this.options.interval,
995
1050
  });
996
1051
  const positions = this.closed.filter((trade) => trade.exit.reason !== "SCALE");
997
1052
  const lastPrice = asNumber(this.candles[this.candles.length - 1]?.close);
@@ -127,3 +127,42 @@ export function dayKeyET(timeMs) {
127
127
  const pseudoEtTime = anchor.getTime() + hoursET * 60 * 60 * 1000 + minutesETDay * 60 * 1000;
128
128
  return dayKeyUTC(pseudoEtTime);
129
129
  }
130
+
131
+ const MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000;
132
+
133
+ /**
134
+ * Count funding boundaries in the half-open interval (fromMs, toMs], given a
135
+ * funding `intervalMs` cadence anchored at `anchorMs`.
136
+ */
137
+ export function fundingEvents(fromMs, toMs, intervalMs, anchorMs = 0) {
138
+ if (!(intervalMs > 0) || toMs <= fromMs) return 0;
139
+ const firstK = Math.floor((fromMs - anchorMs) / intervalMs) + 1;
140
+ const lastK = Math.floor((toMs - anchorMs) / intervalMs);
141
+ return Math.max(0, lastK - firstK + 1);
142
+ }
143
+
144
+ /**
145
+ * Time-based financing cost for holding `notional` from `fromMs` to `toMs`.
146
+ * Positive return = cost to the position (subtract from PnL).
147
+ */
148
+ export function financingCost({ side, notional, fromMs, toMs, costs }) {
149
+ const model = costs || {};
150
+ const absNotional = Math.abs(notional);
151
+ let cost = 0;
152
+
153
+ if (model.carry) {
154
+ const annualBps =
155
+ side === "long" ? (model.carry.longAnnualBps ?? 0) : (model.carry.shortAnnualBps ?? 0);
156
+ const years = Math.max(0, toMs - fromMs) / MS_PER_YEAR;
157
+ cost += absNotional * (annualBps / 10000) * years;
158
+ }
159
+
160
+ const funding = model.funding;
161
+ if (funding && funding.intervalMs > 0 && Number.isFinite(funding.rateBps)) {
162
+ const count = fundingEvents(fromMs, toMs, funding.intervalMs, funding.anchorMs ?? 0);
163
+ const perEvent = absNotional * (funding.rateBps / 10000);
164
+ cost += (side === "long" ? 1 : -1) * perEvent * count;
165
+ }
166
+
167
+ return cost;
168
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Expand a parameter grid into an array of parameter-set objects.
3
+ * Array values are swept; scalar values are held fixed across all sets.
4
+ */
5
+ export function grid(spec = {}) {
6
+ const keys = Object.keys(spec);
7
+ if (!keys.length) return [{}];
8
+ return keys.reduce(
9
+ (acc, key) => {
10
+ const values = Array.isArray(spec[key]) ? spec[key] : [spec[key]];
11
+ return acc.flatMap((base) => values.map((v) => ({ ...base, [key]: v })));
12
+ },
13
+ [{}]
14
+ );
15
+ }
@@ -0,0 +1,84 @@
1
+ import { withBudget } from "./asyncSignal.js";
2
+
3
+ function isArrayIndexKey(property) {
4
+ if (typeof property !== "string") return false;
5
+ const n = Number(property);
6
+ return Number.isInteger(n) && n >= 0;
7
+ }
8
+
9
+ function noLookaheadView(candles, index) {
10
+ return new Proxy(candles, {
11
+ get(target, property, receiver) {
12
+ if (isArrayIndexKey(property) && Number(property) > index) {
13
+ throw new Error(
14
+ `LlmSignal: lookahead access to candles[${String(property)}] (current index ${index})`
15
+ );
16
+ }
17
+ return Reflect.get(target, property, receiver);
18
+ },
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Wraps an async model-backed decision function for use as a tradelab signal.
24
+ *
25
+ * - Caches by bar time: resolve() runs at most once per bar.
26
+ * - Enforces a per-bar `budgetMs` time budget.
27
+ * - Exposes a no-lookahead candle view to resolve().
28
+ * - Logs every decision (context summary, result or error) in `this.log`.
29
+ *
30
+ * `onError`: "skip" (return null, default) or "throw".
31
+ * Use the instance's `.signal` bound method as the engine's `signal` option.
32
+ */
33
+ export class LlmSignal {
34
+ constructor({ resolve, budgetMs = 0, onError = "skip" } = {}) {
35
+ if (typeof resolve !== "function") {
36
+ throw new Error("LlmSignal requires a resolve(context) function");
37
+ }
38
+ this.resolve = resolve;
39
+ this.budgetMs = budgetMs;
40
+ this.onError = onError;
41
+ this.log = [];
42
+ this._cache = new Map();
43
+ this.signal = this.signal.bind(this);
44
+ }
45
+
46
+ async signal(context) {
47
+ const key = context.bar?.time ?? context.index;
48
+ if (this._cache.has(key)) return this._cache.get(key);
49
+
50
+ const safeContext = {
51
+ ...context,
52
+ candles: noLookaheadView(context.candles, context.index),
53
+ };
54
+
55
+ const startedAt = Date.now();
56
+ try {
57
+ const result = await withBudget(
58
+ Promise.resolve().then(() => this.resolve(safeContext)),
59
+ this.budgetMs
60
+ );
61
+ this._cache.set(key, result ?? null);
62
+ this.log.push({
63
+ index: context.index,
64
+ time: context.bar?.time,
65
+ close: context.bar?.close,
66
+ latencyMs: Date.now() - startedAt,
67
+ result: result ?? null,
68
+ });
69
+ return result ?? null;
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ this.log.push({
73
+ index: context.index,
74
+ time: context.bar?.time,
75
+ close: context.bar?.close,
76
+ latencyMs: Date.now() - startedAt,
77
+ error: message,
78
+ });
79
+ this._cache.set(key, null);
80
+ if (this.onError === "throw") throw error;
81
+ return null;
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,110 @@
1
+ import { Worker } from "node:worker_threads";
2
+ import os from "node:os";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ function defaultConcurrency() {
8
+ return Math.max(1, (os.cpus()?.length ?? 2) - 1);
9
+ }
10
+
11
+ function scoreValue(metrics, scoreBy) {
12
+ const v = metrics?.[scoreBy];
13
+ return Number.isFinite(v) ? v : -Infinity;
14
+ }
15
+
16
+ function callerModuleDir() {
17
+ const stack = new Error().stack || "";
18
+ const lines = stack.split("\n").slice(1);
19
+ const match = lines
20
+ .map((line) => line.match(/(?:\()?(file:\/\/\/[^\s)]+|\/[^\s)]+):\d+:\d+/))
21
+ .find(Boolean);
22
+ if (!match) return process.cwd();
23
+ const filePath = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
24
+ return path.dirname(filePath);
25
+ }
26
+
27
+ function workerUrl() {
28
+ const here = callerModuleDir();
29
+ const candidates = [
30
+ path.join(here, "optimizeWorker.js"),
31
+ path.join(here, "..", "..", "src", "engine", "optimizeWorker.js"),
32
+ path.join(process.cwd(), "src", "engine", "optimizeWorker.js"),
33
+ ];
34
+ return pathToFileURL(candidates.find((candidate) => existsSync(candidate)) || candidates[0]);
35
+ }
36
+
37
+ export function optimize({
38
+ candles,
39
+ signalModulePath,
40
+ parameterSets,
41
+ interval,
42
+ backtestOptions = {},
43
+ concurrency,
44
+ scoreBy = "profitFactor",
45
+ }) {
46
+ if (!Array.isArray(parameterSets) || parameterSets.length === 0) {
47
+ return Promise.resolve({ results: [], leaderboard: [], best: null });
48
+ }
49
+
50
+ return new Promise((resolve, reject) => {
51
+ const poolSize = Math.min(concurrency || defaultConcurrency(), parameterSets.length);
52
+ const results = new Array(parameterSets.length);
53
+ const workers = [];
54
+ let nextIndex = 0;
55
+ let completed = 0;
56
+ let settled = false;
57
+
58
+ const finish = () => {
59
+ if (settled) return;
60
+ settled = true;
61
+ for (const w of workers) w.terminate();
62
+ const ranked = results
63
+ .filter((r) => r && r.metrics)
64
+ .sort((a, b) => scoreValue(b.metrics, scoreBy) - scoreValue(a.metrics, scoreBy));
65
+ resolve({ results, leaderboard: ranked, best: ranked[0] ?? null });
66
+ };
67
+
68
+ const fail = (error) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ for (const w of workers) w.terminate();
72
+ reject(error);
73
+ };
74
+
75
+ const dispatch = (worker) => {
76
+ if (nextIndex >= parameterSets.length) {
77
+ worker.postMessage({ type: "stop" });
78
+ return;
79
+ }
80
+ const index = nextIndex;
81
+ nextIndex += 1;
82
+ worker.postMessage({ type: "run", index, params: parameterSets[index] });
83
+ };
84
+
85
+ for (let i = 0; i < poolSize; i += 1) {
86
+ const worker = new Worker(workerUrl(), {
87
+ workerData: { candles, signalModulePath, interval, backtestOptions },
88
+ });
89
+ workers.push(worker);
90
+
91
+ worker.on("message", (msg) => {
92
+ if (msg.type === "ready") {
93
+ dispatch(worker);
94
+ return;
95
+ }
96
+ if (msg.type === "result" || msg.type === "error") {
97
+ results[msg.index] =
98
+ msg.type === "result"
99
+ ? { params: msg.params, metrics: msg.metrics }
100
+ : { params: msg.params, error: msg.error };
101
+ completed += 1;
102
+ if (completed === parameterSets.length) finish();
103
+ else dispatch(worker);
104
+ }
105
+ });
106
+
107
+ worker.on("error", fail);
108
+ }
109
+ });
110
+ }
@@ -0,0 +1,67 @@
1
+ import { workerData, parentPort } from "node:worker_threads";
2
+ import { pathToFileURL } from "node:url";
3
+ import { backtest } from "./backtest.js";
4
+
5
+ const { candles, signalModulePath, interval, backtestOptions } = workerData;
6
+
7
+ const mod = await import(pathToFileURL(signalModulePath).href);
8
+ const createSignal = mod.createSignal ?? mod.default;
9
+ if (typeof createSignal !== "function") {
10
+ throw new Error(
11
+ `optimize: ${signalModulePath} must export createSignal(params) or a default factory`
12
+ );
13
+ }
14
+
15
+ function pickMetrics(metrics) {
16
+ const keep = [
17
+ "trades",
18
+ "winRate",
19
+ "profitFactor",
20
+ "expectancy",
21
+ "totalR",
22
+ "avgR",
23
+ "sharpe",
24
+ "sharpeAnnualized",
25
+ "maxDrawdown",
26
+ "calmar",
27
+ "returnPct",
28
+ "totalPnL",
29
+ "finalEquity",
30
+ ];
31
+ const out = {};
32
+ for (const k of keep) out[k] = metrics[k];
33
+ return out;
34
+ }
35
+
36
+ parentPort.on("message", (msg) => {
37
+ if (msg.type === "stop") {
38
+ process.exit(0);
39
+ }
40
+ if (msg.type === "run") {
41
+ try {
42
+ const result = backtest({
43
+ candles,
44
+ interval,
45
+ signal: createSignal(msg.params),
46
+ collectReplay: false,
47
+ collectEqSeries: false,
48
+ ...backtestOptions,
49
+ });
50
+ parentPort.postMessage({
51
+ type: "result",
52
+ index: msg.index,
53
+ params: msg.params,
54
+ metrics: pickMetrics(result.metrics),
55
+ });
56
+ } catch (error) {
57
+ parentPort.postMessage({
58
+ type: "error",
59
+ index: msg.index,
60
+ params: msg.params,
61
+ error: error instanceof Error ? error.message : String(error),
62
+ });
63
+ }
64
+ }
65
+ });
66
+
67
+ parentPort.postMessage({ type: "ready" });