tradelab 1.0.0 → 1.1.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.
- package/CHANGELOG.md +66 -0
- package/README.md +75 -12
- package/bin/tradelab-mcp.js +7 -0
- package/bin/tradelab.js +29 -0
- package/dist/cjs/data.cjs +149 -26
- package/dist/cjs/index.cjs +1893 -1003
- package/dist/cjs/live.cjs +134 -25
- package/dist/cjs/ta.cjs +339 -0
- package/docs/api-reference.md +46 -0
- package/docs/backtest-engine.md +112 -0
- package/docs/live-trading.md +51 -0
- package/docs/mcp.md +64 -0
- package/docs/research.md +103 -0
- package/docs/superpowers/plans/2026-00-overview.md +101 -0
- package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
- package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
- package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
- package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
- package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
- package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
- package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
- package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
- package/docs/superpowers/plans/HANDOFF.md +88 -0
- package/examples/liveDashboard.js +33 -0
- package/examples/llmSignal.js +33 -0
- package/examples/optimize.js +25 -0
- package/package.json +16 -2
- package/src/engine/asyncSignal.js +28 -0
- package/src/engine/backtest.js +13 -1
- package/src/engine/backtestAsync.js +27 -0
- package/src/engine/backtestTicks.js +13 -2
- package/src/engine/barSystemRunner.js +96 -41
- package/src/engine/execution.js +39 -0
- package/src/engine/grid.js +15 -0
- package/src/engine/llmSignal.js +84 -0
- package/src/engine/optimize.js +86 -0
- package/src/engine/optimizeWorker.js +67 -0
- package/src/engine/walkForward.js +1 -0
- package/src/index.js +9 -0
- package/src/live/dashboard/server.js +120 -0
- package/src/live/engine/liveEngine.js +2 -2
- package/src/live/index.js +1 -0
- package/src/mcp/schemas.js +48 -0
- package/src/mcp/server.js +31 -0
- package/src/mcp/tools.js +142 -0
- package/src/metrics/annualize.js +32 -0
- package/src/metrics/benchmark.js +55 -0
- package/src/metrics/buildMetrics.js +34 -13
- package/src/metrics/finite.js +17 -0
- package/src/research/combinations.js +18 -0
- package/src/research/cpcv.js +47 -0
- package/src/research/deflatedSharpe.js +35 -0
- package/src/research/index.js +6 -0
- package/src/research/monteCarlo.js +88 -0
- package/src/research/pbo.js +69 -0
- package/src/research/stats.js +78 -0
- package/src/strategies/builtins.js +96 -0
- package/src/strategies/index.js +30 -0
- package/src/ta/channels.js +67 -0
- package/src/ta/index.js +16 -0
- package/src/ta/oscillators.js +70 -0
- package/src/ta/trend.js +78 -0
- package/src/utils/random.js +33 -0
- package/templates/dashboard.html +174 -0
- package/types/index.d.ts +154 -0
- package/types/live.d.ts +15 -0
- package/types/ta.d.ts +45 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Run: node examples/llmSignal.js
|
|
2
|
+
import { backtestAsync, LlmSignal, getHistoricalCandles } from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
const candles = await getHistoricalCandles({
|
|
5
|
+
source: "yahoo",
|
|
6
|
+
symbol: "SPY",
|
|
7
|
+
interval: "1d",
|
|
8
|
+
period: "1y",
|
|
9
|
+
cache: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Stand-in for a real model call. Replace `resolve` with an LLM/agent request.
|
|
13
|
+
const llm = new LlmSignal({
|
|
14
|
+
budgetMs: 2000,
|
|
15
|
+
onError: "skip",
|
|
16
|
+
async resolve({ candles: history, bar }) {
|
|
17
|
+
const closes = history.map((c) => c.close);
|
|
18
|
+
const recent = closes.slice(-5);
|
|
19
|
+
const rising = recent.every((c, i) => i === 0 || c >= recent[i - 1]);
|
|
20
|
+
return rising ? { side: "long", stop: bar.close * 0.98, rr: 2 } : null;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const result = await backtestAsync({
|
|
25
|
+
candles,
|
|
26
|
+
symbol: "SPY",
|
|
27
|
+
interval: "1d",
|
|
28
|
+
signal: llm.signal,
|
|
29
|
+
signalBudgetMs: 3000,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
console.log("trades:", result.metrics.trades, "PnL:", result.metrics.totalPnL.toFixed(2));
|
|
33
|
+
console.log("decisions logged:", llm.log.length);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Run: node examples/optimize.js
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { optimize, grid, getHistoricalCandles } from "../src/index.js";
|
|
5
|
+
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
const candles = await getHistoricalCandles({
|
|
9
|
+
source: "yahoo",
|
|
10
|
+
symbol: "SPY",
|
|
11
|
+
interval: "1d",
|
|
12
|
+
period: "2y",
|
|
13
|
+
cache: true,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const { leaderboard, best } = await optimize({
|
|
17
|
+
candles,
|
|
18
|
+
interval: "1d",
|
|
19
|
+
signalModulePath: path.join(here, "..", "test", "fixtures", "emaSignal.js"),
|
|
20
|
+
parameterSets: grid({ fast: [5, 8, 10, 12], slow: [20, 30, 50] }),
|
|
21
|
+
scoreBy: "sharpeAnnualized",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
console.log("best params:", best?.params, "sharpe:", best?.metrics.sharpeAnnualized);
|
|
25
|
+
console.table(leaderboard.slice(0, 5).map((r) => ({ ...r.params, ...r.metrics })));
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tradelab",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Backtesting toolkit for Node.js with strategy simulation, historical data loading, and report generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.cjs",
|
|
7
7
|
"module": "./src/index.js",
|
|
8
8
|
"types": "./types/index.d.ts",
|
|
9
9
|
"bin": {
|
|
10
|
-
"tradelab": "
|
|
10
|
+
"tradelab": "bin/tradelab.js",
|
|
11
|
+
"tradelab-mcp": "bin/tradelab-mcp.js"
|
|
11
12
|
},
|
|
12
13
|
"license": "MIT",
|
|
13
14
|
"repository": {
|
|
@@ -38,6 +39,14 @@
|
|
|
38
39
|
"import": "./src/live/index.js",
|
|
39
40
|
"require": "./dist/cjs/live.cjs"
|
|
40
41
|
},
|
|
42
|
+
"./ta": {
|
|
43
|
+
"types": "./types/ta.d.ts",
|
|
44
|
+
"import": "./src/ta/index.js",
|
|
45
|
+
"require": "./dist/cjs/ta.cjs"
|
|
46
|
+
},
|
|
47
|
+
"./mcp": {
|
|
48
|
+
"import": "./src/mcp/server.js"
|
|
49
|
+
},
|
|
41
50
|
"./package.json": "./package.json"
|
|
42
51
|
},
|
|
43
52
|
"files": [
|
|
@@ -49,6 +58,7 @@
|
|
|
49
58
|
"templates",
|
|
50
59
|
"examples/*.js",
|
|
51
60
|
"README.md",
|
|
61
|
+
"CHANGELOG.md",
|
|
52
62
|
"LICENSE"
|
|
53
63
|
],
|
|
54
64
|
"scripts": {
|
|
@@ -83,5 +93,9 @@
|
|
|
83
93
|
"globals": "^15.15.0",
|
|
84
94
|
"prettier": "^3.5.3",
|
|
85
95
|
"typescript": "^5.8.3"
|
|
96
|
+
},
|
|
97
|
+
"dependencies": {
|
|
98
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
99
|
+
"zod": "^4.4.3"
|
|
86
100
|
}
|
|
87
101
|
}
|
|
@@ -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
|
+
}
|
package/src/engine/backtest.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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);
|
package/src/engine/execution.js
CHANGED
|
@@ -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
|
+
}
|