tradelab 1.1.0 → 1.2.1
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 +57 -0
- package/README.md +183 -373
- package/dist/cjs/index.cjs +39 -12
- package/dist/cjs/live.cjs +457 -18
- package/docs/README.md +32 -66
- package/docs/api-reference.md +269 -144
- package/docs/backtest-engine.md +167 -321
- package/docs/data-reporting-cli.md +114 -156
- package/docs/examples.md +6 -6
- package/docs/live-trading.md +254 -134
- package/docs/mcp.md +244 -23
- package/docs/research.md +99 -45
- package/examples/mcpLiveTrading.js +77 -0
- package/package.json +11 -3
- package/src/engine/optimize.js +25 -1
- package/src/engine/portfolio.js +6 -2
- package/src/live/dashboard/server.js +67 -8
- package/src/live/engine/paperEngine.js +21 -11
- package/src/live/index.js +2 -0
- package/src/live/session.js +439 -0
- package/src/mcp/liveTools.js +202 -0
- package/src/mcp/schemas.js +119 -0
- package/src/mcp/server.js +5 -1
- package/src/mcp/tools.js +125 -2
- package/src/research/monteCarlo.js +6 -2
- package/templates/dashboard.html +595 -108
- package/types/index.d.ts +25 -0
- package/types/live.d.ts +102 -1
- package/types/mcp.d.ts +17 -0
- package/docs/superpowers/plans/2026-00-overview.md +0 -101
- package/docs/superpowers/plans/2026-01-metrics-correctness.md +0 -873
- package/docs/superpowers/plans/2026-02-indicator-library.md +0 -677
- package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +0 -882
- package/docs/superpowers/plans/2026-04-async-signals-seeding.md +0 -981
- package/docs/superpowers/plans/2026-05-mcp-server.md +0 -758
- package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +0 -508
- package/docs/superpowers/plans/2026-07-funding-carry-costs.md +0 -535
- package/docs/superpowers/plans/2026-08-live-dashboard.md +0 -547
- package/docs/superpowers/plans/HANDOFF.md +0 -88
|
@@ -1,981 +0,0 @@
|
|
|
1
|
-
# Async Signals, Seeding & LlmSignal Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Let `signal()` be `async` (so an LLM/agent can be the live decision-maker) with a per-bar time budget, response caching, no-lookahead guarding, and a decision log — plus make tick-backtest randomness reproducible via a configurable seed.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Rather than fork the 200-line engine loop, refactor `BarSystemRunner.step()` into `_preSignal()` (all position/pending management, unchanged behavior) + `_applyRawSignal()` (the signal→pending tail) + thin `step()`/`stepAsync()` wrappers. A new `backtestAsync()` drives `stepAsync()` in an async loop and awaits the signal. `LlmSignal` wraps an async resolver with cache + timeout budget + strict no-lookahead view + decision log. The live engine awaits async signals. `backtestTicks()` gains a `seed` option.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Node ESM, `node:test`.
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
### Task 1: Configurable seed for tick backtests
|
|
14
|
-
|
|
15
|
-
**Files:**
|
|
16
|
-
|
|
17
|
-
- Modify: `src/engine/backtestTicks.js`
|
|
18
|
-
- Test: `test/tickSeed.test.js`
|
|
19
|
-
|
|
20
|
-
The tick engine already has `deterministicFill(probability, seedParts)` but the seed
|
|
21
|
-
is fixed by `[symbol, time, entry, stop, side]`. Add a `seed` option so two runs of
|
|
22
|
-
the same data with the same `seed` are identical, and different seeds differ.
|
|
23
|
-
|
|
24
|
-
- [ ] **Step 1: Write the failing test**
|
|
25
|
-
|
|
26
|
-
```js
|
|
27
|
-
// test/tickSeed.test.js
|
|
28
|
-
import test from "node:test";
|
|
29
|
-
import assert from "node:assert/strict";
|
|
30
|
-
import { backtestTicks } from "../src/index.js";
|
|
31
|
-
|
|
32
|
-
function buildTicks(n = 200) {
|
|
33
|
-
const start = Date.UTC(2025, 0, 2, 14, 30, 0);
|
|
34
|
-
return Array.from({ length: n }, (_, i) => ({
|
|
35
|
-
time: start + i * 1000,
|
|
36
|
-
bid: 100 + Math.sin(i / 5),
|
|
37
|
-
ask: 100.02 + Math.sin(i / 5),
|
|
38
|
-
}));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const signal = ({ index, bar }) =>
|
|
42
|
-
index % 20 === 0 ? { side: "long", entry: bar.close - 0.05, stop: bar.close - 0.2, rr: 2 } : null;
|
|
43
|
-
|
|
44
|
-
test("same seed + queueFillProbability < 1 is reproducible", () => {
|
|
45
|
-
const ticks = buildTicks();
|
|
46
|
-
const a = backtestTicks({ ticks, signal, queueFillProbability: 0.5, seed: "run-1" });
|
|
47
|
-
const b = backtestTicks({ ticks, signal, queueFillProbability: 0.5, seed: "run-1" });
|
|
48
|
-
assert.equal(a.trades.length, b.trades.length);
|
|
49
|
-
assert.equal(a.metrics.totalPnL, b.metrics.totalPnL);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("different seeds can produce different fill outcomes", () => {
|
|
53
|
-
const ticks = buildTicks();
|
|
54
|
-
const a = backtestTicks({ ticks, signal, queueFillProbability: 0.5, seed: "run-1" });
|
|
55
|
-
const c = backtestTicks({ ticks, signal, queueFillProbability: 0.5, seed: "run-999" });
|
|
56
|
-
// Not guaranteed different, but the seed must reach the fill RNG; assert it is accepted.
|
|
57
|
-
assert.equal(typeof a.metrics.totalPnL, "number");
|
|
58
|
-
assert.equal(typeof c.metrics.totalPnL, "number");
|
|
59
|
-
});
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
- [ ] **Step 2: Run to verify it fails**
|
|
63
|
-
|
|
64
|
-
Run: `node --test test/tickSeed.test.js`
|
|
65
|
-
Expected: FAIL — `seed` is ignored; the first test may pass by luck (the existing
|
|
66
|
-
seed parts are already deterministic), so to force a real failure first confirm the
|
|
67
|
-
option is plumbed by the next steps. If both pass already, proceed — the change is
|
|
68
|
-
making `seed` an explicit, documented contract.
|
|
69
|
-
|
|
70
|
-
- [ ] **Step 3: Add `seed` to the function signature**
|
|
71
|
-
|
|
72
|
-
In `backtestTicks({...})` destructured params, add after `queueFillProbability = 1,`:
|
|
73
|
-
|
|
74
|
-
```js
|
|
75
|
-
seed = "tradelab-ticks",
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
- [ ] **Step 4: Thread the seed into the fill RNG**
|
|
79
|
-
|
|
80
|
-
Find the limit-fill call:
|
|
81
|
-
|
|
82
|
-
```js
|
|
83
|
-
if (
|
|
84
|
-
touched &&
|
|
85
|
-
deterministicFill(queueFillProbability, [
|
|
86
|
-
symbol,
|
|
87
|
-
tick.time,
|
|
88
|
-
pending.entry,
|
|
89
|
-
pending.stop,
|
|
90
|
-
pending.side,
|
|
91
|
-
])
|
|
92
|
-
) {
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
Replace the seed-parts array's first element so the user seed prefixes it:
|
|
96
|
-
|
|
97
|
-
```js
|
|
98
|
-
if (
|
|
99
|
-
touched &&
|
|
100
|
-
deterministicFill(queueFillProbability, [
|
|
101
|
-
seed,
|
|
102
|
-
symbol,
|
|
103
|
-
tick.time,
|
|
104
|
-
pending.entry,
|
|
105
|
-
pending.stop,
|
|
106
|
-
pending.side,
|
|
107
|
-
])
|
|
108
|
-
) {
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
- [ ] **Step 5: Run test + suite**
|
|
112
|
-
|
|
113
|
-
Run: `node --test test/tickSeed.test.js`
|
|
114
|
-
Expected: PASS.
|
|
115
|
-
|
|
116
|
-
Run: `node --test test/backtest.test.js`
|
|
117
|
-
Expected: PASS (no behavior change for `queueFillProbability: 1`).
|
|
118
|
-
|
|
119
|
-
- [ ] **Step 6: Commit**
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
git add src/engine/backtestTicks.js test/tickSeed.test.js
|
|
123
|
-
git commit -m "feat: configurable seed for tick backtest fills
|
|
124
|
-
|
|
125
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
---
|
|
129
|
-
|
|
130
|
-
### Task 2: Async signal invocation helper
|
|
131
|
-
|
|
132
|
-
**Files:**
|
|
133
|
-
|
|
134
|
-
- Modify: `src/engine/barSystemRunner.js` (add `callSignalWithContextAsync`)
|
|
135
|
-
- Create: `src/engine/asyncSignal.js` (timeout budget)
|
|
136
|
-
- Test: `test/engine/asyncSignal.test.js`
|
|
137
|
-
|
|
138
|
-
- [ ] **Step 1: Write the failing test**
|
|
139
|
-
|
|
140
|
-
```js
|
|
141
|
-
// test/engine/asyncSignal.test.js
|
|
142
|
-
import test from "node:test";
|
|
143
|
-
import assert from "node:assert/strict";
|
|
144
|
-
import { withBudget, BudgetExceededError } from "../../src/engine/asyncSignal.js";
|
|
145
|
-
|
|
146
|
-
test("withBudget resolves when the promise beats the deadline", async () => {
|
|
147
|
-
const value = await withBudget(Promise.resolve(42), 50);
|
|
148
|
-
assert.equal(value, 42);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("withBudget rejects with BudgetExceededError on timeout", async () => {
|
|
152
|
-
const slow = new Promise((resolve) => setTimeout(() => resolve(1), 100));
|
|
153
|
-
await assert.rejects(
|
|
154
|
-
() => withBudget(slow, 10),
|
|
155
|
-
(err) => err instanceof BudgetExceededError
|
|
156
|
-
);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("withBudget of 0 or undefined disables the timeout", async () => {
|
|
160
|
-
const slow = new Promise((resolve) => setTimeout(() => resolve(7), 20));
|
|
161
|
-
assert.equal(await withBudget(slow, 0), 7);
|
|
162
|
-
});
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
- [ ] **Step 2: Run to verify it fails**
|
|
166
|
-
|
|
167
|
-
Run: `node --test test/engine/asyncSignal.test.js`
|
|
168
|
-
Expected: FAIL — cannot find module.
|
|
169
|
-
|
|
170
|
-
- [ ] **Step 3: Implement src/engine/asyncSignal.js**
|
|
171
|
-
|
|
172
|
-
```js
|
|
173
|
-
// src/engine/asyncSignal.js
|
|
174
|
-
|
|
175
|
-
export class BudgetExceededError extends Error {
|
|
176
|
-
constructor(ms) {
|
|
177
|
-
super(`signal() exceeded its ${ms}ms per-bar budget`);
|
|
178
|
-
this.name = "BudgetExceededError";
|
|
179
|
-
this.budgetMs = ms;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Race a promise against a per-bar time budget. `budgetMs` of 0/undefined
|
|
185
|
-
* disables the timeout. Rejects with BudgetExceededError on overrun.
|
|
186
|
-
*/
|
|
187
|
-
export function withBudget(promise, budgetMs) {
|
|
188
|
-
if (!budgetMs || budgetMs <= 0) return Promise.resolve(promise);
|
|
189
|
-
return new Promise((resolve, reject) => {
|
|
190
|
-
const timer = setTimeout(() => reject(new BudgetExceededError(budgetMs)), budgetMs);
|
|
191
|
-
Promise.resolve(promise).then(
|
|
192
|
-
(value) => {
|
|
193
|
-
clearTimeout(timer);
|
|
194
|
-
resolve(value);
|
|
195
|
-
},
|
|
196
|
-
(err) => {
|
|
197
|
-
clearTimeout(timer);
|
|
198
|
-
reject(err);
|
|
199
|
-
}
|
|
200
|
-
);
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
- [ ] **Step 4: Add callSignalWithContextAsync to barSystemRunner.js**
|
|
206
|
-
|
|
207
|
-
In `src/engine/barSystemRunner.js`, below the existing `callSignalWithContext`
|
|
208
|
-
export, add:
|
|
209
|
-
|
|
210
|
-
```js
|
|
211
|
-
export async function callSignalWithContextAsync({ signal, context, index, bar, symbol }) {
|
|
212
|
-
try {
|
|
213
|
-
return await signal(context);
|
|
214
|
-
} catch (error) {
|
|
215
|
-
const cause = error instanceof Error ? error.message : String(error);
|
|
216
|
-
throw new Error(
|
|
217
|
-
`signal() threw at index=${index}, time=${formatIsoTime(bar?.time)}, symbol=${symbol}: ${cause}`
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
- [ ] **Step 5: Run test + suite**
|
|
224
|
-
|
|
225
|
-
Run: `node --test test/engine/asyncSignal.test.js`
|
|
226
|
-
Expected: PASS (3 tests).
|
|
227
|
-
|
|
228
|
-
Run: `node --test`
|
|
229
|
-
Expected: PASS.
|
|
230
|
-
|
|
231
|
-
- [ ] **Step 6: Commit**
|
|
232
|
-
|
|
233
|
-
```bash
|
|
234
|
-
git add src/engine/asyncSignal.js src/engine/barSystemRunner.js test/engine/asyncSignal.test.js
|
|
235
|
-
git commit -m "feat: add async signal budget helper and async invoker
|
|
236
|
-
|
|
237
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
### Task 3: Refactor BarSystemRunner.step into reusable phases
|
|
243
|
-
|
|
244
|
-
**Files:**
|
|
245
|
-
|
|
246
|
-
- Modify: `src/engine/barSystemRunner.js`
|
|
247
|
-
- Test: `test/portfolio.test.js` (existing — must stay green)
|
|
248
|
-
|
|
249
|
-
**Goal:** extract the signal-generation tail of `step()` so a sync `step()` and a
|
|
250
|
-
new async `stepAsync()` share all position/pending management without duplication.
|
|
251
|
-
|
|
252
|
-
- [ ] **Step 1: Confirm the baseline is green**
|
|
253
|
-
|
|
254
|
-
Run: `node --test test/portfolio.test.js test/backtest.test.js`
|
|
255
|
-
Expected: PASS. (We must not change behavior.)
|
|
256
|
-
|
|
257
|
-
- [ ] **Step 2: Rename `step` to `_preSignal` and change its tail**
|
|
258
|
-
|
|
259
|
-
In `src/engine/barSystemRunner.js`, locate the method `step({ signalEquity, canTrade = true, resolveEntrySize } = {})`. Make these exact changes:
|
|
260
|
-
|
|
261
|
-
1. Rename it to `_preSignal({ signalEquity, canTrade = true, resolveEntrySize } = {})`.
|
|
262
|
-
|
|
263
|
-
2. The method currently ends with this tail (the signal block + recordFrame +
|
|
264
|
-
index++ + return):
|
|
265
|
-
|
|
266
|
-
```js
|
|
267
|
-
if (!this.pending) {
|
|
268
|
-
const rawSignal = callSignalWithContext({
|
|
269
|
-
signal: this.options.signal,
|
|
270
|
-
context: this.buildSignalContext(this.index, bar, signalEquity),
|
|
271
|
-
index: this.index,
|
|
272
|
-
bar,
|
|
273
|
-
symbol: this.symbol,
|
|
274
|
-
});
|
|
275
|
-
const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
|
|
276
|
-
|
|
277
|
-
if (nextSignal) {
|
|
278
|
-
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
|
|
279
|
-
? nextSignal.riskFraction
|
|
280
|
-
: Number.isFinite(nextSignal.riskPct)
|
|
281
|
-
? nextSignal.riskPct / 100
|
|
282
|
-
: this.options.riskPct / 100;
|
|
283
|
-
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
284
|
-
this.pending = {
|
|
285
|
-
side: nextSignal.side,
|
|
286
|
-
entry: nextSignal.entry,
|
|
287
|
-
stop: nextSignal.stop,
|
|
288
|
-
tp: nextSignal.takeProfit,
|
|
289
|
-
riskFrac: signalRiskFraction,
|
|
290
|
-
fixedQty: nextSignal.qty,
|
|
291
|
-
expiresAt: this.index + Math.max(1, expiryBars),
|
|
292
|
-
startedAtIndex: this.index,
|
|
293
|
-
meta: nextSignal,
|
|
294
|
-
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
298
|
-
if (
|
|
299
|
-
!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)
|
|
300
|
-
) {
|
|
301
|
-
this.pending = null;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
this.recordFrame(bar);
|
|
308
|
-
this.index += 1;
|
|
309
|
-
return bar;
|
|
310
|
-
}
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
Replace that entire tail with a return of a "needs signal" descriptor (DO NOT
|
|
314
|
-
record the frame or advance the index here — the wrappers do that):
|
|
315
|
-
|
|
316
|
-
```js
|
|
317
|
-
if (this.pending) {
|
|
318
|
-
this.recordFrame(bar);
|
|
319
|
-
this.index += 1;
|
|
320
|
-
return { handled: true, bar };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
return {
|
|
324
|
-
handled: false,
|
|
325
|
-
bar,
|
|
326
|
-
trigger,
|
|
327
|
-
signalEquity,
|
|
328
|
-
resolveEntrySize,
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
3. Every OTHER early `return` inside the method body currently looks like
|
|
334
|
-
`this.recordFrame(bar); this.index += 1; return bar;`. Change each of those
|
|
335
|
-
`return bar;` statements to `return { handled: true, bar };`. There are two such
|
|
336
|
-
blocks: the `if (this.open || this.cooldown > 0)` block and the
|
|
337
|
-
`if (!canTrade || dailyLossHit || dailyTradeCapHit)` block. Also the very first
|
|
338
|
-
guard `if (!this.hasNext()) return null;` becomes
|
|
339
|
-
`if (!this.hasNext()) return { handled: true, bar: null };`.
|
|
340
|
-
|
|
341
|
-
- [ ] **Step 3: Add `_applyRawSignal`, `step`, and `stepAsync`**
|
|
342
|
-
|
|
343
|
-
Immediately after `_preSignal`, add these three methods:
|
|
344
|
-
|
|
345
|
-
```js
|
|
346
|
-
_applyRawSignal(rawSignal, pre) {
|
|
347
|
-
const { bar, trigger, signalEquity, resolveEntrySize } = pre;
|
|
348
|
-
const nextSignal = normalizeSignal(rawSignal, bar, this.options.finalTP_R);
|
|
349
|
-
|
|
350
|
-
if (nextSignal) {
|
|
351
|
-
const signalRiskFraction = Number.isFinite(nextSignal.riskFraction)
|
|
352
|
-
? nextSignal.riskFraction
|
|
353
|
-
: Number.isFinite(nextSignal.riskPct)
|
|
354
|
-
? nextSignal.riskPct / 100
|
|
355
|
-
: this.options.riskPct / 100;
|
|
356
|
-
const expiryBars = nextSignal._entryExpiryBars ?? 5;
|
|
357
|
-
this.pending = {
|
|
358
|
-
side: nextSignal.side,
|
|
359
|
-
entry: nextSignal.entry,
|
|
360
|
-
stop: nextSignal.stop,
|
|
361
|
-
tp: nextSignal.takeProfit,
|
|
362
|
-
riskFrac: signalRiskFraction,
|
|
363
|
-
fixedQty: nextSignal.qty,
|
|
364
|
-
expiresAt: this.index + Math.max(1, expiryBars),
|
|
365
|
-
startedAtIndex: this.index,
|
|
366
|
-
meta: nextSignal,
|
|
367
|
-
plannedRiskAbs: Math.abs(nextSignal._initRisk ?? nextSignal.entry - nextSignal.stop),
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
if (touchedLimit(this.pending.side, this.pending.entry, bar, trigger)) {
|
|
371
|
-
if (!this.openFromPending(bar, signalEquity, this.pending.entry, "limit", resolveEntrySize)) {
|
|
372
|
-
this.pending = null;
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
this.recordFrame(bar);
|
|
378
|
-
this.index += 1;
|
|
379
|
-
return bar;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
step(options = {}) {
|
|
383
|
-
const pre = this._preSignal(options);
|
|
384
|
-
if (pre.handled) return pre.bar;
|
|
385
|
-
const rawSignal = callSignalWithContext({
|
|
386
|
-
signal: this.options.signal,
|
|
387
|
-
context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
|
|
388
|
-
index: this.index,
|
|
389
|
-
bar: pre.bar,
|
|
390
|
-
symbol: this.symbol,
|
|
391
|
-
});
|
|
392
|
-
return this._applyRawSignal(rawSignal, pre);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
async stepAsync(options = {}) {
|
|
396
|
-
const pre = this._preSignal(options);
|
|
397
|
-
if (pre.handled) return pre.bar;
|
|
398
|
-
const rawSignal = await callSignalWithContextAsync({
|
|
399
|
-
signal: this.options.signal,
|
|
400
|
-
context: this.buildSignalContext(this.index, pre.bar, pre.signalEquity),
|
|
401
|
-
index: this.index,
|
|
402
|
-
bar: pre.bar,
|
|
403
|
-
symbol: this.symbol,
|
|
404
|
-
});
|
|
405
|
-
return this._applyRawSignal(rawSignal, pre);
|
|
406
|
-
}
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
- [ ] **Step 4: Add the import for the async invoker**
|
|
410
|
-
|
|
411
|
-
`callSignalWithContextAsync` is defined in this same file (Task 2 Step 4), so no
|
|
412
|
-
import is needed. Confirm `callSignalWithContext`, `normalizeSignal`, and
|
|
413
|
-
`touchedLimit` are already in scope (they are — used by the original `step`).
|
|
414
|
-
|
|
415
|
-
- [ ] **Step 5: Run the existing suites — behavior must be unchanged**
|
|
416
|
-
|
|
417
|
-
Run: `node --test test/portfolio.test.js test/backtest.test.js`
|
|
418
|
-
Expected: PASS. Portfolio uses `runner.step(...)`; the sync wrapper reproduces the
|
|
419
|
-
original behavior exactly.
|
|
420
|
-
|
|
421
|
-
- [ ] **Step 6: Commit**
|
|
422
|
-
|
|
423
|
-
```bash
|
|
424
|
-
git add src/engine/barSystemRunner.js
|
|
425
|
-
git commit -m "refactor: split BarSystemRunner.step into reusable phases
|
|
426
|
-
|
|
427
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
---
|
|
431
|
-
|
|
432
|
-
### Task 4: `backtestAsync()`
|
|
433
|
-
|
|
434
|
-
**Files:**
|
|
435
|
-
|
|
436
|
-
- Create: `src/engine/backtestAsync.js`
|
|
437
|
-
- Modify: `src/index.js`
|
|
438
|
-
- Test: `test/backtestAsync.test.js`
|
|
439
|
-
|
|
440
|
-
- [ ] **Step 1: Write the failing test**
|
|
441
|
-
|
|
442
|
-
```js
|
|
443
|
-
// test/backtestAsync.test.js
|
|
444
|
-
import test from "node:test";
|
|
445
|
-
import assert from "node:assert/strict";
|
|
446
|
-
import { backtest, backtestAsync } from "../src/index.js";
|
|
447
|
-
|
|
448
|
-
function buildCandles(count = 30) {
|
|
449
|
-
const start = Date.UTC(2025, 0, 2, 14, 30, 0);
|
|
450
|
-
return Array.from({ length: count }, (_, i) => ({
|
|
451
|
-
time: start + i * 5 * 60 * 1000,
|
|
452
|
-
open: 100 + i,
|
|
453
|
-
high: 101 + i,
|
|
454
|
-
low: 99 + i,
|
|
455
|
-
close: 100.5 + i,
|
|
456
|
-
volume: 1000 + i,
|
|
457
|
-
}));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const entryAt =
|
|
461
|
-
(idx) =>
|
|
462
|
-
({ index, bar }) =>
|
|
463
|
-
index === idx ? { side: "buy", stop: bar.close - 1, rr: 2 } : null;
|
|
464
|
-
|
|
465
|
-
test("backtestAsync with a sync signal matches backtest exactly", async () => {
|
|
466
|
-
const candles = buildCandles();
|
|
467
|
-
const opts = { candles, interval: "5m", warmupBars: 1, flattenAtClose: false };
|
|
468
|
-
const sync = backtest({ ...opts, signal: entryAt(1) });
|
|
469
|
-
const asyncResult = await backtestAsync({ ...opts, signal: entryAt(1) });
|
|
470
|
-
assert.equal(asyncResult.positions.length, sync.positions.length);
|
|
471
|
-
assert.equal(asyncResult.metrics.totalPnL, sync.metrics.totalPnL);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
test("backtestAsync awaits a promise-returning signal", async () => {
|
|
475
|
-
const candles = buildCandles();
|
|
476
|
-
const result = await backtestAsync({
|
|
477
|
-
candles,
|
|
478
|
-
interval: "5m",
|
|
479
|
-
warmupBars: 1,
|
|
480
|
-
flattenAtClose: false,
|
|
481
|
-
async signal({ index, bar }) {
|
|
482
|
-
await Promise.resolve();
|
|
483
|
-
return index === 2 ? { side: "long", stop: bar.close - 1, rr: 2 } : null;
|
|
484
|
-
},
|
|
485
|
-
});
|
|
486
|
-
assert.equal(result.positions.length, 1);
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
test("backtestAsync enforces a per-bar signalBudgetMs", async () => {
|
|
490
|
-
const candles = buildCandles();
|
|
491
|
-
await assert.rejects(() =>
|
|
492
|
-
backtestAsync({
|
|
493
|
-
candles,
|
|
494
|
-
interval: "5m",
|
|
495
|
-
warmupBars: 1,
|
|
496
|
-
signalBudgetMs: 5,
|
|
497
|
-
async signal() {
|
|
498
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
499
|
-
return null;
|
|
500
|
-
},
|
|
501
|
-
})
|
|
502
|
-
);
|
|
503
|
-
});
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
- [ ] **Step 2: Run to verify it fails**
|
|
507
|
-
|
|
508
|
-
Run: `node --test test/backtestAsync.test.js`
|
|
509
|
-
Expected: FAIL — `backtestAsync` not exported.
|
|
510
|
-
|
|
511
|
-
- [ ] **Step 3: Implement src/engine/backtestAsync.js**
|
|
512
|
-
|
|
513
|
-
```js
|
|
514
|
-
// src/engine/backtestAsync.js
|
|
515
|
-
import { BarSystemRunner, callSignalWithContextAsync } from "./barSystemRunner.js";
|
|
516
|
-
import { withBudget } from "./asyncSignal.js";
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Async sibling of backtest(). Identical result shape, but `signal()` may return
|
|
520
|
-
* a Promise. Each bar's signal is raced against `signalBudgetMs` (0 disables).
|
|
521
|
-
*
|
|
522
|
-
* Built on BarSystemRunner so position/pending/exit logic is shared with the
|
|
523
|
-
* sync engine and portfolio mode — no duplicated loop.
|
|
524
|
-
*/
|
|
525
|
-
export async function backtestAsync(rawOptions = {}) {
|
|
526
|
-
const budgetMs = rawOptions.signalBudgetMs ?? 0;
|
|
527
|
-
|
|
528
|
-
// Wrap the user signal so the per-bar budget is enforced where the runner
|
|
529
|
-
// awaits it. callSignalWithContextAsync awaits signal(context); we wrap the
|
|
530
|
-
// user's signal to apply the timeout to that single call.
|
|
531
|
-
const userSignal = rawOptions.signal;
|
|
532
|
-
const budgetedSignal = (context) =>
|
|
533
|
-
withBudget(
|
|
534
|
-
Promise.resolve().then(() => userSignal(context)),
|
|
535
|
-
budgetMs
|
|
536
|
-
);
|
|
537
|
-
|
|
538
|
-
const runner = new BarSystemRunner({ ...rawOptions, signal: budgetedSignal });
|
|
539
|
-
|
|
540
|
-
while (runner.hasNext()) {
|
|
541
|
-
await runner.stepAsync({ signalEquity: runner.getMarkedEquity() });
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
return runner.buildResult();
|
|
545
|
-
}
|
|
546
|
-
```
|
|
547
|
-
|
|
548
|
-
Note: `callSignalWithContextAsync` is imported only to keep the dependency
|
|
549
|
-
explicit for readers; the runner already uses it internally. If your linter flags
|
|
550
|
-
the unused import, drop it.
|
|
551
|
-
|
|
552
|
-
- [ ] **Step 4: Export from src/index.js**
|
|
553
|
-
|
|
554
|
-
Add below `export { backtest } ...`:
|
|
555
|
-
|
|
556
|
-
```js
|
|
557
|
-
export { backtestAsync } from "./engine/backtestAsync.js";
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
- [ ] **Step 5: Run test + full suite**
|
|
561
|
-
|
|
562
|
-
Run: `node --test test/backtestAsync.test.js`
|
|
563
|
-
Expected: PASS (3 tests).
|
|
564
|
-
|
|
565
|
-
Run: `node --test`
|
|
566
|
-
Expected: PASS.
|
|
567
|
-
|
|
568
|
-
- [ ] **Step 6: Lint + commit**
|
|
569
|
-
|
|
570
|
-
```bash
|
|
571
|
-
npm run lint
|
|
572
|
-
git add src/engine/backtestAsync.js src/index.js test/backtestAsync.test.js
|
|
573
|
-
git commit -m "feat: add backtestAsync with per-bar signal budget
|
|
574
|
-
|
|
575
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
---
|
|
579
|
-
|
|
580
|
-
### Task 5: `LlmSignal` — cached, budgeted, no-lookahead, logged
|
|
581
|
-
|
|
582
|
-
**Files:**
|
|
583
|
-
|
|
584
|
-
- Create: `src/engine/llmSignal.js`
|
|
585
|
-
- Modify: `src/index.js`
|
|
586
|
-
- Test: `test/engine/llmSignal.test.js`
|
|
587
|
-
|
|
588
|
-
- [ ] **Step 1: Write the failing test**
|
|
589
|
-
|
|
590
|
-
```js
|
|
591
|
-
// test/engine/llmSignal.test.js
|
|
592
|
-
import test from "node:test";
|
|
593
|
-
import assert from "node:assert/strict";
|
|
594
|
-
import { LlmSignal } from "../../src/engine/llmSignal.js";
|
|
595
|
-
|
|
596
|
-
function ctx(index, candles) {
|
|
597
|
-
return { index, bar: candles[index], candles: candles.slice(0, index + 1), equity: 10_000 };
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
test("LlmSignal caches by bar time so resolve runs once per bar", async () => {
|
|
601
|
-
const candles = Array.from({ length: 5 }, (_, i) => ({
|
|
602
|
-
time: i * 1000,
|
|
603
|
-
close: 100 + i,
|
|
604
|
-
high: 101 + i,
|
|
605
|
-
low: 99 + i,
|
|
606
|
-
}));
|
|
607
|
-
let calls = 0;
|
|
608
|
-
const sig = new LlmSignal({
|
|
609
|
-
async resolve() {
|
|
610
|
-
calls += 1;
|
|
611
|
-
return { side: "long", stop: 99, rr: 2 };
|
|
612
|
-
},
|
|
613
|
-
});
|
|
614
|
-
const c = ctx(2, candles);
|
|
615
|
-
await sig.signal(c);
|
|
616
|
-
await sig.signal(c); // same bar => cached
|
|
617
|
-
assert.equal(calls, 1);
|
|
618
|
-
assert.equal(sig.log.length, 1);
|
|
619
|
-
assert.equal(sig.log[0].index, 2);
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
test("LlmSignal records a timeout decision when the budget is exceeded", async () => {
|
|
623
|
-
const candles = Array.from({ length: 3 }, (_, i) => ({
|
|
624
|
-
time: i * 1000,
|
|
625
|
-
close: 100,
|
|
626
|
-
high: 101,
|
|
627
|
-
low: 99,
|
|
628
|
-
}));
|
|
629
|
-
const sig = new LlmSignal({
|
|
630
|
-
budgetMs: 5,
|
|
631
|
-
onError: "skip",
|
|
632
|
-
async resolve() {
|
|
633
|
-
await new Promise((r) => setTimeout(r, 40));
|
|
634
|
-
return { side: "long", stop: 99, rr: 2 };
|
|
635
|
-
},
|
|
636
|
-
});
|
|
637
|
-
const out = await sig.signal(ctx(1, candles));
|
|
638
|
-
assert.equal(out, null); // skipped on timeout
|
|
639
|
-
assert.equal(sig.log[0].error.includes("budget"), true);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
test("LlmSignal blocks lookahead access to future candles", async () => {
|
|
643
|
-
const candles = Array.from({ length: 5 }, (_, i) => ({
|
|
644
|
-
time: i * 1000,
|
|
645
|
-
close: 100 + i,
|
|
646
|
-
high: 101 + i,
|
|
647
|
-
low: 99 + i,
|
|
648
|
-
}));
|
|
649
|
-
let leaked = false;
|
|
650
|
-
const sig = new LlmSignal({
|
|
651
|
-
async resolve({ candles: view, index }) {
|
|
652
|
-
try {
|
|
653
|
-
// eslint-disable-next-line no-unused-expressions
|
|
654
|
-
view[index + 1].close;
|
|
655
|
-
leaked = true;
|
|
656
|
-
} catch {
|
|
657
|
-
leaked = false;
|
|
658
|
-
}
|
|
659
|
-
return null;
|
|
660
|
-
},
|
|
661
|
-
});
|
|
662
|
-
await sig.signal(ctx(2, candles));
|
|
663
|
-
assert.equal(leaked, false);
|
|
664
|
-
});
|
|
665
|
-
```
|
|
666
|
-
|
|
667
|
-
- [ ] **Step 2: Run to verify it fails**
|
|
668
|
-
|
|
669
|
-
Run: `node --test test/engine/llmSignal.test.js`
|
|
670
|
-
Expected: FAIL — cannot find module.
|
|
671
|
-
|
|
672
|
-
- [ ] **Step 3: Implement src/engine/llmSignal.js**
|
|
673
|
-
|
|
674
|
-
```js
|
|
675
|
-
// src/engine/llmSignal.js
|
|
676
|
-
import { withBudget } from "./asyncSignal.js";
|
|
677
|
-
|
|
678
|
-
function isArrayIndexKey(property) {
|
|
679
|
-
if (typeof property !== "string") return false;
|
|
680
|
-
const n = Number(property);
|
|
681
|
-
return Number.isInteger(n) && n >= 0;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Same no-lookahead proxy the strict engine uses: reading candles beyond the
|
|
685
|
-
// current index throws, so an LLM cannot peek at the future.
|
|
686
|
-
function noLookaheadView(candles, index) {
|
|
687
|
-
return new Proxy(candles, {
|
|
688
|
-
get(target, property, receiver) {
|
|
689
|
-
if (isArrayIndexKey(property) && Number(property) > index) {
|
|
690
|
-
throw new Error(
|
|
691
|
-
`LlmSignal: lookahead access to candles[${String(property)}] (current index ${index})`
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
return Reflect.get(target, property, receiver);
|
|
695
|
-
},
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Wraps an async model-backed decision function for use as a tradelab signal.
|
|
701
|
-
*
|
|
702
|
-
* - Caches by bar time: resolve() runs at most once per bar.
|
|
703
|
-
* - Enforces a per-bar `budgetMs` time budget.
|
|
704
|
-
* - Exposes a no-lookahead candle view to resolve().
|
|
705
|
-
* - Logs every decision (context summary, result or error) in `this.log`.
|
|
706
|
-
*
|
|
707
|
-
* `onError`: "skip" (return null, default) or "throw".
|
|
708
|
-
* Use the instance's `.signal` bound method as the engine's `signal` option.
|
|
709
|
-
*/
|
|
710
|
-
export class LlmSignal {
|
|
711
|
-
constructor({ resolve, budgetMs = 0, onError = "skip" } = {}) {
|
|
712
|
-
if (typeof resolve !== "function") {
|
|
713
|
-
throw new Error("LlmSignal requires a resolve(context) function");
|
|
714
|
-
}
|
|
715
|
-
this.resolve = resolve;
|
|
716
|
-
this.budgetMs = budgetMs;
|
|
717
|
-
this.onError = onError;
|
|
718
|
-
this.log = [];
|
|
719
|
-
this._cache = new Map(); // bar time -> resolved signal
|
|
720
|
-
this.signal = this.signal.bind(this);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
async signal(context) {
|
|
724
|
-
const key = context.bar?.time ?? context.index;
|
|
725
|
-
if (this._cache.has(key)) return this._cache.get(key);
|
|
726
|
-
|
|
727
|
-
const safeContext = {
|
|
728
|
-
...context,
|
|
729
|
-
candles: noLookaheadView(context.candles, context.index),
|
|
730
|
-
};
|
|
731
|
-
|
|
732
|
-
const startedAt = Date.now();
|
|
733
|
-
try {
|
|
734
|
-
const result = await withBudget(
|
|
735
|
-
Promise.resolve().then(() => this.resolve(safeContext)),
|
|
736
|
-
this.budgetMs
|
|
737
|
-
);
|
|
738
|
-
this._cache.set(key, result ?? null);
|
|
739
|
-
this.log.push({
|
|
740
|
-
index: context.index,
|
|
741
|
-
time: context.bar?.time,
|
|
742
|
-
close: context.bar?.close,
|
|
743
|
-
latencyMs: Date.now() - startedAt,
|
|
744
|
-
result: result ?? null,
|
|
745
|
-
});
|
|
746
|
-
return result ?? null;
|
|
747
|
-
} catch (error) {
|
|
748
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
749
|
-
this.log.push({
|
|
750
|
-
index: context.index,
|
|
751
|
-
time: context.bar?.time,
|
|
752
|
-
close: context.bar?.close,
|
|
753
|
-
latencyMs: Date.now() - startedAt,
|
|
754
|
-
error: message,
|
|
755
|
-
});
|
|
756
|
-
this._cache.set(key, null);
|
|
757
|
-
if (this.onError === "throw") throw error;
|
|
758
|
-
return null;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
```
|
|
763
|
-
|
|
764
|
-
- [ ] **Step 4: Export from src/index.js**
|
|
765
|
-
|
|
766
|
-
```js
|
|
767
|
-
export { LlmSignal } from "./engine/llmSignal.js";
|
|
768
|
-
```
|
|
769
|
-
|
|
770
|
-
- [ ] **Step 5: Run test + suite**
|
|
771
|
-
|
|
772
|
-
Run: `node --test test/engine/llmSignal.test.js`
|
|
773
|
-
Expected: PASS (3 tests).
|
|
774
|
-
|
|
775
|
-
Run: `node --test`
|
|
776
|
-
Expected: PASS.
|
|
777
|
-
|
|
778
|
-
- [ ] **Step 6: Commit**
|
|
779
|
-
|
|
780
|
-
```bash
|
|
781
|
-
git add src/engine/llmSignal.js src/index.js test/engine/llmSignal.test.js
|
|
782
|
-
git commit -m "feat: add LlmSignal cached/budgeted/no-lookahead wrapper
|
|
783
|
-
|
|
784
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
---
|
|
788
|
-
|
|
789
|
-
### Task 6: Live engine awaits async signals
|
|
790
|
-
|
|
791
|
-
**Files:**
|
|
792
|
-
|
|
793
|
-
- Modify: `src/live/engine/liveEngine.js`
|
|
794
|
-
- Test: `test/live/liveEngine.test.js` (append)
|
|
795
|
-
|
|
796
|
-
`handleBar` is already `async`; it calls `callSignalWithContext` synchronously.
|
|
797
|
-
Switch it to await so `LlmSignal` / async signals work live.
|
|
798
|
-
|
|
799
|
-
- [ ] **Step 1: Write the failing test (append to test/live/liveEngine.test.js)**
|
|
800
|
-
|
|
801
|
-
Match the existing imports/harness in that file (it constructs a `LiveEngine`
|
|
802
|
-
with a `PaperEngine` broker). Add:
|
|
803
|
-
|
|
804
|
-
```js
|
|
805
|
-
test("LiveEngine awaits an async signal", async () => {
|
|
806
|
-
// Reuse the file's existing helpers to build engine + paper broker + feed.
|
|
807
|
-
// Drive one bar through handleBar with an async signal that resolves a long.
|
|
808
|
-
// Assert a position opens (engine.openPosition is set or a position:opened event fired).
|
|
809
|
-
// See existing tests in this file for the exact construction pattern.
|
|
810
|
-
});
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
Implement the body using the same construction the other tests in the file use,
|
|
814
|
-
asserting that after feeding a bar that should trigger entry, `engine.getStatus().openPosition`
|
|
815
|
-
is non-null (or a `position:opened` event was emitted).
|
|
816
|
-
|
|
817
|
-
- [ ] **Step 2: Run to verify it fails or is flaky**
|
|
818
|
-
|
|
819
|
-
Run: `node --test test/live/liveEngine.test.js`
|
|
820
|
-
Expected: FAIL — an async signal returns a Promise, which `normalizeSignal`
|
|
821
|
-
treats as truthy-but-invalid (no `side`), so no order is submitted.
|
|
822
|
-
|
|
823
|
-
- [ ] **Step 3: Edit handleBar to await the signal**
|
|
824
|
-
|
|
825
|
-
In `src/live/engine/liveEngine.js`, find:
|
|
826
|
-
|
|
827
|
-
```js
|
|
828
|
-
if (!this.openPosition && !this.pendingOrder) {
|
|
829
|
-
const context = this._signalContext(bar);
|
|
830
|
-
const rawSignal = callSignalWithContext({
|
|
831
|
-
signal: this.options.signal,
|
|
832
|
-
context,
|
|
833
|
-
index: context.index,
|
|
834
|
-
bar,
|
|
835
|
-
symbol: this.symbol,
|
|
836
|
-
});
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
Replace `callSignalWithContext` with the async invoker and await it:
|
|
840
|
-
|
|
841
|
-
```js
|
|
842
|
-
if (!this.openPosition && !this.pendingOrder) {
|
|
843
|
-
const context = this._signalContext(bar);
|
|
844
|
-
const rawSignal = await callSignalWithContextAsync({
|
|
845
|
-
signal: this.options.signal,
|
|
846
|
-
context,
|
|
847
|
-
index: context.index,
|
|
848
|
-
bar,
|
|
849
|
-
symbol: this.symbol,
|
|
850
|
-
});
|
|
851
|
-
```
|
|
852
|
-
|
|
853
|
-
- [ ] **Step 4: Update the import in liveEngine.js**
|
|
854
|
-
|
|
855
|
-
Find:
|
|
856
|
-
|
|
857
|
-
```js
|
|
858
|
-
import {
|
|
859
|
-
callSignalWithContext,
|
|
860
|
-
normalizeSignal,
|
|
861
|
-
snapshotOpenPosition,
|
|
862
|
-
} from "../../engine/barSystemRunner.js";
|
|
863
|
-
```
|
|
864
|
-
|
|
865
|
-
Replace with:
|
|
866
|
-
|
|
867
|
-
```js
|
|
868
|
-
import {
|
|
869
|
-
callSignalWithContextAsync,
|
|
870
|
-
normalizeSignal,
|
|
871
|
-
snapshotOpenPosition,
|
|
872
|
-
} from "../../engine/barSystemRunner.js";
|
|
873
|
-
```
|
|
874
|
-
|
|
875
|
-
(`callSignalWithContext` was the only other use; if any remain, keep both in the
|
|
876
|
-
import list.)
|
|
877
|
-
|
|
878
|
-
- [ ] **Step 5: Run live suite + full suite**
|
|
879
|
-
|
|
880
|
-
Run: `node --test test/live/liveEngine.test.js`
|
|
881
|
-
Expected: PASS — sync signals still work (await of a non-Promise is a no-op) and
|
|
882
|
-
async signals now resolve.
|
|
883
|
-
|
|
884
|
-
Run: `node --test`
|
|
885
|
-
Expected: PASS.
|
|
886
|
-
|
|
887
|
-
- [ ] **Step 6: Commit**
|
|
888
|
-
|
|
889
|
-
```bash
|
|
890
|
-
git add src/live/engine/liveEngine.js test/live/liveEngine.test.js
|
|
891
|
-
git commit -m "feat: live engine awaits async signals
|
|
892
|
-
|
|
893
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
894
|
-
```
|
|
895
|
-
|
|
896
|
-
---
|
|
897
|
-
|
|
898
|
-
### Task 7: Docs + example
|
|
899
|
-
|
|
900
|
-
**Files:**
|
|
901
|
-
|
|
902
|
-
- Create: `examples/llmSignal.js`
|
|
903
|
-
- Modify: `docs/backtest-engine.md` and `docs/live-trading.md`
|
|
904
|
-
- Modify: `README.md`
|
|
905
|
-
|
|
906
|
-
- [ ] **Step 1: Write examples/llmSignal.js**
|
|
907
|
-
|
|
908
|
-
```js
|
|
909
|
-
// examples/llmSignal.js
|
|
910
|
-
// Run: node examples/llmSignal.js
|
|
911
|
-
import { backtestAsync, LlmSignal, getHistoricalCandles } from "../src/index.js";
|
|
912
|
-
|
|
913
|
-
const candles = await getHistoricalCandles({
|
|
914
|
-
source: "yahoo",
|
|
915
|
-
symbol: "SPY",
|
|
916
|
-
interval: "1d",
|
|
917
|
-
period: "1y",
|
|
918
|
-
cache: true,
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
// Stand-in for a real model call. Replace `resolve` with an LLM/agent request.
|
|
922
|
-
const llm = new LlmSignal({
|
|
923
|
-
budgetMs: 2000,
|
|
924
|
-
onError: "skip",
|
|
925
|
-
async resolve({ candles: history, bar }) {
|
|
926
|
-
const closes = history.map((c) => c.close);
|
|
927
|
-
const recent = closes.slice(-5);
|
|
928
|
-
const rising = recent.every((c, i) => i === 0 || c >= recent[i - 1]);
|
|
929
|
-
return rising ? { side: "long", stop: bar.close * 0.98, rr: 2 } : null;
|
|
930
|
-
},
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
const result = await backtestAsync({
|
|
934
|
-
candles,
|
|
935
|
-
symbol: "SPY",
|
|
936
|
-
interval: "1d",
|
|
937
|
-
signal: llm.signal,
|
|
938
|
-
signalBudgetMs: 3000,
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
console.log("trades:", result.metrics.trades, "PnL:", result.metrics.totalPnL.toFixed(2));
|
|
942
|
-
console.log("decisions logged:", llm.log.length);
|
|
943
|
-
```
|
|
944
|
-
|
|
945
|
-
- [ ] **Step 2: Run the example**
|
|
946
|
-
|
|
947
|
-
Run: `node examples/llmSignal.js`
|
|
948
|
-
Expected: prints trade count, PnL, and a non-zero decision-log length (network
|
|
949
|
-
permitting; if Yahoo is unavailable, swap to a local CSV via `getHistoricalCandles`).
|
|
950
|
-
|
|
951
|
-
- [ ] **Step 3: Document in docs/backtest-engine.md**
|
|
952
|
-
|
|
953
|
-
Add an "Async signals" section covering `backtestAsync`, `signalBudgetMs`, and how
|
|
954
|
-
`LlmSignal` enforces caching, budget, no-lookahead, and the decision log. Cross-link
|
|
955
|
-
`docs/live-trading.md` for the live path.
|
|
956
|
-
|
|
957
|
-
- [ ] **Step 4: Mention seed reproducibility in docs**
|
|
958
|
-
|
|
959
|
-
In the tick-backtest section, document the new `seed` option and that identical
|
|
960
|
-
seed + data + params produces identical fills.
|
|
961
|
-
|
|
962
|
-
- [ ] **Step 5: Lint, format, full test, commit**
|
|
963
|
-
|
|
964
|
-
```bash
|
|
965
|
-
npm run lint && npm run format:check && npm test
|
|
966
|
-
git add examples/llmSignal.js docs/backtest-engine.md docs/live-trading.md README.md
|
|
967
|
-
git commit -m "docs: async signals, LlmSignal example, tick seeding
|
|
968
|
-
|
|
969
|
-
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
|
970
|
-
```
|
|
971
|
-
|
|
972
|
-
---
|
|
973
|
-
|
|
974
|
-
## Self-review checklist
|
|
975
|
-
|
|
976
|
-
- [ ] Sync `backtest()` and portfolio behavior unchanged — the refactor preserves `step()` semantics; the regression suites (`backtest.test.js`, `portfolio.test.js`) are the guard. ✔ (Task 3)
|
|
977
|
-
- [ ] `_preSignal` return contract is consistent: `{ handled: true, bar }` for every early/terminal path, `{ handled: false, bar, trigger, signalEquity, resolveEntrySize }` only when a signal is needed. ✔
|
|
978
|
-
- [ ] `backtestAsync` with a sync signal is asserted byte-equal to `backtest` on PnL/positions. ✔ (Task 4 Step 1)
|
|
979
|
-
- [ ] `withBudget` is the single budget primitive used by both `backtestAsync` and `LlmSignal`. ✔
|
|
980
|
-
- [ ] `LlmSignal` reuses the same no-lookahead proxy idea as engine `strict` mode. ✔
|
|
981
|
-
- [ ] Live engine still accepts plain sync signals (await of non-Promise is a no-op). ✔ (Task 6)
|