backtest-kit 14.1.0 β 15.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/LICENSE +21 -21
- package/README.md +853 -808
- package/build/index.cjs +5413 -3483
- package/build/index.mjs +5411 -3485
- package/package.json +86 -86
- package/types.d.ts +451 -170
package/README.md
CHANGED
|
@@ -1,808 +1,853 @@
|
|
|
1
|
-
<img src="https://github.com/tripolskypetr/backtest-kit/raw/refs/heads/master/assets/consciousness.svg" height="45px" align="right">
|
|
2
|
-
|
|
3
|
-
# π§Ώ Backtest Kit
|
|
4
|
-
|
|
5
|
-
> A TypeScript engine for backtesting **and** live-trading strategies β crypto, forex, DEX, spot or futures β where the code you test is the code you ship.
|
|
6
|
-
|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
[](https://deepwiki.com/tripolskypetr/backtest-kit)
|
|
10
|
-
[](https://npmjs.org/package/backtest-kit)
|
|
11
|
-
[]()
|
|
12
|
-
[](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
|
|
13
|
-
|
|
14
|
-
Most trading bots don't die because the strategy was wrong. They die because the backtest quietly read tomorrow's candle, because the process crashed mid-fill and opened the position twice, because the exchange rejected an order and the bot kept trading a ghost. The strategy was never the hard part β the *plumbing* was.
|
|
15
|
-
|
|
16
|
-
`backtest-kit` is that plumbing, closed off one failure at a time over a year of live trading and running real money in production at [TheOneTrade](https://theonetrade.github.io). This page walks the failures that kill bots and shows how each one is designed out of the default path β not "discouraged," not "documented," but structurally unavailable unless you go out of your way to defeat the engine. Every claim opens into **The Code / The Math / The Proof** so you (or the model reading this for you) can check the work instead of trusting the pitch.
|
|
17
|
-
|
|
18
|
-
π **[API Reference](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** Β· π **[Reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** Β· π° **[Article series](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)**
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## Start here
|
|
23
|
-
|
|
24
|
-
Three on-ramps, one engine. Casual keeps the boilerplate inside the CLI; Sidekick ejects every wire into your repo; Docker gives you a restart-safe box.
|
|
25
|
-
|
|
26
|
-
<details>
|
|
27
|
-
<summary>The Code</summary>
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
# Casual β your repo holds only strategy files; docs auto-fetched into docs/lib/
|
|
31
|
-
npx @backtest-kit/cli --init --output backtest-kit-project
|
|
32
|
-
cd backtest-kit-project && npm install && npm start
|
|
33
|
-
|
|
34
|
-
# Full control β exchange/frames/risk/runner all editable in your project
|
|
35
|
-
npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
|
|
36
|
-
|
|
37
|
-
# Docker β zero-downtime live trading
|
|
38
|
-
npx @backtest-kit/cli --docker && cd backtest-kit-docker
|
|
39
|
-
MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
A whole strategy is three registrations and a run call. No bootstrap, no DI container to learn:
|
|
43
|
-
|
|
44
|
-
```typescript
|
|
45
|
-
import ccxt from 'ccxt';
|
|
46
|
-
import { addExchangeSchema, addStrategySchema, addFrameSchema, Position,
|
|
47
|
-
Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
|
|
48
|
-
|
|
49
|
-
addExchangeSchema({
|
|
50
|
-
exchangeName: 'binance',
|
|
51
|
-
getCandles: async (symbol, interval, since, limit) => {
|
|
52
|
-
const ex = new ccxt.binance();
|
|
53
|
-
const ohlcv = await ex.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
54
|
-
return ohlcv.map(([timestamp, open, high, low, close, volume]) =>
|
|
55
|
-
({ timestamp, open, high, low, close, volume }));
|
|
56
|
-
},
|
|
57
|
-
formatPrice: (s, p) => p.toFixed(2), formatQuantity: (s, q) => q.toFixed(8),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
addFrameSchema({ frameName: 'feb-2026', interval: '1m',
|
|
61
|
-
startDate: new Date('2026-02-01'), endDate: new Date('2026-02-28') });
|
|
62
|
-
|
|
63
|
-
addStrategySchema({
|
|
64
|
-
strategyName: 'my-strategy', interval: '15m',
|
|
65
|
-
getSignal: async (symbol, when, currentPrice) => ({
|
|
66
|
-
position: 'long',
|
|
67
|
-
...Position.bracket({ position: 'long', currentPrice, percentTakeProfit: 2, percentStopLoss: 1 }),
|
|
68
|
-
minuteEstimatedTime: 60 * 24, cost: 100,
|
|
69
|
-
}),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
Backtest.background('BTCUSDT', { strategyName: 'my-strategy', exchangeName: 'binance', frameName: 'feb-2026' });
|
|
73
|
-
listenSignalBacktest(console.log);
|
|
74
|
-
listenDoneBacktest(async (e) => { await Backtest.dump(e.symbol, e.strategyName); });
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
</details>
|
|
78
|
-
|
|
79
|
-
---
|
|
80
|
-
|
|
81
|
-
## The rakes β and where they went
|
|
82
|
-
|
|
83
|
-
What follows isn't a feature list. It's the set of mistakes that quietly drain accounts, each one paired with the design decision that took it off the table. If you've shipped a bot before, you've stepped on at least three of these.
|
|
84
|
-
|
|
85
|
-
### 1. Your backtest lied to you, and you'll only find out with real money
|
|
86
|
-
|
|
87
|
-
Look-ahead bias is the assassin of algo trading: a single line that touches a future candle, an indicator loaded without a timestamp filter, one forgotten `<=`. The backtest prints a beautiful equity curve that can *never* be reproduced live, and you deploy straight into a drawdown.
|
|
88
|
-
|
|
89
|
-
The usual defense is "be careful." Careful doesn't survive a 2,000-line strategy or a refactor at 1 a.m. So the cure here isn't discipline β it's removal of the failure surface. There is no timestamp parameter to forget. An ambient temporal context flows through every async call via Node's `AsyncLocalStorage`, and the data layer physically refuses to hand you a candle past "now." The pending (still-forming) candle is never returned, because its half-finished OHLC would poison every indicator.
|
|
90
|
-
|
|
91
|
-
The one rule this rests on: that context is live for the whole `await` chain of your `getSignal` and every `listen*` callback β including across `Promise.all`, which is where strategy code actually runs. It is not sorcery over execution you deliberately detach from that chain. A bare timer, an `EventEmitter`, a forked process, or the web dashboard reads engine state by **identifier** (signal id / symbol), not by inheriting the ambient clock β that explicit, id-based interop is exactly how the frontend talks to a running backtest. Inside the hooks the guarantee holds; step outside them on purpose and you address the engine deliberately rather than by accident.
|
|
92
|
-
|
|
93
|
-
<details>
|
|
94
|
-
<summary>The Math</summary>
|
|
95
|
-
|
|
96
|
-
Every request resolves "now" from the ambient context, aligns down to the interval boundary, and treats the pending candle as exclusive:
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
when = current execution-context time (AsyncLocalStorage)
|
|
100
|
-
stepMs = interval duration (1m β 60000)
|
|
101
|
-
alignedWhen = Math.floor(when / stepMs) * stepMs // round down to boundary
|
|
102
|
-
since = alignedWhen β limit * stepMs // go back `limit` candles
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
- `since` is **inclusive** β first candle has `timestamp === since`.
|
|
106
|
-
- `alignedWhen` is **exclusive** β the candle covering `[alignedWhen, alignedWhen+stepMs)` is still open and is never returned.
|
|
107
|
-
- Range is the half-open `[since, alignedWhen)`; exactly `limit` candles return; timestamps are `since + iΒ·stepMs`.
|
|
108
|
-
|
|
109
|
-
`getNextCandles()` is backtest-only and **throws in live mode** β there is no future to look at when "now" is wall-clock. `getRawCandles(limit?, sDate?, eDate?)` supports flexible windows, all clamped to `eDate β€ when`. Order books and aggregated trades use the same alignment (trades always to a 1-minute boundary). All boundaries are **UTC**: a 4h candle aligns to `00/04/08/12/16/20 UTC` regardless of your local offset β so `since` values that look "uneven" in local time are exact in UTC. Because `since` is derived from the ambient `when`, multi-timeframe pulls inside one `getSignal` are automatically synchronized, and runtime and the persistent cache compute identical keys β deterministic, exact-timestamp retrieval.
|
|
110
|
-
|
|
111
|
-
</details>
|
|
112
|
-
|
|
113
|
-
<details>
|
|
114
|
-
<summary>The Code</summary>
|
|
115
|
-
|
|
116
|
-
```typescript
|
|
117
|
-
getSignal: async (symbol) => {
|
|
118
|
-
// No timestamps anywhere. Context flows even through Promise.all β
|
|
119
|
-
// all four timeframes are pinned to the same tick automatically.
|
|
120
|
-
const [c1h, c15m, c5m, c1m] = await Promise.all([
|
|
121
|
-
getCandles(symbol, '1h', 24),
|
|
122
|
-
getCandles(symbol, '15m', 48),
|
|
123
|
-
getCandles(symbol, '5m', 60),
|
|
124
|
-
getCandles(symbol, '1m', 60),
|
|
125
|
-
]);
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
The bias you can't introduce by hand is the bias you'll never debug in production.
|
|
130
|
-
|
|
131
|
-
</details>
|
|
132
|
-
|
|
133
|
-
### 2. "It worked in the backtest" means nothing if live runs different code
|
|
134
|
-
|
|
135
|
-
The standard path productionizes a strategy by rewriting it: the research notebook becomes a second, hand-built live system with its own order logic, its own bugs, its own divergence. Now you have two strategies that *look* identical and behave differently exactly when it matters.
|
|
136
|
-
|
|
137
|
-
Here there is one code path. The `getSignal` you backtested is the `getSignal` that trades. Backtest mode feeds it historical timestamps; live mode feeds it `Date.now()`. The business logic β entries, validation, scheduled activation, TP/SL/timeout, partial closes β is byte-for-byte the same in both. The only differences are infrastructural: where the data comes from, not what you do with it.
|
|
138
|
-
|
|
139
|
-
<details>
|
|
140
|
-
<summary>The Code</summary>
|
|
141
|
-
|
|
142
|
-
```typescript
|
|
143
|
-
// Backtest β a historical frame drives the clock
|
|
144
|
-
Backtest.background('BTCUSDT', { strategyName, exchangeName, frameName });
|
|
145
|
-
|
|
146
|
-
// Live β wall-clock drives the clock; the strategy file is untouched
|
|
147
|
-
Live.background('BTCUSDT', { strategyName, exchangeName }); // keys via .env
|
|
148
|
-
listenSignalLive(async (e) => { if (e.action === 'closed') await Live.dump(e.symbol, e.strategyName); });
|
|
149
|
-
|
|
150
|
-
// Paper β live prices, no real orders, identical path. Validate here before risking capital.
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
And one engine, two ways to consume it β pick by use case, not by capability:
|
|
154
|
-
|
|
155
|
-
```typescript
|
|
156
|
-
// Event-driven (production bots, monitoring)
|
|
157
|
-
Backtest.background('BTCUSDT', config);
|
|
158
|
-
listenSignalBacktest(e => {/* β¦ */});
|
|
159
|
-
|
|
160
|
-
// Async iterator (research, scripts, LLM agents)
|
|
161
|
-
for await (const event of Backtest.run('BTCUSDT', config)) { /* signal | progress | done */ }
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
</details>
|
|
165
|
-
|
|
166
|
-
<details>
|
|
167
|
-
<summary>The Proof</summary>
|
|
168
|
-
|
|
169
|
-
This is the property the test suite exists to defend, and the line in the sand for the whole project: **business logic is 100% synchronous across backtest and live.** Signal validation is identical in both modes; immediate activation behaves identically; scheduled-signal logic is fully synchronized; TP / SL / timeout checks do not differ. The only divergence is infrastructural β how candles, order books, and time are sourced. `validation.test.mjs`, `backtest.test.mjs`, and `callbacks.test.mjs` pin this behavior; `event.test.mjs` pins the live path against the same expectations. If the two ever drift, a test goes red before you do.
|
|
170
|
-
|
|
171
|
-
</details>
|
|
172
|
-
|
|
173
|
-
### 3. The crash that opens your position twice
|
|
174
|
-
|
|
175
|
-
A bot updating a position when the process dies β OOM, deploy, power blip β usually wakes up to corrupted state: a half-opened position, a cost basis that's wrong, an exit that never registered. Recovery by hand is where money leaks.
|
|
176
|
-
|
|
177
|
-
Every state mutation is written atomically to disk *before* it counts as done (write-temp-then-rename), and on restart the engine reloads to the last consistent state. Live runs reload persisted signal state on every start, and `Live.background()` shuts down gracefully β it waits for open positions to reach `closed` before stopping, so a deploy never severs a live trade mid-flight.
|
|
178
|
-
|
|
179
|
-
<details>
|
|
180
|
-
<summary>The Proof</summary>
|
|
181
|
-
|
|
182
|
-
Recovery is structural, not a feature you remember to enable. `PersistBase` does atomic write-to-temp + rename, repairs corrupted files, and verifies integrity in `waitForInit()`. Fifteen per-domain `Persist*Instance` classes cover everything that can change: Signal, State, Session, Candle, Risk, Partial, Breakeven, Schedule, Recent, Notification, Log, Measure, Interval, Memory. Concrete scenarios that resolve cleanly:
|
|
183
|
-
|
|
184
|
-
- Process killed during order placement β internal state unchanged, retried next tick.
|
|
185
|
-
- Network failure during an exchange call β automatic retry on the next tick.
|
|
186
|
-
- Power loss during a save β recovery from the last atomic write.
|
|
187
|
-
- OOM β graceful shutdown with state preserved.
|
|
188
|
-
|
|
189
|
-
```typescript
|
|
190
|
-
listenSignalLive(async (event) => {
|
|
191
|
-
if (event.action === 'closed') {
|
|
192
|
-
await Live.dump(event.symbol, event.strategyName); // atomic snapshot to disk
|
|
193
|
-
await Partial.dump(event.symbol, event.strategyName);
|
|
194
|
-
}
|
|
195
|
-
if (event.action === 'scheduled' || event.action === 'cancelled') {
|
|
196
|
-
await Schedule.dump(event.symbol, event.strategyName);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
</details>
|
|
202
|
-
|
|
203
|
-
### 4. The state that can't be corrupted because it can't be expressed
|
|
204
|
-
|
|
205
|
-
"Is this position closed?" is a question you should never have to ask at runtime. A signal here moves through a strict lifecycle β **idle β scheduled β opened β active β closed** β modeled with TypeScript discriminated unions. Reading a closed position's live PnL, or mutating an active trade as if it were idle, isn't a bug you catch in QA; it's a line that won't compile.
|
|
206
|
-
|
|
207
|
-
<details>
|
|
208
|
-
<summary>The Code</summary>
|
|
209
|
-
|
|
210
|
-
Each state exposes only the data that is meaningful in that state, so the wrong access never type-checks:
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
listenSignal((event) => {
|
|
214
|
-
switch (event.action) {
|
|
215
|
-
case 'idle': /* no signal β only monitoring fields exist */ break;
|
|
216
|
-
case 'scheduled': /* waiting for entry price β has priceOpen, scheduledAt */ break;
|
|
217
|
-
case 'opened': /* just filled β entry data, no closeReason yet */ break;
|
|
218
|
-
case 'active': /* live position β pnl, peakProfit, maxDrawdown */ break;
|
|
219
|
-
case 'closed': /* exited β closeReason, final pnl; live fields gone */ break;
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
Before any signal reaches the engine it passes a validation pipeline: TP/SL prices positive, relationship correct (`TP > entry > SL` long, inverse short), risk/reward β₯ your minimum, timestamps not in the future, interval-throttling respected. Invalid signals are rejected or logged β never executed.
|
|
225
|
-
|
|
226
|
-
</details>
|
|
227
|
-
|
|
228
|
-
<details>
|
|
229
|
-
<summary>The Proof</summary>
|
|
230
|
-
|
|
231
|
-
The discriminated-union result types (`IStrategyTickResultWaiting / β¦Opened / β¦Closed / β¦Scheduled / β¦Cancelled`) are enforced end-to-end: `ClientStrategy.tick()/backtest()`, `StrategyCoreService`, the persistence layer, and every notification contract (`SignalOpenedNotification`, `SignalClosedNotification`, `SignalCancelledNotification`, `SignalScheduledNotification`) carry the lifecycle state explicitly. `validation.test.mjs` exercises valid long/short, inverted TP/SL, negative prices, and future timestamps; `backtest.test.mjs` walks every close reason (`take_profit`, `stop_loss`, `time_expired`).
|
|
232
|
-
|
|
233
|
-
</details>
|
|
234
|
-
|
|
235
|
-
### 5. The order the exchange silently rejected
|
|
236
|
-
|
|
237
|
-
Live trading's quiet killer: the exchange rejects, times out, or fills partially, and your bot's internal state no longer matches reality. The textbook "fix" is hand-written `try/catch` rollback around every order β which is exactly the code that breaks on the edge case you didn't think of.
|
|
238
|
-
|
|
239
|
-
Here, every state-mutating action fires through the broker adapter *before* the internal state changes. If the adapter throws β rejection, timeout, network failure β the mutation is skipped, the state stays exactly as it was, and the engine retries on the next tick. You never write rollback logic, and there is no half-applied state to reconcile. In backtest mode no adapter is called at all, so historical replays never touch exchange code.
|
|
240
|
-
|
|
241
|
-
<details>
|
|
242
|
-
<summary>The Code</summary>
|
|
243
|
-
|
|
244
|
-
The reusable core: place β poll to fill β on timeout cancel, market-out any partial fill, restore TP/SL so the position is never left naked, then throw so the engine retries.
|
|
245
|
-
|
|
246
|
-
```typescript
|
|
247
|
-
async function createLimitOrderAndWait(exchange, symbol, side, qty, price, restore?) {
|
|
248
|
-
const order = await exchange.createOrder(symbol, 'limit', side, qty, price);
|
|
249
|
-
|
|
250
|
-
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
251
|
-
await sleep(FILL_POLL_INTERVAL_MS);
|
|
252
|
-
if ((await exchange.fetchOrder(order.id, symbol)).status === 'closed') return; // filled
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
await exchange.cancelOrder(order.id, symbol);
|
|
256
|
-
await sleep(CANCEL_SETTLE_MS); // let the exchange settle before reading
|
|
257
|
-
|
|
258
|
-
const filledQty = (await exchange.fetchOrder(order.id, symbol)).filled ?? 0;
|
|
259
|
-
if (filledQty > 0) { // roll the partial fill back to clean state
|
|
260
|
-
await exchange.createOrder(symbol, 'market', side === 'buy' ? 'sell' : 'buy', filledQty);
|
|
261
|
-
}
|
|
262
|
-
if (restore) { /* re-place TP + stop-loss on the remaining position so it is never unprotected */ }
|
|
263
|
-
|
|
264
|
-
throw new Error('not filled in time β partial fill rolled back, backtest-kit will retry');
|
|
265
|
-
}
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
A hook wires it to position open. Signal open/close are routed automatically by an internal event bus the moment `Broker.enable()` is called β no manual wiring. The other mutations are intercepted explicitly before their state change:
|
|
269
|
-
|
|
270
|
-
```typescript
|
|
271
|
-
Broker.useBrokerAdapter(class implements IBroker {
|
|
272
|
-
async waitForInit() { await getExchange(); }
|
|
273
|
-
|
|
274
|
-
async
|
|
275
|
-
const ex = await getExchange();
|
|
276
|
-
const qty = truncateQty(ex, symbol, cost / priceOpen);
|
|
277
|
-
await createLimitOrderAndWait(ex, symbol, 'buy', qty, priceOpen); // entry
|
|
278
|
-
try { // protect immediately
|
|
279
|
-
await ex.createOrder(symbol, 'limit', 'sell', qty, priceTakeProfit);
|
|
280
|
-
await createStopLossOrder(ex, symbol, qty, priceStopLoss);
|
|
281
|
-
} catch (err) { await ex.createOrder(symbol, 'market', 'sell', qty); throw err; }
|
|
282
|
-
}
|
|
283
|
-
//
|
|
284
|
-
// onTrailingStopCommit Β· onTrailingTakeCommit Β· onBreakevenCommit Β· onAverageBuyCommit
|
|
285
|
-
});
|
|
286
|
-
Broker.enable();
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
Complete, production-grade **Spot** (`stop_loss_limit`, balance truncation, dust/notional guards) and **Futures** (`reduceOnly`, hedge-mode `positionSide`, `setLeverage`, ghost-position guards) adapters β every hook, every edge case β ship verbatim in the docs. The CLI can also dry-fire any single hook against your live adapter for verification before you wait hours for a real signal:
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
npx @backtest-kit/cli --brokerdebug --commit signal-open --symbol BTCUSDT
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
</details>
|
|
296
|
-
|
|
297
|
-
### 6. Averaging up is how a dip becomes a margin call
|
|
298
|
-
|
|
299
|
-
Dollar-cost averaging is where hand-rolled position math quietly bankrupts people. Average into a *rising* price by accident and you've raised your cost basis on a losing-direction trade β the opposite of the intent. And once you add partial closes on top, the cost-basis bookkeeping becomes a second strategy you have to get right.
|
|
300
|
-
|
|
301
|
-
`commitAverageBuy` is, by default, *only* accepted when price is below the running effective entry β averaging up is silently rejected, structurally. The effective price is a cost-weighted harmonic mean (correct for fixed-dollar entries, where $100 buys different quantities at different prices), and every partial close snapshots its cost basis so PnL replays exactly without re-walking history. No math required from you β the guardrail is in the engine.
|
|
302
|
-
|
|
303
|
-
<details>
|
|
304
|
-
<summary>The Math</summary>
|
|
305
|
-
|
|
306
|
-
```
|
|
307
|
-
effectivePrice = Ξ£cost / Ξ£(cost / price) // cost-weighted harmonic mean
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
Each partial stores `costBasisAtClose` (the running dollar basis *before* it fired); a partial sell does not change the effective price of the coins still held. Final PnL is a dollar-weighted sum across every partial (each at its own effective price) plus the remainder, with slippage and per-leg fees:
|
|
311
|
-
|
|
312
|
-
```
|
|
313
|
-
weight[i] = (percent[i]/100 Γ costBasisAtClose[i]) / totalInvested
|
|
314
|
-
totalWeightedPnl = Ξ£ weight[i]Β·pnl[i] + remainingWeightΒ·pnlRemaining
|
|
315
|
-
pnlPercentage = totalWeightedPnl β fees // open fee once + per-partial + final close
|
|
316
|
-
pnlCost = pnlPercentage / 100 Γ totalInvested
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
Worked example β LONG @1000, 4 accepted DCA + 1 rejected, 3 partials, close @1200 β reconciles two independent ways to **+17.9%**:
|
|
320
|
-
|
|
321
|
-
```
|
|
322
|
-
0.075Β·(+15.00) + 0.135Β·(β7.98) + 0.316Β·(+12.91) + 0.474Β·(+29.04) β +17.89%
|
|
323
|
-
coin cross-check: (34.50 + 49.69 + 142.72 + 244.67 β 400) / 400 β +17.90% β
|
|
324
|
-
entry #5 @980 REJECTED β 980 > effective entry β929.92 (the guard firing)
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
</details>
|
|
328
|
-
|
|
329
|
-
<details>
|
|
330
|
-
<summary>The Code</summary>
|
|
331
|
-
|
|
332
|
-
A complete DCA-ladder strategy β open once, average on overlap-free dips up to 10 rungs, close at target β is about thirty lines, and the dangerous math is all inside the engine:
|
|
333
|
-
|
|
334
|
-
```typescript
|
|
335
|
-
import { addStrategySchema, listenActivePing, Position,
|
|
336
|
-
commitAverageBuy, commitClosePending,
|
|
337
|
-
getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent } from 'backtest-kit';
|
|
338
|
-
|
|
339
|
-
addStrategySchema({
|
|
340
|
-
strategyName: 'apr_2026_strategy',
|
|
341
|
-
getSignal: async (symbol, when, currentPrice) => ({
|
|
342
|
-
position: 'long',
|
|
343
|
-
...Position.moonbag({ position: 'long', currentPrice, percentStopLoss: 25 }),
|
|
344
|
-
minuteEstimatedTime: Infinity, cost: 100,
|
|
345
|
-
}),
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
listenActivePing(async ({ symbol, currentPrice }) => { // the ladder
|
|
349
|
-
if ((await getPositionEntries(symbol)).length >= 10) return;
|
|
350
|
-
if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return;
|
|
351
|
-
await commitAverageBuy(symbol, 100); // rejected if it averages up
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
listenActivePing(async ({ symbol }) => { // exit on blended target
|
|
355
|
-
if (await getPositionPnlPercent(symbol) < 3) return;
|
|
356
|
-
await commitClosePending(symbol, { id: 'unknown', note: '# closed by target pnl' });
|
|
357
|
-
});
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
Every order primitive is here, each with per-entry PnL, peak-profit and max-drawdown tracking: market/limit entries, TP/SL/OCO exits, grid with auto-cancel, partial profit/loss levels, trailing take/stop (absorbed only when they tighten in your favour, computed from the *original* distance to avoid drift), breakeven (moves the stop to entry once profit clears fees+slippage), stop-limit entries, DCA, and time-attack / infinite-hold.
|
|
361
|
-
|
|
362
|
-
</details>
|
|
363
|
-
|
|
364
|
-
### 7. Ten strategies, one account, 100% exposure
|
|
365
|
-
|
|
366
|
-
Per-strategy risk checks miss the obvious portfolio truth: ten strategies each "risking 10%" is one account risking everything. Risk validation here runs across *all* strategies and symbols at once, with an atomic check-and-reserve that closes the race between "is this allowed?" and "the order went out."
|
|
367
|
-
|
|
368
|
-
<details>
|
|
369
|
-
<summary>The Code</summary>
|
|
370
|
-
|
|
371
|
-
```typescript
|
|
372
|
-
addRiskSchema({
|
|
373
|
-
riskName: 'demo',
|
|
374
|
-
validations: [
|
|
375
|
-
({ pendingSignal, currentPrice }) => { // TP β₯ 1%
|
|
376
|
-
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
|
|
377
|
-
const tp = position === 'long'
|
|
378
|
-
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
|
|
379
|
-
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
|
|
380
|
-
if (tp < 1) throw new Error(`TP too close: ${tp.toFixed(2)}%`);
|
|
381
|
-
},
|
|
382
|
-
({ pendingSignal, currentPrice }) => { // R/R β₯ 2:1
|
|
383
|
-
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
|
|
384
|
-
const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
|
|
385
|
-
const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
|
|
386
|
-
if (reward / risk < 2) throw new Error('Poor R/R ratio');
|
|
387
|
-
},
|
|
388
|
-
],
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
listenRisk(async (event) => { await Risk.dump(event.symbol, event.strategyName); }); // every rejection, logged
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
`ClientRisk` tracks every open position across the portfolio; multiple strategies can share one profile for holistic exposure. `checkSignalAndReserve` is the thread-safe variant β after a successful reserve you **must** `addSignal` (finalize) or `removeSignal` (cancel) so reservations never go stale. A real LLM-gated portfolio improved from **+52.22% β +68.90%** PNL, Sharpe **+0.309 β +0.512**, win-rate **68% β 82%** simply by letting a local model veto 6 signals β 4 of them losers.
|
|
395
|
-
|
|
396
|
-
</details>
|
|
397
|
-
|
|
398
|
-
### 8. One process can trade the whole market
|
|
399
|
-
|
|
400
|
-
Spawning a process per symbol burns CPU on IPC and turns shared state β global risk, candle cache β into a distributed-systems problem you didn't sign up for. Dozens of symbols run concurrently here inside a **single Node process**, sharing one event loop, one Mongo pool, one Redis cache, with strict per-symbol state isolation.
|
|
401
|
-
|
|
402
|
-
<details>
|
|
403
|
-
<summary>The Proof</summary>
|
|
404
|
-
|
|
405
|
-
Measured on a commodity laptop (HP Victus, i5-13420H, 16 GB DDR4, NVMe SSD), 9 symbols in parallel, one Node process:
|
|
406
|
-
|
|
407
|
-
| Metric | Value |
|
|
408
|
-
|---|---|
|
|
409
|
-
| Wall-clock span (first β last event) | **2,893 ms** |
|
|
410
|
-
| Events captured | **297** |
|
|
411
|
-
| Historical time advanced / symbol | **34 minutes** |
|
|
412
|
-
| Per-symbol replay speed | **β703Γ** real-time |
|
|
413
|
-
| Aggregate (9 symbols) | **β6,326Γ** real-time |
|
|
414
|
-
| Hot-loop throughput | **β103 events/sec** |
|
|
415
|
-
|
|
416
|
-
Why it's fast: single-process concurrency (no IPC, no fork), an in-memory activity registry (`Lookup`) tracking every in-flight workload, a cooperative event-loop hand-off (`Candle.spinLock`) so parallel symbols advance round-robin instead of one hogging the CPU, Redis O(1) candle lookups, atomic `findOneAndUpdate` upserts (no read-modify-write), and `--cache` pre-warming so the inner loop never blocks on HTTP.
|
|
417
|
-
|
|
418
|
-
In live mode the bottleneck moves from CPU to the exchange β and that is where the shared cache earns its keep. Every symbol pulls candles, order books, and trades through one **deduplicated** layer, so nine strategies asking for the same `BTCUSDT 1m` candle issue *one* request, not nine. Hand-written per-bot code with no cache hammers the REST endpoint until the exchange rate-limits it; here the dedup + Redis O(1) layer keeps request volume flat as you add symbols, so rate limits stay off your back instead of throttling the desk. The Γ700 / Γ6,300 figures are CPU-bound backtest replay; live throughput is paced by the exchange, but the request layer is built so that pacing is the exchange's published limit, not self-inflicted spam.
|
|
419
|
-
|
|
420
|
-
```typescript
|
|
421
|
-
import { Backtest, warmCandles } from 'backtest-kit';
|
|
422
|
-
|
|
423
|
-
for (const symbol of ['BTCUSDT','ETHUSDT','SOLUSDT','BNBUSDT','XRPUSDT']) {
|
|
424
|
-
await warmCandles({ exchangeName: 'binance', interval: '1m', symbol,
|
|
425
|
-
from: new Date('2026-02-01T00:00:00Z'), to: new Date('2026-02-28T23:59:59Z') });
|
|
426
|
-
Backtest.background(symbol, { strategyName, exchangeName: 'binance', frameName: 'feb-2026' });
|
|
427
|
-
}
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
```bash
|
|
431
|
-
npx @backtest-kit/cli --backtest --entry ./content/multi-symbol.ts # CLI defers symbol selection to your file
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
</details>
|
|
435
|
-
|
|
436
|
-
### 9. When `./dump/` stops being enough
|
|
437
|
-
|
|
438
|
-
File storage is perfect on day one and a bottleneck the day you're doing thousands of context-keyed reads per second. Swap to MongoDB (durable, queryable, atomic) with a Redis O(1) cache via a single `setup()` β all 15 persistence contracts reimplemented, and **not one line of strategy code changes.**
|
|
439
|
-
|
|
440
|
-
<details>
|
|
441
|
-
<summary>The Code</summary>
|
|
442
|
-
|
|
443
|
-
```typescript
|
|
444
|
-
// config/setup.config.ts β loaded once before any persistence call
|
|
445
|
-
import { setup } from '@backtest-kit/mongo';
|
|
446
|
-
setup(); // reads CC_MONGO_CONNECTION_STRING / CC_REDIS_* from env, or pass explicitly
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
Fifteen adapters, each with a unique compound index (`Signal β symbol+strategyName+exchangeName`, `Candle β symbol+interval+timestamp`, `Memory β signalId+bucketName+memoryId`, β¦). Candle records are immutable (`$setOnInsert`, first write wins); Measure/Interval/Memory use soft delete (`removed` flag) for an audit trail. Reads go Redis-first for the Mongo `_id`, then `findById` β two O(1) ops; a miss falls back to an indexed `findOne` and backfills. Writes are one `findOneAndUpdate({ upsert:true, new:true })` round-trip, so the unique index rejects concurrent duplicates at the storage engine and a write-then-read always sees fresh data. Signal-affecting adapters store the simulation `when`, so look-ahead protection is enforceable even inside the database.
|
|
450
|
-
|
|
451
|
-
```
|
|
452
|
-
read signal (BTCUSDT, my_strategy, binance)
|
|
453
|
-
ββ Redis GET β hit β Mongo findById(_id) β O(1) + O(1)
|
|
454
|
-
ββ Redis GET β miss β Mongo findOne(filter) β Redis SET β return
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
The default file adapter is already crash-safe (atomic temp+rename, repair on restart) β you get durability before you ever add a database.
|
|
458
|
-
|
|
459
|
-
</details>
|
|
460
|
-
|
|
461
|
-
### 10. A Sharpe of 10,000,000 is a bug, not an edge
|
|
462
|
-
|
|
463
|
-
Metrics that a tiny sample can't support are worse than no metrics β they're false confidence you bet money on. The analytics engine was rebuilt against canonical definitions and an independent 84-file reference testbed, and it prints **`N/A`** rather than a number it can't stand behind.
|
|
464
|
-
|
|
465
|
-
<details>
|
|
466
|
-
<summary>The Math</summary>
|
|
467
|
-
|
|
468
|
-
- **Pooled Sharpe** (v10.2.0+): per-trade returns are pooled across all symbols into one sample, then Sharpe is computed on that distribution β replacing the trade-count-weighted *average of ratios*, which inflates when one symbol is great and another negative. The header reads `Pooled Sharpe`, not `Portfolio Sharpe`, with a Markowitz disclaimer so it's never mistaken for covariance-based optimization.
|
|
469
|
-
- **Bessel's correction (Nβ1)** for unbiased variance β no risk underestimation on small samples.
|
|
470
|
-
- **Compounded equity curve** for Max Drawdown / Calmar / Recovery Factor β no double-counting of percentage returns.
|
|
471
|
-
- **Geometric annualization** for expected yearly returns β accounts for volatility drag (a 50% loss needs a 100% gain to recover).
|
|
472
|
-
- **Canonical Sortino (1991)** with downside deviation over `N_total`.
|
|
473
|
-
- **Float-artifact guard:** identical-return series produce stddev β1e-17; an `STDDEV_EPSILON` guard returns `N/A` instead of a fake Sharpe of 10,000,000. Gates of β₯10 signals and β₯14 calendar days gate publication.
|
|
474
|
-
|
|
475
|
-
Dashboard revenue is dollar-true: `pnlCost = pnlPercentage/100 Γ pnlEntries`, summed across closed signals per window (Today / Yesterday / 7d / 31d), anchored to the run end in backtest and `Date.now()` live.
|
|
476
|
-
|
|
477
|
-
</details>
|
|
478
|
-
|
|
479
|
-
### 11. The jobs that fire on virtual time
|
|
480
|
-
|
|
481
|
-
Most schedulers run on wall-clock β useless in a backtest that replays a month in three seconds. `Cron` runs on the *same* time stream your strategies see, firing on candle boundaries, coordinated across parallel backtests so one boundary never double-fires. The identical API drives live re-polling and one-shot backtest prep.
|
|
482
|
-
|
|
483
|
-
<details>
|
|
484
|
-
<summary>The Code</summary>
|
|
485
|
-
|
|
486
|
-
```typescript
|
|
487
|
-
import { Cron, Backtest } from 'backtest-kit';
|
|
488
|
-
|
|
489
|
-
Cron.register({ name: 'tg-parser', interval: '1h', // global, hourly
|
|
490
|
-
handler: async ({ when }) => { await parseTelegramSignals(when); } });
|
|
491
|
-
|
|
492
|
-
Cron.register({ name: 'funding', interval: '1h', symbols: ['BTCUSDT','ETHUSDT'], // per-symbol fan-out
|
|
493
|
-
handler: async ({ symbol, when }) => { await fetchFundingRate(symbol, when); } });
|
|
494
|
-
|
|
495
|
-
Cron.register({ name: 'warm-cache', // fire-once, global
|
|
496
|
-
handler: async () => { await warmupCache(); } });
|
|
497
|
-
|
|
498
|
-
Cron.enable(); // wire to engine lifecycle once; every tick is forwarded automatically
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
`enable()` merges four lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`) into one serial queue via `singlerun`; each tick is base-aligned to the minute. Coordination keys `${name}:${alignedMs}:${symbol?}:g${generation}` give mutex semantics β parallel backtests on the same boundary share one in-flight promise (first opens the slot, others await). Fire-once marks record only on success, so a failed handler retries; the generation suffix isolates re-registrations from late writes.
|
|
502
|
-
|
|
503
|
-
</details>
|
|
504
|
-
|
|
505
|
-
### 12. You shouldn't have to abandon TradingView or Python to use TypeScript
|
|
506
|
-
|
|
507
|
-
The honest objection to a TS trading engine is "but my indicators live in Pine Script and TA-Lib." So they don't have to move. Run native Pine Script, run Python via WASM, use 50+ built-in indicators, or drop in zero-dependency quant ports β all under the same temporal guarantees.
|
|
508
|
-
|
|
509
|
-
<details>
|
|
510
|
-
<summary>The Code</summary>
|
|
511
|
-
|
|
512
|
-
**Pine Script** β v5/v6, 60+ indicators, 1:1 syntax, look-ahead-safe ([`@backtest-kit/pinets`](https://www.npmjs.com/package/@backtest-kit/pinets)):
|
|
513
|
-
|
|
514
|
-
```typescript
|
|
515
|
-
import { File, getSignal } from '@backtest-kit/pinets';
|
|
516
|
-
const signal = await getSignal(File.fromPath('strategy.pine'),
|
|
517
|
-
{ symbol: 'BTCUSDT', timeframe: '5m', limit: 100 }); // plots: Signal/Close/StopLoss/TakeProfit/EstimatedTime
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
**50+ indicators across 1m/15m/30m/1h + order book, as LLM-ready Markdown, in one call** ([`@backtest-kit/signals`](https://www.npmjs.com/package/@backtest-kit/signals)):
|
|
521
|
-
|
|
522
|
-
```typescript
|
|
523
|
-
import { commitHistorySetup } from '@backtest-kit/signals';
|
|
524
|
-
await commitHistorySetup('BTCUSDT', messages); // order book + candles + indicators, cached per TTL
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
**Typed DAG** of computations, resolved in topological order with `Promise.all` parallelism, serializable to a DB ([`@backtest-kit/graph`](https://www.npmjs.com/package/@backtest-kit/graph)):
|
|
528
|
-
|
|
529
|
-
```typescript
|
|
530
|
-
import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
|
|
531
|
-
const higher = sourceNode(async (symbol) => extract(await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 }), { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' }));
|
|
532
|
-
const lower = sourceNode(async (symbol) => extract(await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 }), { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss' }));
|
|
533
|
-
const mtf = outputNode(([h, l]) => { // combine; null when timeframes disagree
|
|
534
|
-
if (h.noTrades || l.position === 0) return null;
|
|
535
|
-
if (h.allowShort && l.position === 1) return null;
|
|
536
|
-
if (h.allowLong && l.position === -1) return null;
|
|
537
|
-
return toSignalDto(randomString(), l, null);
|
|
538
|
-
}, higher, lower);
|
|
539
|
-
addStrategySchema({ strategyName: 'mtf', interval: '5m', getSignal: () => resolve(mtf) });
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
**Python via WASM (WASI)** runs `ta-lib`/`pandas`/`scikit-learn` indicators in the Node event loop with no IPC. And zero-dependency TS ports of the math behind vectorbt β see [See also](#-see-also).
|
|
543
|
-
|
|
544
|
-
</details>
|
|
545
|
-
|
|
546
|
-
### 13. AI strategies without ten provider SDKs
|
|
547
|
-
|
|
548
|
-
LLM-driven signals normally mean per-provider boilerplate and JSON you can't trust. One HOF API spans 10+ providers; structured output is schema-enforced; trading context is injected automatically.
|
|
549
|
-
|
|
550
|
-
<details>
|
|
551
|
-
<summary>The Code</summary>
|
|
552
|
-
|
|
553
|
-
```typescript
|
|
554
|
-
import { deepseek } from '@backtest-kit/ollama';
|
|
555
|
-
addStrategy({
|
|
556
|
-
strategyName: 'llm-signal', interval: '5m',
|
|
557
|
-
// swap deepseek() β claude() / gpt5() / ollama() with no other change
|
|
558
|
-
getSignal: deepseek(getSignal, 'deepseek-chat', process.env.DEEPSEEK_API_KEY),
|
|
559
|
-
});
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
Providers: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama (local), GLM-4. Structured output is enforced with Zod / JSON schema via `addOutline` (auto-retry on malformed output, custom rules like "SL must be below entry for LONG"); token rotation accepts a key array; prompts live in `config/prompt/*.cjs` and are memoized to kill redundant backtest API calls. The full LLM strategy β fetch multi-timeframe candles, ask the model, dump the reasoning, return a validated signal:
|
|
563
|
-
|
|
564
|
-
```typescript
|
|
565
|
-
import { v4 as uuid } from 'uuid';
|
|
566
|
-
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
|
|
567
|
-
import { json } from './utils/json.mjs';
|
|
568
|
-
import { getMessages } from './utils/messages.mjs';
|
|
569
|
-
|
|
570
|
-
addStrategySchema({
|
|
571
|
-
strategyName: 'llm-strategy', interval: '5m', riskName: 'demo',
|
|
572
|
-
getSignal: async (symbol) => {
|
|
573
|
-
const messages = await getMessages(symbol, {
|
|
574
|
-
candles1h: await getCandles(symbol, '1h', 24),
|
|
575
|
-
candles15m: await getCandles(symbol, '15m', 48),
|
|
576
|
-
candles5m: await getCandles(symbol, '5m', 60),
|
|
577
|
-
candles1m: await getCandles(symbol, '1m', 60),
|
|
578
|
-
});
|
|
579
|
-
const resultId = uuid();
|
|
580
|
-
const signal = await json(messages); // LLM β structured signal
|
|
581
|
-
await dumpAgentAnswer({ dumpId: 'position-context', bucketName: 'mtf', messages, description: 'agent reasoning' });
|
|
582
|
-
await dumpRecord({ dumpId: 'position-entry', bucketName: 'mtf', record: signal, description: 'signal params' });
|
|
583
|
-
return { ...signal, id: resultId };
|
|
584
|
-
},
|
|
585
|
-
});
|
|
586
|
-
```
|
|
587
|
-
|
|
588
|
-
Memory adapters persist LLM reasoning per signal (BM25 search, soft delete); `dumpAgentAnswer` archives the full conversation β roles, reasoning, tool calls β attached to the signal, so an opaque model decision becomes a debuggable record.
|
|
589
|
-
|
|
590
|
-
</details>
|
|
591
|
-
|
|
592
|
-
---
|
|
593
|
-
|
|
594
|
-
## The API assumes you will make every mistake
|
|
595
|
-
|
|
596
|
-
Read back through the rakes and a pattern shows: none of them are solved by *telling you to be careful*. Look-ahead bias isn't prevented by a lint rule β there's simply no timestamp to pass. Averaging up isn't discouraged in the docs β the call is rejected. A closed position's live PnL isn't a runtime guard β it doesn't compile. The whole surface is built on the assumption that you, or the model writing your strategy, will eventually do the wrong thing at 3 a.m. β so the wrong thing is made unreachable. This is the "pit of success": the easy path and the correct path are the same path.
|
|
597
|
-
|
|
598
|
-
And the shape of that surface is **reactive β React for traders.** You never write the time loop. You don't iterate candles, advance a clock, or poll for fills. You *declare reactions* to lifecycle events, and the engine owns the loop in both backtest and live. `getSignal` is your pure render function β given the current state of the world, return a signal or `null`. The `listen*` family is your effects layer β small handlers that fire when the position's state changes, exactly like subscribing to state in a component. Composition is additive: stack independent listeners and each one minds its own concern, the same way you'd split hooks.
|
|
599
|
-
|
|
600
|
-
<details>
|
|
601
|
-
<summary>The Code</summary>
|
|
602
|
-
|
|
603
|
-
`getSignal` declares *what* to open; the listeners declare *how the position behaves once alive* β a DCA ladder, a profit target, and an error sink, three independent reactions to the same event stream, no shared loop, no manual bookkeeping:
|
|
604
|
-
|
|
605
|
-
```typescript
|
|
606
|
-
import {
|
|
607
|
-
addStrategySchema, listenActivePing, listenError, Log, Position,
|
|
608
|
-
commitAverageBuy, commitClosePending,
|
|
609
|
-
getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent,
|
|
610
|
-
} from "backtest-kit";
|
|
611
|
-
import { errorData, getErrorMessage, str } from "functools-kit";
|
|
612
|
-
|
|
613
|
-
const HARD_STOP = 25, TARGET_PROFIT = 3, STEP = 100, MAX_STEPS = 10;
|
|
614
|
-
|
|
615
|
-
// render: given "now", declare the position to open (or null to stay flat)
|
|
616
|
-
addStrategySchema({
|
|
617
|
-
strategyName: "apr_2026_strategy",
|
|
618
|
-
getSignal: async (symbol, when, currentPrice) => ({
|
|
619
|
-
position: "long",
|
|
620
|
-
...Position.moonbag({ position: "long", currentPrice, percentStopLoss: HARD_STOP }),
|
|
621
|
-
minuteEstimatedTime: Infinity, cost: STEP,
|
|
622
|
-
}),
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
// effect: average into dips, up to 10 overlap-free rungs (averaging up is rejected for you)
|
|
626
|
-
listenActivePing(async ({ symbol, currentPrice }) => {
|
|
627
|
-
if ((await getPositionEntries(symbol)).length >= MAX_STEPS) return;
|
|
628
|
-
if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return;
|
|
629
|
-
await commitAverageBuy(symbol, STEP);
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
// effect: close the whole position once blended PnL clears the target
|
|
633
|
-
listenActivePing(async ({ symbol, data }) => {
|
|
634
|
-
if (await getPositionPnlPercent(symbol) < TARGET_PROFIT) return;
|
|
635
|
-
Log.info("position closed due to the target pnl reached", { symbol, data });
|
|
636
|
-
await commitClosePending(symbol, { id: "unknown", note: str.newline("# Closed by target pnl") });
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
// effect: a single place for anything that goes wrong
|
|
640
|
-
listenError((error) => Log.debug("error", { error: errorData(error), message: getErrorMessage(error) }));
|
|
641
|
-
```
|
|
642
|
-
|
|
643
|
-
The full reactive surface β subscribe to any point in a position's life and the engine fires it in order, queued, never overlapping: `listenSignal` / `listenSignalBacktest` / `listenSignalLive` (lifecycle), `listenActivePing` (per-minute while a position is live), `listenSchedulePing` / `listenIdlePing`, `listenPartialProfit` / `listenPartialLoss`, `listenBreakevenAvailable`, `listenHighestProfit`, `listenMaxDrawdown`, `listenRisk` (rejections), `listenError` / `listenExit`, `listenDone*`, plus `*Once` filtered variants for one-shot reactions. You compose behavior by adding handlers, not by editing a loop.
|
|
644
|
-
|
|
645
|
-
</details>
|
|
646
|
-
|
|
647
|
-
<details>
|
|
648
|
-
<summary>The Proof</summary>
|
|
649
|
-
|
|
650
|
-
The five guarantees that make the surface fool-proof, each enforced by the engine rather than by convention:
|
|
651
|
-
|
|
652
|
-
1. **Ambient temporal context** β no `currentDate`/`timestamp` parameter exists to forget; the engine resolves "now" from `AsyncLocalStorage` and blocks future data at the adapter level.
|
|
653
|
-
2. **Type-safe state machine** β `idle β scheduled β pending β opened β active β closed` as discriminated unions; calling a close on an already-closed signal, or editing an active trade's entry, is a compile error.
|
|
654
|
-
3. **Guarded DCA** β `commitAverageBuy` rejects any call that would worsen the harmonic-mean effective entry; you cannot accidentally average up.
|
|
655
|
-
4. **Transactional broker commits (the "no-try-catch" rule)** β the adapter intercepts every mutation before internal state changes; an exchange throw rolls back and retries on the next tick, so you never hand-write rollback.
|
|
656
|
-
5. **Automatic signal validation** β TP/SL soundness, R/R minimum, and interval throttling are checked before a signal reaches execution; invalid signals are logged or rejected, never run.
|
|
657
|
-
|
|
658
|
-
Because the loop belongs to the engine, the *same* declarations run identically in backtest and live β the reactive model is the reason "same code, both modes" is structurally true, not just aspirational.
|
|
659
|
-
|
|
660
|
-
</details>
|
|
661
|
-
|
|
662
|
-
---
|
|
663
|
-
|
|
664
|
-
## Receipts
|
|
665
|
-
|
|
666
|
-
Toy READMEs prove a moving-average crossover on daily candles. These are eight production-quality strategies, each a *different* signal source, each backtested on real history with the numbers written down. They live in [`/example`](https://github.com/tripolskypetr/backtest-kit/tree/master/example) β clone it, run it, get the same prints.
|
|
667
|
-
|
|
668
|
-
| Strategy | Ticker Β· Period | Signal source | Net PNL | Sharpe |
|
|
669
|
-
|---|---|---|---:|---:|
|
|
670
|
-
| [Neural Network](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy) | BTC Β· Oct 2021 | TensorFlow NN (8β6β4β1) predicting next-candle close | **+18.26%** | 0.31 |
|
|
671
|
-
| [Python EMA Crossover](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy) | DOT Β· Feb 2021 | EMA(9)/EMA(21) via WebAssembly (WASI) | **+5.52%** | 0.09 |
|
|
672
|
-
| [Polymarket Ξprob](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2024.strategy) | BTC Β· Apr 2024 | Prediction-market probability shifts | **+0.63%** | 0.065 |
|
|
673
|
-
| [Pine Script Range Breakout](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy) | BTC Β· Dec 2025 | Bollinger + range + volume spike (Pine) | **+2.40%** | 0.06 |
|
|
674
|
-
| [Liquidity Harvesting](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy) | TRX Β· Jan 2026 | Telegram channel signals, **inverted** | **+8.58%** | **1.14** |
|
|
675
|
-
| [AI News Sentiment](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy) | BTC Β· Feb 2026 | LLM on live news (Tavily + Ollama) | **+16.99%** | 0.25 |
|
|
676
|
-
| [SHORT DCA Ladder](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy) | BTC Β· Mar 2026 | Fixed SHORT + ladder up (β€10 rungs) | **+37.83%** | 0.35 |
|
|
677
|
-
| [LONG DCA Ladder](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy) | BTC Β· Apr 2026 | Fixed LONG + ladder down (β€10 rungs) | **+67.85%** | 0.12 |
|
|
678
|
-
|
|
679
|
-
<details>
|
|
680
|
-
<summary>The Proof</summary>
|
|
681
|
-
|
|
682
|
-
- **Liquidity Harvesting (Sharpe 1.14)** β a Telegram channel published SHORT signals with ~0.375:1 R/R and 106% deposit at risk at 25Γ leverage, mathematically guaranteed to lose; a volume spike appeared 15 min before every post and the TP step multipliers were identical across signals β an algorithm. Inverting it turned **β5.05% β +8.58%**, profit factor **0.56 β 7.31**. The edge was the bot crowd, not the indicators.
|
|
683
|
-
- **AI News Sentiment** held SHORT through nearly all of a β16.4% month, flipped to LONG on the recovery bounce, and flipped back on geopolitical news β **+16.99%** where buy-and-hold lost 16%.
|
|
684
|
-
- **DCA Ladders** show the trade-off honestly: high % return on deployed capital, but absolute fiat risk grows with rungs (Mar: β$104.93 on a 10-rung position; theoretical max β$2,500 if a non-reverting trend hits the 25% hard stop with all rungs filled). The README states the downside, not just the upside.
|
|
685
|
-
|
|
686
|
-
Every example documents price context, trade log, equity curve, and risk analysis β and several ship a `--noDCA` / single-entry variant so you can see exactly what the position management bought you.
|
|
687
|
-
|
|
688
|
-
</details>
|
|
689
|
-
|
|
690
|
-
---
|
|
691
|
-
|
|
692
|
-
## How it sits next to the alternatives
|
|
693
|
-
|
|
694
|
-
The honest version: for a quick research prototype or a single MA crossover, VectorBT or Backtrader are hard to beat on raw speed. The moment you need to *deploy* β complex position sizing, AI agents, a network outage that mustn't desync your bot β is where the guardrails below start to matter.
|
|
695
|
-
|
|
696
|
-
| | Backtest Kit | Backtrader | VectorBT | MetaTrader/MQL5 | QuantConnect | Freqtrade |
|
|
697
|
-
|---|---|---|---|---|---|---|
|
|
698
|
-
| Language | TypeScript | Python | Python | MQL5 | C#/Python | Python |
|
|
699
|
-
| Live trading | β
built-in | β οΈ manual | β research | β
| β
| β
|
|
|
700
|
-
| Look-ahead prevention | β
engine-enforced | β οΈ discipline | β οΈ discipline | β οΈ discipline | β οΈ partial | β οΈ partial |
|
|
701
|
-
| Crash-safe persistence | β
atomic + Mongo | β | β | β | β οΈ cloud | β οΈ basic |
|
|
702
|
-
| Transactional broker | β
auto rollback | β | β | β | β οΈ partial | β οΈ basic |
|
|
703
|
-
| Type-safe state machine | β
compile-time | β | β | β | β | β |
|
|
704
|
-
| DCA / partial closes | β
first-class | β οΈ manual | β οΈ manual | β οΈ manual | β οΈ manual | β οΈ limited |
|
|
705
|
-
| AI / LLM integration | β
built-in | β | β | β | β οΈ custom | β |
|
|
706
|
-
| Pine Script | β
native | β | β | β
| β | β |
|
|
707
|
-
| Self-hosted | β
100% | β
| β
| β οΈ desktop | β cloud | β
|
|
|
708
|
-
|
|
709
|
-
Open-source QuantConnect/MetaTrader without the lock-in: pure TypeScript, your code, your data, your machines, no platform fees, no proprietary GUI. Drop any library into `getSignal` β Ollama, [`neural-trader`](https://www.npmjs.com/package/neural-trader), your own.
|
|
710
|
-
|
|
711
|
-
---
|
|
712
|
-
|
|
713
|
-
## π Ecosystem
|
|
714
|
-
|
|
715
|
-
**The core is a library; the CLI is the framework on top β and the framework is optional.** Think React vs Next.js. `backtest-kit` (the reactive engine β `getSignal` + the `listen*`/`commit*` API) is the library you build against directly. `@backtest-kit/cli` is the Next.js: it wires the runner, candle cache, dashboard, Telegram, and graceful shutdown so you don't have to β but you can ignore it entirely and call `Backtest.run()` / `Live.background()` yourself. `@backtest-kit/sidekick` is the explicit middle ground β it scaffolds a project where every wire (exchange adapter, frames, risk rules, strategy, runner) lives as plain, editable source in **your** userspace, with no CLI in the loop and nothing hidden. You pick how much magic you want.
|
|
716
|
-
|
|
717
|
-
On the "dependency zoo": every package below is authored by one team and shipped by the commercial vendor [TheOneTrade](https://theonetrade.github.io) β versioned together, released together. Treat it like the .NET base class library: a single coherent contract where the userspace surface (`getSignal`, `commit*`, `listen*`, `get*`) does not churn under you between releases. You install only what a given strategy needs, and the heavy or platform-specific pieces (Python-via-WASM, TensorFlow builds) sit behind their own optional packages so the core install stays clean and conflict-free.
|
|
718
|
-
|
|
719
|
-
### `@backtest-kit/cli` β [npm](https://www.npmjs.com/package/@backtest-kit/cli)
|
|
720
|
-
Zero-boilerplate runner. Modes: `--backtest / --paper / --live / --walker / --main / --pine / --editor / --dump / --pnldebug / --brokerdebug / --flush / --init / --docker`. Auto candle caching, monorepo cwd-resolution with per-strategy `.env` override, folder-based import aliases, broker module hooks, `setup.config` / `loader.config` / `alias.config`, graceful SIGINT.
|
|
721
|
-
```bash
|
|
722
|
-
npx -y @backtest-kit/cli --init
|
|
723
|
-
```
|
|
724
|
-
|
|
725
|
-
### `@backtest-kit/pinets` β [npm](https://www.npmjs.com/package/@backtest-kit/pinets)
|
|
726
|
-
Run TradingView Pine Script v5/v6 in Node, 60+ indicators, 1:1 syntax, `getSignal` / `run` / `extract` / `extractRows`.
|
|
727
|
-
```bash
|
|
728
|
-
npm install @backtest-kit/pinets pinets backtest-kit
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
### `@backtest-kit/graph` β [npm](https://www.npmjs.com/package/@backtest-kit/graph)
|
|
732
|
-
Compose computations as a typed DAG; resolved in topological order with `Promise.all`, serializable to a DB for storage.
|
|
733
|
-
```bash
|
|
734
|
-
npm install @backtest-kit/graph backtest-kit
|
|
735
|
-
```
|
|
736
|
-
|
|
737
|
-
### `@backtest-kit/ui` β [npm](https://www.npmjs.com/package/@backtest-kit/ui)
|
|
738
|
-
React/MUI dashboard with Lightweight Charts: live signal-lifecycle state-machine view, per-signal inspection, risk/partial/trailing/breakeven views, manual control, Pine editor.
|
|
739
|
-
```typescript
|
|
740
|
-
import { serve } from '@backtest-kit/ui';
|
|
741
|
-
serve('0.0.0.0', 60050); // http://localhost:60050
|
|
742
|
-
```
|
|
743
|
-
|
|
744
|
-
### `@backtest-kit/mongo` β [npm](https://www.npmjs.com/package/@backtest-kit/mongo)
|
|
745
|
-
MongoDB source-of-truth + Redis O(1) cache. All 15 persistence contracts, atomic upserts, soft delete, look-ahead-safe `when`. Zero strategy changes.
|
|
746
|
-
```bash
|
|
747
|
-
npm install @backtest-kit/mongo backtest-kit mongoose ioredis
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
### `@backtest-kit/ollama` β [npm](https://www.npmjs.com/package/@backtest-kit/ollama)
|
|
751
|
-
Universal LLM adapter: 10+ providers, structured output, token rotation, fallback chains, trading-context injection.
|
|
752
|
-
```bash
|
|
753
|
-
npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
### `@backtest-kit/signals` β [npm](https://www.npmjs.com/package/@backtest-kit/signals)
|
|
757
|
-
50+ indicators across 4 timeframes + order book, multi-timeframe synchronized, LLM-ready Markdown reports.
|
|
758
|
-
```bash
|
|
759
|
-
npm install @backtest-kit/signals backtest-kit
|
|
760
|
-
```
|
|
761
|
-
|
|
762
|
-
### `@backtest-kit/sidekick` β [npm](https://www.npmjs.com/package/@backtest-kit/sidekick)
|
|
763
|
-
The "eject" of `--init`: scaffolds a project where exchange adapter, frames, risk rules, strategy, and runner are all editable source. 4H-trend + 15m-signal Pine template, partial profit taking, breakeven trailing.
|
|
764
|
-
```bash
|
|
765
|
-
npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
|
|
766
|
-
```
|
|
767
|
-
|
|
768
|
-
---
|
|
769
|
-
|
|
770
|
-
## πͺ Community
|
|
771
|
-
|
|
772
|
-
Real, runnable templates β not slideware. And worth naming the concern directly: yes, this is one author's ecosystem, which is exactly what makes it *coherent* β but coherent is not captive. Everything is **MIT and open-source**, the core engine has **zero hard dependency** on any `@backtest-kit/*` add-on (you can run `getSignal` + `listen*` against a bare `addExchangeSchema` and nothing else), and each repo below is an independent reference you're meant to **fork and own**. The lock-in you'd normally fear β a closed runtime, a proprietary data format, a cloud you can't leave β none of it applies; the persistence is plain files or your own Mongo, the signals are your code, and the exit cost is a `git clone`.
|
|
773
|
-
|
|
774
|
-
- **[backtest-monorepo-parallel](https://github.com/backtest-kit/backtest-monorepo-parallel)** β 9 symbols in parallel in one Node process on shared Mongo+Redis, ~6,300Γ real-time, self-enforcement runtime exposing the workspace DI container to `./content/` strategy files. The scaling recipe: +1 service = +1 file, +1 provider, +1 ioc entry.
|
|
775
|
-
- **[backtest-ollama-crontab](https://github.com/backtest-kit/backtest-ollama-crontab)** β a local Ollama (`gpt-oss` quantized) as a per-signal risk gate plus a 15-minute crontab ingesting any public Telegram channel; the *same code* re-polls live and bulk-prepares in backtest. Documented result: **+52.22% β +68.90%** with the LLM gate on.
|
|
776
|
-
- **[backtest-kit-redis-mongo-docker](https://github.com/backtest-kit/backtest-kit-redis-mongo-docker)** β production persistence: all 15 adapters on Mongo+Redis, atomic read-after-write, `docker-compose` one-command deploy.
|
|
777
|
-
- **[backtest-kit-skills](https://github.com/backtest-kit/backtest-kit-skills)** β a Claude Code skill + Mintlify docs: describe a strategy in plain language, get working TypeScript with every schema registration wired. `npx skills add https://github.com/backtest-kit/backtest-kit-skills`
|
|
778
|
-
- **[uzse-backtest-app](https://github.com/backtest-kit/uzse-backtest-app)** β Pine Script on regional exchanges that aren't on TradingView (UZSE, MSE, DSEβ¦): download raw trades, build candles, feed them through a custom Mongo exchange adapter.
|
|
779
|
-
- **[backtest-kit-docs](https://github.com/backtest-kit/backtest-kit-docs)** β Architecture handbook and knowledge base: explains the engine's design, AI workflows, production patterns, and quantitative trading concepts beyond the API.
|
|
780
|
-
|
|
781
|
-
---
|
|
782
|
-
|
|
783
|
-
## πͺ See also
|
|
784
|
-
|
|
785
|
-
Zero-dependency TypeScript ports of the quant math behind [vectorbt](https://github.com/polakowo/vectorbt) β same models, native to the `Exchange` schema, no Python runtime. Each estimates a different dimension of speculative pressure and plugs in independently:
|
|
786
|
-
|
|
787
|
-
- **[garch](https://www.npmjs.com/package/garch)** β conditional variance of log-returns (GARCH / EGARCH / GJR-GARCH / HAR-RV / NoVaS, auto-selected by QLIKE) to bound how far flow can push price next candle; fitted `Ο` β log-normal corridor `PΒ·exp(Β±zΒ·Ο)` for TP/SL. Via `Exchange.getCandles`.
|
|
788
|
-
- **[pump-anomaly](https://www.npmjs.com/package/pump-anomaly)** β coordinated-speculation detection: cross-correlation + union-find author clustering separates real multi-actor inflow from one actor on many channels; volume z-scores score cascade pressure (pump vs stop-hunt). Returns an entry/exit plan, exits fitted by OHLC replay and screened against winner's-curse (DSR / PBO / SPA). Via `Exchange.getRawCandles`.
|
|
789
|
-
- **[volume-anomaly](https://www.npmjs.com/package/volume-anomaly)** β order-flow intensity: Hawkes branching ratio (arrival clustering), CUSUM (imbalance shift), BOCPD (regime break) β composite outlier score as an entry-timing gate. Via `Exchange.getAggregatedTrades`.
|
|
790
|
-
|
|
791
|
-
---
|
|
792
|
-
|
|
793
|
-
##
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1
|
+
<img src="https://github.com/tripolskypetr/backtest-kit/raw/refs/heads/master/assets/consciousness.svg" height="45px" align="right">
|
|
2
|
+
|
|
3
|
+
# π§Ώ Backtest Kit
|
|
4
|
+
|
|
5
|
+
> A TypeScript engine for backtesting **and** live-trading strategies β crypto, forex, DEX, spot or futures β where the code you test is the code you ship. See [reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example)
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
[](https://deepwiki.com/tripolskypetr/backtest-kit)
|
|
10
|
+
[](https://npmjs.org/package/backtest-kit)
|
|
11
|
+
[]()
|
|
12
|
+
[](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
|
|
13
|
+
|
|
14
|
+
Most trading bots don't die because the strategy was wrong. They die because the backtest quietly read tomorrow's candle, because the process crashed mid-fill and opened the position twice, because the exchange rejected an order and the bot kept trading a ghost. The strategy was never the hard part β the *plumbing* was.
|
|
15
|
+
|
|
16
|
+
`backtest-kit` is that plumbing, closed off one failure at a time over a year of live trading and running real money in production at [TheOneTrade](https://theonetrade.github.io). This page walks the failures that kill bots and shows how each one is designed out of the default path β not "discouraged," not "documented," but structurally unavailable unless you go out of your way to defeat the engine. Every claim opens into **The Code / The Math / The Proof** so you (or the model reading this for you) can check the work instead of trusting the pitch.
|
|
17
|
+
|
|
18
|
+
π **[API Reference](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** Β· π **[Reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** Β· π° **[Article series](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)**
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Start here
|
|
23
|
+
|
|
24
|
+
Three on-ramps, one engine. Casual keeps the boilerplate inside the CLI; Sidekick ejects every wire into your repo; Docker gives you a restart-safe box.
|
|
25
|
+
|
|
26
|
+
<details>
|
|
27
|
+
<summary>The Code</summary>
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Casual β your repo holds only strategy files; docs auto-fetched into docs/lib/
|
|
31
|
+
npx @backtest-kit/cli --init --output backtest-kit-project
|
|
32
|
+
cd backtest-kit-project && npm install && npm start
|
|
33
|
+
|
|
34
|
+
# Full control β exchange/frames/risk/runner all editable in your project
|
|
35
|
+
npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
|
|
36
|
+
|
|
37
|
+
# Docker β zero-downtime live trading
|
|
38
|
+
npx @backtest-kit/cli --docker && cd backtest-kit-docker
|
|
39
|
+
MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A whole strategy is three registrations and a run call. No bootstrap, no DI container to learn:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import ccxt from 'ccxt';
|
|
46
|
+
import { addExchangeSchema, addStrategySchema, addFrameSchema, Position,
|
|
47
|
+
Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
|
|
48
|
+
|
|
49
|
+
addExchangeSchema({
|
|
50
|
+
exchangeName: 'binance',
|
|
51
|
+
getCandles: async (symbol, interval, since, limit) => {
|
|
52
|
+
const ex = new ccxt.binance();
|
|
53
|
+
const ohlcv = await ex.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
54
|
+
return ohlcv.map(([timestamp, open, high, low, close, volume]) =>
|
|
55
|
+
({ timestamp, open, high, low, close, volume }));
|
|
56
|
+
},
|
|
57
|
+
formatPrice: (s, p) => p.toFixed(2), formatQuantity: (s, q) => q.toFixed(8),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
addFrameSchema({ frameName: 'feb-2026', interval: '1m',
|
|
61
|
+
startDate: new Date('2026-02-01'), endDate: new Date('2026-02-28') });
|
|
62
|
+
|
|
63
|
+
addStrategySchema({
|
|
64
|
+
strategyName: 'my-strategy', interval: '15m',
|
|
65
|
+
getSignal: async (symbol, when, currentPrice) => ({
|
|
66
|
+
position: 'long',
|
|
67
|
+
...Position.bracket({ position: 'long', currentPrice, percentTakeProfit: 2, percentStopLoss: 1 }),
|
|
68
|
+
minuteEstimatedTime: 60 * 24, cost: 100,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
Backtest.background('BTCUSDT', { strategyName: 'my-strategy', exchangeName: 'binance', frameName: 'feb-2026' });
|
|
73
|
+
listenSignalBacktest(console.log);
|
|
74
|
+
listenDoneBacktest(async (e) => { await Backtest.dump(e.symbol, e.strategyName); });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
</details>
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## The rakes β and where they went
|
|
82
|
+
|
|
83
|
+
What follows isn't a feature list. It's the set of mistakes that quietly drain accounts, each one paired with the design decision that took it off the table. If you've shipped a bot before, you've stepped on at least three of these.
|
|
84
|
+
|
|
85
|
+
### 1. Your backtest lied to you, and you'll only find out with real money
|
|
86
|
+
|
|
87
|
+
Look-ahead bias is the assassin of algo trading: a single line that touches a future candle, an indicator loaded without a timestamp filter, one forgotten `<=`. The backtest prints a beautiful equity curve that can *never* be reproduced live, and you deploy straight into a drawdown.
|
|
88
|
+
|
|
89
|
+
The usual defense is "be careful." Careful doesn't survive a 2,000-line strategy or a refactor at 1 a.m. So the cure here isn't discipline β it's removal of the failure surface. There is no timestamp parameter to forget. An ambient temporal context flows through every async call via Node's `AsyncLocalStorage`, and the data layer physically refuses to hand you a candle past "now." The pending (still-forming) candle is never returned, because its half-finished OHLC would poison every indicator.
|
|
90
|
+
|
|
91
|
+
The one rule this rests on: that context is live for the whole `await` chain of your `getSignal` and every `listen*` callback β including across `Promise.all`, which is where strategy code actually runs. It is not sorcery over execution you deliberately detach from that chain. A bare timer, an `EventEmitter`, a forked process, or the web dashboard reads engine state by **identifier** (signal id / symbol), not by inheriting the ambient clock β that explicit, id-based interop is exactly how the frontend talks to a running backtest. Inside the hooks the guarantee holds; step outside them on purpose and you address the engine deliberately rather than by accident.
|
|
92
|
+
|
|
93
|
+
<details>
|
|
94
|
+
<summary>The Math</summary>
|
|
95
|
+
|
|
96
|
+
Every request resolves "now" from the ambient context, aligns down to the interval boundary, and treats the pending candle as exclusive:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
when = current execution-context time (AsyncLocalStorage)
|
|
100
|
+
stepMs = interval duration (1m β 60000)
|
|
101
|
+
alignedWhen = Math.floor(when / stepMs) * stepMs // round down to boundary
|
|
102
|
+
since = alignedWhen β limit * stepMs // go back `limit` candles
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `since` is **inclusive** β first candle has `timestamp === since`.
|
|
106
|
+
- `alignedWhen` is **exclusive** β the candle covering `[alignedWhen, alignedWhen+stepMs)` is still open and is never returned.
|
|
107
|
+
- Range is the half-open `[since, alignedWhen)`; exactly `limit` candles return; timestamps are `since + iΒ·stepMs`.
|
|
108
|
+
|
|
109
|
+
`getNextCandles()` is backtest-only and **throws in live mode** β there is no future to look at when "now" is wall-clock. `getRawCandles(limit?, sDate?, eDate?)` supports flexible windows, all clamped to `eDate β€ when`. Order books and aggregated trades use the same alignment (trades always to a 1-minute boundary). All boundaries are **UTC**: a 4h candle aligns to `00/04/08/12/16/20 UTC` regardless of your local offset β so `since` values that look "uneven" in local time are exact in UTC. Because `since` is derived from the ambient `when`, multi-timeframe pulls inside one `getSignal` are automatically synchronized, and runtime and the persistent cache compute identical keys β deterministic, exact-timestamp retrieval.
|
|
110
|
+
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
<details>
|
|
114
|
+
<summary>The Code</summary>
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
getSignal: async (symbol) => {
|
|
118
|
+
// No timestamps anywhere. Context flows even through Promise.all β
|
|
119
|
+
// all four timeframes are pinned to the same tick automatically.
|
|
120
|
+
const [c1h, c15m, c5m, c1m] = await Promise.all([
|
|
121
|
+
getCandles(symbol, '1h', 24),
|
|
122
|
+
getCandles(symbol, '15m', 48),
|
|
123
|
+
getCandles(symbol, '5m', 60),
|
|
124
|
+
getCandles(symbol, '1m', 60),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The bias you can't introduce by hand is the bias you'll never debug in production.
|
|
130
|
+
|
|
131
|
+
</details>
|
|
132
|
+
|
|
133
|
+
### 2. "It worked in the backtest" means nothing if live runs different code
|
|
134
|
+
|
|
135
|
+
The standard path productionizes a strategy by rewriting it: the research notebook becomes a second, hand-built live system with its own order logic, its own bugs, its own divergence. Now you have two strategies that *look* identical and behave differently exactly when it matters.
|
|
136
|
+
|
|
137
|
+
Here there is one code path. The `getSignal` you backtested is the `getSignal` that trades. Backtest mode feeds it historical timestamps; live mode feeds it `Date.now()`. The business logic β entries, validation, scheduled activation, TP/SL/timeout, partial closes β is byte-for-byte the same in both. The only differences are infrastructural: where the data comes from, not what you do with it.
|
|
138
|
+
|
|
139
|
+
<details>
|
|
140
|
+
<summary>The Code</summary>
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// Backtest β a historical frame drives the clock
|
|
144
|
+
Backtest.background('BTCUSDT', { strategyName, exchangeName, frameName });
|
|
145
|
+
|
|
146
|
+
// Live β wall-clock drives the clock; the strategy file is untouched
|
|
147
|
+
Live.background('BTCUSDT', { strategyName, exchangeName }); // keys via .env
|
|
148
|
+
listenSignalLive(async (e) => { if (e.action === 'closed') await Live.dump(e.symbol, e.strategyName); });
|
|
149
|
+
|
|
150
|
+
// Paper β live prices, no real orders, identical path. Validate here before risking capital.
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
And one engine, two ways to consume it β pick by use case, not by capability:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// Event-driven (production bots, monitoring)
|
|
157
|
+
Backtest.background('BTCUSDT', config);
|
|
158
|
+
listenSignalBacktest(e => {/* β¦ */});
|
|
159
|
+
|
|
160
|
+
// Async iterator (research, scripts, LLM agents)
|
|
161
|
+
for await (const event of Backtest.run('BTCUSDT', config)) { /* signal | progress | done */ }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
</details>
|
|
165
|
+
|
|
166
|
+
<details>
|
|
167
|
+
<summary>The Proof</summary>
|
|
168
|
+
|
|
169
|
+
This is the property the test suite exists to defend, and the line in the sand for the whole project: **business logic is 100% synchronous across backtest and live.** Signal validation is identical in both modes; immediate activation behaves identically; scheduled-signal logic is fully synchronized; TP / SL / timeout checks do not differ. The only divergence is infrastructural β how candles, order books, and time are sourced. `validation.test.mjs`, `backtest.test.mjs`, and `callbacks.test.mjs` pin this behavior; `event.test.mjs` pins the live path against the same expectations. If the two ever drift, a test goes red before you do.
|
|
170
|
+
|
|
171
|
+
</details>
|
|
172
|
+
|
|
173
|
+
### 3. The crash that opens your position twice
|
|
174
|
+
|
|
175
|
+
A bot updating a position when the process dies β OOM, deploy, power blip β usually wakes up to corrupted state: a half-opened position, a cost basis that's wrong, an exit that never registered. Recovery by hand is where money leaks.
|
|
176
|
+
|
|
177
|
+
Every state mutation is written atomically to disk *before* it counts as done (write-temp-then-rename), and on restart the engine reloads to the last consistent state. Live runs reload persisted signal state on every start, and `Live.background()` shuts down gracefully β it waits for open positions to reach `closed` before stopping, so a deploy never severs a live trade mid-flight.
|
|
178
|
+
|
|
179
|
+
<details>
|
|
180
|
+
<summary>The Proof</summary>
|
|
181
|
+
|
|
182
|
+
Recovery is structural, not a feature you remember to enable. `PersistBase` does atomic write-to-temp + rename, repairs corrupted files, and verifies integrity in `waitForInit()`. Fifteen per-domain `Persist*Instance` classes cover everything that can change: Signal, State, Session, Candle, Risk, Partial, Breakeven, Schedule, Recent, Notification, Log, Measure, Interval, Memory. Concrete scenarios that resolve cleanly:
|
|
183
|
+
|
|
184
|
+
- Process killed during order placement β internal state unchanged, retried next tick.
|
|
185
|
+
- Network failure during an exchange call β automatic retry on the next tick.
|
|
186
|
+
- Power loss during a save β recovery from the last atomic write.
|
|
187
|
+
- OOM β graceful shutdown with state preserved.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
listenSignalLive(async (event) => {
|
|
191
|
+
if (event.action === 'closed') {
|
|
192
|
+
await Live.dump(event.symbol, event.strategyName); // atomic snapshot to disk
|
|
193
|
+
await Partial.dump(event.symbol, event.strategyName);
|
|
194
|
+
}
|
|
195
|
+
if (event.action === 'scheduled' || event.action === 'cancelled') {
|
|
196
|
+
await Schedule.dump(event.symbol, event.strategyName);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
</details>
|
|
202
|
+
|
|
203
|
+
### 4. The state that can't be corrupted because it can't be expressed
|
|
204
|
+
|
|
205
|
+
"Is this position closed?" is a question you should never have to ask at runtime. A signal here moves through a strict lifecycle β **idle β scheduled β opened β active β closed** β modeled with TypeScript discriminated unions. Reading a closed position's live PnL, or mutating an active trade as if it were idle, isn't a bug you catch in QA; it's a line that won't compile.
|
|
206
|
+
|
|
207
|
+
<details>
|
|
208
|
+
<summary>The Code</summary>
|
|
209
|
+
|
|
210
|
+
Each state exposes only the data that is meaningful in that state, so the wrong access never type-checks:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
listenSignal((event) => {
|
|
214
|
+
switch (event.action) {
|
|
215
|
+
case 'idle': /* no signal β only monitoring fields exist */ break;
|
|
216
|
+
case 'scheduled': /* waiting for entry price β has priceOpen, scheduledAt */ break;
|
|
217
|
+
case 'opened': /* just filled β entry data, no closeReason yet */ break;
|
|
218
|
+
case 'active': /* live position β pnl, peakProfit, maxDrawdown */ break;
|
|
219
|
+
case 'closed': /* exited β closeReason, final pnl; live fields gone */ break;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Before any signal reaches the engine it passes a validation pipeline: TP/SL prices positive, relationship correct (`TP > entry > SL` long, inverse short), risk/reward β₯ your minimum, timestamps not in the future, interval-throttling respected. Invalid signals are rejected or logged β never executed.
|
|
225
|
+
|
|
226
|
+
</details>
|
|
227
|
+
|
|
228
|
+
<details>
|
|
229
|
+
<summary>The Proof</summary>
|
|
230
|
+
|
|
231
|
+
The discriminated-union result types (`IStrategyTickResultWaiting / β¦Opened / β¦Closed / β¦Scheduled / β¦Cancelled`) are enforced end-to-end: `ClientStrategy.tick()/backtest()`, `StrategyCoreService`, the persistence layer, and every notification contract (`SignalOpenedNotification`, `SignalClosedNotification`, `SignalCancelledNotification`, `SignalScheduledNotification`) carry the lifecycle state explicitly. `validation.test.mjs` exercises valid long/short, inverted TP/SL, negative prices, and future timestamps; `backtest.test.mjs` walks every close reason (`take_profit`, `stop_loss`, `time_expired`).
|
|
232
|
+
|
|
233
|
+
</details>
|
|
234
|
+
|
|
235
|
+
### 5. The order the exchange silently rejected
|
|
236
|
+
|
|
237
|
+
Live trading's quiet killer: the exchange rejects, times out, or fills partially, and your bot's internal state no longer matches reality. The textbook "fix" is hand-written `try/catch` rollback around every order β which is exactly the code that breaks on the edge case you didn't think of.
|
|
238
|
+
|
|
239
|
+
Here, every state-mutating action fires through the broker adapter *before* the internal state changes. If the adapter throws β rejection, timeout, network failure β the mutation is skipped, the state stays exactly as it was, and the engine retries on the next tick. You never write rollback logic, and there is no half-applied state to reconcile. In backtest mode no adapter is called at all, so historical replays never touch exchange code.
|
|
240
|
+
|
|
241
|
+
<details>
|
|
242
|
+
<summary>The Code</summary>
|
|
243
|
+
|
|
244
|
+
The reusable core: place β poll to fill β on timeout cancel, market-out any partial fill, restore TP/SL so the position is never left naked, then throw so the engine retries.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
async function createLimitOrderAndWait(exchange, symbol, side, qty, price, restore?) {
|
|
248
|
+
const order = await exchange.createOrder(symbol, 'limit', side, qty, price);
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
251
|
+
await sleep(FILL_POLL_INTERVAL_MS);
|
|
252
|
+
if ((await exchange.fetchOrder(order.id, symbol)).status === 'closed') return; // filled
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await exchange.cancelOrder(order.id, symbol);
|
|
256
|
+
await sleep(CANCEL_SETTLE_MS); // let the exchange settle before reading
|
|
257
|
+
|
|
258
|
+
const filledQty = (await exchange.fetchOrder(order.id, symbol)).filled ?? 0;
|
|
259
|
+
if (filledQty > 0) { // roll the partial fill back to clean state
|
|
260
|
+
await exchange.createOrder(symbol, 'market', side === 'buy' ? 'sell' : 'buy', filledQty);
|
|
261
|
+
}
|
|
262
|
+
if (restore) { /* re-place TP + stop-loss on the remaining position so it is never unprotected */ }
|
|
263
|
+
|
|
264
|
+
throw new Error('not filled in time β partial fill rolled back, backtest-kit will retry');
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
A hook wires it to position open. Signal open/close are routed automatically by an internal event bus the moment `Broker.enable()` is called β no manual wiring. The other mutations are intercepted explicitly before their state change:
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
Broker.useBrokerAdapter(class implements IBroker {
|
|
272
|
+
async waitForInit() { await getExchange(); }
|
|
273
|
+
|
|
274
|
+
async onOrderOpenCommit({ symbol, cost, priceOpen, priceTakeProfit, priceStopLoss }) {
|
|
275
|
+
const ex = await getExchange();
|
|
276
|
+
const qty = truncateQty(ex, symbol, cost / priceOpen);
|
|
277
|
+
await createLimitOrderAndWait(ex, symbol, 'buy', qty, priceOpen); // entry
|
|
278
|
+
try { // protect immediately
|
|
279
|
+
await ex.createOrder(symbol, 'limit', 'sell', qty, priceTakeProfit);
|
|
280
|
+
await createStopLossOrder(ex, symbol, qty, priceStopLoss);
|
|
281
|
+
} catch (err) { await ex.createOrder(symbol, 'market', 'sell', qty); throw err; }
|
|
282
|
+
}
|
|
283
|
+
// onOrderCloseCommit Β· onPartialProfitCommit Β· onPartialLossCommit
|
|
284
|
+
// onTrailingStopCommit Β· onTrailingTakeCommit Β· onBreakevenCommit Β· onAverageBuyCommit
|
|
285
|
+
});
|
|
286
|
+
Broker.enable();
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Complete, production-grade **Spot** (`stop_loss_limit`, balance truncation, dust/notional guards) and **Futures** (`reduceOnly`, hedge-mode `positionSide`, `setLeverage`, ghost-position guards) adapters β every hook, every edge case β ship verbatim in the docs. The CLI can also dry-fire any single hook against your live adapter for verification before you wait hours for a real signal:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
npx @backtest-kit/cli --brokerdebug --commit signal-open --symbol BTCUSDT
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
</details>
|
|
296
|
+
|
|
297
|
+
### 6. Averaging up is how a dip becomes a margin call
|
|
298
|
+
|
|
299
|
+
Dollar-cost averaging is where hand-rolled position math quietly bankrupts people. Average into a *rising* price by accident and you've raised your cost basis on a losing-direction trade β the opposite of the intent. And once you add partial closes on top, the cost-basis bookkeeping becomes a second strategy you have to get right.
|
|
300
|
+
|
|
301
|
+
`commitAverageBuy` is, by default, *only* accepted when price is below the running effective entry β averaging up is silently rejected, structurally. The effective price is a cost-weighted harmonic mean (correct for fixed-dollar entries, where $100 buys different quantities at different prices), and every partial close snapshots its cost basis so PnL replays exactly without re-walking history. No math required from you β the guardrail is in the engine.
|
|
302
|
+
|
|
303
|
+
<details>
|
|
304
|
+
<summary>The Math</summary>
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
effectivePrice = Ξ£cost / Ξ£(cost / price) // cost-weighted harmonic mean
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Each partial stores `costBasisAtClose` (the running dollar basis *before* it fired); a partial sell does not change the effective price of the coins still held. Final PnL is a dollar-weighted sum across every partial (each at its own effective price) plus the remainder, with slippage and per-leg fees:
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
weight[i] = (percent[i]/100 Γ costBasisAtClose[i]) / totalInvested
|
|
314
|
+
totalWeightedPnl = Ξ£ weight[i]Β·pnl[i] + remainingWeightΒ·pnlRemaining
|
|
315
|
+
pnlPercentage = totalWeightedPnl β fees // open fee once + per-partial + final close
|
|
316
|
+
pnlCost = pnlPercentage / 100 Γ totalInvested
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Worked example β LONG @1000, 4 accepted DCA + 1 rejected, 3 partials, close @1200 β reconciles two independent ways to **+17.9%**:
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
0.075Β·(+15.00) + 0.135Β·(β7.98) + 0.316Β·(+12.91) + 0.474Β·(+29.04) β +17.89%
|
|
323
|
+
coin cross-check: (34.50 + 49.69 + 142.72 + 244.67 β 400) / 400 β +17.90% β
|
|
324
|
+
entry #5 @980 REJECTED β 980 > effective entry β929.92 (the guard firing)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
</details>
|
|
328
|
+
|
|
329
|
+
<details>
|
|
330
|
+
<summary>The Code</summary>
|
|
331
|
+
|
|
332
|
+
A complete DCA-ladder strategy β open once, average on overlap-free dips up to 10 rungs, close at target β is about thirty lines, and the dangerous math is all inside the engine:
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import { addStrategySchema, listenActivePing, Position,
|
|
336
|
+
commitAverageBuy, commitClosePending,
|
|
337
|
+
getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent } from 'backtest-kit';
|
|
338
|
+
|
|
339
|
+
addStrategySchema({
|
|
340
|
+
strategyName: 'apr_2026_strategy',
|
|
341
|
+
getSignal: async (symbol, when, currentPrice) => ({
|
|
342
|
+
position: 'long',
|
|
343
|
+
...Position.moonbag({ position: 'long', currentPrice, percentStopLoss: 25 }),
|
|
344
|
+
minuteEstimatedTime: Infinity, cost: 100,
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
listenActivePing(async ({ symbol, currentPrice }) => { // the ladder
|
|
349
|
+
if ((await getPositionEntries(symbol)).length >= 10) return;
|
|
350
|
+
if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return;
|
|
351
|
+
await commitAverageBuy(symbol, 100); // rejected if it averages up
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
listenActivePing(async ({ symbol }) => { // exit on blended target
|
|
355
|
+
if (await getPositionPnlPercent(symbol) < 3) return;
|
|
356
|
+
await commitClosePending(symbol, { id: 'unknown', note: '# closed by target pnl' });
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Every order primitive is here, each with per-entry PnL, peak-profit and max-drawdown tracking: market/limit entries, TP/SL/OCO exits, grid with auto-cancel, partial profit/loss levels, trailing take/stop (absorbed only when they tighten in your favour, computed from the *original* distance to avoid drift), breakeven (moves the stop to entry once profit clears fees+slippage), stop-limit entries, DCA, and time-attack / infinite-hold.
|
|
361
|
+
|
|
362
|
+
</details>
|
|
363
|
+
|
|
364
|
+
### 7. Ten strategies, one account, 100% exposure
|
|
365
|
+
|
|
366
|
+
Per-strategy risk checks miss the obvious portfolio truth: ten strategies each "risking 10%" is one account risking everything. Risk validation here runs across *all* strategies and symbols at once, with an atomic check-and-reserve that closes the race between "is this allowed?" and "the order went out."
|
|
367
|
+
|
|
368
|
+
<details>
|
|
369
|
+
<summary>The Code</summary>
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
addRiskSchema({
|
|
373
|
+
riskName: 'demo',
|
|
374
|
+
validations: [
|
|
375
|
+
({ pendingSignal, currentPrice }) => { // TP β₯ 1%
|
|
376
|
+
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
|
|
377
|
+
const tp = position === 'long'
|
|
378
|
+
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
|
|
379
|
+
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
|
|
380
|
+
if (tp < 1) throw new Error(`TP too close: ${tp.toFixed(2)}%`);
|
|
381
|
+
},
|
|
382
|
+
({ pendingSignal, currentPrice }) => { // R/R β₯ 2:1
|
|
383
|
+
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
|
|
384
|
+
const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
|
|
385
|
+
const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
|
|
386
|
+
if (reward / risk < 2) throw new Error('Poor R/R ratio');
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
listenRisk(async (event) => { await Risk.dump(event.symbol, event.strategyName); }); // every rejection, logged
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`ClientRisk` tracks every open position across the portfolio; multiple strategies can share one profile for holistic exposure. `checkSignalAndReserve` is the thread-safe variant β after a successful reserve you **must** `addSignal` (finalize) or `removeSignal` (cancel) so reservations never go stale. A real LLM-gated portfolio improved from **+52.22% β +68.90%** PNL, Sharpe **+0.309 β +0.512**, win-rate **68% β 82%** simply by letting a local model veto 6 signals β 4 of them losers.
|
|
395
|
+
|
|
396
|
+
</details>
|
|
397
|
+
|
|
398
|
+
### 8. One process can trade the whole market
|
|
399
|
+
|
|
400
|
+
Spawning a process per symbol burns CPU on IPC and turns shared state β global risk, candle cache β into a distributed-systems problem you didn't sign up for. Dozens of symbols run concurrently here inside a **single Node process**, sharing one event loop, one Mongo pool, one Redis cache, with strict per-symbol state isolation.
|
|
401
|
+
|
|
402
|
+
<details>
|
|
403
|
+
<summary>The Proof</summary>
|
|
404
|
+
|
|
405
|
+
Measured on a commodity laptop (HP Victus, i5-13420H, 16 GB DDR4, NVMe SSD), 9 symbols in parallel, one Node process:
|
|
406
|
+
|
|
407
|
+
| Metric | Value |
|
|
408
|
+
|---|---|
|
|
409
|
+
| Wall-clock span (first β last event) | **2,893 ms** |
|
|
410
|
+
| Events captured | **297** |
|
|
411
|
+
| Historical time advanced / symbol | **34 minutes** |
|
|
412
|
+
| Per-symbol replay speed | **β703Γ** real-time |
|
|
413
|
+
| Aggregate (9 symbols) | **β6,326Γ** real-time |
|
|
414
|
+
| Hot-loop throughput | **β103 events/sec** |
|
|
415
|
+
|
|
416
|
+
Why it's fast: single-process concurrency (no IPC, no fork), an in-memory activity registry (`Lookup`) tracking every in-flight workload, a cooperative event-loop hand-off (`Candle.spinLock`) so parallel symbols advance round-robin instead of one hogging the CPU, Redis O(1) candle lookups, atomic `findOneAndUpdate` upserts (no read-modify-write), and `--cache` pre-warming so the inner loop never blocks on HTTP.
|
|
417
|
+
|
|
418
|
+
In live mode the bottleneck moves from CPU to the exchange β and that is where the shared cache earns its keep. Every symbol pulls candles, order books, and trades through one **deduplicated** layer, so nine strategies asking for the same `BTCUSDT 1m` candle issue *one* request, not nine. Hand-written per-bot code with no cache hammers the REST endpoint until the exchange rate-limits it; here the dedup + Redis O(1) layer keeps request volume flat as you add symbols, so rate limits stay off your back instead of throttling the desk. The Γ700 / Γ6,300 figures are CPU-bound backtest replay; live throughput is paced by the exchange, but the request layer is built so that pacing is the exchange's published limit, not self-inflicted spam.
|
|
419
|
+
|
|
420
|
+
```typescript
|
|
421
|
+
import { Backtest, warmCandles } from 'backtest-kit';
|
|
422
|
+
|
|
423
|
+
for (const symbol of ['BTCUSDT','ETHUSDT','SOLUSDT','BNBUSDT','XRPUSDT']) {
|
|
424
|
+
await warmCandles({ exchangeName: 'binance', interval: '1m', symbol,
|
|
425
|
+
from: new Date('2026-02-01T00:00:00Z'), to: new Date('2026-02-28T23:59:59Z') });
|
|
426
|
+
Backtest.background(symbol, { strategyName, exchangeName: 'binance', frameName: 'feb-2026' });
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
```bash
|
|
431
|
+
npx @backtest-kit/cli --backtest --entry ./content/multi-symbol.ts # CLI defers symbol selection to your file
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
</details>
|
|
435
|
+
|
|
436
|
+
### 9. When `./dump/` stops being enough
|
|
437
|
+
|
|
438
|
+
File storage is perfect on day one and a bottleneck the day you're doing thousands of context-keyed reads per second. Swap to MongoDB (durable, queryable, atomic) with a Redis O(1) cache via a single `setup()` β all 15 persistence contracts reimplemented, and **not one line of strategy code changes.**
|
|
439
|
+
|
|
440
|
+
<details>
|
|
441
|
+
<summary>The Code</summary>
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// config/setup.config.ts β loaded once before any persistence call
|
|
445
|
+
import { setup } from '@backtest-kit/mongo';
|
|
446
|
+
setup(); // reads CC_MONGO_CONNECTION_STRING / CC_REDIS_* from env, or pass explicitly
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Fifteen adapters, each with a unique compound index (`Signal β symbol+strategyName+exchangeName`, `Candle β symbol+interval+timestamp`, `Memory β signalId+bucketName+memoryId`, β¦). Candle records are immutable (`$setOnInsert`, first write wins); Measure/Interval/Memory use soft delete (`removed` flag) for an audit trail. Reads go Redis-first for the Mongo `_id`, then `findById` β two O(1) ops; a miss falls back to an indexed `findOne` and backfills. Writes are one `findOneAndUpdate({ upsert:true, new:true })` round-trip, so the unique index rejects concurrent duplicates at the storage engine and a write-then-read always sees fresh data. Signal-affecting adapters store the simulation `when`, so look-ahead protection is enforceable even inside the database.
|
|
450
|
+
|
|
451
|
+
```
|
|
452
|
+
read signal (BTCUSDT, my_strategy, binance)
|
|
453
|
+
ββ Redis GET β hit β Mongo findById(_id) β O(1) + O(1)
|
|
454
|
+
ββ Redis GET β miss β Mongo findOne(filter) β Redis SET β return
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
The default file adapter is already crash-safe (atomic temp+rename, repair on restart) β you get durability before you ever add a database.
|
|
458
|
+
|
|
459
|
+
</details>
|
|
460
|
+
|
|
461
|
+
### 10. A Sharpe of 10,000,000 is a bug, not an edge
|
|
462
|
+
|
|
463
|
+
Metrics that a tiny sample can't support are worse than no metrics β they're false confidence you bet money on. The analytics engine was rebuilt against canonical definitions and an independent 84-file reference testbed, and it prints **`N/A`** rather than a number it can't stand behind.
|
|
464
|
+
|
|
465
|
+
<details>
|
|
466
|
+
<summary>The Math</summary>
|
|
467
|
+
|
|
468
|
+
- **Pooled Sharpe** (v10.2.0+): per-trade returns are pooled across all symbols into one sample, then Sharpe is computed on that distribution β replacing the trade-count-weighted *average of ratios*, which inflates when one symbol is great and another negative. The header reads `Pooled Sharpe`, not `Portfolio Sharpe`, with a Markowitz disclaimer so it's never mistaken for covariance-based optimization.
|
|
469
|
+
- **Bessel's correction (Nβ1)** for unbiased variance β no risk underestimation on small samples.
|
|
470
|
+
- **Compounded equity curve** for Max Drawdown / Calmar / Recovery Factor β no double-counting of percentage returns.
|
|
471
|
+
- **Geometric annualization** for expected yearly returns β accounts for volatility drag (a 50% loss needs a 100% gain to recover).
|
|
472
|
+
- **Canonical Sortino (1991)** with downside deviation over `N_total`.
|
|
473
|
+
- **Float-artifact guard:** identical-return series produce stddev β1e-17; an `STDDEV_EPSILON` guard returns `N/A` instead of a fake Sharpe of 10,000,000. Gates of β₯10 signals and β₯14 calendar days gate publication.
|
|
474
|
+
|
|
475
|
+
Dashboard revenue is dollar-true: `pnlCost = pnlPercentage/100 Γ pnlEntries`, summed across closed signals per window (Today / Yesterday / 7d / 31d), anchored to the run end in backtest and `Date.now()` live.
|
|
476
|
+
|
|
477
|
+
</details>
|
|
478
|
+
|
|
479
|
+
### 11. The jobs that fire on virtual time
|
|
480
|
+
|
|
481
|
+
Most schedulers run on wall-clock β useless in a backtest that replays a month in three seconds. `Cron` runs on the *same* time stream your strategies see, firing on candle boundaries, coordinated across parallel backtests so one boundary never double-fires. The identical API drives live re-polling and one-shot backtest prep.
|
|
482
|
+
|
|
483
|
+
<details>
|
|
484
|
+
<summary>The Code</summary>
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
import { Cron, Backtest } from 'backtest-kit';
|
|
488
|
+
|
|
489
|
+
Cron.register({ name: 'tg-parser', interval: '1h', // global, hourly
|
|
490
|
+
handler: async ({ when }) => { await parseTelegramSignals(when); } });
|
|
491
|
+
|
|
492
|
+
Cron.register({ name: 'funding', interval: '1h', symbols: ['BTCUSDT','ETHUSDT'], // per-symbol fan-out
|
|
493
|
+
handler: async ({ symbol, when }) => { await fetchFundingRate(symbol, when); } });
|
|
494
|
+
|
|
495
|
+
Cron.register({ name: 'warm-cache', // fire-once, global
|
|
496
|
+
handler: async () => { await warmupCache(); } });
|
|
497
|
+
|
|
498
|
+
Cron.enable(); // wire to engine lifecycle once; every tick is forwarded automatically
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
`enable()` merges four lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`) into one serial queue via `singlerun`; each tick is base-aligned to the minute. Coordination keys `${name}:${alignedMs}:${symbol?}:g${generation}` give mutex semantics β parallel backtests on the same boundary share one in-flight promise (first opens the slot, others await). Fire-once marks record only on success, so a failed handler retries; the generation suffix isolates re-registrations from late writes.
|
|
502
|
+
|
|
503
|
+
</details>
|
|
504
|
+
|
|
505
|
+
### 12. You shouldn't have to abandon TradingView or Python to use TypeScript
|
|
506
|
+
|
|
507
|
+
The honest objection to a TS trading engine is "but my indicators live in Pine Script and TA-Lib." So they don't have to move. Run native Pine Script, run Python via WASM, use 50+ built-in indicators, or drop in zero-dependency quant ports β all under the same temporal guarantees.
|
|
508
|
+
|
|
509
|
+
<details>
|
|
510
|
+
<summary>The Code</summary>
|
|
511
|
+
|
|
512
|
+
**Pine Script** β v5/v6, 60+ indicators, 1:1 syntax, look-ahead-safe ([`@backtest-kit/pinets`](https://www.npmjs.com/package/@backtest-kit/pinets)):
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
import { File, getSignal } from '@backtest-kit/pinets';
|
|
516
|
+
const signal = await getSignal(File.fromPath('strategy.pine'),
|
|
517
|
+
{ symbol: 'BTCUSDT', timeframe: '5m', limit: 100 }); // plots: Signal/Close/StopLoss/TakeProfit/EstimatedTime
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
**50+ indicators across 1m/15m/30m/1h + order book, as LLM-ready Markdown, in one call** ([`@backtest-kit/signals`](https://www.npmjs.com/package/@backtest-kit/signals)):
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { commitHistorySetup } from '@backtest-kit/signals';
|
|
524
|
+
await commitHistorySetup('BTCUSDT', messages); // order book + candles + indicators, cached per TTL
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
**Typed DAG** of computations, resolved in topological order with `Promise.all` parallelism, serializable to a DB ([`@backtest-kit/graph`](https://www.npmjs.com/package/@backtest-kit/graph)):
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
|
|
531
|
+
const higher = sourceNode(async (symbol) => extract(await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 }), { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' }));
|
|
532
|
+
const lower = sourceNode(async (symbol) => extract(await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 }), { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss' }));
|
|
533
|
+
const mtf = outputNode(([h, l]) => { // combine; null when timeframes disagree
|
|
534
|
+
if (h.noTrades || l.position === 0) return null;
|
|
535
|
+
if (h.allowShort && l.position === 1) return null;
|
|
536
|
+
if (h.allowLong && l.position === -1) return null;
|
|
537
|
+
return toSignalDto(randomString(), l, null);
|
|
538
|
+
}, higher, lower);
|
|
539
|
+
addStrategySchema({ strategyName: 'mtf', interval: '5m', getSignal: () => resolve(mtf) });
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**Python via WASM (WASI)** runs `ta-lib`/`pandas`/`scikit-learn` indicators in the Node event loop with no IPC. And zero-dependency TS ports of the math behind vectorbt β see [See also](#-see-also).
|
|
543
|
+
|
|
544
|
+
</details>
|
|
545
|
+
|
|
546
|
+
### 13. AI strategies without ten provider SDKs
|
|
547
|
+
|
|
548
|
+
LLM-driven signals normally mean per-provider boilerplate and JSON you can't trust. One HOF API spans 10+ providers; structured output is schema-enforced; trading context is injected automatically.
|
|
549
|
+
|
|
550
|
+
<details>
|
|
551
|
+
<summary>The Code</summary>
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
import { deepseek } from '@backtest-kit/ollama';
|
|
555
|
+
addStrategy({
|
|
556
|
+
strategyName: 'llm-signal', interval: '5m',
|
|
557
|
+
// swap deepseek() β claude() / gpt5() / ollama() with no other change
|
|
558
|
+
getSignal: deepseek(getSignal, 'deepseek-chat', process.env.DEEPSEEK_API_KEY),
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Providers: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama (local), GLM-4. Structured output is enforced with Zod / JSON schema via `addOutline` (auto-retry on malformed output, custom rules like "SL must be below entry for LONG"); token rotation accepts a key array; prompts live in `config/prompt/*.cjs` and are memoized to kill redundant backtest API calls. The full LLM strategy β fetch multi-timeframe candles, ask the model, dump the reasoning, return a validated signal:
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
import { v4 as uuid } from 'uuid';
|
|
566
|
+
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
|
|
567
|
+
import { json } from './utils/json.mjs';
|
|
568
|
+
import { getMessages } from './utils/messages.mjs';
|
|
569
|
+
|
|
570
|
+
addStrategySchema({
|
|
571
|
+
strategyName: 'llm-strategy', interval: '5m', riskName: 'demo',
|
|
572
|
+
getSignal: async (symbol) => {
|
|
573
|
+
const messages = await getMessages(symbol, {
|
|
574
|
+
candles1h: await getCandles(symbol, '1h', 24),
|
|
575
|
+
candles15m: await getCandles(symbol, '15m', 48),
|
|
576
|
+
candles5m: await getCandles(symbol, '5m', 60),
|
|
577
|
+
candles1m: await getCandles(symbol, '1m', 60),
|
|
578
|
+
});
|
|
579
|
+
const resultId = uuid();
|
|
580
|
+
const signal = await json(messages); // LLM β structured signal
|
|
581
|
+
await dumpAgentAnswer({ dumpId: 'position-context', bucketName: 'mtf', messages, description: 'agent reasoning' });
|
|
582
|
+
await dumpRecord({ dumpId: 'position-entry', bucketName: 'mtf', record: signal, description: 'signal params' });
|
|
583
|
+
return { ...signal, id: resultId };
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Memory adapters persist LLM reasoning per signal (BM25 search, soft delete); `dumpAgentAnswer` archives the full conversation β roles, reasoning, tool calls β attached to the signal, so an opaque model decision becomes a debuggable record.
|
|
589
|
+
|
|
590
|
+
</details>
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## The API assumes you will make every mistake
|
|
595
|
+
|
|
596
|
+
Read back through the rakes and a pattern shows: none of them are solved by *telling you to be careful*. Look-ahead bias isn't prevented by a lint rule β there's simply no timestamp to pass. Averaging up isn't discouraged in the docs β the call is rejected. A closed position's live PnL isn't a runtime guard β it doesn't compile. The whole surface is built on the assumption that you, or the model writing your strategy, will eventually do the wrong thing at 3 a.m. β so the wrong thing is made unreachable. This is the "pit of success": the easy path and the correct path are the same path.
|
|
597
|
+
|
|
598
|
+
And the shape of that surface is **reactive β React for traders.** You never write the time loop. You don't iterate candles, advance a clock, or poll for fills. You *declare reactions* to lifecycle events, and the engine owns the loop in both backtest and live. `getSignal` is your pure render function β given the current state of the world, return a signal or `null`. The `listen*` family is your effects layer β small handlers that fire when the position's state changes, exactly like subscribing to state in a component. Composition is additive: stack independent listeners and each one minds its own concern, the same way you'd split hooks.
|
|
599
|
+
|
|
600
|
+
<details>
|
|
601
|
+
<summary>The Code</summary>
|
|
602
|
+
|
|
603
|
+
`getSignal` declares *what* to open; the listeners declare *how the position behaves once alive* β a DCA ladder, a profit target, and an error sink, three independent reactions to the same event stream, no shared loop, no manual bookkeeping:
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
import {
|
|
607
|
+
addStrategySchema, listenActivePing, listenError, Log, Position,
|
|
608
|
+
commitAverageBuy, commitClosePending,
|
|
609
|
+
getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent,
|
|
610
|
+
} from "backtest-kit";
|
|
611
|
+
import { errorData, getErrorMessage, str } from "functools-kit";
|
|
612
|
+
|
|
613
|
+
const HARD_STOP = 25, TARGET_PROFIT = 3, STEP = 100, MAX_STEPS = 10;
|
|
614
|
+
|
|
615
|
+
// render: given "now", declare the position to open (or null to stay flat)
|
|
616
|
+
addStrategySchema({
|
|
617
|
+
strategyName: "apr_2026_strategy",
|
|
618
|
+
getSignal: async (symbol, when, currentPrice) => ({
|
|
619
|
+
position: "long",
|
|
620
|
+
...Position.moonbag({ position: "long", currentPrice, percentStopLoss: HARD_STOP }),
|
|
621
|
+
minuteEstimatedTime: Infinity, cost: STEP,
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// effect: average into dips, up to 10 overlap-free rungs (averaging up is rejected for you)
|
|
626
|
+
listenActivePing(async ({ symbol, currentPrice }) => {
|
|
627
|
+
if ((await getPositionEntries(symbol)).length >= MAX_STEPS) return;
|
|
628
|
+
if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return;
|
|
629
|
+
await commitAverageBuy(symbol, STEP);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// effect: close the whole position once blended PnL clears the target
|
|
633
|
+
listenActivePing(async ({ symbol, data }) => {
|
|
634
|
+
if (await getPositionPnlPercent(symbol) < TARGET_PROFIT) return;
|
|
635
|
+
Log.info("position closed due to the target pnl reached", { symbol, data });
|
|
636
|
+
await commitClosePending(symbol, { id: "unknown", note: str.newline("# Closed by target pnl") });
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// effect: a single place for anything that goes wrong
|
|
640
|
+
listenError((error) => Log.debug("error", { error: errorData(error), message: getErrorMessage(error) }));
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The full reactive surface β subscribe to any point in a position's life and the engine fires it in order, queued, never overlapping: `listenSignal` / `listenSignalBacktest` / `listenSignalLive` (lifecycle), `listenActivePing` (per-minute while a position is live), `listenSchedulePing` / `listenIdlePing`, `listenPartialProfit` / `listenPartialLoss`, `listenBreakevenAvailable`, `listenHighestProfit`, `listenMaxDrawdown`, `listenRisk` (rejections), `listenError` / `listenExit`, `listenDone*`, plus `*Once` filtered variants for one-shot reactions. You compose behavior by adding handlers, not by editing a loop.
|
|
644
|
+
|
|
645
|
+
</details>
|
|
646
|
+
|
|
647
|
+
<details>
|
|
648
|
+
<summary>The Proof</summary>
|
|
649
|
+
|
|
650
|
+
The five guarantees that make the surface fool-proof, each enforced by the engine rather than by convention:
|
|
651
|
+
|
|
652
|
+
1. **Ambient temporal context** β no `currentDate`/`timestamp` parameter exists to forget; the engine resolves "now" from `AsyncLocalStorage` and blocks future data at the adapter level.
|
|
653
|
+
2. **Type-safe state machine** β `idle β scheduled β pending β opened β active β closed` as discriminated unions; calling a close on an already-closed signal, or editing an active trade's entry, is a compile error.
|
|
654
|
+
3. **Guarded DCA** β `commitAverageBuy` rejects any call that would worsen the harmonic-mean effective entry; you cannot accidentally average up.
|
|
655
|
+
4. **Transactional broker commits (the "no-try-catch" rule)** β the adapter intercepts every mutation before internal state changes; an exchange throw rolls back and retries on the next tick, so you never hand-write rollback.
|
|
656
|
+
5. **Automatic signal validation** β TP/SL soundness, R/R minimum, and interval throttling are checked before a signal reaches execution; invalid signals are logged or rejected, never run.
|
|
657
|
+
|
|
658
|
+
Because the loop belongs to the engine, the *same* declarations run identically in backtest and live β the reactive model is the reason "same code, both modes" is structurally true, not just aspirational.
|
|
659
|
+
|
|
660
|
+
</details>
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## Receipts
|
|
665
|
+
|
|
666
|
+
Toy READMEs prove a moving-average crossover on daily candles. These are eight production-quality strategies, each a *different* signal source, each backtested on real history with the numbers written down. They live in [`/example`](https://github.com/tripolskypetr/backtest-kit/tree/master/example) β clone it, run it, get the same prints.
|
|
667
|
+
|
|
668
|
+
| Strategy | Ticker Β· Period | Signal source | Net PNL | Sharpe |
|
|
669
|
+
|---|---|---|---:|---:|
|
|
670
|
+
| [Neural Network](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy) | BTC Β· Oct 2021 | TensorFlow NN (8β6β4β1) predicting next-candle close | **+18.26%** | 0.31 |
|
|
671
|
+
| [Python EMA Crossover](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy) | DOT Β· Feb 2021 | EMA(9)/EMA(21) via WebAssembly (WASI) | **+5.52%** | 0.09 |
|
|
672
|
+
| [Polymarket Ξprob](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2024.strategy) | BTC Β· Apr 2024 | Prediction-market probability shifts | **+0.63%** | 0.065 |
|
|
673
|
+
| [Pine Script Range Breakout](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy) | BTC Β· Dec 2025 | Bollinger + range + volume spike (Pine) | **+2.40%** | 0.06 |
|
|
674
|
+
| [Liquidity Harvesting](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy) | TRX Β· Jan 2026 | Telegram channel signals, **inverted** | **+8.58%** | **1.14** |
|
|
675
|
+
| [AI News Sentiment](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy) | BTC Β· Feb 2026 | LLM on live news (Tavily + Ollama) | **+16.99%** | 0.25 |
|
|
676
|
+
| [SHORT DCA Ladder](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy) | BTC Β· Mar 2026 | Fixed SHORT + ladder up (β€10 rungs) | **+37.83%** | 0.35 |
|
|
677
|
+
| [LONG DCA Ladder](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy) | BTC Β· Apr 2026 | Fixed LONG + ladder down (β€10 rungs) | **+67.85%** | 0.12 |
|
|
678
|
+
|
|
679
|
+
<details>
|
|
680
|
+
<summary>The Proof</summary>
|
|
681
|
+
|
|
682
|
+
- **Liquidity Harvesting (Sharpe 1.14)** β a Telegram channel published SHORT signals with ~0.375:1 R/R and 106% deposit at risk at 25Γ leverage, mathematically guaranteed to lose; a volume spike appeared 15 min before every post and the TP step multipliers were identical across signals β an algorithm. Inverting it turned **β5.05% β +8.58%**, profit factor **0.56 β 7.31**. The edge was the bot crowd, not the indicators.
|
|
683
|
+
- **AI News Sentiment** held SHORT through nearly all of a β16.4% month, flipped to LONG on the recovery bounce, and flipped back on geopolitical news β **+16.99%** where buy-and-hold lost 16%.
|
|
684
|
+
- **DCA Ladders** show the trade-off honestly: high % return on deployed capital, but absolute fiat risk grows with rungs (Mar: β$104.93 on a 10-rung position; theoretical max β$2,500 if a non-reverting trend hits the 25% hard stop with all rungs filled). The README states the downside, not just the upside.
|
|
685
|
+
|
|
686
|
+
Every example documents price context, trade log, equity curve, and risk analysis β and several ship a `--noDCA` / single-entry variant so you can see exactly what the position management bought you.
|
|
687
|
+
|
|
688
|
+
</details>
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## How it sits next to the alternatives
|
|
693
|
+
|
|
694
|
+
The honest version: for a quick research prototype or a single MA crossover, VectorBT or Backtrader are hard to beat on raw speed. The moment you need to *deploy* β complex position sizing, AI agents, a network outage that mustn't desync your bot β is where the guardrails below start to matter.
|
|
695
|
+
|
|
696
|
+
| | Backtest Kit | Backtrader | VectorBT | MetaTrader/MQL5 | QuantConnect | Freqtrade |
|
|
697
|
+
|---|---|---|---|---|---|---|
|
|
698
|
+
| Language | TypeScript | Python | Python | MQL5 | C#/Python | Python |
|
|
699
|
+
| Live trading | β
built-in | β οΈ manual | β research | β
| β
| β
|
|
|
700
|
+
| Look-ahead prevention | β
engine-enforced | β οΈ discipline | β οΈ discipline | β οΈ discipline | β οΈ partial | β οΈ partial |
|
|
701
|
+
| Crash-safe persistence | β
atomic + Mongo | β | β | β | β οΈ cloud | β οΈ basic |
|
|
702
|
+
| Transactional broker | β
auto rollback | β | β | β | β οΈ partial | β οΈ basic |
|
|
703
|
+
| Type-safe state machine | β
compile-time | β | β | β | β | β |
|
|
704
|
+
| DCA / partial closes | β
first-class | β οΈ manual | β οΈ manual | β οΈ manual | β οΈ manual | β οΈ limited |
|
|
705
|
+
| AI / LLM integration | β
built-in | β | β | β | β οΈ custom | β |
|
|
706
|
+
| Pine Script | β
native | β | β | β
| β | β |
|
|
707
|
+
| Self-hosted | β
100% | β
| β
| β οΈ desktop | β cloud | β
|
|
|
708
|
+
|
|
709
|
+
Open-source QuantConnect/MetaTrader without the lock-in: pure TypeScript, your code, your data, your machines, no platform fees, no proprietary GUI. Drop any library into `getSignal` β Ollama, [`neural-trader`](https://www.npmjs.com/package/neural-trader), your own.
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## π Ecosystem
|
|
714
|
+
|
|
715
|
+
**The core is a library; the CLI is the framework on top β and the framework is optional.** Think React vs Next.js. `backtest-kit` (the reactive engine β `getSignal` + the `listen*`/`commit*` API) is the library you build against directly. `@backtest-kit/cli` is the Next.js: it wires the runner, candle cache, dashboard, Telegram, and graceful shutdown so you don't have to β but you can ignore it entirely and call `Backtest.run()` / `Live.background()` yourself. `@backtest-kit/sidekick` is the explicit middle ground β it scaffolds a project where every wire (exchange adapter, frames, risk rules, strategy, runner) lives as plain, editable source in **your** userspace, with no CLI in the loop and nothing hidden. You pick how much magic you want.
|
|
716
|
+
|
|
717
|
+
On the "dependency zoo": every package below is authored by one team and shipped by the commercial vendor [TheOneTrade](https://theonetrade.github.io) β versioned together, released together. Treat it like the .NET base class library: a single coherent contract where the userspace surface (`getSignal`, `commit*`, `listen*`, `get*`) does not churn under you between releases. You install only what a given strategy needs, and the heavy or platform-specific pieces (Python-via-WASM, TensorFlow builds) sit behind their own optional packages so the core install stays clean and conflict-free.
|
|
718
|
+
|
|
719
|
+
### `@backtest-kit/cli` β [npm](https://www.npmjs.com/package/@backtest-kit/cli)
|
|
720
|
+
Zero-boilerplate runner. Modes: `--backtest / --paper / --live / --walker / --main / --pine / --editor / --dump / --pnldebug / --brokerdebug / --flush / --init / --docker`. Auto candle caching, monorepo cwd-resolution with per-strategy `.env` override, folder-based import aliases, broker module hooks, `setup.config` / `loader.config` / `alias.config`, graceful SIGINT.
|
|
721
|
+
```bash
|
|
722
|
+
npx -y @backtest-kit/cli --init
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
### `@backtest-kit/pinets` β [npm](https://www.npmjs.com/package/@backtest-kit/pinets)
|
|
726
|
+
Run TradingView Pine Script v5/v6 in Node, 60+ indicators, 1:1 syntax, `getSignal` / `run` / `extract` / `extractRows`.
|
|
727
|
+
```bash
|
|
728
|
+
npm install @backtest-kit/pinets pinets backtest-kit
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
### `@backtest-kit/graph` β [npm](https://www.npmjs.com/package/@backtest-kit/graph)
|
|
732
|
+
Compose computations as a typed DAG; resolved in topological order with `Promise.all`, serializable to a DB for storage.
|
|
733
|
+
```bash
|
|
734
|
+
npm install @backtest-kit/graph backtest-kit
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### `@backtest-kit/ui` β [npm](https://www.npmjs.com/package/@backtest-kit/ui)
|
|
738
|
+
React/MUI dashboard with Lightweight Charts: live signal-lifecycle state-machine view, per-signal inspection, risk/partial/trailing/breakeven views, manual control, Pine editor.
|
|
739
|
+
```typescript
|
|
740
|
+
import { serve } from '@backtest-kit/ui';
|
|
741
|
+
serve('0.0.0.0', 60050); // http://localhost:60050
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
### `@backtest-kit/mongo` β [npm](https://www.npmjs.com/package/@backtest-kit/mongo)
|
|
745
|
+
MongoDB source-of-truth + Redis O(1) cache. All 15 persistence contracts, atomic upserts, soft delete, look-ahead-safe `when`. Zero strategy changes.
|
|
746
|
+
```bash
|
|
747
|
+
npm install @backtest-kit/mongo backtest-kit mongoose ioredis
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
### `@backtest-kit/ollama` β [npm](https://www.npmjs.com/package/@backtest-kit/ollama)
|
|
751
|
+
Universal LLM adapter: 10+ providers, structured output, token rotation, fallback chains, trading-context injection.
|
|
752
|
+
```bash
|
|
753
|
+
npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### `@backtest-kit/signals` β [npm](https://www.npmjs.com/package/@backtest-kit/signals)
|
|
757
|
+
50+ indicators across 4 timeframes + order book, multi-timeframe synchronized, LLM-ready Markdown reports.
|
|
758
|
+
```bash
|
|
759
|
+
npm install @backtest-kit/signals backtest-kit
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
### `@backtest-kit/sidekick` β [npm](https://www.npmjs.com/package/@backtest-kit/sidekick)
|
|
763
|
+
The "eject" of `--init`: scaffolds a project where exchange adapter, frames, risk rules, strategy, and runner are all editable source. 4H-trend + 15m-signal Pine template, partial profit taking, breakeven trailing.
|
|
764
|
+
```bash
|
|
765
|
+
npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
---
|
|
769
|
+
|
|
770
|
+
## πͺ Community
|
|
771
|
+
|
|
772
|
+
Real, runnable templates β not slideware. And worth naming the concern directly: yes, this is one author's ecosystem, which is exactly what makes it *coherent* β but coherent is not captive. Everything is **MIT and open-source**, the core engine has **zero hard dependency** on any `@backtest-kit/*` add-on (you can run `getSignal` + `listen*` against a bare `addExchangeSchema` and nothing else), and each repo below is an independent reference you're meant to **fork and own**. The lock-in you'd normally fear β a closed runtime, a proprietary data format, a cloud you can't leave β none of it applies; the persistence is plain files or your own Mongo, the signals are your code, and the exit cost is a `git clone`.
|
|
773
|
+
|
|
774
|
+
- **[backtest-monorepo-parallel](https://github.com/backtest-kit/backtest-monorepo-parallel)** β 9 symbols in parallel in one Node process on shared Mongo+Redis, ~6,300Γ real-time, self-enforcement runtime exposing the workspace DI container to `./content/` strategy files. The scaling recipe: +1 service = +1 file, +1 provider, +1 ioc entry.
|
|
775
|
+
- **[backtest-ollama-crontab](https://github.com/backtest-kit/backtest-ollama-crontab)** β a local Ollama (`gpt-oss` quantized) as a per-signal risk gate plus a 15-minute crontab ingesting any public Telegram channel; the *same code* re-polls live and bulk-prepares in backtest. Documented result: **+52.22% β +68.90%** with the LLM gate on.
|
|
776
|
+
- **[backtest-kit-redis-mongo-docker](https://github.com/backtest-kit/backtest-kit-redis-mongo-docker)** β production persistence: all 15 adapters on Mongo+Redis, atomic read-after-write, `docker-compose` one-command deploy.
|
|
777
|
+
- **[backtest-kit-skills](https://github.com/backtest-kit/backtest-kit-skills)** β a Claude Code skill + Mintlify docs: describe a strategy in plain language, get working TypeScript with every schema registration wired. `npx skills add https://github.com/backtest-kit/backtest-kit-skills`
|
|
778
|
+
- **[uzse-backtest-app](https://github.com/backtest-kit/uzse-backtest-app)** β Pine Script on regional exchanges that aren't on TradingView (UZSE, MSE, DSEβ¦): download raw trades, build candles, feed them through a custom Mongo exchange adapter.
|
|
779
|
+
- **[backtest-kit-docs](https://github.com/backtest-kit/backtest-kit-docs)** β Architecture handbook and knowledge base: explains the engine's design, AI workflows, production patterns, and quantitative trading concepts beyond the API.
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
## πͺ See also
|
|
784
|
+
|
|
785
|
+
Zero-dependency TypeScript ports of the quant math behind [vectorbt](https://github.com/polakowo/vectorbt) β same models, native to the `Exchange` schema, no Python runtime. Each estimates a different dimension of speculative pressure and plugs in independently:
|
|
786
|
+
|
|
787
|
+
- **[garch](https://www.npmjs.com/package/garch)** β conditional variance of log-returns (GARCH / EGARCH / GJR-GARCH / HAR-RV / NoVaS, auto-selected by QLIKE) to bound how far flow can push price next candle; fitted `Ο` β log-normal corridor `PΒ·exp(Β±zΒ·Ο)` for TP/SL. Via `Exchange.getCandles`.
|
|
788
|
+
- **[pump-anomaly](https://www.npmjs.com/package/pump-anomaly)** β coordinated-speculation detection: cross-correlation + union-find author clustering separates real multi-actor inflow from one actor on many channels; volume z-scores score cascade pressure (pump vs stop-hunt). Returns an entry/exit plan, exits fitted by OHLC replay and screened against winner's-curse (DSR / PBO / SPA). Via `Exchange.getRawCandles`.
|
|
789
|
+
- **[volume-anomaly](https://www.npmjs.com/package/volume-anomaly)** β order-flow intensity: Hawkes branching ratio (arrival clustering), CUSUM (imbalance shift), BOCPD (regime break) β composite outlier score as an entry-timing gate. Via `Exchange.getAggregatedTrades`.
|
|
790
|
+
|
|
791
|
+
---
|
|
792
|
+
|
|
793
|
+
## π― Tested
|
|
794
|
+
|
|
795
|
+
950+ unit and integration tests cover exchange helpers, the event-listener system, signal validation (valid long/short, inverted TP/SL, negative prices, future timestamps), PnL accuracy with 0.1% fees + 0.1% slippage, the full lifecycle and every close reason, strategy callbacks, and report generation. Tests use unique schema names per case (no cross-contamination), a forward-progressing mock candle generator, and event-driven completion detection.
|
|
796
|
+
|
|
797
|
+
<details>
|
|
798
|
+
<summary>Core test axes</summary>
|
|
799
|
+
|
|
800
|
+
- β
State machine under rejections (gates, throttle rollbacks, terminal drops, stopStrategy race)
|
|
801
|
+
- β
Deferred commands, Live Γ Backtest
|
|
802
|
+
- β
Broker: 8-stage lifecycle routing, gates, backtest silence, enable/disable, commit\* layer
|
|
803
|
+
- β
Position commands + interleaved DCA Γ partial exits
|
|
804
|
+
- β
Context-free surface (62 bare calls)
|
|
805
|
+
- β
Crash recovery of every deferred flag + commit queue
|
|
806
|
+
- β
SHORT mirror of the key paths
|
|
807
|
+
- β
Timeouts, Once-listeners, action gate, Infinity holds, whipsaw restore, shared-risk contention, cancellation stats
|
|
808
|
+
- β
Order events: types, emission/silence per mode
|
|
809
|
+
- β
Look-ahead bias protection: candle alignment, pending-candle exclusion, `getNextCandles` throwing in live
|
|
810
|
+
- β
Signal validation: inverted TP/SL, negative/NaN/Infinity prices, future timestamps, micro-profit eaten by fees, excessive lifetime
|
|
811
|
+
- β
Full lifecycle and every close reason: take_profit, stop_loss, time_expired, user close, external "closed"
|
|
812
|
+
- β
Scheduled signals: price/wick activation, pre-activation SL cancellation, timeout, frame-end, immediate activation
|
|
813
|
+
- β
Money-safety edges: SL-before-activation, TP-vs-SL priority on a single candle, extreme volatility, exchange errors surfaced not swallowed
|
|
814
|
+
- β
Signal queues: sequences of mixed outcomes, winning/losing streaks, deterministic-id retry vs whipsaw block
|
|
815
|
+
- β
Infinity holds: chunked candle processing across frame boundaries, close-at-boundary edge cases
|
|
816
|
+
- β
PnL math: 0.1% fees + 0.1% slippage, cost-basis snapshots, partial-close weighting, DCA harmonic effective price
|
|
817
|
+
- β
Partial profit/loss: dollar-exact closes on the remaining cost basis, 100%-of-remaining epsilon cap
|
|
818
|
+
- β
Trailing stop/take: percentage-point shifts from ORIGINAL levels, absorption, intrusion protection
|
|
819
|
+
- β
Breakeven: threshold math over fees+slippage, trailing upgrade paths, zero-risk exits landing exactly on entry
|
|
820
|
+
- β
Risk schemas: reservation/release accounting, rejection callbacks, shared risk maps across strategies
|
|
821
|
+
- β
Position sizing: fixed/percent/Kelly calculators, min/max caps ordering
|
|
822
|
+
- β
Persistence: atomic writes, JSON adapters, restore after restart (pending, scheduled, deferred flags, commit queue), context-mismatch skips, Infinity round-trip
|
|
823
|
+
- β
Candle cache: hit/miss keys, interval separation, adapter call counting
|
|
824
|
+
- β
Event-listener system: every `listen*`/`listen*Once` channel, queued async processing, unsubscribe
|
|
825
|
+
- β
Actions: schema validation (discouraged-method redirects), handler/callback routing, risk rejection hooks
|
|
826
|
+
- β
Reports and markdown: column renderers, null-vs-zero semantics, closed-history merging, schedule activation/cancellation rates
|
|
827
|
+
- β
Heat: per-symbol and portfolio statistics against independently computed references
|
|
828
|
+
- β
Walker: strategy comparison sweeps, unbounded measures
|
|
829
|
+
- β
Performance metrics: emission ordering, duration accounting
|
|
830
|
+
- β
Parallel execution: multi-backtest interleaving without cross-contamination
|
|
831
|
+
- β
Graceful shutdown: `Backtest.stop()`/`Live.stop()` mid-run, no new signals after stop, stopStrategy draining through the cancel pipeline
|
|
832
|
+
- β
Live-tick semantics: schedule-ping rejection cancelling the resting order, time_expired and schedule-await timeouts, VWAP TP/SL crossings between ticks, pre-activation SL break never opening, getSignal throttled to one call per aligned interval, live/backtest channel routing with typed sync open/close pair, out-of-context Price/TimeMeta reads by identifiers
|
|
833
|
+
- β
Broker-driven order cancellation in live: onOrderScheduleCheck throw cancelling the resting order, onOrderActiveCheck throw closing the position as externally closed, onOrderOpenCommit throw rejecting placement with same-interval retry, and terminally cancelling a rejected activation fill
|
|
834
|
+
- β
Full commit/getter canon of function/strategy.ts: absolute-price trailing, dollar-exact partial loss off the remaining basis, confirmed SL fill bypassing VWAP, user-created signals, ladder overlap corridors, phase-tracking helpers, deferred-ops status snapshot, signal notifications, remaining-basis alias getters
|
|
835
|
+
- β
Edge contracts: SHORT mirrors of absolute-price trailing and dollar partials, deferred-command races, 100%-partial leaving a zero-basis position that TP still closes, invalid percent/dollar/alien-symbol rejections, and schema-change restarts
|
|
836
|
+
- β
Unexpected-stop family: mid-tick stop races, deferred user activation under stop, post-stop activateScheduled rejected, pre-stop deferred close/cancel and broker-confirmed TP fills still draining, stop flag being process-local across restarts
|
|
837
|
+
- β
Statistics engine: 100 golden backtest datasets β expectancy, Sharpe, drawdown, corrupted-row filtering
|
|
838
|
+
- β
Config: global overrides, validation toggles, partial `setConfig` merges
|
|
839
|
+
|
|
840
|
+
</details>
|
|
841
|
+
|
|
842
|
+
## π€ Reading this as a model?
|
|
843
|
+
|
|
844
|
+
Every claim above opens into **The Code / The Math / The Proof** precisely so you can verify the implementation instead of trusting the prose β the API in use, the cost-basis derivation, the timestamp algebra, the persistence internals. Extended machine-oriented description: [./LLMs.md](./LLMs.md). Full class/function/interface reference: the [documentation site](https://backtest-kit.github.io/documents/example_02_first_backtest.html).
|
|
845
|
+
|
|
846
|
+
## π¨βπΌ Commercial support
|
|
847
|
+
|
|
848
|
+
Built over a year of live trading and run in production at **[TheOneTrade](https://theonetrade.github.io)** β the commercial vendor behind the framework, offering paid support, custom strategy development, managed accounts, team training, and enterprise licensing for engagements outside the MIT scope.
|
|
849
|
+
|
|
850
|
+
## π License
|
|
851
|
+
|
|
852
|
+
MIT Β© [tripolskypetr](https://github.com/tripolskypetr)
|
|
853
|
+
|