tradelab 1.2.1 → 1.3.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 +42 -0
- package/README.md +17 -5
- package/bin/tradelab.js +36 -0
- package/dist/cjs/index.cjs +90 -0
- package/dist/cjs/live.cjs +242 -51
- package/docs/live-trading.md +130 -0
- package/docs/mcp.md +89 -20
- package/examples/agentResearchLoop.js +188 -0
- package/examples/multiSymbolPortfolio.js +122 -0
- package/package.json +1 -1
- package/src/cli/runPreset.js +42 -0
- package/src/index.js +2 -0
- package/src/live/engine/riskManager.js +38 -0
- package/src/live/index.js +1 -0
- package/src/live/notify.js +42 -0
- package/src/live/session.js +166 -52
- package/src/mcp/liveTools.js +28 -24
- package/src/mcp/researchSession.js +24 -0
- package/src/mcp/schemas.js +5 -1
- package/src/mcp/tools.js +27 -2
- package/src/reporting/summarize.js +43 -0
- package/src/research/store.js +67 -0
- package/types/index.d.ts +30 -0
package/docs/mcp.md
CHANGED
|
@@ -94,41 +94,78 @@ Local checkout example:
|
|
|
94
94
|
| `fetch_candles` | Load Yahoo or CSV candles and return first/last bars |
|
|
95
95
|
| `run_backtest` | Run one named strategy and return compact metrics |
|
|
96
96
|
| `walk_forward` | Run a parameter grid through walk-forward validation |
|
|
97
|
-
| `analyze_robustness` | Backtest + Monte Carlo + Deflated Sharpe
|
|
97
|
+
| `analyze_robustness` | Backtest + Monte Carlo + Deflated Sharpe; validate before you trade |
|
|
98
98
|
| `optimize_strategy` | In-process grid sweep; returns a leaderboard sorted by chosen metric |
|
|
99
99
|
| `compare_strategies` | Run several named strategies on the same dataset, ranked head-to-head |
|
|
100
100
|
| `candle_stats` | Sanity-check candle data: count, date range, price range, interval |
|
|
101
101
|
|
|
102
|
+
### Research loop tools
|
|
103
|
+
|
|
104
|
+
| Tool | Args (required) | Returns |
|
|
105
|
+
| ------------------ | --------------------- | ---------------------------------------------------- |
|
|
106
|
+
| `research_open` | `id`, `goal?` | Record with `id`, `goal`, `entries`, `createdAt` |
|
|
107
|
+
| `research_log` | `id`, `hypothesis?`, `params?`, `metrics?`, `verdict?` | Appended entry |
|
|
108
|
+
| `research_recall` | `id`, `limit?` | Recent entries plus a synthesized `summary` string |
|
|
109
|
+
| `research_close` | `id` | Final record with `closedAt` timestamp |
|
|
110
|
+
|
|
111
|
+
Research sessions are file-backed in `.tradelab/research/` (one JSON file per `id`). They persist across MCP server restarts so agents can resume a session after a context reset.
|
|
112
|
+
|
|
113
|
+
`run_backtest` also accepts a `researchId` argument. When provided, it auto-logs the backtest result and a Deflated Sharpe verdict to the session without requiring a separate `research_log` call:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"data": { "source": "yahoo", "symbol": "SPY", "interval": "1d", "period": "2y" },
|
|
118
|
+
"strategy": "ema-cross",
|
|
119
|
+
"params": { "fast": 10, "slow": 30 },
|
|
120
|
+
"researchId": "spy-ema-study",
|
|
121
|
+
"numTrials": 3
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The auto-logged verdict contains:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"deflatedSharpe": 0.87,
|
|
130
|
+
"overfit": true,
|
|
131
|
+
"note": "PSR 87.0%"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`overfit: true` means the Probabilistic Sharpe Ratio fell below the 0.9 threshold given the number of trials.
|
|
136
|
+
|
|
102
137
|
Tool responses are intentionally compact. They are meant for planning and comparison, not for replacing full HTML/CSV/JSON reports from the CLI.
|
|
103
138
|
|
|
104
139
|
### Live trading tools
|
|
105
140
|
|
|
106
|
-
| Tool | Args (required)
|
|
107
|
-
| ----------------- |
|
|
108
|
-
| `create_session` | `sessionId`, `symbol`
|
|
109
|
-
| `list_sessions` |
|
|
110
|
-
| `session_status` | `sessionId`
|
|
111
|
-
| `feed_price` | `sessionId`, `bar` OR `price`
|
|
112
|
-
| `place_order` | `sessionId`, `side`, `type?`, `qty?` OR `riskPct`+`stop` | order receipt
|
|
113
|
-
| `close_position` | `sessionId`, `symbol?`
|
|
114
|
-
| `flatten` | `sessionId`
|
|
115
|
-
| `cancel_order` | `sessionId`, `orderId`
|
|
116
|
-
| `account` | `sessionId`
|
|
117
|
-
| `positions` | `sessionId`
|
|
118
|
-
| `recent_events` | `sessionId`, `limit?`
|
|
119
|
-
| `attach_strategy` | `sessionId`, `strategy`, `params?`
|
|
120
|
-
| `halt_all` |
|
|
141
|
+
| Tool | Args (required) | Returns |
|
|
142
|
+
| ----------------- | ------------------------------------------------------------- | ------------------------------------ |
|
|
143
|
+
| `create_session` | `sessionId`, `symbol` OR `symbols` | session status snapshot |
|
|
144
|
+
| `list_sessions` | `(none)` | array of session statuses |
|
|
145
|
+
| `session_status` | `sessionId` | full refresh (positions/orders/risk) |
|
|
146
|
+
| `feed_price` | `sessionId`, `bar` OR `price`, `symbol?` | status after fills |
|
|
147
|
+
| `place_order` | `sessionId`, `side`, `type?`, `qty?` OR `riskPct`+`stop`, `symbol?` | order receipt |
|
|
148
|
+
| `close_position` | `sessionId`, `symbol?` | order receipt |
|
|
149
|
+
| `flatten` | `sessionId` | `{ ok: true }` |
|
|
150
|
+
| `cancel_order` | `sessionId`, `orderId` | `{ ok: true }` |
|
|
151
|
+
| `account` | `sessionId` | broker account info |
|
|
152
|
+
| `positions` | `sessionId` | open positions |
|
|
153
|
+
| `recent_events` | `sessionId`, `limit?` | event log |
|
|
154
|
+
| `attach_strategy` | `sessionId`, `strategy`, `params?`, `symbol?` | `{ ok: true }` |
|
|
155
|
+
| `halt_all` | `(none)` | `{ ok: true, sessionsHalted: N }` |
|
|
121
156
|
|
|
122
157
|
## Agent trading loop
|
|
123
158
|
|
|
124
159
|
A typical autonomous paper-trading loop:
|
|
125
160
|
|
|
126
|
-
1. Call `create_session` with `sessionId`, `symbol
|
|
127
|
-
2. Call `feed_price` with each new bar as it arrives
|
|
128
|
-
3. Call `place_order` with `riskPct` + `stop` to size automatically; add `target` or `rr` for a bracket.
|
|
129
|
-
4. Call `session_status` any time for a snapshot of positions, orders, equity, and risk state.
|
|
161
|
+
1. Call `create_session` with `sessionId`, `symbol` (or `symbols` for a multi-symbol session), and `equity` (paper by default).
|
|
162
|
+
2. Call `feed_price` with each new bar as it arrives, passing `symbol` when tracking more than one instrument. Fills resting bracket orders automatically.
|
|
163
|
+
3. Call `place_order` with `riskPct` + `stop` to size automatically; add `target` or `rr` for a bracket. Pass `symbol` for multi-symbol sessions.
|
|
164
|
+
4. Call `session_status` any time for a snapshot of positions, orders, equity, and risk state. The snapshot includes a `symbols` array.
|
|
130
165
|
5. Call `flatten` or `halt_all` to emergency-close everything.
|
|
131
166
|
|
|
167
|
+
For multi-symbol sessions you can also pass `maxGrossExposurePct` or `maxNetExposurePct` to `create_session` to cap portfolio-level exposure. Orders that would breach the cap are rejected before they reach the broker.
|
|
168
|
+
|
|
132
169
|
If you attach a strategy with `attach_strategy`, `feed_price` will auto-evaluate it each bar and place orders when the session is flat. Attached strategies receive the same `{ candles, index, bar, equity, openPosition, pendingOrder }` context as `backtest()`, and returned order intents default to a market order unless `type` is set.
|
|
133
170
|
|
|
134
171
|
## Typical Research Flow
|
|
@@ -140,6 +177,38 @@ If you attach a strategy with `attach_strategy`, `feed_price` will auto-evaluate
|
|
|
140
177
|
5. Inspect trade count, profit factor, drawdown, return, and Sharpe fields.
|
|
141
178
|
6. Call `walk_forward` with a grid to see whether parameters hold up out of sample.
|
|
142
179
|
|
|
180
|
+
## Agent Research Loop
|
|
181
|
+
|
|
182
|
+
The research loop tools let an agent track hypothesis iteration across many `run_backtest` calls without losing context:
|
|
183
|
+
|
|
184
|
+
1. Call `research_open` with an `id` and a plain-text `goal`.
|
|
185
|
+
2. For each parameter set you want to test: call `run_backtest` with `researchId` set to that `id`. The result is auto-logged with a Deflated Sharpe verdict.
|
|
186
|
+
3. Alternatively, call `research_log` directly to record results from external tools or your own computations.
|
|
187
|
+
4. Call `research_recall` at any time to get the last N entries plus a synthesized one-liner: best Sharpe, how many runs flagged as overfit.
|
|
188
|
+
5. Call `research_close` when the study is complete.
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
// Step 1: open
|
|
192
|
+
{ "id": "spy-cross-study", "goal": "Find the best EMA pair for SPY daily" }
|
|
193
|
+
|
|
194
|
+
// Step 2: run with auto-logging
|
|
195
|
+
{
|
|
196
|
+
"data": { "source": "yahoo", "symbol": "SPY", "interval": "1d", "period": "3y" },
|
|
197
|
+
"strategy": "ema-cross",
|
|
198
|
+
"params": { "fast": 10, "slow": 30 },
|
|
199
|
+
"researchId": "spy-cross-study"
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Step 4: recall
|
|
203
|
+
{ "id": "spy-cross-study", "limit": 10 }
|
|
204
|
+
// returns: { goal, entries: [...], summary: "Best Sharpe so far: 1.42 via {fast:10,slow:30}. 1 of 4 flagged overfit." }
|
|
205
|
+
|
|
206
|
+
// Step 5: close
|
|
207
|
+
{ "id": "spy-cross-study" }
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Research files are stored in `.tradelab/research/` in the directory where the MCP server was launched. They persist across server restarts.
|
|
211
|
+
|
|
143
212
|
## Example Calls
|
|
144
213
|
|
|
145
214
|
Fetch candles:
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentResearchLoop.js: Open a research session, run two backtests, log each
|
|
3
|
+
* result with an overfit verdict, then recall entries and print the synthesis
|
|
4
|
+
* plus a plain-English summary via summarize().
|
|
5
|
+
*
|
|
6
|
+
* Shows:
|
|
7
|
+
* - createResearchStore() with open / log / recall / close
|
|
8
|
+
* - Logging a backtest result manually (mirrors what run_backtest does when
|
|
9
|
+
* researchId is passed to the MCP tool)
|
|
10
|
+
* - research_recall synthesized summary (best Sharpe, overfit count)
|
|
11
|
+
* - summarize(metrics) for a one-paragraph plain-English output
|
|
12
|
+
*
|
|
13
|
+
* node examples/agentResearchLoop.js
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { backtest, ema } from "../src/index.js";
|
|
17
|
+
import { createResearchStore } from "../src/research/store.js";
|
|
18
|
+
import { deflatedSharpe } from "../src/research/index.js";
|
|
19
|
+
import { summarize } from "../src/reporting/summarize.js";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Seeded candle generator; deterministic so the example output is stable.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
function makeRng(seed) {
|
|
25
|
+
let s = seed >>> 0;
|
|
26
|
+
return () => {
|
|
27
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
28
|
+
return s / 0x100000000;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function syntheticCandles(count = 500) {
|
|
33
|
+
const rng = makeRng(99999);
|
|
34
|
+
const candles = [];
|
|
35
|
+
let price = 100;
|
|
36
|
+
const base = Date.UTC(2022, 0, 3);
|
|
37
|
+
for (let i = 0; i < count; i++) {
|
|
38
|
+
const noise = (rng() - 0.5) * 5;
|
|
39
|
+
price = Math.max(price + noise + 0.1, 5);
|
|
40
|
+
const range = Math.abs(noise) + 1;
|
|
41
|
+
const open = price + (rng() - 0.5) * range * 0.4;
|
|
42
|
+
const close = price;
|
|
43
|
+
const high = Math.max(open, close) + rng() * range;
|
|
44
|
+
const low = Math.min(open, close) - rng() * range;
|
|
45
|
+
candles.push({ time: base + i * 86_400_000, open, high, low, close, volume: 10_000 });
|
|
46
|
+
}
|
|
47
|
+
return candles;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Simple bidirectional EMA-cross signal.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
function makeEmaSignal({ fast = 10, slow = 30, rr = 2 } = {}) {
|
|
54
|
+
return ({ candles: history, bar }) => {
|
|
55
|
+
if (history.length < slow + 2) return null;
|
|
56
|
+
const closes = history.map((c) => c.close);
|
|
57
|
+
const f = ema(closes, fast);
|
|
58
|
+
const s = ema(closes, slow);
|
|
59
|
+
const last = closes.length - 1;
|
|
60
|
+
if (f[last - 1] <= s[last - 1] && f[last] > s[last]) {
|
|
61
|
+
const stop = Math.min(...history.slice(-15).map((c) => c.low));
|
|
62
|
+
if (stop >= bar.close) return null;
|
|
63
|
+
return { side: "long", entry: bar.close, stop, rr };
|
|
64
|
+
}
|
|
65
|
+
if (f[last - 1] >= s[last - 1] && f[last] < s[last]) {
|
|
66
|
+
const stop = Math.max(...history.slice(-15).map((c) => c.high));
|
|
67
|
+
if (stop <= bar.close) return null;
|
|
68
|
+
return { side: "short", entry: bar.close, stop, rr };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Helper: run a backtest, compute a DSR overfit verdict, log to the store.
|
|
76
|
+
// This mirrors what the MCP run_backtest tool does when researchId is passed.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
async function runAndLog(store, researchId, { label, params, candles, symbol }) {
|
|
79
|
+
const signal = makeEmaSignal(params);
|
|
80
|
+
const result = backtest({ candles, symbol, interval: "1d", signal, collectReplay: false });
|
|
81
|
+
const m = result.metrics;
|
|
82
|
+
|
|
83
|
+
let verdict = null;
|
|
84
|
+
try {
|
|
85
|
+
const psr = deflatedSharpe({
|
|
86
|
+
sharpe: m.sharpe,
|
|
87
|
+
sampleSize: m.trades,
|
|
88
|
+
numTrials: 2, // two parameter sets tried in this session
|
|
89
|
+
});
|
|
90
|
+
verdict = {
|
|
91
|
+
deflatedSharpe: psr,
|
|
92
|
+
overfit: Number.isFinite(psr) ? psr < 0.9 : false,
|
|
93
|
+
note: Number.isFinite(psr) ? `PSR ${(psr * 100).toFixed(1)}%` : "insufficient data",
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
verdict = { deflatedSharpe: null, overfit: false, note: "verdict unavailable" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await store.log(researchId, {
|
|
100
|
+
hypothesis: label,
|
|
101
|
+
params,
|
|
102
|
+
metrics: {
|
|
103
|
+
trades: m.trades,
|
|
104
|
+
winRate: m.winRate,
|
|
105
|
+
profitFactor: m.profitFactor,
|
|
106
|
+
sharpe: m.sharpe,
|
|
107
|
+
maxDrawdown: m.maxDrawdown,
|
|
108
|
+
returnPct: m.returnPct,
|
|
109
|
+
},
|
|
110
|
+
verdict,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return { metrics: m, verdict };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Main
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
async function main() {
|
|
120
|
+
const candles = syntheticCandles(500);
|
|
121
|
+
// Use a unique subdirectory so repeated runs start fresh.
|
|
122
|
+
const store = createResearchStore({ dir: ".tradelab/research-example" });
|
|
123
|
+
const researchId = "ema-cross-study";
|
|
124
|
+
|
|
125
|
+
// 1. Open (or resume) a named research session.
|
|
126
|
+
const session = await store.open(researchId, "Compare fast vs slow EMA-cross on synthetic data");
|
|
127
|
+
console.log("Research session:", session.id);
|
|
128
|
+
console.log("Goal:", session.goal);
|
|
129
|
+
console.log("");
|
|
130
|
+
|
|
131
|
+
// 2. First backtest: tighter EMA pair, moderate R:R.
|
|
132
|
+
const run1 = await runAndLog(store, researchId, {
|
|
133
|
+
label: "EMA 5/20 rr=2",
|
|
134
|
+
params: { fast: 5, slow: 20, rr: 2 },
|
|
135
|
+
candles,
|
|
136
|
+
symbol: "SYNTHETIC",
|
|
137
|
+
});
|
|
138
|
+
console.log("Run 1 (fast=5, slow=20, rr=2):");
|
|
139
|
+
console.log(" trades:", run1.metrics.trades);
|
|
140
|
+
console.log(" profitFactor:", run1.metrics.profitFactor?.toFixed(2));
|
|
141
|
+
console.log(" sharpe:", run1.metrics.sharpe?.toFixed(2));
|
|
142
|
+
console.log(" verdict:", run1.verdict);
|
|
143
|
+
|
|
144
|
+
// 3. Second backtest: wider EMA pair, higher R:R.
|
|
145
|
+
const run2 = await runAndLog(store, researchId, {
|
|
146
|
+
label: "EMA 10/30 rr=3",
|
|
147
|
+
params: { fast: 10, slow: 30, rr: 3 },
|
|
148
|
+
candles,
|
|
149
|
+
symbol: "SYNTHETIC",
|
|
150
|
+
});
|
|
151
|
+
console.log("\nRun 2 (fast=10, slow=30, rr=3):");
|
|
152
|
+
console.log(" trades:", run2.metrics.trades);
|
|
153
|
+
console.log(" profitFactor:", run2.metrics.profitFactor?.toFixed(2));
|
|
154
|
+
console.log(" sharpe:", run2.metrics.sharpe?.toFixed(2));
|
|
155
|
+
console.log(" verdict:", run2.verdict);
|
|
156
|
+
|
|
157
|
+
// 4. Recall entries: the store returns recent entries plus a plain-text synthesis.
|
|
158
|
+
const recall = await store.recall(researchId);
|
|
159
|
+
console.log("\nResearch recall summary:");
|
|
160
|
+
console.log(" ", recall.summary);
|
|
161
|
+
console.log(" entries logged:", recall.entries.length);
|
|
162
|
+
|
|
163
|
+
// 5. Pick the run with the better profitFactor and produce a human-readable summary.
|
|
164
|
+
const bestMetrics =
|
|
165
|
+
(run1.metrics.profitFactor ?? 0) >= (run2.metrics.profitFactor ?? 0)
|
|
166
|
+
? run1.metrics
|
|
167
|
+
: run2.metrics;
|
|
168
|
+
|
|
169
|
+
// summarize() expects percent-valued drawdown/return; backtest returns fractions.
|
|
170
|
+
const normalized = {
|
|
171
|
+
trades: bestMetrics.trades,
|
|
172
|
+
winRate: bestMetrics.winRate,
|
|
173
|
+
totalReturnPct: (bestMetrics.returnPct ?? 0) * 100,
|
|
174
|
+
maxDrawdownPct: (bestMetrics.maxDrawdown ?? 0) * 100,
|
|
175
|
+
sharpe: bestMetrics.sharpe,
|
|
176
|
+
};
|
|
177
|
+
console.log("\nBest run plain-English summary:");
|
|
178
|
+
console.log(" ", summarize(normalized));
|
|
179
|
+
|
|
180
|
+
// 6. Close the research session.
|
|
181
|
+
const closed = await store.close(researchId);
|
|
182
|
+
console.log("\nResearch session closed:", closed.closedAt);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main().catch((err) => {
|
|
186
|
+
console.error(err);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* multiSymbolPortfolio.js: Paper session trading two symbols with independent
|
|
3
|
+
* bracket orders and a portfolio exposure cap.
|
|
4
|
+
*
|
|
5
|
+
* Shows:
|
|
6
|
+
* - SessionManager.create() with a symbols array
|
|
7
|
+
* - Per-symbol pushBar() and placeOrder()
|
|
8
|
+
* - maxGrossExposurePct exposure cap blocking over-sized orders
|
|
9
|
+
* - getStatus() reporting positions, equity, and the symbols list
|
|
10
|
+
*
|
|
11
|
+
* node examples/multiSymbolPortfolio.js
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { SessionManager, PaperEngine } from "../src/live/index.js";
|
|
15
|
+
|
|
16
|
+
function bar(time, price, { high = price, low = price } = {}) {
|
|
17
|
+
return { time, open: price, high, low, close: price, volume: 1000 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
// 1. One shared broker for two symbols.
|
|
22
|
+
const broker = new PaperEngine({ equity: 20_000 });
|
|
23
|
+
|
|
24
|
+
const manager = new SessionManager();
|
|
25
|
+
const session = await manager.create({
|
|
26
|
+
id: "btc-eth-portfolio",
|
|
27
|
+
symbols: ["BTC", "ETH"],
|
|
28
|
+
interval: "1h",
|
|
29
|
+
equity: 20_000,
|
|
30
|
+
riskPct: 1,
|
|
31
|
+
// Cap total gross notional to 150% of equity.
|
|
32
|
+
// A second order that would push exposure past that limit is rejected.
|
|
33
|
+
maxGrossExposurePct: 150,
|
|
34
|
+
broker,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log("Session started:", session.id);
|
|
38
|
+
console.log("Tracked symbols:", session.symbols);
|
|
39
|
+
|
|
40
|
+
// 2. Feed opening bars for each symbol.
|
|
41
|
+
await session.pushBar(bar(1, 30_000), "BTC");
|
|
42
|
+
await session.pushBar(bar(1, 2_000), "ETH");
|
|
43
|
+
|
|
44
|
+
// 3. Place a risk-sized long bracket on BTC.
|
|
45
|
+
// 1% of $20k = $200 risk, $500/BTC stop distance -> 0.4 BTC.
|
|
46
|
+
const btcReceipt = await session.placeOrder({
|
|
47
|
+
symbol: "BTC",
|
|
48
|
+
side: "long",
|
|
49
|
+
riskPct: 1,
|
|
50
|
+
stop: 29_500,
|
|
51
|
+
rr: 3,
|
|
52
|
+
});
|
|
53
|
+
console.log(
|
|
54
|
+
"\nBTC entry filled:",
|
|
55
|
+
btcReceipt.status,
|
|
56
|
+
"qty:", btcReceipt.filledQty,
|
|
57
|
+
"@", btcReceipt.avgFillPrice
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// 4. Place a risk-sized long bracket on ETH.
|
|
61
|
+
// 1% of $20k = $200 risk, $100/ETH stop distance -> 2 ETH.
|
|
62
|
+
const ethReceipt = await session.placeOrder({
|
|
63
|
+
symbol: "ETH",
|
|
64
|
+
side: "long",
|
|
65
|
+
riskPct: 1,
|
|
66
|
+
stop: 1_900,
|
|
67
|
+
rr: 2,
|
|
68
|
+
});
|
|
69
|
+
console.log(
|
|
70
|
+
"ETH entry filled:",
|
|
71
|
+
ethReceipt.status,
|
|
72
|
+
"qty:", ethReceipt.filledQty,
|
|
73
|
+
"@", ethReceipt.avgFillPrice
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
let status = session.getStatus();
|
|
77
|
+
console.log("\nAfter both entries:");
|
|
78
|
+
console.log(" positions:", status.positions.length, "(BTC + ETH)");
|
|
79
|
+
console.log(" open orders:", status.openOrders.length, "(4 bracket legs)");
|
|
80
|
+
console.log(" equity:", status.equity.toFixed(2));
|
|
81
|
+
console.log(" symbols:", status.symbols);
|
|
82
|
+
|
|
83
|
+
// 5. Demonstrate the exposure cap: trying to open a large third position is rejected.
|
|
84
|
+
try {
|
|
85
|
+
await session.placeOrder({
|
|
86
|
+
symbol: "BTC",
|
|
87
|
+
side: "long",
|
|
88
|
+
qty: 1, // large fixed size that would exceed the 150% cap
|
|
89
|
+
stop: 28_000,
|
|
90
|
+
});
|
|
91
|
+
console.log("\nLarge order unexpectedly succeeded.");
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log("\nExposure cap enforced:", err.message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 6. Simulate BTC hitting its target (high = 31_500).
|
|
97
|
+
await session.pushBar(bar(2, 31_500, { high: 31_500 }), "BTC");
|
|
98
|
+
await session.pushBar(bar(2, 2_000), "ETH");
|
|
99
|
+
|
|
100
|
+
// 7. Close the remaining ETH position manually.
|
|
101
|
+
await session.closePosition("ETH");
|
|
102
|
+
|
|
103
|
+
status = session.getStatus();
|
|
104
|
+
console.log("\nAfter BTC target + ETH close:");
|
|
105
|
+
console.log(" positions:", status.positions.length, "(should be 0)");
|
|
106
|
+
console.log(" open orders:", status.openOrders.length, "(should be 0)");
|
|
107
|
+
console.log(" final equity:", status.equity.toFixed(2));
|
|
108
|
+
console.log(" dayPnl:", status.dayPnl.toFixed(2));
|
|
109
|
+
console.log(" risk.halted:", status.risk.halted);
|
|
110
|
+
|
|
111
|
+
// 8. Per-symbol price and candle buffer accessors.
|
|
112
|
+
console.log("\nlastPriceFor BTC:", session.lastPriceFor("BTC"));
|
|
113
|
+
console.log("candleBufferFor ETH length:", session.candleBufferFor("ETH").length);
|
|
114
|
+
|
|
115
|
+
await session.stop();
|
|
116
|
+
console.log("\nDone.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch((err) => {
|
|
120
|
+
console.error(err);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/cli/runPreset.js
|
|
2
|
+
import { getStrategy, listStrategies } from "../strategies/index.js";
|
|
3
|
+
import { backtest } from "../engine/backtest.js";
|
|
4
|
+
import { summarize } from "../reporting/summarize.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run a named built-in strategy over provided candles and return metrics + summary.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ preset: string, candles: object[], params?: object, symbol?: string, interval?: string }} options
|
|
10
|
+
* @returns {{ metrics: object, summary: string }}
|
|
11
|
+
*/
|
|
12
|
+
export function runPreset({ preset, candles, params = {}, symbol = "PRESET", interval = "1d" } = {}) {
|
|
13
|
+
let factory;
|
|
14
|
+
try {
|
|
15
|
+
factory = getStrategy(preset);
|
|
16
|
+
} catch {
|
|
17
|
+
factory = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!factory) {
|
|
21
|
+
const names = listStrategies()
|
|
22
|
+
.map((s) => s.name)
|
|
23
|
+
.join(", ");
|
|
24
|
+
throw new Error(`unknown preset "${preset}". Available: ${names}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const signal = factory(params);
|
|
28
|
+
const result = backtest({ candles, symbol, interval, signal, warmupBars: 0 });
|
|
29
|
+
const m = result.metrics;
|
|
30
|
+
|
|
31
|
+
// Normalize units: backtest metrics use fractions; summarize() expects percent-valued fields.
|
|
32
|
+
const normalized = {
|
|
33
|
+
trades: m.trades,
|
|
34
|
+
winRate: m.winRate,
|
|
35
|
+
totalReturnPct: m.returnPct * 100,
|
|
36
|
+
maxDrawdownPct: m.maxDrawdown * 100,
|
|
37
|
+
sharpe: m.sharpe,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const summary = summarize(normalized);
|
|
41
|
+
return { metrics: result.metrics, summary };
|
|
42
|
+
}
|
package/src/index.js
CHANGED
|
@@ -27,10 +27,12 @@ export {
|
|
|
27
27
|
saveCandlesToCache,
|
|
28
28
|
} from "./data/index.js";
|
|
29
29
|
|
|
30
|
+
export { createResearchStore } from "./research/store.js";
|
|
30
31
|
export { renderHtmlReport, exportHtmlReport } from "./reporting/renderHtmlReport.js";
|
|
31
32
|
export { exportTradesCsv } from "./reporting/exportTradesCsv.js";
|
|
32
33
|
export { exportMetricsJSON } from "./reporting/exportMetricsJson.js";
|
|
33
34
|
export { exportBacktestArtifacts } from "./reporting/exportBacktestArtifacts.js";
|
|
35
|
+
export { summarize } from "./reporting/summarize.js";
|
|
34
36
|
|
|
35
37
|
export {
|
|
36
38
|
ema,
|
|
@@ -21,6 +21,8 @@ export class RiskManager {
|
|
|
21
21
|
cooldownAfterLossMs: 0,
|
|
22
22
|
allowedSessions: "AUTO",
|
|
23
23
|
allowedWindows: null,
|
|
24
|
+
maxGrossExposurePct: 0,
|
|
25
|
+
maxNetExposurePct: 0,
|
|
24
26
|
...options,
|
|
25
27
|
};
|
|
26
28
|
this.allowedWindows = parseWindowsCSV(this.options.allowedWindows);
|
|
@@ -114,6 +116,8 @@ export class RiskManager {
|
|
|
114
116
|
positionCount = 0,
|
|
115
117
|
positionValue = 0,
|
|
116
118
|
equity = null,
|
|
119
|
+
grossExposure = undefined,
|
|
120
|
+
netExposure = undefined,
|
|
117
121
|
} = {}) {
|
|
118
122
|
const base = this.canTrade({ timeMs });
|
|
119
123
|
if (!base.ok) return base;
|
|
@@ -135,6 +139,40 @@ export class RiskManager {
|
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check only the portfolio exposure caps (no session/halt/trade-count checks).
|
|
147
|
+
* Called from placeOrder after the halt check has already run.
|
|
148
|
+
*/
|
|
149
|
+
checkExposure({ grossExposure = undefined, netExposure = undefined, equity = null } = {}) {
|
|
150
|
+
const eq = Number.isFinite(equity) ? equity : this.currentEquity;
|
|
151
|
+
return this._checkExposureCaps({ grossExposure, netExposure, equity: eq });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Shared gross/net exposure cap logic used by canOpenPosition and checkExposure.
|
|
156
|
+
* Expects a resolved equity value (NaN/null fallback already applied by caller).
|
|
157
|
+
* Returns { ok: true, reason: null } when within caps or caps are disabled.
|
|
158
|
+
*/
|
|
159
|
+
_checkExposureCaps({ grossExposure = undefined, netExposure = undefined, equity } = {}) {
|
|
160
|
+
const eq = equity;
|
|
161
|
+
|
|
162
|
+
const grossCap = pctToFraction(this.options.maxGrossExposurePct, 0);
|
|
163
|
+
if (grossCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(grossExposure)) {
|
|
164
|
+
if (Math.abs(grossExposure) / eq > grossCap) {
|
|
165
|
+
return { ok: false, reason: "max gross exposure exceeded" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const netCap = pctToFraction(this.options.maxNetExposurePct, 0);
|
|
170
|
+
if (netCap > 0 && Number.isFinite(eq) && eq > 0 && Number.isFinite(netExposure)) {
|
|
171
|
+
if (Math.abs(netExposure) / eq > netCap) {
|
|
172
|
+
return { ok: false, reason: "max net exposure exceeded" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
138
176
|
return { ok: true, reason: null };
|
|
139
177
|
}
|
|
140
178
|
|
package/src/live/index.js
CHANGED
|
@@ -28,3 +28,4 @@ export { LiveOrchestrator, createLiveOrchestrator } from "./orchestrator.js";
|
|
|
28
28
|
export { createDashboardServer } from "./dashboard/server.js";
|
|
29
29
|
|
|
30
30
|
export { TradingSession, SessionManager, createSessionManager } from "./session.js";
|
|
31
|
+
export { attachNotifier } from "./notify.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// src/live/notify.js
|
|
2
|
+
const DEFAULT_EVENTS = ["order:filled", "risk:halt"];
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe a notifier to a trading session's event bus. Returns an unsubscribe
|
|
6
|
+
* function. Fires onEvent and/or POSTs to webhookUrl for the configured events,
|
|
7
|
+
* plus a drawdown breach on equity updates.
|
|
8
|
+
*/
|
|
9
|
+
export function attachNotifier(session, { onEvent, webhookUrl, events = DEFAULT_EVENTS, drawdownPct = 0 } = {}) {
|
|
10
|
+
const wanted = new Set(events);
|
|
11
|
+
let peak = null;
|
|
12
|
+
|
|
13
|
+
const deliver = async (event, payload) => {
|
|
14
|
+
if (typeof onEvent === "function") {
|
|
15
|
+
try { await onEvent({ event, payload }); } catch { /* non-fatal */ }
|
|
16
|
+
}
|
|
17
|
+
if (webhookUrl && typeof fetch === "function") {
|
|
18
|
+
try {
|
|
19
|
+
await fetch(webhookUrl, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ event, payload }),
|
|
23
|
+
});
|
|
24
|
+
} catch { /* non-fatal */ }
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handler = ({ event, payload }) => {
|
|
29
|
+
if (wanted.has(event)) { deliver(event, payload).catch(() => {}); return; }
|
|
30
|
+
if (drawdownPct > 0 && event === "equity:update") {
|
|
31
|
+
const eq = payload?.equity;
|
|
32
|
+
if (Number.isFinite(eq)) {
|
|
33
|
+
if (peak === null || eq > peak) peak = eq;
|
|
34
|
+
if (peak > 0 && ((peak - eq) / peak) * 100 >= drawdownPct) {
|
|
35
|
+
deliver("drawdown:breach", { equity: eq, peak, drawdownPct: ((peak - eq) / peak) * 100 }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return session.eventBus.onAny(handler);
|
|
42
|
+
}
|