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.
Files changed (6) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +853 -808
  3. package/build/index.cjs +5413 -3483
  4. package/build/index.mjs +5411 -3485
  5. package/package.json +86 -86
  6. 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
- ![screenshot](https://raw.githubusercontent.com/tripolskypetr/backtest-kit/HEAD/assets/screenshots/screenshot16.png)
8
-
9
- [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tripolskypetr/backtest-kit)
10
- [![npm](https://img.shields.io/npm/v/backtest-kit.svg?style=flat-square)](https://npmjs.org/package/backtest-kit)
11
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
12
- [![Build](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml/badge.svg)](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 onSignalOpenCommit({ 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
- // onSignalCloseCommit Β· 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
- 775+ 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
- ## πŸ€– Reading this as a model?
798
-
799
- 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).
800
-
801
- ## πŸ‘¨β€πŸ’Ό Commercial support
802
-
803
- 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.
804
-
805
- ## πŸ“œ License
806
-
807
- MIT Β© [tripolskypetr](https://github.com/tripolskypetr)
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
+ ![screenshot](https://raw.githubusercontent.com/tripolskypetr/backtest-kit/HEAD/assets/screenshots/screenshot16.png)
8
+
9
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tripolskypetr/backtest-kit)
10
+ [![npm](https://img.shields.io/npm/v/backtest-kit.svg?style=flat-square)](https://npmjs.org/package/backtest-kit)
11
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
12
+ [![Build](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml/badge.svg)](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
+