backtest-kit 9.8.5 โ†’ 10.2.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 +1995 -1898
  3. package/build/index.cjs +1387 -412
  4. package/build/index.mjs +1386 -413
  5. package/package.json +86 -86
  6. package/types.d.ts +448 -8
package/README.md CHANGED
@@ -1,1898 +1,1995 @@
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 framework for backtesting and live trading strategies on multi-asset, crypto, forex or [DEX (peer-to-peer marketplace)](https://en.wikipedia.org/wiki/Decentralized_finance#Decentralized_exchanges), spot, futures with crash-safe persistence, signal validation, and AI optimization.
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
- Build reliable trading systems: backtest on historical data, deploy live bots with recovery, and optimize strategies using LLMs like Ollama.
15
-
16
- ๐Ÿ“š **[API Reference](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** | ๐ŸŒŸ **[Quick Start](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** | **๐Ÿ“ฐ [Article](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)**
17
-
18
- ## ๐Ÿš€ Quick Start
19
-
20
- > **New to backtest-kit?** The fastest way to get a real, production-ready setup is to clone the [reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example) โ€” a fully working news-sentiment AI trading system with LLM forecasting, multi-timeframe data, and a documented February 2026 backtest. Start there instead of from scratch.
21
-
22
- ### ๐ŸŽฏ The Casual Way: CLI Init
23
-
24
- > **Minimal scaffold โ€” all boilerplate stays inside `@backtest-kit/cli`:**
25
-
26
- ```bash
27
- npx @backtest-kit/cli --init --output backtest-kit-project
28
- cd backtest-kit-project
29
- npm install
30
- npm start
31
- ```
32
-
33
- The generated project contains only your strategy files. There is no bootstrap, exchange registration, or runner code to maintain โ€” all of that lives inside `@backtest-kit/cli` and is invoked via `npm start`. Library documentation is fetched automatically into `docs/lib/` on init.
34
-
35
- ### ๐Ÿ—๏ธ Alternative: Sidekick CLI
36
-
37
- > **Full-control scaffold โ€” all wiring is in your project files:**
38
-
39
- ```bash
40
- npx -y @backtest-kit/sidekick my-trading-bot
41
- cd my-trading-bot
42
- npm start
43
- ```
44
-
45
- Sidekick generates a project where the exchange adapter, frame definitions, risk rules, strategy logic, and runner script all live as editable source files inside the project. Use it when you need full visibility and control over every part of the setup.
46
-
47
- ### ๐Ÿณ Running in Docker
48
-
49
- > **Automatic restarts โ€” Zero-downtime trading:**
50
-
51
- ```bash
52
- npx @backtest-kit/cli --docker
53
- cd backtest-kit-docker
54
- MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
55
- docker-compose logs -f
56
- ```
57
-
58
- CLI can create a ready-to-use Docker workspace: self-contained directory with `docker-compose.yaml` and a strategy entry point. CLI supports [Multiple Symbol in Parallel](https://www.npmjs.com/package/@backtest-kit/cli#-multiple-symbol-parallel) for powerusers.
59
-
60
- ### ๐Ÿ“ฆ Manual Installation
61
-
62
- > **Want to see the code?** ๐Ÿ‘‰ [Demo app](https://github.com/tripolskypetr/backtest-kit/tree/master/example) ๐Ÿ‘ˆ
63
-
64
- ```bash
65
- npm install backtest-kit ccxt ollama uuid
66
- ```
67
-
68
- Install the core library and peer dependencies manually. Use this approach when integrating backtest-kit into an existing project or when you need full control over your package setup.
69
-
70
- ## โœจ Why Choose Backtest Kit?
71
-
72
- - ๐Ÿš€ **Production-Ready**: Seamless switch between backtest/live modes; identical code across environments.
73
- - ๐Ÿ’พ **Crash-Safe**: Atomic persistence recovers states after crashes, preventing duplicates or losses.
74
- - โœ… **Validation**: Checks signals for TP/SL logic, risk/reward ratios, and portfolio limits.
75
- - ๐Ÿ”„ **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
76
- - ๐Ÿค– **AI Integration**: LLM-powered strategy generation (Optimizer) with multi-timeframe analysis.
77
- - ๐Ÿ“Š **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
78
- - ๐Ÿ›ก๏ธ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
79
- - ๐Ÿ”Œ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
80
- - ๐Ÿ—ƒ๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ€” exchange rejection rolls back the operation atomically.
81
- - ๐Ÿงช **Tested**: 520+ unit/integration tests for validation, recovery, and events.
82
- - ๐Ÿ”“ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
83
-
84
- ## ๐Ÿ“‹ Supported Order Types
85
-
86
- > With the calculation of PnL, Peak Profit and Max Drawdown for each Entry
87
-
88
- - Market/Limit entries
89
- - TP/SL/OCO exits
90
- - Grid with auto-cancel on unmet conditions
91
- - Partial profit/loss levels
92
- - Trailing stop-loss
93
- - Breakeven protection
94
- - Stop limit entries (before OCO)
95
- - Dollar cost averaging
96
- - Time attack / Infinite hold
97
-
98
- ## ๐Ÿ“š Code Samples
99
-
100
- ### โš™๏ธ Basic Configuration
101
- ```typescript
102
- import { setLogger, setConfig } from 'backtest-kit';
103
-
104
- // Enable logging
105
- setLogger({
106
- log: console.log,
107
- debug: console.debug,
108
- info: console.info,
109
- warn: console.warn,
110
- });
111
-
112
- // Global config (optional)
113
- setConfig({
114
- CC_PERCENT_SLIPPAGE: 0.1, // % slippage
115
- CC_PERCENT_FEE: 0.1, // % fee
116
- CC_SCHEDULE_AWAIT_MINUTES: 120, // Pending signal timeout
117
- });
118
- ```
119
-
120
- ### ๐Ÿ”ง Register Components
121
- ```typescript
122
- import ccxt from 'ccxt';
123
- import { addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema } from 'backtest-kit';
124
-
125
- // Exchange (data source)
126
- addExchangeSchema({
127
- exchangeName: 'binance',
128
- getCandles: async (symbol, interval, since, limit) => {
129
- const exchange = new ccxt.binance();
130
- const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
131
- return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume }));
132
- },
133
- formatPrice: (symbol, price) => price.toFixed(2),
134
- formatQuantity: (symbol, quantity) => quantity.toFixed(8),
135
- });
136
-
137
- // Risk profile
138
- addRiskSchema({
139
- riskName: 'demo',
140
- validations: [
141
- // TP at least 1%
142
- ({ pendingSignal, currentPrice }) => {
143
- const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
144
- const tpDistance = position === 'long' ? ((priceTakeProfit - priceOpen) / priceOpen) * 100 : ((priceOpen - priceTakeProfit) / priceOpen) * 100;
145
- if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
146
- },
147
- // R/R at least 2:1
148
- ({ pendingSignal, currentPrice }) => {
149
- const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
150
- const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
151
- const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
152
- if (reward / risk < 2) throw new Error('Poor R/R ratio');
153
- },
154
- ],
155
- });
156
-
157
- // Time frame
158
- addFrameSchema({
159
- frameName: '1d-test',
160
- interval: '1m',
161
- startDate: new Date('2025-12-01'),
162
- endDate: new Date('2025-12-02'),
163
- });
164
- ```
165
-
166
- ### ๐Ÿ’ก Example Strategy (with LLM)
167
- ```typescript
168
- import { v4 as uuid } from 'uuid';
169
- import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
170
- import { json } from './utils/json.mjs'; // LLM wrapper
171
- import { getMessages } from './utils/messages.mjs'; // Market data prep
172
-
173
- addStrategySchema({
174
- strategyName: 'llm-strategy',
175
- interval: '5m',
176
- riskName: 'demo',
177
- getSignal: async (symbol) => {
178
-
179
- const candles1h = await getCandles(symbol, "1h", 24);
180
- const candles15m = await getCandles(symbol, "15m", 48);
181
- const candles5m = await getCandles(symbol, "5m", 60);
182
- const candles1m = await getCandles(symbol, "1m", 60);
183
-
184
- const messages = await getMessages(symbol, {
185
- candles1h,
186
- candles15m,
187
- candles5m,
188
- candles1m,
189
- }); // Calculate indicators / Fetch news
190
-
191
- const resultId = uuid();
192
- const signal = await json(messages); // LLM generates signal
193
-
194
- await dumpAgentAnswer({
195
- dumpId: "position-context",
196
- bucketName: "multi-timeframe-strategy",
197
- messages: messages, // pass saved messages here
198
- description: "agent reasoning for this signal",
199
- });
200
-
201
- await dumpRecord({
202
- dumpId: "position-entry",
203
- bucketName: "multi-timeframe-strategy",
204
- record: signal, // pass saved signal record here
205
- description: "signal entry parameters",
206
- });
207
-
208
- return { ...signal, id: resultId };
209
- },
210
- });
211
- ```
212
-
213
- ### ๐Ÿงช Run Backtest
214
- ```typescript
215
- import { Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
216
-
217
- Backtest.background('BTCUSDT', {
218
- strategyName: 'llm-strategy',
219
- exchangeName: 'binance',
220
- frameName: '1d-test',
221
- });
222
-
223
- listenSignalBacktest((event) => console.log(event));
224
- listenDoneBacktest(async (event) => {
225
- await Backtest.dump(event.symbol, event.strategyName); // Generate report
226
- });
227
- ```
228
-
229
- ### ๐Ÿ“ˆ Run Live Trading
230
- ```typescript
231
- import { Live, listenSignalLive } from 'backtest-kit';
232
-
233
- Live.background('BTCUSDT', {
234
- strategyName: 'llm-strategy',
235
- exchangeName: 'binance', // Use API keys in .env
236
- });
237
-
238
- listenSignalLive((event) => console.log(event));
239
- ```
240
-
241
- ### ๐Ÿ“ก Monitoring & Events
242
-
243
- - Use `listenRisk`, `listenError`, `listenPartialProfit/Loss` for alerts.
244
- - Dump reports: `Backtest.dump()`, `Live.dump()`.
245
-
246
- ## ๐ŸŒ Global Configuration
247
-
248
- Customize via `setConfig()`:
249
-
250
- - `CC_SCHEDULE_AWAIT_MINUTES`: Pending timeout (default: 120).
251
- - `CC_AVG_PRICE_CANDLES_COUNT`: VWAP candles (default: 5).
252
-
253
- ## ๐Ÿ’ป Developer Note
254
-
255
- Backtest Kit is **not a data-processing library** - it is a **time execution engine**. Think of the engine as an **async stream of time**, where your strategy is evaluated step by step.
256
-
257
- ### ๐Ÿ” How PNL Works
258
-
259
- These three functions work together to dynamically manage the position. To reduce position linearity, by default, each DCA entry is formatted as a fixed **unit of $100**. This can be changed. No mathematical knowledge is required.
260
-
261
- **Public API:**
262
- - **`commitAverageBuy`** โ€” adds a new DCA entry. By default, **only accepted when current price is below a new low**. Silently rejected otherwise. This prevents averaging up. Can be overridden using `setConfig`
263
- - **`commitPartialProfit`** โ€” closes X% of the position at a profit. Locks in gains while keeping exposure.
264
- - **`commitPartialLoss`** โ€” closes X% of the position at a loss. Cuts exposure before the stop-loss is hit.
265
-
266
- <details>
267
- <summary>
268
- The Math
269
- </summary>
270
-
271
- **Scenario:** LONG entry @ 1000, 4 DCA attempts (1 rejected), 3 partials, closed at TP.
272
- `totalInvested = $400` (4 ร— $100, rejected attempt not counted).
273
-
274
- **Entries**
275
- ```
276
- entry#1 @ 1000 โ†’ 0.10000 coins
277
- commitPartialProfit(30%) @ 1150 โ† cnt=1
278
- entry#2 @ 950 โ†’ 0.10526 coins
279
- entry#3 @ 880 โ†’ 0.11364 coins
280
- commitPartialLoss(20%) @ 860 โ† cnt=3
281
- entry#4 @ 920 โ†’ 0.10870 coins
282
- commitPartialProfit(40%) @ 1050 โ† cnt=4
283
- entry#5 @ 980 โœ— REJECTED (980 > ep3โ‰ˆ929.92)
284
- totalInvested = $400
285
- ```
286
-
287
- **Partial#1 โ€” commitPartialProfit @ 1150, 30%, cnt=1**
288
- ```
289
- effectivePrice = hm(1000) = 1000
290
- costBasis = $100
291
- partialDollarValue = 30% ร— 100 = $30 โ†’ weight = 30/400 = 0.075
292
- pnl = (1150โˆ’1000)/1000 ร— 100 = +15.00%
293
- costBasis โ†’ $70
294
- coins sold: 0.03000 ร— 1150 = $34.50
295
- remaining: 0.07000
296
- ```
297
-
298
- **DCA after Partial#1**
299
- ```
300
- entry#2 @ 950 (950 < ep1=1000 โœ“ accepted)
301
- entry#3 @ 880 (880 < ep1=1000 โœ“ accepted)
302
- coins: 0.07000 + 0.10526 + 0.11364 = 0.28890
303
- ```
304
-
305
- **Partial#2 โ€” commitPartialLoss @ 860, 20%, cnt=3**
306
- ```
307
- costBasis = 70 + 100 + 100 = $270
308
- ep2 = 270 / 0.28890 โ‰ˆ 934.58
309
- partialDollarValue = 20% ร— 270 = $54 โ†’ weight = 54/400 = 0.135
310
- pnl = (860โˆ’934.58)/934.58 ร— 100 โ‰ˆ โˆ’7.98%
311
- costBasis โ†’ $216
312
- coins sold: 0.05778 ร— 860 = $49.69
313
- remaining: 0.23112
314
- ```
315
-
316
- **DCA after Partial#2**
317
- ```
318
- entry#4 @ 920 (920 < ep2=934.58 โœ“ accepted)
319
- coins: 0.23112 + 0.10870 = 0.33982
320
- ```
321
-
322
- **Partial#3 โ€” commitPartialProfit @ 1050, 40%, cnt=4**
323
- ```
324
- costBasis = 216 + 100 = $316
325
- ep3 = 316 / 0.33982 โ‰ˆ 929.92
326
- partialDollarValue = 40% ร— 316 = $126.4 โ†’ weight = 126.4/400 = 0.316
327
- pnl = (1050โˆ’929.92)/929.92 ร— 100 โ‰ˆ +12.91%
328
- costBasis โ†’ $189.6
329
- coins sold: 0.13593 ร— 1050 = $142.72
330
- remaining: 0.20389
331
- ```
332
-
333
- **DCA after Partial#3 โ€” rejected**
334
- ```
335
- entry#5 @ 980 (980 > ep3โ‰ˆ929.92 โœ— REJECTED)
336
- ```
337
-
338
- **Close at TP @ 1200**
339
- ```
340
- ep_final = ep3 โ‰ˆ 929.92 (no new entries)
341
- coins: 0.20389
342
-
343
- remainingDollarValue = 400 โˆ’ 30 โˆ’ 54 โˆ’ 126.4 = $189.6
344
- weight = 189.6/400 = 0.474
345
- pnl = (1200โˆ’929.92)/929.92 ร— 100 โ‰ˆ +29.04%
346
- coins sold: 0.20389 ร— 1200 = $244.67
347
- ```
348
-
349
- **Result (toProfitLossDto)**
350
- ```
351
- 0.075 ร— (+15.00) = +1.125
352
- 0.135 ร— (โˆ’7.98) = โˆ’1.077
353
- 0.316 ร— (+12.91) = +4.080
354
- 0.474 ร— (+29.04) = +13.765
355
- โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
356
- โ‰ˆ +17.89%
357
-
358
- Cross-check (coins):
359
- 34.50 + 49.69 + 142.72 + 244.67 = $471.58
360
- (471.58 โˆ’ 400) / 400 ร— 100 = +17.90% โœ“
361
- ```
362
- </details>
363
-
364
- #### Internals
365
-
366
- **`priceOpen`** is the harmonic mean of all accepted DCA entries. After each partial close (`commitPartialProfit` or `commitPartialLoss`), the remaining cost basis is carried forward into the harmonic mean calculation for subsequent entries โ€” so `priceOpen` shifts after every partial, which in turn changes whether the next `commitAverageBuy` call will be accepted.
367
-
368
- ### ๐Ÿ” How Broker Transactional Integrity Works
369
-
370
- `Broker.useBrokerAdapter` connects a live exchange (ccxt, Binance, etc.) to the framework with transaction safety. Every commit method fires **before** the internal position state mutates. If the exchange rejects the order, the fill times out, or the network fails, the adapter throws, the mutation is skipped, and backtest-kit retries automatically on the next tick.
371
-
372
- <details>
373
- <summary>
374
- The code
375
- </summary>
376
-
377
- **Spot**
378
-
379
- ```typescript
380
- import ccxt from "ccxt";
381
- import { singleshot, sleep } from "functools-kit";
382
- import {
383
- Broker,
384
- IBroker,
385
- BrokerSignalOpenPayload,
386
- BrokerSignalClosePayload,
387
- BrokerPartialProfitPayload,
388
- BrokerPartialLossPayload,
389
- BrokerTrailingStopPayload,
390
- BrokerTrailingTakePayload,
391
- BrokerBreakevenPayload,
392
- BrokerAverageBuyPayload,
393
- } from "backtest-kit";
394
-
395
- const FILL_POLL_INTERVAL_MS = 10_000;
396
- const FILL_POLL_ATTEMPTS = 10;
397
-
398
- /**
399
- * Sleep between cancelOrder and fetchBalance to allow Binance to settle the
400
- * cancellation โ€” reads immediately after cancel may return stale data.
401
- */
402
- const CANCEL_SETTLE_MS = 2_000;
403
-
404
- /**
405
- * Slippage buffer for stop_loss_limit on Spot โ€” limit price is set slightly
406
- * below stopPrice so the order fills even on a gap down instead of hanging.
407
- */
408
- const STOP_LIMIT_SLIPPAGE = 0.995;
409
-
410
- const getSpotExchange = singleshot(async () => {
411
- const exchange = new ccxt.binance({
412
- apiKey: process.env.BINANCE_API_KEY,
413
- secret: process.env.BINANCE_API_SECRET,
414
- options: {
415
- defaultType: "spot",
416
- adjustForTimeDifference: true,
417
- recvWindow: 60000,
418
- },
419
- enableRateLimit: true,
420
- });
421
- await exchange.loadMarkets();
422
- return exchange;
423
- });
424
-
425
- /**
426
- * Resolve base currency from market metadata โ€” safe for all quote currencies (USDT, USDC, FDUSD, etc.)
427
- */
428
- function getBase(exchange: ccxt.binance, symbol: string): string {
429
- return exchange.markets[symbol].base;
430
- }
431
-
432
- /**
433
- * Truncate qty to exchange precision, always rounding down.
434
- * Prevents over-selling due to floating point drift from fetchBalance.
435
- */
436
- function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
437
- return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
438
- }
439
-
440
- /**
441
- * Fetch current free balance for base currency of symbol.
442
- */
443
- async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
444
- const balance = await exchange.fetchBalance();
445
- const base = getBase(exchange, symbol);
446
- return parseFloat(String(balance?.free?.[base] ?? 0));
447
- }
448
-
449
- /**
450
- * Cancel all orders in parallel โ€” allSettled so a single failure (already filled,
451
- * network blip) does not leave remaining orders uncancelled.
452
- */
453
- async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
454
- await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
455
- }
456
-
457
- /**
458
- * Place a stop_loss_limit sell order with a slippage buffer on the limit price.
459
- * stop_loss_limit requires both stopPrice (trigger) and price (limit fill).
460
- * Setting them equal risks non-fill on gap down โ€” limit is offset by STOP_LIMIT_SLIPPAGE.
461
- */
462
- async function createStopLossOrder(
463
- exchange: ccxt.binance,
464
- symbol: string,
465
- qty: number,
466
- stopPrice: number
467
- ): Promise<void> {
468
- const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
469
- await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
470
- }
471
-
472
- /**
473
- * Place a limit order and poll until filled (status === "closed").
474
- * On timeout: cancel the order, settle, check partial fill and sell it via market,
475
- * restore SL/TP on remaining position so it is never left unprotected, then throw.
476
- */
477
- async function createLimitOrderAndWait(
478
- exchange: ccxt.binance,
479
- symbol: string,
480
- side: "buy" | "sell",
481
- qty: number,
482
- price: number,
483
- restore?: { tpPrice: number; slPrice: number }
484
- ): Promise<void> {
485
- const order = await exchange.createOrder(symbol, "limit", side, qty, price);
486
-
487
- for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
488
- await sleep(FILL_POLL_INTERVAL_MS);
489
- const status = await exchange.fetchOrder(order.id, symbol);
490
- if (status.status === "closed") {
491
- return;
492
- }
493
- }
494
-
495
- await exchange.cancelOrder(order.id, symbol);
496
-
497
- // Wait for Binance to settle the cancellation before reading filled qty
498
- await sleep(CANCEL_SETTLE_MS);
499
-
500
- const final = await exchange.fetchOrder(order.id, symbol);
501
- const filledQty = final.filled ?? 0;
502
-
503
- if (filledQty > 0) {
504
- // Sell partial fill via market to restore clean exchange state before backtest-kit retries
505
- const rollbackSide = side === "buy" ? "sell" : "buy";
506
- await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
507
- }
508
-
509
- // Restore SL/TP on remaining position so it is not left unprotected during retry
510
- if (restore) {
511
- const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
512
- if (remainingQty > 0) {
513
- await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
514
- await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
515
- }
516
- }
517
-
518
- throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ€” partial fill rolled back, backtest-kit will retry`);
519
- }
520
-
521
- Broker.useBrokerAdapter(
522
- class implements IBroker {
523
-
524
- async waitForInit(): Promise<void> {
525
- await getSpotExchange();
526
- }
527
-
528
- async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
529
- const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
530
-
531
- // Spot does not support short selling โ€” reject immediately so backtest-kit skips the mutation
532
- if (position === "short") {
533
- throw new Error(`SpotBrokerAdapter: short position is not supported on spot (symbol=${symbol})`);
534
- }
535
-
536
- const exchange = await getSpotExchange();
537
-
538
- const qty = truncateQty(exchange, symbol, cost / priceOpen);
539
-
540
- // Guard: truncation may produce 0 if cost/price is below lot size
541
- if (qty <= 0) {
542
- throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${priceOpen}`);
543
- }
544
-
545
- const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
546
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
547
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
548
-
549
- // Entry: no restore needed โ€” position does not exist yet if entry times out
550
- await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
551
-
552
- // Post-fill: if TP/SL placement fails, position is open and unprotected โ€” close via market
553
- try {
554
- await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
555
- await createStopLossOrder(exchange, symbol, qty, slPrice);
556
- } catch (err) {
557
- await exchange.createOrder(symbol, "market", "sell", qty);
558
- throw err;
559
- }
560
- }
561
-
562
- async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
563
- const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
564
- const exchange = await getSpotExchange();
565
-
566
- const openOrders = await exchange.fetchOpenOrders(symbol);
567
- await cancelAllOrders(exchange, openOrders, symbol);
568
- await sleep(CANCEL_SETTLE_MS);
569
-
570
- const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
571
-
572
- // Position already closed by SL/TP on exchange โ€” nothing to do, commit succeeds
573
- if (qty === 0) {
574
- return;
575
- }
576
-
577
- const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
578
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
579
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
580
-
581
- // Restore SL/TP if close times out so position is not left unprotected during retry
582
- await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
583
- }
584
-
585
- async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
586
- const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
587
- const exchange = await getSpotExchange();
588
-
589
- const openOrders = await exchange.fetchOpenOrders(symbol);
590
- await cancelAllOrders(exchange, openOrders, symbol);
591
- await sleep(CANCEL_SETTLE_MS);
592
-
593
- const totalQty = await fetchFreeQty(exchange, symbol);
594
-
595
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
596
- if (totalQty === 0) {
597
- throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
598
- }
599
-
600
- const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
601
- const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
602
- const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
603
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
604
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
605
-
606
- // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
607
- await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
608
-
609
- // Restore SL/TP on remaining qty after successful partial close
610
- if (remainingQty > 0) {
611
- try {
612
- await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
613
- await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
614
- } catch (err) {
615
- // Remaining position is unprotected โ€” close via market
616
- await exchange.createOrder(symbol, "market", "sell", remainingQty);
617
- throw err;
618
- }
619
- }
620
- }
621
-
622
- async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
623
- const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
624
- const exchange = await getSpotExchange();
625
-
626
- const openOrders = await exchange.fetchOpenOrders(symbol);
627
- await cancelAllOrders(exchange, openOrders, symbol);
628
- await sleep(CANCEL_SETTLE_MS);
629
-
630
- const totalQty = await fetchFreeQty(exchange, symbol);
631
-
632
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
633
- if (totalQty === 0) {
634
- throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
635
- }
636
-
637
- const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
638
- const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
639
- const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
640
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
641
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
642
-
643
- // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
644
- await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
645
-
646
- // Restore SL/TP on remaining qty after successful partial close
647
- if (remainingQty > 0) {
648
- try {
649
- await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
650
- await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
651
- } catch (err) {
652
- // Remaining position is unprotected โ€” close via market
653
- await exchange.createOrder(symbol, "market", "sell", remainingQty);
654
- throw err;
655
- }
656
- }
657
- }
658
-
659
- async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
660
- const { symbol, newStopLossPrice } = payload;
661
- const exchange = await getSpotExchange();
662
-
663
- // Cancel existing SL order only โ€” Spot has no reduceOnly, filter by side + type
664
- const orders = await exchange.fetchOpenOrders(symbol);
665
- const slOrder = orders.find((o) =>
666
- o.side === "sell" &&
667
- ["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
668
- ) ?? null;
669
- if (slOrder) {
670
- await exchange.cancelOrder(slOrder.id, symbol);
671
- await sleep(CANCEL_SETTLE_MS);
672
- }
673
-
674
- const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
675
-
676
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
677
- if (qty === 0) {
678
- throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
679
- }
680
-
681
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
682
-
683
- await createStopLossOrder(exchange, symbol, qty, slPrice);
684
- }
685
-
686
- async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
687
- const { symbol, newTakeProfitPrice } = payload;
688
- const exchange = await getSpotExchange();
689
-
690
- // Cancel existing TP order only โ€” Spot has no reduceOnly, filter by side + type
691
- const orders = await exchange.fetchOpenOrders(symbol);
692
- const tpOrder = orders.find((o) =>
693
- o.side === "sell" &&
694
- ["limit", "LIMIT"].includes(o.type ?? "")
695
- ) ?? null;
696
- if (tpOrder) {
697
- await exchange.cancelOrder(tpOrder.id, symbol);
698
- await sleep(CANCEL_SETTLE_MS);
699
- }
700
-
701
- const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
702
-
703
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
704
- if (qty === 0) {
705
- throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
706
- }
707
-
708
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
709
-
710
- await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
711
- }
712
-
713
- async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
714
- const { symbol, newStopLossPrice } = payload;
715
- const exchange = await getSpotExchange();
716
-
717
- // Cancel existing SL order only โ€” Spot has no reduceOnly, filter by side + type
718
- const orders = await exchange.fetchOpenOrders(symbol);
719
- const slOrder = orders.find((o) =>
720
- o.side === "sell" &&
721
- ["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
722
- ) ?? null;
723
- if (slOrder) {
724
- await exchange.cancelOrder(slOrder.id, symbol);
725
- await sleep(CANCEL_SETTLE_MS);
726
- }
727
-
728
- const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
729
-
730
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
731
- if (qty === 0) {
732
- throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
733
- }
734
-
735
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
736
-
737
- await createStopLossOrder(exchange, symbol, qty, slPrice);
738
- }
739
-
740
- async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
741
- const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
742
- const exchange = await getSpotExchange();
743
-
744
- // Cancel existing SL/TP first โ€” existing check must happen after cancel+settle
745
- // to avoid race condition where SL/TP fills between the existence check and cancel
746
- const openOrders = await exchange.fetchOpenOrders(symbol);
747
- await cancelAllOrders(exchange, openOrders, symbol);
748
- await sleep(CANCEL_SETTLE_MS);
749
-
750
- // Guard against DCA into a ghost position โ€” checked after cancel so the snapshot is fresh
751
- const existing = await fetchFreeQty(exchange, symbol);
752
- const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
753
-
754
- // Compare notional value rather than raw qty โ€” avoids float === 0 trap
755
- // and correctly rejects dust balances left over from previous trades
756
- if (existing * currentPrice < minNotional) {
757
- throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
758
- }
759
-
760
- const qty = truncateQty(exchange, symbol, cost / currentPrice);
761
-
762
- // Guard: truncation may produce 0 if cost/price is below lot size
763
- if (qty <= 0) {
764
- throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${currentPrice}`);
765
- }
766
-
767
- const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
768
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
769
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
770
-
771
- // DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
772
- await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
773
-
774
- // Refetch balance after fill โ€” existing snapshot is stale after cancel + fill
775
- const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
776
-
777
- // Recreate SL/TP on fresh total qty after successful fill
778
- try {
779
- await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
780
- await createStopLossOrder(exchange, symbol, totalQty, slPrice);
781
- } catch (err) {
782
- // Total position is unprotected โ€” close via market
783
- await exchange.createOrder(symbol, "market", "sell", totalQty);
784
- throw err;
785
- }
786
- }
787
- }
788
- );
789
-
790
- Broker.enable();
791
- ```
792
-
793
- **Futures**
794
-
795
- ```typescript
796
- import ccxt from "ccxt";
797
- import { singleshot, sleep } from "functools-kit";
798
- import {
799
- Broker,
800
- IBroker,
801
- BrokerSignalOpenPayload,
802
- BrokerSignalClosePayload,
803
- BrokerPartialProfitPayload,
804
- BrokerPartialLossPayload,
805
- BrokerTrailingStopPayload,
806
- BrokerTrailingTakePayload,
807
- BrokerBreakevenPayload,
808
- BrokerAverageBuyPayload,
809
- } from "backtest-kit";
810
-
811
- const FILL_POLL_INTERVAL_MS = 10_000;
812
- const FILL_POLL_ATTEMPTS = 10;
813
-
814
- /**
815
- * Sleep between cancelOrder and fetchPositions to allow Binance to settle the
816
- * cancellation โ€” reads immediately after cancel may return stale data.
817
- */
818
- const CANCEL_SETTLE_MS = 2_000;
819
-
820
- /**
821
- * 3x leverage โ€” conservative choice for $1000 total fiat.
822
- * Enough to matter, not enough to liquidate on normal volatility.
823
- * Applied per-symbol on first open via setLeverage.
824
- */
825
- const FUTURES_LEVERAGE = 3;
826
-
827
- const getFuturesExchange = singleshot(async () => {
828
- const exchange = new ccxt.binance({
829
- apiKey: process.env.BINANCE_API_KEY,
830
- secret: process.env.BINANCE_API_SECRET,
831
- options: {
832
- defaultType: "future",
833
- adjustForTimeDifference: true,
834
- recvWindow: 60000,
835
- },
836
- enableRateLimit: true,
837
- });
838
- await exchange.loadMarkets();
839
- return exchange;
840
- });
841
-
842
- /**
843
- * Truncate qty to exchange precision, always rounding down.
844
- * Prevents over-selling due to floating point drift from fetchPositions.
845
- */
846
- function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
847
- return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
848
- }
849
-
850
- /**
851
- * Resolve position for symbol filtered by side โ€” safe in both one-way and hedge mode.
852
- */
853
- function findPosition(positions: ccxt.Position[], symbol: string, side: "long" | "short") {
854
- // Hedge mode: positions have explicit side field
855
- const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
856
- if (hedged) {
857
- return hedged;
858
- }
859
- // One-way mode: single position per symbol, side field may be undefined or mismatched
860
- const pos = positions.find((p) => p.symbol === symbol) ?? null;
861
- if (pos && pos.side && pos.side !== side) {
862
- console.warn(`findPosition: expected side="${side}" but exchange returned side="${pos.side}" for ${symbol} โ€” possible one-way/hedge mode mismatch`);
863
- }
864
- return pos;
865
- }
866
-
867
- /**
868
- * Fetch current contracts qty for symbol/side.
869
- */
870
- async function fetchContractsQty(
871
- exchange: ccxt.binance,
872
- symbol: string,
873
- side: "long" | "short"
874
- ): Promise<number> {
875
- const positions = await exchange.fetchPositions([symbol]);
876
- const pos = findPosition(positions, symbol, side);
877
- return Math.abs(parseFloat(String(pos?.contracts ?? 0)));
878
- }
879
-
880
- /**
881
- * Cancel all orders in parallel โ€” allSettled so a single failure (already filled,
882
- * network blip) does not leave remaining orders uncancelled.
883
- */
884
- async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
885
- await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
886
- }
887
-
888
- /**
889
- * Resolve Binance positionSide string from position direction.
890
- * Required in hedge mode to correctly route orders; ignored in one-way mode.
891
- */
892
- function toPositionSide(position: "long" | "short"): "LONG" | "SHORT" {
893
- return position === "long" ? "LONG" : "SHORT";
894
- }
895
-
896
- /**
897
- * Place a limit order and poll until filled (status === "closed").
898
- * On timeout: cancel the order, settle, check partial fill and close it via market,
899
- * restore SL/TP on remaining position so it is never left unprotected, then throw.
900
- *
901
- * positionSide is forwarded into rollback market order so hedge mode accounts
902
- * correctly route the close without -4061 error.
903
- */
904
- async function createLimitOrderAndWait(
905
- exchange: ccxt.binance,
906
- symbol: string,
907
- side: "buy" | "sell",
908
- qty: number,
909
- price: number,
910
- params: Record<string, unknown> = {},
911
- restore?: { exitSide: "buy" | "sell"; tpPrice: number; slPrice: number; positionSide: "long" | "short" }
912
- ): Promise<void> {
913
- const order = await exchange.createOrder(symbol, "limit", side, qty, price, params);
914
-
915
- for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
916
- await sleep(FILL_POLL_INTERVAL_MS);
917
- const status = await exchange.fetchOrder(order.id, symbol);
918
- if (status.status === "closed") {
919
- return;
920
- }
921
- }
922
-
923
- await exchange.cancelOrder(order.id, symbol);
924
-
925
- // Wait for Binance to settle the cancellation before reading filled qty
926
- await sleep(CANCEL_SETTLE_MS);
927
-
928
- const final = await exchange.fetchOrder(order.id, symbol);
929
- const filledQty = final.filled ?? 0;
930
-
931
- if (filledQty > 0) {
932
- // Close partial fill via market โ€” positionSide required in hedge mode (-4061 without it)
933
- const rollbackSide = side === "buy" ? "sell" : "buy";
934
- const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
935
- await exchange.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
936
- reduceOnly: true,
937
- ...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
938
- });
939
- }
940
-
941
- // Restore SL/TP on remaining position so it is not left unprotected during retry
942
- if (restore) {
943
- const remainingQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, restore.positionSide));
944
- if (remainingQty > 0) {
945
- await exchange.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
946
- await exchange.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
947
- }
948
- }
949
-
950
- throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ€” partial fill rolled back, backtest-kit will retry`);
951
- }
952
-
953
- Broker.useBrokerAdapter(
954
- class implements IBroker {
955
-
956
- async waitForInit(): Promise<void> {
957
- await getFuturesExchange();
958
- }
959
-
960
- async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
961
- const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
962
- const exchange = await getFuturesExchange();
963
-
964
- // Set leverage before entry โ€” ensures consistent leverage regardless of previous session state
965
- await exchange.setLeverage(FUTURES_LEVERAGE, symbol);
966
-
967
- const qty = truncateQty(exchange, symbol, cost / priceOpen);
968
-
969
- // Guard: truncation may produce 0 if cost/price is below lot size
970
- if (qty <= 0) {
971
- throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${priceOpen}`);
972
- }
973
-
974
- const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
975
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
976
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
977
- const entrySide = position === "long" ? "buy" : "sell";
978
- const exitSide = position === "long" ? "sell" : "buy";
979
- // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
980
- const positionSide = toPositionSide(position);
981
-
982
- // Entry: no restore needed โ€” position does not exist yet if entry times out
983
- await createLimitOrderAndWait(exchange, symbol, entrySide, qty, openPrice, { positionSide });
984
-
985
- // Post-fill: if TP/SL placement fails, position is open and unprotected โ€” close via market
986
- try {
987
- await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
988
- await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
989
- } catch (err) {
990
- await exchange.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
991
- throw err;
992
- }
993
- }
994
-
995
- async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
996
- const { symbol, position, currentPrice, priceTakeProfit, priceStopLoss } = payload;
997
- const exchange = await getFuturesExchange();
998
-
999
- const openOrders = await exchange.fetchOpenOrders(symbol);
1000
- await cancelAllOrders(exchange, openOrders, symbol);
1001
- await sleep(CANCEL_SETTLE_MS);
1002
-
1003
- const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1004
- const exitSide = position === "long" ? "sell" : "buy";
1005
-
1006
- // Position already closed by SL/TP on exchange โ€” throw so backtest-kit can reconcile
1007
- // the close price via its own mechanism rather than assuming a successful manual close
1008
- if (qty === 0) {
1009
- throw new Error(`SignalClose skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1010
- }
1011
-
1012
- const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1013
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1014
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1015
-
1016
- // reduceOnly: prevents accidental reversal if qty has drift vs real position
1017
- // Restore SL/TP if close times out so position is not left unprotected during retry
1018
- await createLimitOrderAndWait(
1019
- exchange, symbol, exitSide, qty, closePrice,
1020
- { reduceOnly: true },
1021
- { exitSide, tpPrice, slPrice, positionSide: position }
1022
- );
1023
- }
1024
-
1025
- async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
1026
- const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
1027
- const exchange = await getFuturesExchange();
1028
-
1029
- const openOrders = await exchange.fetchOpenOrders(symbol);
1030
- await cancelAllOrders(exchange, openOrders, symbol);
1031
- await sleep(CANCEL_SETTLE_MS);
1032
-
1033
- const totalQty = await fetchContractsQty(exchange, symbol, position);
1034
-
1035
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1036
- if (totalQty === 0) {
1037
- throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1038
- }
1039
-
1040
- const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
1041
- const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
1042
- const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1043
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1044
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1045
- const exitSide = position === "long" ? "sell" : "buy";
1046
- const positionSide = toPositionSide(position);
1047
-
1048
- // reduceOnly: prevents accidental reversal if qty has drift vs real position
1049
- // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
1050
- await createLimitOrderAndWait(
1051
- exchange, symbol, exitSide, qty, closePrice,
1052
- { reduceOnly: true },
1053
- { exitSide, tpPrice, slPrice, positionSide: position }
1054
- );
1055
-
1056
- // Restore SL/TP on remaining qty after successful partial close
1057
- if (remainingQty > 0) {
1058
- try {
1059
- await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
1060
- await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1061
- } catch (err) {
1062
- // Remaining position is unprotected โ€” close via market
1063
- await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
1064
- throw err;
1065
- }
1066
- }
1067
- }
1068
-
1069
- async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
1070
- const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
1071
- const exchange = await getFuturesExchange();
1072
-
1073
- const openOrders = await exchange.fetchOpenOrders(symbol);
1074
- await cancelAllOrders(exchange, openOrders, symbol);
1075
- await sleep(CANCEL_SETTLE_MS);
1076
-
1077
- const totalQty = await fetchContractsQty(exchange, symbol, position);
1078
-
1079
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1080
- if (totalQty === 0) {
1081
- throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1082
- }
1083
-
1084
- const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
1085
- const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
1086
- const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1087
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1088
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1089
- const exitSide = position === "long" ? "sell" : "buy";
1090
- const positionSide = toPositionSide(position);
1091
-
1092
- // reduceOnly: prevents accidental reversal if qty has drift vs real position
1093
- // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
1094
- await createLimitOrderAndWait(
1095
- exchange, symbol, exitSide, qty, closePrice,
1096
- { reduceOnly: true },
1097
- { exitSide, tpPrice, slPrice, positionSide: position }
1098
- );
1099
-
1100
- // Restore SL/TP on remaining qty after successful partial close
1101
- if (remainingQty > 0) {
1102
- try {
1103
- await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
1104
- await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1105
- } catch (err) {
1106
- // Remaining position is unprotected โ€” close via market
1107
- await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
1108
- throw err;
1109
- }
1110
- }
1111
- }
1112
-
1113
- async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
1114
- const { symbol, newStopLossPrice, position } = payload;
1115
- const exchange = await getFuturesExchange();
1116
-
1117
- // Cancel existing SL order only โ€” filter by reduceOnly to avoid cancelling unrelated orders
1118
- const orders = await exchange.fetchOpenOrders(symbol);
1119
- const slOrder = orders.find((o) =>
1120
- !!o.reduceOnly &&
1121
- ["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
1122
- ) ?? null;
1123
- if (slOrder) {
1124
- await exchange.cancelOrder(slOrder.id, symbol);
1125
- await sleep(CANCEL_SETTLE_MS);
1126
- }
1127
-
1128
- const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1129
- const exitSide = position === "long" ? "sell" : "buy";
1130
-
1131
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1132
- if (qty === 0) {
1133
- throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1134
- }
1135
-
1136
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
1137
- const positionSide = toPositionSide(position);
1138
-
1139
- // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
1140
- await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1141
- }
1142
-
1143
- async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
1144
- const { symbol, newTakeProfitPrice, position } = payload;
1145
- const exchange = await getFuturesExchange();
1146
-
1147
- // Cancel existing TP order only โ€” filter by reduceOnly to avoid cancelling unrelated orders
1148
- const orders = await exchange.fetchOpenOrders(symbol);
1149
- const tpOrder = orders.find((o) =>
1150
- !!o.reduceOnly &&
1151
- ["limit", "LIMIT"].includes(o.type ?? "")
1152
- ) ?? null;
1153
- if (tpOrder) {
1154
- await exchange.cancelOrder(tpOrder.id, symbol);
1155
- await sleep(CANCEL_SETTLE_MS);
1156
- }
1157
-
1158
- const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1159
- const exitSide = position === "long" ? "sell" : "buy";
1160
-
1161
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1162
- if (qty === 0) {
1163
- throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1164
- }
1165
-
1166
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
1167
- const positionSide = toPositionSide(position);
1168
-
1169
- // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
1170
- await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
1171
- }
1172
-
1173
- async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
1174
- const { symbol, newStopLossPrice, position } = payload;
1175
- const exchange = await getFuturesExchange();
1176
-
1177
- // Cancel existing SL order only โ€” filter by reduceOnly to avoid cancelling unrelated orders
1178
- const orders = await exchange.fetchOpenOrders(symbol);
1179
- const slOrder = orders.find((o) =>
1180
- !!o.reduceOnly &&
1181
- ["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
1182
- ) ?? null;
1183
- if (slOrder) {
1184
- await exchange.cancelOrder(slOrder.id, symbol);
1185
- await sleep(CANCEL_SETTLE_MS);
1186
- }
1187
-
1188
- const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1189
- const exitSide = position === "long" ? "sell" : "buy";
1190
-
1191
- // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1192
- if (qty === 0) {
1193
- throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1194
- }
1195
-
1196
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
1197
- const positionSide = toPositionSide(position);
1198
-
1199
- // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
1200
- await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1201
- }
1202
-
1203
- async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
1204
- const { symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss } = payload;
1205
- const exchange = await getFuturesExchange();
1206
-
1207
- // Cancel existing SL/TP first โ€” existing check must happen after cancel+settle
1208
- // to avoid race condition where SL/TP fills between the existence check and cancel
1209
- const openOrders = await exchange.fetchOpenOrders(symbol);
1210
- await cancelAllOrders(exchange, openOrders, symbol);
1211
- await sleep(CANCEL_SETTLE_MS);
1212
-
1213
- // Guard against DCA into a ghost position โ€” checked after cancel so the snapshot is fresh
1214
- const existing = await fetchContractsQty(exchange, symbol, position);
1215
- const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
1216
-
1217
- // Compare notional value rather than raw contracts โ€” avoids float === 0 trap
1218
- // and correctly rejects dust positions left over from previous trades
1219
- if (existing * currentPrice < minNotional) {
1220
- throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1221
- }
1222
-
1223
- const qty = truncateQty(exchange, symbol, cost / currentPrice);
1224
-
1225
- // Guard: truncation may produce 0 if cost/price is below lot size
1226
- if (qty <= 0) {
1227
- throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${currentPrice}`);
1228
- }
1229
-
1230
- const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1231
- const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1232
- const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1233
- // positionSide required in hedge mode to add to correct side; ignored in one-way mode
1234
- const positionSide = toPositionSide(position);
1235
- const entrySide = position === "long" ? "buy" : "sell";
1236
- const exitSide = position === "long" ? "sell" : "buy";
1237
-
1238
- // DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
1239
- await createLimitOrderAndWait(
1240
- exchange, symbol, entrySide, qty, entryPrice,
1241
- { positionSide },
1242
- { exitSide, tpPrice, slPrice, positionSide: position }
1243
- );
1244
-
1245
- // Refetch contracts after fill โ€” existing snapshot is stale after cancel + fill
1246
- const totalQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1247
-
1248
- // Recreate SL/TP on fresh total qty after successful fill
1249
- try {
1250
- await exchange.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
1251
- await exchange.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1252
- } catch (err) {
1253
- // Total position is unprotected โ€” close via market
1254
- await exchange.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
1255
- throw err;
1256
- }
1257
- }
1258
- }
1259
- );
1260
-
1261
- Broker.enable();
1262
- ```
1263
-
1264
- </details>
1265
-
1266
- #### Internals
1267
-
1268
- Signal open/close events are routed automatically via an internal event bus once `Broker.enable()` is called. **No manual wiring needed.** All other operations (`partialProfit`, `trailingStop`, `breakeven`, `averageBuy`) are intercepted explicitly before the corresponding state mutation.
1269
-
1270
- ### ๐Ÿ” How getCandles Works
1271
-
1272
- backtest-kit uses Node.js `AsyncLocalStorage` to automatically provide
1273
- temporal time context to your strategies.
1274
-
1275
- <details>
1276
- <summary>
1277
- The Math
1278
- </summary>
1279
-
1280
- For a candle with:
1281
- - `timestamp` = candle open time (openTime)
1282
- - `stepMs` = interval duration (e.g., 60000ms for "1m")
1283
- - Candle close time = `timestamp + stepMs`
1284
-
1285
- **Alignment:** All timestamps are aligned down to interval boundary.
1286
- For example, for 15m interval: 00:17 โ†’ 00:15, 00:44 โ†’ 00:30
1287
-
1288
- **Adapter contract:**
1289
- - First candle.timestamp must equal aligned `since`
1290
- - Adapter must return exactly `limit` candles
1291
- - Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
1292
-
1293
- **How `since` is calculated from `when`:**
1294
- - `when` = current execution context time (from AsyncLocalStorage)
1295
- - `alignedWhen` = `Math.floor(when / stepMs) * stepMs` (aligned down to interval boundary)
1296
- - `since` = `alignedWhen - limit * stepMs` (go back `limit` candles from aligned when)
1297
-
1298
- **Boundary semantics (inclusive/exclusive):**
1299
- - `since` is always **inclusive** โ€” first candle has `timestamp === since`
1300
- - Exactly `limit` candles are returned
1301
- - Last candle has `timestamp === since + (limit - 1) * stepMs` โ€” **inclusive**
1302
- - For `getCandles`: `alignedWhen` is **exclusive** โ€” candle at that timestamp is NOT included (it's a pending/incomplete candle)
1303
- - For `getRawCandles`: `eDate` is **exclusive** โ€” candle at that timestamp is NOT included (it's a pending/incomplete candle)
1304
- - For `getNextCandles`: `alignedWhen` is **inclusive** โ€” first candle starts at `alignedWhen` (it's the current candle for backtest, already closed in historical data)
1305
-
1306
- - `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
1307
- - Aligns `when` down to interval boundary
1308
- - Calculates `since = alignedWhen - limit * stepMs`
1309
- - **since โ€” inclusive**, first candle.timestamp === since
1310
- - **alignedWhen โ€” exclusive**, candle at alignedWhen is NOT returned
1311
- - Range: `[since, alignedWhen)` โ€” half-open interval
1312
- - Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before aligned when
1313
-
1314
- - `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
1315
- - Aligns `when` down to interval boundary
1316
- - `since = alignedWhen` (starts from aligned when, going forward)
1317
- - **since โ€” inclusive**, first candle.timestamp === since
1318
- - Range: `[alignedWhen, alignedWhen + limit * stepMs)` โ€” half-open interval
1319
- - Throws error in live mode to prevent look-ahead bias
1320
- - Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles starting from aligned when
1321
-
1322
- - `getRawCandles(symbol, interval, limit?, sDate?, eDate?)` - Flexible parameter combinations:
1323
- - `(limit)` - since = alignedWhen - limit * stepMs, range `[since, alignedWhen)`
1324
- - `(limit, sDate)` - since = align(sDate), returns `limit` candles forward, range `[since, since + limit * stepMs)`
1325
- - `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs, **eDate โ€” exclusive**, range `[since, eDate)`
1326
- - `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range, **sDate โ€” inclusive, eDate โ€” exclusive**, range `[sDate, eDate)`
1327
- - `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles, **sDate โ€” inclusive**
1328
- - All combinations respect look-ahead bias protection (eDate/endTime <= when)
1329
-
1330
- **Persistent Cache:**
1331
- - Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
1332
- - Returns all candles if found, null if any missing (cache miss)
1333
- - Cache and runtime use identical timestamp calculation logic
1334
-
1335
- </details>
1336
-
1337
- #### Candle Timestamp Convention:
1338
-
1339
- According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
1340
-
1341
- **Key principles:**
1342
- - All timestamps are aligned down to interval boundary
1343
- - First candle.timestamp must equal aligned `since`
1344
- - Adapter must return exactly `limit` candles
1345
- - Sequential timestamps: `since + i * stepMs`
1346
-
1347
-
1348
- ### ๐Ÿ” How getOrderBook Works
1349
-
1350
- Order book fetching uses the same temporal alignment as candles, but with a configurable time offset window instead of candle intervals.
1351
-
1352
- <details>
1353
- <summary>
1354
- The Math
1355
- </summary>
1356
-
1357
- **Time range calculation:**
1358
- - `when` = current execution context time (from AsyncLocalStorage)
1359
- - `offsetMinutes` = `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` (configurable)
1360
- - `alignedTo` = `Math.floor(when / (offsetMinutes * 60000)) * (offsetMinutes * 60000)`
1361
- - `to` = `alignedTo` (aligned down to offset boundary)
1362
- - `from` = `alignedTo - offsetMinutes * 60000`
1363
-
1364
- **Adapter contract:**
1365
- - `getOrderBook(symbol, depth, from, to, backtest)` is called on the exchange schema
1366
- - `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
1367
- - The `from`/`to` range represents a time window of exactly `offsetMinutes` duration
1368
- - Schema implementation may use the time range (backtest) or ignore it (live trading)
1369
-
1370
- **Example with CC_ORDER_BOOK_TIME_OFFSET_MINUTES = 10:**
1371
- ```
1372
- when = 1704067920000 // 2024-01-01 00:12:00 UTC
1373
- offsetMinutes = 10
1374
- offsetMs = 10 * 60000 // 600000ms
1375
-
1376
- alignedTo = Math.floor(1704067920000 / 600000) * 600000
1377
- = 1704067800000 // 2024-01-01 00:10:00 UTC
1378
-
1379
- to = 1704067800000 // 00:10:00 UTC
1380
- from = 1704067200000 // 00:00:00 UTC
1381
- ```
1382
- </details>
1383
-
1384
- #### Order Book Timestamp Convention:
1385
-
1386
- Unlike candles, most exchanges (e.g. Binance `GET /api/v3/depth`) only expose the **current** order book with no historical query support โ€” for backtest you must provide your own snapshot storage.
1387
-
1388
- **Key principles:**
1389
- - Time range is aligned down to `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` boundary
1390
- - `to` = aligned timestamp, `from` = `to - offsetMinutes * 60000`
1391
- - `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
1392
- - Adapter receives `(symbol, depth, from, to, backtest)` โ€” may ignore `from`/`to` in live mode
1393
-
1394
- ### ๐Ÿ” How getAggregatedTrades Works
1395
-
1396
- Aggregated trades fetching uses the same look-ahead bias protection as candles - `to` is always aligned down to the nearest minute boundary so future trades are never visible to the strategy.
1397
-
1398
- **Key principles:**
1399
- - `to` is always aligned down to the 1-minute boundary โ€” prevents look-ahead bias
1400
- - Without `limit`: returns one full window (`CC_AGGREGATED_TRADES_MAX_MINUTES`)
1401
- - With `limit`: paginates backwards until collected, then slices to most recent `limit`
1402
- - Adapter receives `(symbol, from, to, backtest)` โ€” may ignore `from`/`to` in live mode
1403
-
1404
- <details>
1405
- <summary>
1406
- The Math
1407
- </summary>
1408
-
1409
- **Time range calculation:**
1410
- - `when` = current execution context time (from AsyncLocalStorage)
1411
- - `alignedTo` = `Math.floor(when / 60000) * 60000` (aligned down to 1-minute boundary)
1412
- - `windowMs` = `CC_AGGREGATED_TRADES_MAX_MINUTES * 60000 โˆ’ 60000`
1413
- - `to` = `alignedTo`, `from` = `alignedTo โˆ’ windowMs`
1414
-
1415
- **Without `limit`:** fetches a single window and returns it as-is.
1416
-
1417
- **With `limit`:** paginates backwards in `CC_AGGREGATED_TRADES_MAX_MINUTES` chunks until at least `limit` trades are collected, then slices to the most recent `limit` trades.
1418
-
1419
- **Example with CC_AGGREGATED_TRADES_MAX_MINUTES = 60, limit = 200:**
1420
- ```
1421
- when = 1704067920000 // 2024-01-01 00:12:00 UTC
1422
- alignedTo = 1704067800000 // 2024-01-01 00:12:00 โ†’ aligned to 00:12:00
1423
- windowMs = 59 * 60000 // 3540000ms = 59 minutes
1424
-
1425
- Window 1: from = 00:12:00 โˆ’ 59m = 23:13:00
1426
- to = 00:12:00
1427
- โ†’ got 120 trades โ€” not enough
1428
-
1429
- Window 2: from = 23:13:00 โˆ’ 59m = 22:14:00
1430
- to = 23:13:00
1431
- โ†’ got 100 more โ†’ total 220 trades
1432
-
1433
- result = last 200 of 220 (most recent)
1434
- ```
1435
-
1436
- **Adapter contract:**
1437
- - `getAggregatedTrades(symbol, from, to, backtest)` is called on the exchange schema
1438
- - `from`/`to` are `Date` objects
1439
- - Schema implementation may use the time range (backtest) or ignore it (live trading)
1440
-
1441
- </details>
1442
-
1443
- #### Aggregated Trades Timestamp Convention:
1444
-
1445
- **Compatible with:** [garch](https://www.npmjs.com/package/garch) for volatility modelling and [volume-anomaly](https://www.npmjs.com/package/volume-anomaly) for detecting abnormal trade volume โ€” both accept the same `from`/`to` time range format that `getAggregatedTrades` produces.
1446
-
1447
- ### ๐Ÿ”ฌ Technical Details: Timestamp Alignment
1448
-
1449
- **Why align timestamps to interval boundaries?**
1450
-
1451
- Because candle APIs return data starting from exact interval boundaries:
1452
-
1453
- ```typescript
1454
- // 15-minute interval example:
1455
- when = 1704067920000 // 00:12:00
1456
- step = 15 // 15 minutes
1457
- stepMs = 15 * 60000 // 900000ms
1458
-
1459
- // Alignment: round down to nearest interval boundary
1460
- alignedWhen = Math.floor(when / stepMs) * stepMs
1461
- // = Math.floor(1704067920000 / 900000) * 900000
1462
- // = 1704067200000 (00:00:00)
1463
-
1464
- // Calculate since for 4 candles backwards:
1465
- since = alignedWhen - 4 * stepMs
1466
- // = 1704067200000 - 4 * 900000
1467
- // = 1704063600000 (23:00:00 previous day)
1468
-
1469
- // Expected candles:
1470
- // [0] timestamp = 1704063600000 (23:00)
1471
- // [1] timestamp = 1704064500000 (23:15)
1472
- // [2] timestamp = 1704065400000 (23:30)
1473
- // [3] timestamp = 1704066300000 (23:45)
1474
- ```
1475
-
1476
- **Pending candle exclusion:** The candle at `00:00:00` (alignedWhen) is NOT included in the result. At `when=00:12:00`, this candle covers the period `[00:00, 00:15)` and is still open (pending). Pending candles have incomplete OHLCV data that would distort technical indicators. Only fully closed candles are returned.
1477
-
1478
- **Validation is applied consistently across:**
1479
- - โœ… `getCandles()` - validates first timestamp and count
1480
- - โœ… `getNextCandles()` - validates first timestamp and count
1481
- - โœ… `getRawCandles()` - validates first timestamp and count
1482
- - โœ… Cache read - calculates exact expected timestamps
1483
- - โœ… Cache write - stores validated candles
1484
-
1485
- **Result:** Deterministic candle retrieval with exact timestamp matching.
1486
-
1487
- ### ๐Ÿ• Timezone Warning: Candle Boundaries Are UTC-Based
1488
-
1489
- All candle timestamp alignment uses UTC (Unix epoch). For intervals like `4h`, boundaries are `00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC`. If your local timezone offset is not a multiple of the interval, the `since` timestamps will look "uneven" in local time.
1490
-
1491
- For example, in UTC+5 the same 4h candle request logs as:
1492
-
1493
- ```
1494
- since: Sat Sep 20 2025 13:00:00 GMT+0500 โ† looks uneven (13:00)
1495
- since: Sat Sep 20 2025 17:00:00 GMT+0500 โ† looks uneven (17:00)
1496
- since: Sat Sep 20 2025 21:00:00 GMT+0500 โ† looks uneven (21:00)
1497
- since: Sun Sep 21 2025 05:00:00 GMT+0500 โ† looks uneven (05:00)
1498
- ```
1499
-
1500
- But in UTC these are perfectly aligned 4h boundaries:
1501
-
1502
- ```
1503
- since: Sat, 20 Sep 2025 08:00:00 GMT โ† 08:00 UTC โœ“
1504
- since: Sat, 20 Sep 2025 12:00:00 GMT โ† 12:00 UTC โœ“
1505
- since: Sat, 20 Sep 2025 16:00:00 GMT โ† 16:00 UTC โœ“
1506
- since: Sun, 21 Sep 2025 00:00:00 GMT โ† 00:00 UTC โœ“
1507
- ```
1508
-
1509
- Use `toUTCString()` or `toISOString()` in callbacks to see the actual aligned UTC times.
1510
-
1511
- ### ๐Ÿ’ญ What this means:
1512
- - `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`
1513
- - Multi-timeframe data is automatically synchronized
1514
- - **Impossible to introduce look-ahead bias** - all time boundaries are enforced
1515
- - Same code works in both backtest and live modes
1516
- - Boundary semantics prevent edge cases in signal generation
1517
-
1518
-
1519
- ## ๐Ÿง  Two Ways to Run the Engine
1520
-
1521
- Backtest Kit exposes the same runtime in two equivalent forms. Both approaches use **the same engine and guarantees** - only the consumption model differs.
1522
-
1523
- ### 1๏ธโƒฃ Event-driven (background execution)
1524
-
1525
- Suitable for production bots, monitoring, and long-running processes.
1526
-
1527
- ```typescript
1528
- Backtest.background('BTCUSDT', config);
1529
-
1530
- listenSignalBacktest(event => { /* handle signals */ });
1531
- listenDoneBacktest(event => { /* finalize / dump report */ });
1532
- ```
1533
-
1534
- ### 2๏ธโƒฃ Async Iterator (pull-based execution)
1535
-
1536
- Suitable for research, scripting, testing, and LLM agents.
1537
-
1538
- ```typescript
1539
- for await (const event of Backtest.run('BTCUSDT', config)) {
1540
- // signal | trade | progress | done
1541
- }
1542
- ```
1543
-
1544
- ## โš”๏ธ Think of it as...
1545
-
1546
- **Open-source QuantConnect/MetaTrader without the vendor lock-in**
1547
-
1548
- Unlike cloud-based platforms, backtest-kit runs entirely in your environment. You own the entire stack from data ingestion to live execution. In addition to Ollama, you can use [neural-trader](https://www.npmjs.com/package/neural-trader) in `getSignal` function or any other third party library
1549
-
1550
- - No C#/C++ required - pure TypeScript/JavaScript
1551
- - Self-hosted - your code, your data, your infrastructure
1552
- - No platform fees or hidden costs
1553
- - Full control over execution and data sources
1554
- - [GUI](https://npmjs.com/package/@backtest-kit/ui) for visualization and monitoring
1555
-
1556
- ## ๐ŸŒ Ecosystem
1557
-
1558
- The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
1559
-
1560
-
1561
- ### @backtest-kit/cli
1562
-
1563
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** ๐Ÿ“Ÿ
1564
-
1565
- The **@backtest-kit/cli** package is a zero-boilerplate CLI runner for backtest-kit strategies. Point it at your strategy file and run backtests, paper trading, or live bots โ€” no infrastructure code required.
1566
-
1567
- #### Key Features
1568
- - ๐Ÿš€ **Zero Config**: Run a backtest with one command โ€” no setup code needed
1569
- - ๐Ÿ”„ **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
1570
- - ๐Ÿ’พ **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
1571
- - ๐ŸŒ **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
1572
- - ๐Ÿ“ฌ **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
1573
- - ๐Ÿ—‚๏ธ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
1574
-
1575
- #### Use Case
1576
- The fastest way to run any backtest-kit strategy from the command line. Instead of writing boilerplate for storage, notifications, candle caching, and signal logging, add one dependency and wire up your `package.json` scripts. Works equally well for a single-strategy project or a monorepo with dozens of strategies in separate subdirectories.
1577
-
1578
- #### Get Started
1579
- ```bash
1580
- npx -y @backtest-kit/cli --init
1581
- ```
1582
-
1583
-
1584
- ### @backtest-kit/pinets
1585
-
1586
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** ๐Ÿ“œ
1587
-
1588
- The **@backtest-kit/pinets** package lets you run TradingView Pine Script strategies directly in Node.js. Port your existing Pine Script indicators to backtest-kit with zero rewrite using the [PineTS](https://github.com/QuantForgeOrg/PineTS) runtime.
1589
-
1590
- #### Key Features
1591
- - ๐Ÿ“œ **Pine Script v5/v6**: Native TradingView syntax with 1:1 compatibility
1592
- - ๐ŸŽฏ **60+ Indicators**: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Stochastic built-in
1593
- - ๐Ÿ“ **File or Code**: Load `.pine` files or pass code strings directly
1594
- - ๐Ÿ—บ๏ธ **Plot Extraction**: Flexible mapping from Pine `plot()` outputs to structured signals
1595
- - โšก **Cached Execution**: Memoized file reads for repeated strategy runs
1596
-
1597
- #### Use Case
1598
- Perfect for traders who already have working TradingView strategies. Instead of rewriting your Pine Script logic in JavaScript, simply copy your `.pine` file and use `getSignal()` to extract trading signals. Works seamlessly with backtest-kit's temporal context - no look-ahead bias possible.
1599
-
1600
- #### Get Started
1601
- ```bash
1602
- npm install @backtest-kit/pinets pinets backtest-kit
1603
- ```
1604
-
1605
-
1606
- ### @backtest-kit/graph
1607
-
1608
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** ๐Ÿ”—
1609
-
1610
- The **@backtest-kit/graph** package lets you compose backtest-kit computations as a typed directed acyclic graph (DAG). Define source nodes that fetch market data and output nodes that compute derived values โ€” then resolve the whole graph in topological order with automatic parallelism.
1611
-
1612
- #### Key Features
1613
- - ๐Ÿ”Œ **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
1614
- - ๐Ÿ”’ **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
1615
- - ๐Ÿงฑ **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
1616
- - ๐Ÿ’พ **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
1617
- - ๐ŸŒ **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
1618
-
1619
- #### Use Case
1620
- Perfect for multi-timeframe strategies where multiple Pine Script or indicator computations must be combined. Instead of manually chaining async calls, define each computation as a node and let the graph resolve dependencies in parallel. Adding a new filter or timeframe requires no changes to the existing wiring.
1621
-
1622
- #### Get Started
1623
- ```bash
1624
- npm install @backtest-kit/graph backtest-kit
1625
- ```
1626
-
1627
-
1628
- ### @backtest-kit/ui
1629
-
1630
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ui)** ๐Ÿ“Š
1631
-
1632
- The **@backtest-kit/ui** package is a full-stack UI framework for visualizing cryptocurrency trading signals, backtests, and real-time market data. Combines a Node.js backend server with a React dashboard - all in one package.
1633
-
1634
- #### Key Features
1635
- - ๐Ÿ“ˆ **Interactive Charts**: Candlestick visualization with Lightweight Charts (1m, 15m, 1h timeframes)
1636
- - ๐ŸŽฏ **Signal Tracking**: View opened, closed, scheduled, and cancelled signals with full details
1637
- - ๐Ÿ“Š **Risk Analysis**: Monitor risk rejections and position management
1638
- - ๐Ÿ”” **Notifications**: Real-time notification system for all trading events
1639
- - ๐Ÿ’น **Trailing & Breakeven**: Visualize trailing stop/take and breakeven events
1640
- - ๐ŸŽจ **Material Design**: Beautiful UI with MUI 5 and Mantine components
1641
-
1642
- #### Use Case
1643
- Perfect for monitoring your trading bots in production. Instead of building custom dashboards, `@backtest-kit/ui` provides a complete visualization layer out of the box. Each signal view includes detailed information forms, multi-timeframe candlestick charts, and JSON export for all data.
1644
-
1645
- #### Get Started
1646
- ```bash
1647
- npm install @backtest-kit/ui backtest-kit ccxt
1648
- ```
1649
-
1650
-
1651
- ### @backtest-kit/mongo
1652
-
1653
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/mongo)** ๐Ÿ’พ
1654
-
1655
- The **@backtest-kit/mongo** package replaces the default file-based `./dump/` storage with MongoDB as the source of truth and Redis as an O(1) lookup cache. All 15 `IPersist*Instance` contracts from backtest-kit are implemented โ€” strategy code stays unchanged.
1656
-
1657
- #### Key Features
1658
- - ๐Ÿ—„๏ธ **MongoDB Backend**: All 15 persistence adapters implemented with Mongoose and unique compound indexes
1659
- - โšก **O(1) Reads via Redis**: Every context-key lookup goes through ioredis โ€” one `GET` + one `findById`, no B-tree scans
1660
- - ๐Ÿ”’ **Atomic Writes**: `findOneAndUpdate` with `upsert: true` guarantees read-after-write correctness with no race conditions
1661
- - ๐Ÿ›ก๏ธ **Look-Ahead Bias Protection**: Adapters that affect signal logic store the simulation timestamp so backtest-kit can enforce temporal correctness
1662
- - ๐Ÿชฆ **Soft Delete**: Measure, Interval, and Memory records carry a `removed` flag instead of being physically deleted
1663
- - ๐Ÿ”Œ **Zero Strategy Changes**: Drop `setup()` into your entry point, everything else stays the same
1664
-
1665
- #### Use Case
1666
- Perfect for production deployments where the default file-based storage is a bottleneck or a reliability concern. During backtests, backtest-kit performs thousands of context-keyed reads per second โ€” Redis eliminates the per-request B-tree traversal and makes repeated reads effectively free. MongoDB provides durability, atomic upserts, and a queryable signal history that survives process restarts.
1667
-
1668
- #### Get Started
1669
- ```bash
1670
- npm install @backtest-kit/mongo backtest-kit mongoose ioredis
1671
- ```
1672
-
1673
-
1674
- ### @backtest-kit/ollama
1675
-
1676
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ollama)** ๐Ÿค–
1677
-
1678
- The **@backtest-kit/ollama** package is a multi-provider LLM inference library that supports 10+ providers including OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, and Ollama with unified API and automatic token rotation.
1679
-
1680
- #### Key Features
1681
- - ๐Ÿ”Œ **10+ LLM Providers**: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama
1682
- - ๐Ÿ”„ **Token Rotation**: Automatic API key rotation for Ollama (others throw clear errors)
1683
- - ๐ŸŽฏ **Structured Output**: Enforced JSON schema for trading signals (position, price levels, risk notes)
1684
- - ๐Ÿ”‘ **Flexible Auth**: Context-based API keys or environment variables
1685
- - โšก **Unified API**: Single interface across all providers
1686
- - ๐Ÿ“Š **Trading-First**: Built for backtest-kit with position sizing and risk management
1687
-
1688
- #### Use Case
1689
- Ideal for building multi-provider LLM strategies with fallback chains and ensemble predictions. The package returns structured trading signals with validated TP/SL levels, making it perfect for use in `getSignal` functions. Supports both backtest and live trading modes.
1690
-
1691
- #### Get Started
1692
- ```bash
1693
- npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
1694
- ```
1695
-
1696
-
1697
- ### @backtest-kit/signals
1698
-
1699
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/signals)** ๐Ÿ“Š
1700
-
1701
- The **@backtest-kit/signals** package is a technical analysis and trading signal generation library designed for AI-powered trading systems. It computes 50+ indicators across 4 timeframes and generates markdown reports optimized for LLM consumption.
1702
-
1703
- #### Key Features
1704
- - ๐Ÿ“ˆ **Multi-Timeframe Analysis**: 1m, 15m, 30m, 1h with synchronized indicator computation
1705
- - ๐ŸŽฏ **50+ Technical Indicators**: RSI, MACD, Bollinger Bands, Stochastic, ADX, ATR, CCI, Fibonacci, Support/Resistance
1706
- - ๐Ÿ“Š **Order Book Analysis**: Bid/ask depth, spread, liquidity imbalance, top 20 levels
1707
- - ๐Ÿค– **AI-Ready Output**: Markdown reports formatted for LLM context injection
1708
- - โšก **Performance Optimized**: Intelligent caching with configurable TTL per timeframe
1709
-
1710
- #### Use Case
1711
- Perfect for injecting comprehensive market context into your LLM-powered strategies. Instead of manually calculating indicators, `@backtest-kit/signals` provides a single function call that adds all technical analysis to your message context. Works seamlessly with `getSignal` function in backtest-kit strategies.
1712
-
1713
- #### Get Started
1714
- ```bash
1715
- npm install @backtest-kit/signals backtest-kit
1716
- ```
1717
-
1718
-
1719
- ### @backtest-kit/sidekick
1720
-
1721
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** ๐Ÿš€
1722
-
1723
- The **@backtest-kit/sidekick** package scaffolds a project where **all wiring is visible and editable** in your project files โ€” exchange adapter, frame definitions, risk rules, strategy logic, and the runner script. Think of it as the **eject** of `@backtest-kit/cli --init`: instead of the boilerplate being hidden inside the CLI package, it lives directly in your project.
1724
-
1725
- #### Key Features
1726
- - ๐Ÿš€ **Zero Config**: Get started with one command - no setup required
1727
- - ๐Ÿ“ฆ **Complete Template**: Includes backtest strategy, risk management, and LLM integration
1728
- - ๐Ÿค– **AI-Powered**: Pre-configured with DeepSeek, Claude, and GPT-5 fallback chain
1729
- - ๐Ÿ“Š **Technical Analysis**: Built-in 50+ indicators via @backtest-kit/signals
1730
- - ๐Ÿ”‘ **Environment Setup**: Auto-generated .env with all API key placeholders
1731
- - ๐Ÿ“ **Best Practices**: Production-ready code structure with examples
1732
-
1733
- #### Use Case
1734
- The fastest way to bootstrap a new trading bot project. Instead of manually setting up dependencies, configurations, and boilerplate code, simply run one command and get a working project with LLM-powered strategy, multi-timeframe technical analysis, and risk management validation.
1735
-
1736
- #### Get Started
1737
- ```bash
1738
- npx -y @backtest-kit/sidekick my-trading-bot
1739
- cd my-trading-bot
1740
- npm start
1741
- ```
1742
-
1743
-
1744
- ## ๐Ÿ‘ช Community
1745
-
1746
- ### backtest-monorepo-parallel
1747
-
1748
- > **[Explore on GitHub](https://github.com/backtest-kit/backtest-monorepo-parallel)** ๐ŸŽ๏ธ
1749
-
1750
- The **backtest-monorepo-parallel** repository is a TypeScript monorepo template that runs **9 symbols in parallel** in a single Node process on top of shared Mongo + Redis infrastructure, with a self-enforcement runtime that exposes the workspace DI container to `./content/` strategy files. No wiring, no bundler hooks, no strategy-author changes.
1751
-
1752
- #### Key Features
1753
- - โšก **~6 300ร— Real-Time Aggregate**: 9 symbols ร— ~703ร— per-symbol replay speed, ~103 events/sec in the hot `listenActivePing โ†’ commitAverageBuy` loop on a commodity i5-13420H laptop
1754
- - ๐Ÿงต **Single-Process Concurrency**: All 9 `Backtest.background(...)` contexts share one event loop, one Mongo pool, one Redis pool โ€” no IPC, no fork overhead
1755
- - ๐Ÿ’‰ **DI Surface**: Workspace services typed via rolled-up `types.d.ts` and reachable from strategy files at evaluation time
1756
- - ๐Ÿ—‚๏ธ **Mode A / Mode B**: `--entry` flag toggles between parallel runner (`CC_SYMBOL_LIST` fan-out) and single-strategy CLI mode
1757
- - ๐Ÿงฉ **Linear Scaling Recipe**: Adding a service = +1 file, +1 symbol, +1 provider, +1 ioc entry โ€” no churn under `./content/`
1758
-
1759
- #### Use Case
1760
- Use when you need to backtest many symbols concurrently against the same strategy without spawning subprocesses, and want a scaffold where new services, collections, and Redis caches drop in alongside existing ones without restructuring. Ideal as the starting point for a production parallel-symbol backtesting setup.
1761
-
1762
- #### Get Started
1763
- ```bash
1764
- git clone https://github.com/backtest-kit/backtest-monorepo-parallel.git
1765
- ```
1766
-
1767
-
1768
- ### backtest-kit-redis-mongo-docker
1769
-
1770
- > **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-redis-mongo-docker)** ๐Ÿณ
1771
-
1772
- The **backtest-kit-redis-mongo-docker** repository is a production-grade integration that replaces the default file-based `./dump/` persistence with **MongoDB** as the source of truth and **Redis** as an O(1) lookup cache, packaged with `docker-compose` for one-command deploys.
1773
-
1774
- #### Key Features
1775
- - ๐Ÿ—‚๏ธ **15 Persist Adapters**: Full implementation of every `IPersist*Instance` contract (Candle, Signal, Schedule, Risk, Partial, Breakeven, Storage, Notification, Log, Measure, Interval, Memory, Recent, State, Session) on top of MongoDB + Redis
1776
- - โš›๏ธ **Atomic Read-After-Write**: Single-round-trip `findOneAndUpdate` with unique compound indexes โ€” no E11000 leaks under concurrent writes
1777
- - โšก **Redis O(1) Cache**: Per-domain `*CacheService` over `ioredis` for context-key โ†’ id lookups; cache miss falls back to Mongo and backfills automatically
1778
- - ๐Ÿ›ก๏ธ **Look-Ahead Bias Protection**: Indexed `when: Number` column on every signal-affecting schema, fed by backtest-kit 9.0+'s `when: Date` adapter argument
1779
- - ๐Ÿณ **Docker Compose Stack**: Separate compose files for Mongo and Redis plus a main container with networks; configurable via `CC_MONGO_CONNECTION_STRING` / `CC_REDIS_*` env vars
1780
-
1781
- #### Use Case
1782
- Drop-in persistence upgrade for any backtest-kit project that outgrows the default file-based `./dump/` layout โ€” strategy code, runners, and the CLI entry point stay unchanged. Use it when you need durable storage, concurrent-safe writes, fast restart recovery, or a containerized deployment for live and paper trading.
1783
-
1784
- #### Get Started
1785
- ```bash
1786
- git clone https://github.com/backtest-kit/backtest-kit-redis-mongo-docker.git
1787
- ```
1788
-
1789
-
1790
- ### backtest-kit-skills
1791
-
1792
- > **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-skills)** ๐Ÿค–
1793
-
1794
- The **backtest-kit-skills** repository is a Claude Code agent skill and Mintlify documentation source for the backtest-kit framework โ€” AI-assisted strategy writing, debugging help, and full API reference in one place.
1795
-
1796
- #### Key Features
1797
- - ๐Ÿค– **Claude Code Skill**: Installed under `~/.claude/skills/backtest-kit/` โ€” strategy generation, debugging, and API reference
1798
- - ๐Ÿ“– **Mintlify Docs**: Full documentation site runnable locally
1799
- - ๐ŸŽฏ **Strategy Generation**: Complete TypeScript files with all schema registrations and runner setup
1800
- - ๐Ÿ› **Debugging Help**: Catches common mistakes (missing `await`, wrong TP/SL direction, top-level commit calls)
1801
- - ๐Ÿ“š **API Reference**: All schemas, commit functions, event listeners, LLM integration, graph pipelines, and persistence adapters
1802
-
1803
- #### Use Case
1804
- Install the skill once and get AI-assisted backtest-kit development inside Claude Code. The skill knows the full API surface โ€” schemas, commit functions, event listeners, broker adapters โ€” so you can describe what you want in plain language and get working TypeScript strategy code.
1805
-
1806
- #### Get Started
1807
- ```bash
1808
- npx skills add https://github.com/backtest-kit/backtest-kit-skills
1809
- ```
1810
-
1811
-
1812
- ### uzse-backtest-app
1813
-
1814
- > **[Explore on GitHub](https://github.com/backtest-kit/uzse-backtest-app)** ๐Ÿ“ˆ
1815
-
1816
- The **uzse-backtest-app** repository is a reference implementation for running Pine Script strategies on regional stock exchanges not available on TradingView (UZSE, MSE, DSE, and others). It downloads raw trade history, builds Japanese candlesticks, and feeds them into backtest-kit via a custom MongoDB exchange adapter.
1817
-
1818
- #### Key Features
1819
- - ๐ŸŒ **Off-TradingView Markets**: Works with any exchange that exposes trade history โ€” no TradingView dependency
1820
- - ๐Ÿ•ฏ๏ธ **Candle Builder**: Aggregates raw trades into 1m candles, fills intraday and non-trading day gaps, builds higher timeframes up to `1d`
1821
- - ๐Ÿ—„๏ธ **MongoDB Backend**: Idempotent import with unique index โ€” re-runs never create duplicates
1822
- - ๐Ÿ”Œ **Custom Exchange Adapter**: Connects MongoDB candles to backtest-kit via `addExchangeSchema`
1823
- - ๐Ÿ“œ **Pine Script Support**: Full `@backtest-kit/pinets` integration โ€” run any Pine Script v5/v6 indicator on local market data
1824
-
1825
- #### Use Case
1826
- Perfect for traders working with emerging or regional markets absent from TradingView. Download trade history, build candles once, then use the full backtest-kit + Pine Script toolchain for backtesting and live signal generation โ€” with no dependency on any third-party charting platform.
1827
-
1828
- #### Get Started
1829
- ```bash
1830
- git clone https://github.com/backtest-kit/uzse-backtest-app.git
1831
- ```
1832
-
1833
- ## ๐Ÿงฉ Strategy Examples
1834
-
1835
- #### ๐Ÿง  Neural Network Strategy (Oct 2021)
1836
-
1837
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy)
1838
-
1839
- Trains a feed-forward `TensorFlow` neural network (8โ†’6โ†’4โ†’1 architecture) every 8 hours to predict where the next candle will close within its high-low range. When current price is below predicted price, opens a LONG with 1% trailing take-profit.
1840
-
1841
- #### ๐ŸŒฒ Pine Script Range Breakout (Dec 2025)
1842
-
1843
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy)
1844
-
1845
- Runs `btc_dec2025_range.pine` on 1h candles via `@backtest-kit/pinets`, extracting Bollinger Bands, range boundaries, and volume spikes. Signals fire only on confirmed breakouts when price hasn't already moved past the signal close.
1846
-
1847
- #### ๐Ÿ”ช Signal Inversion Strategy (Jan 2026)
1848
-
1849
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy)
1850
-
1851
- The strategy takes published signals from a real Telegram crypto channel (Crypto Yoda), enters at the same price zone and timestamp, but **inverts the direction** and uses the liquidity of the crowd that blindly follows the recommendation regardless of the contents of the order book.
1852
-
1853
- #### ๐Ÿ“ฐ AI News Sentiment (Feb 2026)
1854
-
1855
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy)
1856
-
1857
- Every 4-8 hours, fetches live crypto/macro news via Tavily, passes headlines to Ollama (local LLM), and opens positions based on `bullish`/`bearish`/`wait` forecasts. Conflicting signals flip positions mid-trade. Achieved +16.99% during a -16.4% month.
1858
-
1859
- #### ๐Ÿช‚ SHORT DCA Ladder (Mar 2026)
1860
-
1861
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy)
1862
-
1863
- Opens a SHORT on every pending signal, then adds rungs (up to 10) whenever price spikes upward outside a ยฑ1-5% band around last entry. Closes at 0.5% blended profit.
1864
-
1865
- #### ๐Ÿง— LONG DCA Ladder (Apr 2026)
1866
-
1867
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy)
1868
-
1869
- Same mechanics as SHORT version but LONG-biased with 3% profit target. Deployed 2.4 entries per trade on average, achieved +67.85% PNL on deployed capital with improved percentage drawdown (-2.59% vs -3.99% without DCA).
1870
-
1871
- #### ๐Ÿ Python EMA Crossover (Feb 2021)
1872
-
1873
- > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy)
1874
-
1875
- Python-based (WASI) strategy that uses EMA(9) and EMA(21) crossover signals executed via WebAssembly. Trades trigger when fast EMA crosses slow EMA, confirmed by 4h range midpoint.
1876
-
1877
- ## ๐Ÿ‘จโ€๐Ÿ’ผ Commercial Support
1878
-
1879
- > **[TheOneTrade](https://theonetrade.github.io)** ๐Ÿ’ผ
1880
-
1881
- **TheOneTrade** is the commercial vendor behind backtest-kit, providing paid support, custom strategy development, managed accounts, team training, and enterprise licensing. Contact for SLAs, private features, or quant team engagements that fall outside the MIT-licensed open-source scope.
1882
-
1883
- ## ๐Ÿค– Are you a robot?
1884
-
1885
- **For language models**: Read extended description in [./LLMs.md](./LLMs.md)
1886
-
1887
- ## โœ… Tested & Reliable
1888
-
1889
- 520+ tests cover validation, recovery, reports, and events.
1890
-
1891
- ## ๐Ÿค Contribute
1892
-
1893
- Fork/PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
1894
-
1895
- ## ๐Ÿ“œ License
1896
-
1897
- MIT ยฉ [tripolskypetr](https://github.com/tripolskypetr)
1898
-
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 framework for backtesting and live trading strategies on multi-asset, crypto, forex or [DEX (peer-to-peer marketplace)](https://en.wikipedia.org/wiki/Decentralized_finance#Decentralized_exchanges), spot, futures with crash-safe persistence, signal validation, and AI optimization.
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
+ Build reliable trading systems: backtest on historical data, deploy live bots with recovery, and optimize strategies using LLMs like Ollama.
15
+
16
+ ๐Ÿ“š **[API Reference](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** | ๐ŸŒŸ **[Quick Start](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** | **๐Ÿ“ฐ [Article](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)**
17
+
18
+ ## ๐Ÿš€ Quick Start
19
+
20
+ > **New to backtest-kit?** The fastest way to get a real, production-ready setup is to clone the [reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example) โ€” a fully working news-sentiment AI trading system with LLM forecasting, multi-timeframe data, and a documented February 2026 backtest. Start there instead of from scratch.
21
+
22
+ ### ๐ŸŽฏ The Casual Way: CLI Init
23
+
24
+ > **Minimal scaffold โ€” all boilerplate stays inside `@backtest-kit/cli`:**
25
+
26
+ ```bash
27
+ npx @backtest-kit/cli --init --output backtest-kit-project
28
+ cd backtest-kit-project
29
+ npm install
30
+ npm start
31
+ ```
32
+
33
+ The generated project contains only your strategy files. There is no bootstrap, exchange registration, or runner code to maintain โ€” all of that lives inside `@backtest-kit/cli` and is invoked via `npm start`. Library documentation is fetched automatically into `docs/lib/` on init.
34
+
35
+ ### ๐Ÿ—๏ธ Alternative: Sidekick CLI
36
+
37
+ > **Full-control scaffold โ€” all wiring is in your project files:**
38
+
39
+ ```bash
40
+ npx -y @backtest-kit/sidekick my-trading-bot
41
+ cd my-trading-bot
42
+ npm start
43
+ ```
44
+
45
+ Sidekick generates a project where the exchange adapter, frame definitions, risk rules, strategy logic, and runner script all live as editable source files inside the project. Use it when you need full visibility and control over every part of the setup.
46
+
47
+ ### ๐Ÿณ Running in Docker
48
+
49
+ > **Automatic restarts โ€” Zero-downtime trading:**
50
+
51
+ ```bash
52
+ npx @backtest-kit/cli --docker
53
+ cd backtest-kit-docker
54
+ MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
55
+ docker-compose logs -f
56
+ ```
57
+
58
+ CLI can create a ready-to-use Docker workspace: self-contained directory with `docker-compose.yaml` and a strategy entry point. CLI supports [Multiple Symbol in Parallel](https://www.npmjs.com/package/@backtest-kit/cli#-multiple-symbol-parallel) for powerusers.
59
+
60
+ ### ๐Ÿ“ฆ Manual Installation
61
+
62
+ > **Want to see the code?** ๐Ÿ‘‰ [Demo app](https://github.com/tripolskypetr/backtest-kit/tree/master/example) ๐Ÿ‘ˆ
63
+
64
+ ```bash
65
+ npm install backtest-kit ccxt ollama uuid
66
+ ```
67
+
68
+ Install the core library and peer dependencies manually. Use this approach when integrating backtest-kit into an existing project or when you need full control over your package setup.
69
+
70
+ ## โœจ Why Choose Backtest Kit?
71
+
72
+ - ๐Ÿš€ **Production-Ready**: Seamless switch between backtest/live modes; identical code across environments.
73
+ - ๐Ÿ’พ **Crash-Safe**: Atomic persistence recovers states after crashes, preventing duplicates or losses.
74
+ - โœ… **Validation**: Checks signals for TP/SL logic, risk/reward ratios, whipsaw protection and portfolio limits.
75
+ - ๐Ÿ”„ **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
76
+ - ๐Ÿค– **AI Integration**: LLM-powered strategy generation (Optimizer) with multi-timeframe analysis.
77
+ - ๐Ÿ“Š **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
78
+ - ๐Ÿ›ก๏ธ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
79
+ - ๐Ÿ”Œ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
80
+ - ๐Ÿ—ƒ๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ€” exchange rejection rolls back the operation atomically.
81
+ - โฐ **Built-in Crontab**: Register periodic or fire-once jobs that fire on virtual-time boundaries with singleshot coordination across parallel backtests โ€” one handler invocation per boundary, no double-fires.
82
+ - ๐Ÿงช **Tested**: 740+ unit/integration tests for validation, recovery, and events.
83
+ - ๐Ÿ”“ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
84
+
85
+ ## ๐Ÿ“‹ Supported Order Types
86
+
87
+ > With the calculation of PnL, Peak Profit and Max Drawdown for each Entry
88
+
89
+ - Market/Limit entries
90
+ - TP/SL/OCO exits
91
+ - Grid with auto-cancel on unmet conditions
92
+ - Partial profit/loss levels
93
+ - Trailing stop-loss
94
+ - Breakeven protection
95
+ - Stop limit entries (before OCO)
96
+ - Dollar cost averaging
97
+ - Time attack / Infinite hold
98
+
99
+ ## ๐Ÿ“š Code Samples
100
+
101
+ ### โš™๏ธ Basic Configuration
102
+ ```typescript
103
+ import { setLogger, setConfig } from 'backtest-kit';
104
+
105
+ // Enable logging
106
+ setLogger({
107
+ log: console.log,
108
+ debug: console.debug,
109
+ info: console.info,
110
+ warn: console.warn,
111
+ });
112
+
113
+ // Global config (optional)
114
+ setConfig({
115
+ CC_PERCENT_SLIPPAGE: 0.1, // % slippage
116
+ CC_PERCENT_FEE: 0.1, // % fee
117
+ CC_SCHEDULE_AWAIT_MINUTES: 120, // Pending signal timeout
118
+ });
119
+ ```
120
+
121
+ ### ๐Ÿ”ง Register Components
122
+ ```typescript
123
+ import ccxt from 'ccxt';
124
+ import { addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema } from 'backtest-kit';
125
+
126
+ // Exchange (data source)
127
+ addExchangeSchema({
128
+ exchangeName: 'binance',
129
+ getCandles: async (symbol, interval, since, limit) => {
130
+ const exchange = new ccxt.binance();
131
+ const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
132
+ return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume }));
133
+ },
134
+ formatPrice: (symbol, price) => price.toFixed(2),
135
+ formatQuantity: (symbol, quantity) => quantity.toFixed(8),
136
+ });
137
+
138
+ // Risk profile
139
+ addRiskSchema({
140
+ riskName: 'demo',
141
+ validations: [
142
+ // TP at least 1%
143
+ ({ pendingSignal, currentPrice }) => {
144
+ const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
145
+ const tpDistance = position === 'long' ? ((priceTakeProfit - priceOpen) / priceOpen) * 100 : ((priceOpen - priceTakeProfit) / priceOpen) * 100;
146
+ if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
147
+ },
148
+ // R/R at least 2:1
149
+ ({ pendingSignal, currentPrice }) => {
150
+ const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
151
+ const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
152
+ const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
153
+ if (reward / risk < 2) throw new Error('Poor R/R ratio');
154
+ },
155
+ ],
156
+ });
157
+
158
+ // Time frame
159
+ addFrameSchema({
160
+ frameName: '1d-test',
161
+ interval: '1m',
162
+ startDate: new Date('2025-12-01'),
163
+ endDate: new Date('2025-12-02'),
164
+ });
165
+ ```
166
+
167
+ ### ๐Ÿ’ก Example Strategy (with LLM)
168
+ ```typescript
169
+ import { v4 as uuid } from 'uuid';
170
+ import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
171
+ import { json } from './utils/json.mjs'; // LLM wrapper
172
+ import { getMessages } from './utils/messages.mjs'; // Market data prep
173
+
174
+ addStrategySchema({
175
+ strategyName: 'llm-strategy',
176
+ interval: '5m',
177
+ riskName: 'demo',
178
+ getSignal: async (symbol) => {
179
+
180
+ const candles1h = await getCandles(symbol, "1h", 24);
181
+ const candles15m = await getCandles(symbol, "15m", 48);
182
+ const candles5m = await getCandles(symbol, "5m", 60);
183
+ const candles1m = await getCandles(symbol, "1m", 60);
184
+
185
+ const messages = await getMessages(symbol, {
186
+ candles1h,
187
+ candles15m,
188
+ candles5m,
189
+ candles1m,
190
+ }); // Calculate indicators / Fetch news
191
+
192
+ const resultId = uuid();
193
+ const signal = await json(messages); // LLM generates signal
194
+
195
+ await dumpAgentAnswer({
196
+ dumpId: "position-context",
197
+ bucketName: "multi-timeframe-strategy",
198
+ messages: messages, // pass saved messages here
199
+ description: "agent reasoning for this signal",
200
+ });
201
+
202
+ await dumpRecord({
203
+ dumpId: "position-entry",
204
+ bucketName: "multi-timeframe-strategy",
205
+ record: signal, // pass saved signal record here
206
+ description: "signal entry parameters",
207
+ });
208
+
209
+ return { ...signal, id: resultId };
210
+ },
211
+ });
212
+ ```
213
+
214
+ ### ๐Ÿงช Run Backtest
215
+ ```typescript
216
+ import { Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
217
+
218
+ Backtest.background('BTCUSDT', {
219
+ strategyName: 'llm-strategy',
220
+ exchangeName: 'binance',
221
+ frameName: '1d-test',
222
+ });
223
+
224
+ listenSignalBacktest((event) => console.log(event));
225
+ listenDoneBacktest(async (event) => {
226
+ await Backtest.dump(event.symbol, event.strategyName); // Generate report
227
+ });
228
+ ```
229
+
230
+ ### ๐Ÿ“ˆ Run Live Trading
231
+ ```typescript
232
+ import { Live, listenSignalLive } from 'backtest-kit';
233
+
234
+ Live.background('BTCUSDT', {
235
+ strategyName: 'llm-strategy',
236
+ exchangeName: 'binance', // Use API keys in .env
237
+ });
238
+
239
+ listenSignalLive((event) => console.log(event));
240
+ ```
241
+
242
+ ### ๐Ÿ“ก Monitoring & Events
243
+
244
+ - Use `listenRisk`, `listenError`, `listenPartialProfit/Loss` for alerts.
245
+ - Dump reports: `Backtest.dump()`, `Live.dump()`.
246
+
247
+ ## ๐ŸŒ Global Configuration
248
+
249
+ Customize via `setConfig()`:
250
+
251
+ - `CC_SCHEDULE_AWAIT_MINUTES`: Pending timeout (default: 120).
252
+ - `CC_AVG_PRICE_CANDLES_COUNT`: VWAP candles (default: 5).
253
+
254
+ ## ๐Ÿ’ป Developer Note
255
+
256
+ Backtest Kit is **not a data-processing library** - it is a **time execution engine**. Think of the engine as an **async stream of time**, where your strategy is evaluated step by step.
257
+
258
+ ### ๐Ÿ” How PNL Works
259
+
260
+ These three functions work together to dynamically manage the position. To reduce position linearity, by default, each DCA entry is formatted as a fixed **unit of $100**. This can be changed. No mathematical knowledge is required.
261
+
262
+ **Public API:**
263
+ - **`commitAverageBuy`** โ€” adds a new DCA entry. By default, **only accepted when current price is below a new low**. Silently rejected otherwise. This prevents averaging up. Can be overridden using `setConfig`
264
+ - **`commitPartialProfit`** โ€” closes X% of the position at a profit. Locks in gains while keeping exposure.
265
+ - **`commitPartialLoss`** โ€” closes X% of the position at a loss. Cuts exposure before the stop-loss is hit.
266
+
267
+ <details>
268
+ <summary>
269
+ The Math
270
+ </summary>
271
+
272
+ **Scenario:** LONG entry @ 1000, 4 DCA attempts (1 rejected), 3 partials, closed at TP.
273
+ `totalInvested = $400` (4 ร— $100, rejected attempt not counted).
274
+
275
+ **Entries**
276
+ ```
277
+ entry#1 @ 1000 โ†’ 0.10000 coins
278
+ commitPartialProfit(30%) @ 1150 โ† cnt=1
279
+ entry#2 @ 950 โ†’ 0.10526 coins
280
+ entry#3 @ 880 โ†’ 0.11364 coins
281
+ commitPartialLoss(20%) @ 860 โ† cnt=3
282
+ entry#4 @ 920 โ†’ 0.10870 coins
283
+ commitPartialProfit(40%) @ 1050 โ† cnt=4
284
+ entry#5 @ 980 โœ— REJECTED (980 > ep3โ‰ˆ929.92)
285
+ totalInvested = $400
286
+ ```
287
+
288
+ **Partial#1 โ€” commitPartialProfit @ 1150, 30%, cnt=1**
289
+ ```
290
+ effectivePrice = hm(1000) = 1000
291
+ costBasis = $100
292
+ partialDollarValue = 30% ร— 100 = $30 โ†’ weight = 30/400 = 0.075
293
+ pnl = (1150โˆ’1000)/1000 ร— 100 = +15.00%
294
+ costBasis โ†’ $70
295
+ coins sold: 0.03000 ร— 1150 = $34.50
296
+ remaining: 0.07000
297
+ ```
298
+
299
+ **DCA after Partial#1**
300
+ ```
301
+ entry#2 @ 950 (950 < ep1=1000 โœ“ accepted)
302
+ entry#3 @ 880 (880 < ep1=1000 โœ“ accepted)
303
+ coins: 0.07000 + 0.10526 + 0.11364 = 0.28890
304
+ ```
305
+
306
+ **Partial#2 โ€” commitPartialLoss @ 860, 20%, cnt=3**
307
+ ```
308
+ costBasis = 70 + 100 + 100 = $270
309
+ ep2 = 270 / 0.28890 โ‰ˆ 934.58
310
+ partialDollarValue = 20% ร— 270 = $54 โ†’ weight = 54/400 = 0.135
311
+ pnl = (860โˆ’934.58)/934.58 ร— 100 โ‰ˆ โˆ’7.98%
312
+ costBasis โ†’ $216
313
+ coins sold: 0.05778 ร— 860 = $49.69
314
+ remaining: 0.23112
315
+ ```
316
+
317
+ **DCA after Partial#2**
318
+ ```
319
+ entry#4 @ 920 (920 < ep2=934.58 โœ“ accepted)
320
+ coins: 0.23112 + 0.10870 = 0.33982
321
+ ```
322
+
323
+ **Partial#3 โ€” commitPartialProfit @ 1050, 40%, cnt=4**
324
+ ```
325
+ costBasis = 216 + 100 = $316
326
+ ep3 = 316 / 0.33982 โ‰ˆ 929.92
327
+ partialDollarValue = 40% ร— 316 = $126.4 โ†’ weight = 126.4/400 = 0.316
328
+ pnl = (1050โˆ’929.92)/929.92 ร— 100 โ‰ˆ +12.91%
329
+ costBasis โ†’ $189.6
330
+ coins sold: 0.13593 ร— 1050 = $142.72
331
+ remaining: 0.20389
332
+ ```
333
+
334
+ **DCA after Partial#3 โ€” rejected**
335
+ ```
336
+ entry#5 @ 980 (980 > ep3โ‰ˆ929.92 โœ— REJECTED)
337
+ ```
338
+
339
+ **Close at TP @ 1200**
340
+ ```
341
+ ep_final = ep3 โ‰ˆ 929.92 (no new entries)
342
+ coins: 0.20389
343
+
344
+ remainingDollarValue = 400 โˆ’ 30 โˆ’ 54 โˆ’ 126.4 = $189.6
345
+ weight = 189.6/400 = 0.474
346
+ pnl = (1200โˆ’929.92)/929.92 ร— 100 โ‰ˆ +29.04%
347
+ coins sold: 0.20389 ร— 1200 = $244.67
348
+ ```
349
+
350
+ **Result (toProfitLossDto)**
351
+ ```
352
+ 0.075 ร— (+15.00) = +1.125
353
+ 0.135 ร— (โˆ’7.98) = โˆ’1.077
354
+ 0.316 ร— (+12.91) = +4.080
355
+ 0.474 ร— (+29.04) = +13.765
356
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
357
+ โ‰ˆ +17.89%
358
+
359
+ Cross-check (coins):
360
+ 34.50 + 49.69 + 142.72 + 244.67 = $471.58
361
+ (471.58 โˆ’ 400) / 400 ร— 100 = +17.90% โœ“
362
+ ```
363
+ </details>
364
+
365
+ #### Internals
366
+
367
+ **`priceOpen`** is the harmonic mean of all accepted DCA entries. After each partial close (`commitPartialProfit` or `commitPartialLoss`), the remaining cost basis is carried forward into the harmonic mean calculation for subsequent entries โ€” so `priceOpen` shifts after every partial, which in turn changes whether the next `commitAverageBuy` call will be accepted.
368
+
369
+ ### ๐Ÿ” How Broker Transactional Integrity Works
370
+
371
+ `Broker.useBrokerAdapter` connects a live exchange (ccxt, Binance, etc.) to the framework with transaction safety. Every commit method fires **before** the internal position state mutates. If the exchange rejects the order, the fill times out, or the network fails, the adapter throws, the mutation is skipped, and backtest-kit retries automatically on the next tick.
372
+
373
+ <details>
374
+ <summary>
375
+ The code
376
+ </summary>
377
+
378
+ **Spot**
379
+
380
+ ```typescript
381
+ import ccxt from "ccxt";
382
+ import { singleshot, sleep } from "functools-kit";
383
+ import {
384
+ Broker,
385
+ IBroker,
386
+ BrokerSignalOpenPayload,
387
+ BrokerSignalClosePayload,
388
+ BrokerPartialProfitPayload,
389
+ BrokerPartialLossPayload,
390
+ BrokerTrailingStopPayload,
391
+ BrokerTrailingTakePayload,
392
+ BrokerBreakevenPayload,
393
+ BrokerAverageBuyPayload,
394
+ } from "backtest-kit";
395
+
396
+ const FILL_POLL_INTERVAL_MS = 10_000;
397
+ const FILL_POLL_ATTEMPTS = 10;
398
+
399
+ /**
400
+ * Sleep between cancelOrder and fetchBalance to allow Binance to settle the
401
+ * cancellation โ€” reads immediately after cancel may return stale data.
402
+ */
403
+ const CANCEL_SETTLE_MS = 2_000;
404
+
405
+ /**
406
+ * Slippage buffer for stop_loss_limit on Spot โ€” limit price is set slightly
407
+ * below stopPrice so the order fills even on a gap down instead of hanging.
408
+ */
409
+ const STOP_LIMIT_SLIPPAGE = 0.995;
410
+
411
+ const getSpotExchange = singleshot(async () => {
412
+ const exchange = new ccxt.binance({
413
+ apiKey: process.env.BINANCE_API_KEY,
414
+ secret: process.env.BINANCE_API_SECRET,
415
+ options: {
416
+ defaultType: "spot",
417
+ adjustForTimeDifference: true,
418
+ recvWindow: 60000,
419
+ },
420
+ enableRateLimit: true,
421
+ });
422
+ await exchange.loadMarkets();
423
+ return exchange;
424
+ });
425
+
426
+ /**
427
+ * Resolve base currency from market metadata โ€” safe for all quote currencies (USDT, USDC, FDUSD, etc.)
428
+ */
429
+ function getBase(exchange: ccxt.binance, symbol: string): string {
430
+ return exchange.markets[symbol].base;
431
+ }
432
+
433
+ /**
434
+ * Truncate qty to exchange precision, always rounding down.
435
+ * Prevents over-selling due to floating point drift from fetchBalance.
436
+ */
437
+ function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
438
+ return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
439
+ }
440
+
441
+ /**
442
+ * Fetch current free balance for base currency of symbol.
443
+ */
444
+ async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
445
+ const balance = await exchange.fetchBalance();
446
+ const base = getBase(exchange, symbol);
447
+ return parseFloat(String(balance?.free?.[base] ?? 0));
448
+ }
449
+
450
+ /**
451
+ * Cancel all orders in parallel โ€” allSettled so a single failure (already filled,
452
+ * network blip) does not leave remaining orders uncancelled.
453
+ */
454
+ async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
455
+ await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
456
+ }
457
+
458
+ /**
459
+ * Place a stop_loss_limit sell order with a slippage buffer on the limit price.
460
+ * stop_loss_limit requires both stopPrice (trigger) and price (limit fill).
461
+ * Setting them equal risks non-fill on gap down โ€” limit is offset by STOP_LIMIT_SLIPPAGE.
462
+ */
463
+ async function createStopLossOrder(
464
+ exchange: ccxt.binance,
465
+ symbol: string,
466
+ qty: number,
467
+ stopPrice: number
468
+ ): Promise<void> {
469
+ const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
470
+ await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
471
+ }
472
+
473
+ /**
474
+ * Place a limit order and poll until filled (status === "closed").
475
+ * On timeout: cancel the order, settle, check partial fill and sell it via market,
476
+ * restore SL/TP on remaining position so it is never left unprotected, then throw.
477
+ */
478
+ async function createLimitOrderAndWait(
479
+ exchange: ccxt.binance,
480
+ symbol: string,
481
+ side: "buy" | "sell",
482
+ qty: number,
483
+ price: number,
484
+ restore?: { tpPrice: number; slPrice: number }
485
+ ): Promise<void> {
486
+ const order = await exchange.createOrder(symbol, "limit", side, qty, price);
487
+
488
+ for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
489
+ await sleep(FILL_POLL_INTERVAL_MS);
490
+ const status = await exchange.fetchOrder(order.id, symbol);
491
+ if (status.status === "closed") {
492
+ return;
493
+ }
494
+ }
495
+
496
+ await exchange.cancelOrder(order.id, symbol);
497
+
498
+ // Wait for Binance to settle the cancellation before reading filled qty
499
+ await sleep(CANCEL_SETTLE_MS);
500
+
501
+ const final = await exchange.fetchOrder(order.id, symbol);
502
+ const filledQty = final.filled ?? 0;
503
+
504
+ if (filledQty > 0) {
505
+ // Sell partial fill via market to restore clean exchange state before backtest-kit retries
506
+ const rollbackSide = side === "buy" ? "sell" : "buy";
507
+ await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
508
+ }
509
+
510
+ // Restore SL/TP on remaining position so it is not left unprotected during retry
511
+ if (restore) {
512
+ const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
513
+ if (remainingQty > 0) {
514
+ await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
515
+ await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
516
+ }
517
+ }
518
+
519
+ throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ€” partial fill rolled back, backtest-kit will retry`);
520
+ }
521
+
522
+ Broker.useBrokerAdapter(
523
+ class implements IBroker {
524
+
525
+ async waitForInit(): Promise<void> {
526
+ await getSpotExchange();
527
+ }
528
+
529
+ async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
530
+ const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
531
+
532
+ // Spot does not support short selling โ€” reject immediately so backtest-kit skips the mutation
533
+ if (position === "short") {
534
+ throw new Error(`SpotBrokerAdapter: short position is not supported on spot (symbol=${symbol})`);
535
+ }
536
+
537
+ const exchange = await getSpotExchange();
538
+
539
+ const qty = truncateQty(exchange, symbol, cost / priceOpen);
540
+
541
+ // Guard: truncation may produce 0 if cost/price is below lot size
542
+ if (qty <= 0) {
543
+ throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${priceOpen}`);
544
+ }
545
+
546
+ const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
547
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
548
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
549
+
550
+ // Entry: no restore needed โ€” position does not exist yet if entry times out
551
+ await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
552
+
553
+ // Post-fill: if TP/SL placement fails, position is open and unprotected โ€” close via market
554
+ try {
555
+ await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
556
+ await createStopLossOrder(exchange, symbol, qty, slPrice);
557
+ } catch (err) {
558
+ await exchange.createOrder(symbol, "market", "sell", qty);
559
+ throw err;
560
+ }
561
+ }
562
+
563
+ async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
564
+ const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
565
+ const exchange = await getSpotExchange();
566
+
567
+ const openOrders = await exchange.fetchOpenOrders(symbol);
568
+ await cancelAllOrders(exchange, openOrders, symbol);
569
+ await sleep(CANCEL_SETTLE_MS);
570
+
571
+ const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
572
+
573
+ // Position already closed by SL/TP on exchange โ€” nothing to do, commit succeeds
574
+ if (qty === 0) {
575
+ return;
576
+ }
577
+
578
+ const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
579
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
580
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
581
+
582
+ // Restore SL/TP if close times out so position is not left unprotected during retry
583
+ await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
584
+ }
585
+
586
+ async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
587
+ const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
588
+ const exchange = await getSpotExchange();
589
+
590
+ const openOrders = await exchange.fetchOpenOrders(symbol);
591
+ await cancelAllOrders(exchange, openOrders, symbol);
592
+ await sleep(CANCEL_SETTLE_MS);
593
+
594
+ const totalQty = await fetchFreeQty(exchange, symbol);
595
+
596
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
597
+ if (totalQty === 0) {
598
+ throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
599
+ }
600
+
601
+ const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
602
+ const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
603
+ const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
604
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
605
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
606
+
607
+ // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
608
+ await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
609
+
610
+ // Restore SL/TP on remaining qty after successful partial close
611
+ if (remainingQty > 0) {
612
+ try {
613
+ await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
614
+ await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
615
+ } catch (err) {
616
+ // Remaining position is unprotected โ€” close via market
617
+ await exchange.createOrder(symbol, "market", "sell", remainingQty);
618
+ throw err;
619
+ }
620
+ }
621
+ }
622
+
623
+ async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
624
+ const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
625
+ const exchange = await getSpotExchange();
626
+
627
+ const openOrders = await exchange.fetchOpenOrders(symbol);
628
+ await cancelAllOrders(exchange, openOrders, symbol);
629
+ await sleep(CANCEL_SETTLE_MS);
630
+
631
+ const totalQty = await fetchFreeQty(exchange, symbol);
632
+
633
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
634
+ if (totalQty === 0) {
635
+ throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
636
+ }
637
+
638
+ const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
639
+ const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
640
+ const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
641
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
642
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
643
+
644
+ // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
645
+ await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
646
+
647
+ // Restore SL/TP on remaining qty after successful partial close
648
+ if (remainingQty > 0) {
649
+ try {
650
+ await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
651
+ await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
652
+ } catch (err) {
653
+ // Remaining position is unprotected โ€” close via market
654
+ await exchange.createOrder(symbol, "market", "sell", remainingQty);
655
+ throw err;
656
+ }
657
+ }
658
+ }
659
+
660
+ async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
661
+ const { symbol, newStopLossPrice } = payload;
662
+ const exchange = await getSpotExchange();
663
+
664
+ // Cancel existing SL order only โ€” Spot has no reduceOnly, filter by side + type
665
+ const orders = await exchange.fetchOpenOrders(symbol);
666
+ const slOrder = orders.find((o) =>
667
+ o.side === "sell" &&
668
+ ["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
669
+ ) ?? null;
670
+ if (slOrder) {
671
+ await exchange.cancelOrder(slOrder.id, symbol);
672
+ await sleep(CANCEL_SETTLE_MS);
673
+ }
674
+
675
+ const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
676
+
677
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
678
+ if (qty === 0) {
679
+ throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
680
+ }
681
+
682
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
683
+
684
+ await createStopLossOrder(exchange, symbol, qty, slPrice);
685
+ }
686
+
687
+ async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
688
+ const { symbol, newTakeProfitPrice } = payload;
689
+ const exchange = await getSpotExchange();
690
+
691
+ // Cancel existing TP order only โ€” Spot has no reduceOnly, filter by side + type
692
+ const orders = await exchange.fetchOpenOrders(symbol);
693
+ const tpOrder = orders.find((o) =>
694
+ o.side === "sell" &&
695
+ ["limit", "LIMIT"].includes(o.type ?? "")
696
+ ) ?? null;
697
+ if (tpOrder) {
698
+ await exchange.cancelOrder(tpOrder.id, symbol);
699
+ await sleep(CANCEL_SETTLE_MS);
700
+ }
701
+
702
+ const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
703
+
704
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
705
+ if (qty === 0) {
706
+ throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
707
+ }
708
+
709
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
710
+
711
+ await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
712
+ }
713
+
714
+ async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
715
+ const { symbol, newStopLossPrice } = payload;
716
+ const exchange = await getSpotExchange();
717
+
718
+ // Cancel existing SL order only โ€” Spot has no reduceOnly, filter by side + type
719
+ const orders = await exchange.fetchOpenOrders(symbol);
720
+ const slOrder = orders.find((o) =>
721
+ o.side === "sell" &&
722
+ ["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
723
+ ) ?? null;
724
+ if (slOrder) {
725
+ await exchange.cancelOrder(slOrder.id, symbol);
726
+ await sleep(CANCEL_SETTLE_MS);
727
+ }
728
+
729
+ const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
730
+
731
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
732
+ if (qty === 0) {
733
+ throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
734
+ }
735
+
736
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
737
+
738
+ await createStopLossOrder(exchange, symbol, qty, slPrice);
739
+ }
740
+
741
+ async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
742
+ const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
743
+ const exchange = await getSpotExchange();
744
+
745
+ // Cancel existing SL/TP first โ€” existing check must happen after cancel+settle
746
+ // to avoid race condition where SL/TP fills between the existence check and cancel
747
+ const openOrders = await exchange.fetchOpenOrders(symbol);
748
+ await cancelAllOrders(exchange, openOrders, symbol);
749
+ await sleep(CANCEL_SETTLE_MS);
750
+
751
+ // Guard against DCA into a ghost position โ€” checked after cancel so the snapshot is fresh
752
+ const existing = await fetchFreeQty(exchange, symbol);
753
+ const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
754
+
755
+ // Compare notional value rather than raw qty โ€” avoids float === 0 trap
756
+ // and correctly rejects dust balances left over from previous trades
757
+ if (existing * currentPrice < minNotional) {
758
+ throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
759
+ }
760
+
761
+ const qty = truncateQty(exchange, symbol, cost / currentPrice);
762
+
763
+ // Guard: truncation may produce 0 if cost/price is below lot size
764
+ if (qty <= 0) {
765
+ throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${currentPrice}`);
766
+ }
767
+
768
+ const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
769
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
770
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
771
+
772
+ // DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
773
+ await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
774
+
775
+ // Refetch balance after fill โ€” existing snapshot is stale after cancel + fill
776
+ const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
777
+
778
+ // Recreate SL/TP on fresh total qty after successful fill
779
+ try {
780
+ await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
781
+ await createStopLossOrder(exchange, symbol, totalQty, slPrice);
782
+ } catch (err) {
783
+ // Total position is unprotected โ€” close via market
784
+ await exchange.createOrder(symbol, "market", "sell", totalQty);
785
+ throw err;
786
+ }
787
+ }
788
+ }
789
+ );
790
+
791
+ Broker.enable();
792
+ ```
793
+
794
+ **Futures**
795
+
796
+ ```typescript
797
+ import ccxt from "ccxt";
798
+ import { singleshot, sleep } from "functools-kit";
799
+ import {
800
+ Broker,
801
+ IBroker,
802
+ BrokerSignalOpenPayload,
803
+ BrokerSignalClosePayload,
804
+ BrokerPartialProfitPayload,
805
+ BrokerPartialLossPayload,
806
+ BrokerTrailingStopPayload,
807
+ BrokerTrailingTakePayload,
808
+ BrokerBreakevenPayload,
809
+ BrokerAverageBuyPayload,
810
+ } from "backtest-kit";
811
+
812
+ const FILL_POLL_INTERVAL_MS = 10_000;
813
+ const FILL_POLL_ATTEMPTS = 10;
814
+
815
+ /**
816
+ * Sleep between cancelOrder and fetchPositions to allow Binance to settle the
817
+ * cancellation โ€” reads immediately after cancel may return stale data.
818
+ */
819
+ const CANCEL_SETTLE_MS = 2_000;
820
+
821
+ /**
822
+ * 3x leverage โ€” conservative choice for $1000 total fiat.
823
+ * Enough to matter, not enough to liquidate on normal volatility.
824
+ * Applied per-symbol on first open via setLeverage.
825
+ */
826
+ const FUTURES_LEVERAGE = 3;
827
+
828
+ const getFuturesExchange = singleshot(async () => {
829
+ const exchange = new ccxt.binance({
830
+ apiKey: process.env.BINANCE_API_KEY,
831
+ secret: process.env.BINANCE_API_SECRET,
832
+ options: {
833
+ defaultType: "future",
834
+ adjustForTimeDifference: true,
835
+ recvWindow: 60000,
836
+ },
837
+ enableRateLimit: true,
838
+ });
839
+ await exchange.loadMarkets();
840
+ return exchange;
841
+ });
842
+
843
+ /**
844
+ * Truncate qty to exchange precision, always rounding down.
845
+ * Prevents over-selling due to floating point drift from fetchPositions.
846
+ */
847
+ function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
848
+ return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
849
+ }
850
+
851
+ /**
852
+ * Resolve position for symbol filtered by side โ€” safe in both one-way and hedge mode.
853
+ */
854
+ function findPosition(positions: ccxt.Position[], symbol: string, side: "long" | "short") {
855
+ // Hedge mode: positions have explicit side field
856
+ const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
857
+ if (hedged) {
858
+ return hedged;
859
+ }
860
+ // One-way mode: single position per symbol, side field may be undefined or mismatched
861
+ const pos = positions.find((p) => p.symbol === symbol) ?? null;
862
+ if (pos && pos.side && pos.side !== side) {
863
+ console.warn(`findPosition: expected side="${side}" but exchange returned side="${pos.side}" for ${symbol} โ€” possible one-way/hedge mode mismatch`);
864
+ }
865
+ return pos;
866
+ }
867
+
868
+ /**
869
+ * Fetch current contracts qty for symbol/side.
870
+ */
871
+ async function fetchContractsQty(
872
+ exchange: ccxt.binance,
873
+ symbol: string,
874
+ side: "long" | "short"
875
+ ): Promise<number> {
876
+ const positions = await exchange.fetchPositions([symbol]);
877
+ const pos = findPosition(positions, symbol, side);
878
+ return Math.abs(parseFloat(String(pos?.contracts ?? 0)));
879
+ }
880
+
881
+ /**
882
+ * Cancel all orders in parallel โ€” allSettled so a single failure (already filled,
883
+ * network blip) does not leave remaining orders uncancelled.
884
+ */
885
+ async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
886
+ await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
887
+ }
888
+
889
+ /**
890
+ * Resolve Binance positionSide string from position direction.
891
+ * Required in hedge mode to correctly route orders; ignored in one-way mode.
892
+ */
893
+ function toPositionSide(position: "long" | "short"): "LONG" | "SHORT" {
894
+ return position === "long" ? "LONG" : "SHORT";
895
+ }
896
+
897
+ /**
898
+ * Place a limit order and poll until filled (status === "closed").
899
+ * On timeout: cancel the order, settle, check partial fill and close it via market,
900
+ * restore SL/TP on remaining position so it is never left unprotected, then throw.
901
+ *
902
+ * positionSide is forwarded into rollback market order so hedge mode accounts
903
+ * correctly route the close without -4061 error.
904
+ */
905
+ async function createLimitOrderAndWait(
906
+ exchange: ccxt.binance,
907
+ symbol: string,
908
+ side: "buy" | "sell",
909
+ qty: number,
910
+ price: number,
911
+ params: Record<string, unknown> = {},
912
+ restore?: { exitSide: "buy" | "sell"; tpPrice: number; slPrice: number; positionSide: "long" | "short" }
913
+ ): Promise<void> {
914
+ const order = await exchange.createOrder(symbol, "limit", side, qty, price, params);
915
+
916
+ for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
917
+ await sleep(FILL_POLL_INTERVAL_MS);
918
+ const status = await exchange.fetchOrder(order.id, symbol);
919
+ if (status.status === "closed") {
920
+ return;
921
+ }
922
+ }
923
+
924
+ await exchange.cancelOrder(order.id, symbol);
925
+
926
+ // Wait for Binance to settle the cancellation before reading filled qty
927
+ await sleep(CANCEL_SETTLE_MS);
928
+
929
+ const final = await exchange.fetchOrder(order.id, symbol);
930
+ const filledQty = final.filled ?? 0;
931
+
932
+ if (filledQty > 0) {
933
+ // Close partial fill via market โ€” positionSide required in hedge mode (-4061 without it)
934
+ const rollbackSide = side === "buy" ? "sell" : "buy";
935
+ const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
936
+ await exchange.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
937
+ reduceOnly: true,
938
+ ...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
939
+ });
940
+ }
941
+
942
+ // Restore SL/TP on remaining position so it is not left unprotected during retry
943
+ if (restore) {
944
+ const remainingQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, restore.positionSide));
945
+ if (remainingQty > 0) {
946
+ await exchange.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
947
+ await exchange.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
948
+ }
949
+ }
950
+
951
+ throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ€” partial fill rolled back, backtest-kit will retry`);
952
+ }
953
+
954
+ Broker.useBrokerAdapter(
955
+ class implements IBroker {
956
+
957
+ async waitForInit(): Promise<void> {
958
+ await getFuturesExchange();
959
+ }
960
+
961
+ async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
962
+ const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
963
+ const exchange = await getFuturesExchange();
964
+
965
+ // Set leverage before entry โ€” ensures consistent leverage regardless of previous session state
966
+ await exchange.setLeverage(FUTURES_LEVERAGE, symbol);
967
+
968
+ const qty = truncateQty(exchange, symbol, cost / priceOpen);
969
+
970
+ // Guard: truncation may produce 0 if cost/price is below lot size
971
+ if (qty <= 0) {
972
+ throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${priceOpen}`);
973
+ }
974
+
975
+ const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
976
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
977
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
978
+ const entrySide = position === "long" ? "buy" : "sell";
979
+ const exitSide = position === "long" ? "sell" : "buy";
980
+ // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
981
+ const positionSide = toPositionSide(position);
982
+
983
+ // Entry: no restore needed โ€” position does not exist yet if entry times out
984
+ await createLimitOrderAndWait(exchange, symbol, entrySide, qty, openPrice, { positionSide });
985
+
986
+ // Post-fill: if TP/SL placement fails, position is open and unprotected โ€” close via market
987
+ try {
988
+ await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
989
+ await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
990
+ } catch (err) {
991
+ await exchange.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
992
+ throw err;
993
+ }
994
+ }
995
+
996
+ async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
997
+ const { symbol, position, currentPrice, priceTakeProfit, priceStopLoss } = payload;
998
+ const exchange = await getFuturesExchange();
999
+
1000
+ const openOrders = await exchange.fetchOpenOrders(symbol);
1001
+ await cancelAllOrders(exchange, openOrders, symbol);
1002
+ await sleep(CANCEL_SETTLE_MS);
1003
+
1004
+ const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1005
+ const exitSide = position === "long" ? "sell" : "buy";
1006
+
1007
+ // Position already closed by SL/TP on exchange โ€” throw so backtest-kit can reconcile
1008
+ // the close price via its own mechanism rather than assuming a successful manual close
1009
+ if (qty === 0) {
1010
+ throw new Error(`SignalClose skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1011
+ }
1012
+
1013
+ const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1014
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1015
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1016
+
1017
+ // reduceOnly: prevents accidental reversal if qty has drift vs real position
1018
+ // Restore SL/TP if close times out so position is not left unprotected during retry
1019
+ await createLimitOrderAndWait(
1020
+ exchange, symbol, exitSide, qty, closePrice,
1021
+ { reduceOnly: true },
1022
+ { exitSide, tpPrice, slPrice, positionSide: position }
1023
+ );
1024
+ }
1025
+
1026
+ async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
1027
+ const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
1028
+ const exchange = await getFuturesExchange();
1029
+
1030
+ const openOrders = await exchange.fetchOpenOrders(symbol);
1031
+ await cancelAllOrders(exchange, openOrders, symbol);
1032
+ await sleep(CANCEL_SETTLE_MS);
1033
+
1034
+ const totalQty = await fetchContractsQty(exchange, symbol, position);
1035
+
1036
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1037
+ if (totalQty === 0) {
1038
+ throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1039
+ }
1040
+
1041
+ const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
1042
+ const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
1043
+ const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1044
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1045
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1046
+ const exitSide = position === "long" ? "sell" : "buy";
1047
+ const positionSide = toPositionSide(position);
1048
+
1049
+ // reduceOnly: prevents accidental reversal if qty has drift vs real position
1050
+ // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
1051
+ await createLimitOrderAndWait(
1052
+ exchange, symbol, exitSide, qty, closePrice,
1053
+ { reduceOnly: true },
1054
+ { exitSide, tpPrice, slPrice, positionSide: position }
1055
+ );
1056
+
1057
+ // Restore SL/TP on remaining qty after successful partial close
1058
+ if (remainingQty > 0) {
1059
+ try {
1060
+ await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
1061
+ await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1062
+ } catch (err) {
1063
+ // Remaining position is unprotected โ€” close via market
1064
+ await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
1065
+ throw err;
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
1071
+ const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
1072
+ const exchange = await getFuturesExchange();
1073
+
1074
+ const openOrders = await exchange.fetchOpenOrders(symbol);
1075
+ await cancelAllOrders(exchange, openOrders, symbol);
1076
+ await sleep(CANCEL_SETTLE_MS);
1077
+
1078
+ const totalQty = await fetchContractsQty(exchange, symbol, position);
1079
+
1080
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1081
+ if (totalQty === 0) {
1082
+ throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1083
+ }
1084
+
1085
+ const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
1086
+ const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
1087
+ const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1088
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1089
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1090
+ const exitSide = position === "long" ? "sell" : "buy";
1091
+ const positionSide = toPositionSide(position);
1092
+
1093
+ // reduceOnly: prevents accidental reversal if qty has drift vs real position
1094
+ // Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
1095
+ await createLimitOrderAndWait(
1096
+ exchange, symbol, exitSide, qty, closePrice,
1097
+ { reduceOnly: true },
1098
+ { exitSide, tpPrice, slPrice, positionSide: position }
1099
+ );
1100
+
1101
+ // Restore SL/TP on remaining qty after successful partial close
1102
+ if (remainingQty > 0) {
1103
+ try {
1104
+ await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
1105
+ await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1106
+ } catch (err) {
1107
+ // Remaining position is unprotected โ€” close via market
1108
+ await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
1109
+ throw err;
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
1115
+ const { symbol, newStopLossPrice, position } = payload;
1116
+ const exchange = await getFuturesExchange();
1117
+
1118
+ // Cancel existing SL order only โ€” filter by reduceOnly to avoid cancelling unrelated orders
1119
+ const orders = await exchange.fetchOpenOrders(symbol);
1120
+ const slOrder = orders.find((o) =>
1121
+ !!o.reduceOnly &&
1122
+ ["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
1123
+ ) ?? null;
1124
+ if (slOrder) {
1125
+ await exchange.cancelOrder(slOrder.id, symbol);
1126
+ await sleep(CANCEL_SETTLE_MS);
1127
+ }
1128
+
1129
+ const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1130
+ const exitSide = position === "long" ? "sell" : "buy";
1131
+
1132
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1133
+ if (qty === 0) {
1134
+ throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1135
+ }
1136
+
1137
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
1138
+ const positionSide = toPositionSide(position);
1139
+
1140
+ // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
1141
+ await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1142
+ }
1143
+
1144
+ async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
1145
+ const { symbol, newTakeProfitPrice, position } = payload;
1146
+ const exchange = await getFuturesExchange();
1147
+
1148
+ // Cancel existing TP order only โ€” filter by reduceOnly to avoid cancelling unrelated orders
1149
+ const orders = await exchange.fetchOpenOrders(symbol);
1150
+ const tpOrder = orders.find((o) =>
1151
+ !!o.reduceOnly &&
1152
+ ["limit", "LIMIT"].includes(o.type ?? "")
1153
+ ) ?? null;
1154
+ if (tpOrder) {
1155
+ await exchange.cancelOrder(tpOrder.id, symbol);
1156
+ await sleep(CANCEL_SETTLE_MS);
1157
+ }
1158
+
1159
+ const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1160
+ const exitSide = position === "long" ? "sell" : "buy";
1161
+
1162
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1163
+ if (qty === 0) {
1164
+ throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1165
+ }
1166
+
1167
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
1168
+ const positionSide = toPositionSide(position);
1169
+
1170
+ // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
1171
+ await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
1172
+ }
1173
+
1174
+ async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
1175
+ const { symbol, newStopLossPrice, position } = payload;
1176
+ const exchange = await getFuturesExchange();
1177
+
1178
+ // Cancel existing SL order only โ€” filter by reduceOnly to avoid cancelling unrelated orders
1179
+ const orders = await exchange.fetchOpenOrders(symbol);
1180
+ const slOrder = orders.find((o) =>
1181
+ !!o.reduceOnly &&
1182
+ ["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
1183
+ ) ?? null;
1184
+ if (slOrder) {
1185
+ await exchange.cancelOrder(slOrder.id, symbol);
1186
+ await sleep(CANCEL_SETTLE_MS);
1187
+ }
1188
+
1189
+ const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1190
+ const exitSide = position === "long" ? "sell" : "buy";
1191
+
1192
+ // Position may have already been closed by SL/TP on exchange โ€” skip gracefully
1193
+ if (qty === 0) {
1194
+ throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1195
+ }
1196
+
1197
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
1198
+ const positionSide = toPositionSide(position);
1199
+
1200
+ // positionSide required in hedge mode (-4061 without it); ignored in one-way mode
1201
+ await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1202
+ }
1203
+
1204
+ async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
1205
+ const { symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss } = payload;
1206
+ const exchange = await getFuturesExchange();
1207
+
1208
+ // Cancel existing SL/TP first โ€” existing check must happen after cancel+settle
1209
+ // to avoid race condition where SL/TP fills between the existence check and cancel
1210
+ const openOrders = await exchange.fetchOpenOrders(symbol);
1211
+ await cancelAllOrders(exchange, openOrders, symbol);
1212
+ await sleep(CANCEL_SETTLE_MS);
1213
+
1214
+ // Guard against DCA into a ghost position โ€” checked after cancel so the snapshot is fresh
1215
+ const existing = await fetchContractsQty(exchange, symbol, position);
1216
+ const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
1217
+
1218
+ // Compare notional value rather than raw contracts โ€” avoids float === 0 trap
1219
+ // and correctly rejects dust positions left over from previous trades
1220
+ if (existing * currentPrice < minNotional) {
1221
+ throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ€” SL/TP may have already been filled`);
1222
+ }
1223
+
1224
+ const qty = truncateQty(exchange, symbol, cost / currentPrice);
1225
+
1226
+ // Guard: truncation may produce 0 if cost/price is below lot size
1227
+ if (qty <= 0) {
1228
+ throw new Error(`Computed qty is zero for ${symbol} โ€” cost=${cost}, price=${currentPrice}`);
1229
+ }
1230
+
1231
+ const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
1232
+ const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
1233
+ const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
1234
+ // positionSide required in hedge mode to add to correct side; ignored in one-way mode
1235
+ const positionSide = toPositionSide(position);
1236
+ const entrySide = position === "long" ? "buy" : "sell";
1237
+ const exitSide = position === "long" ? "sell" : "buy";
1238
+
1239
+ // DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
1240
+ await createLimitOrderAndWait(
1241
+ exchange, symbol, entrySide, qty, entryPrice,
1242
+ { positionSide },
1243
+ { exitSide, tpPrice, slPrice, positionSide: position }
1244
+ );
1245
+
1246
+ // Refetch contracts after fill โ€” existing snapshot is stale after cancel + fill
1247
+ const totalQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
1248
+
1249
+ // Recreate SL/TP on fresh total qty after successful fill
1250
+ try {
1251
+ await exchange.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
1252
+ await exchange.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
1253
+ } catch (err) {
1254
+ // Total position is unprotected โ€” close via market
1255
+ await exchange.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
1256
+ throw err;
1257
+ }
1258
+ }
1259
+ }
1260
+ );
1261
+
1262
+ Broker.enable();
1263
+ ```
1264
+
1265
+ </details>
1266
+
1267
+ #### Internals
1268
+
1269
+ Signal open/close events are routed automatically via an internal event bus once `Broker.enable()` is called. **No manual wiring needed.** All other operations (`partialProfit`, `trailingStop`, `breakeven`, `averageBuy`) are intercepted explicitly before the corresponding state mutation.
1270
+
1271
+ ### ๐Ÿ” How Cron Works
1272
+
1273
+ `Cron` is a periodic / fire-once scheduler that runs in **virtual time** โ€” the same time stream your strategies see in backtest mode. Handlers fire on candle-interval boundaries (`1m`, `5m`, `1h`, `1d`, โ€ฆ) and are coordinated across parallel `Backtest.background(symbol, ...)` runs so the same boundary never produces two concurrent invocations.
1274
+
1275
+ **Public API:**
1276
+ - **`Cron.register({ name, interval?, symbols?, handler })`** โ€” register a job. Returns a disposer. Re-registering the same `name` replaces the previous entry and bumps an internal generation counter (late writes from old handlers are ignored).
1277
+ - **`Cron.enable()`** โ€” subscribe `Cron` to the engine's lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`). Wrapped in `singleshot`; call once at startup.
1278
+ - **`Cron.disable()`** โ€” tear down the subscriptions installed by `enable()`. Safe to call multiple times and before `enable()`.
1279
+ - **`Cron.unregister(name)`** โ€” remove a registered job.
1280
+ - **`Cron.clear(symbol?)`** โ€” clear fire-once marks. `symbol` provided โ†’ fan-out marks for that symbol only; no argument โ†’ all marks. Does **not** touch in-flight handlers.
1281
+
1282
+ **Two modes per `interval`:**
1283
+ - **Periodic** (`interval: "1h"`) โ€” handler fires once per boundary of that interval.
1284
+ - **Fire-once** (`interval` omitted) โ€” handler fires on the first matching tick and never again until `clear()` / `unregister` / re-`register`.
1285
+
1286
+ **Two scopes per `symbols`:**
1287
+ - **Global** (`symbols` omitted) โ€” handler fires once per boundary across all parallel backtests. First symbol to reach the boundary opens the slot; others await the same promise.
1288
+ - **Fan-out** (`symbols: ["BTC", "ETH"]`) โ€” handler fires once per boundary **per whitelisted symbol**. Each symbol has its own slot.
1289
+
1290
+ <details>
1291
+ <summary>
1292
+ The code
1293
+ </summary>
1294
+
1295
+ ```typescript
1296
+ import { Cron, Backtest } from "backtest-kit";
1297
+
1298
+ // Global hourly job โ€” fires once per virtual hour across all parallel backtests.
1299
+ Cron.register({
1300
+ name: "tg-signal-parser",
1301
+ interval: "1h",
1302
+ handler: async (symbol, when, backtest) => {
1303
+ await parseTelegramSignalsToMongo(when);
1304
+ },
1305
+ });
1306
+
1307
+ // Per-symbol fan-out โ€” fires once per hour per whitelisted symbol.
1308
+ Cron.register({
1309
+ name: "fetch-funding",
1310
+ interval: "1h",
1311
+ symbols: ["BTCUSDT", "ETHUSDT"],
1312
+ handler: async (symbol, when, backtest) => {
1313
+ await fetchFundingRate(symbol, when);
1314
+ },
1315
+ });
1316
+
1317
+ // Fire-once warm-up โ€” runs once globally on the very first tick.
1318
+ Cron.register({
1319
+ name: "warm-cache",
1320
+ handler: async (symbol, when, backtest) => {
1321
+ await warmupCache();
1322
+ },
1323
+ });
1324
+
1325
+ // Wire Cron to the engine once at startup. After this every strategy tick is
1326
+ // forwarded into Cron automatically โ€” no manual listener wiring needed.
1327
+ Cron.enable();
1328
+
1329
+ for (const symbol of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "TRXUSDT"]) {
1330
+ Backtest.background(symbol, { strategyName, exchangeName, frameName });
1331
+ }
1332
+
1333
+ // On shutdown:
1334
+ // Cron.disable();
1335
+ ```
1336
+
1337
+ </details>
1338
+
1339
+ #### Internals
1340
+
1341
+ `Cron.enable()` subscribes a single `singlerun`-wrapped handler to four lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`). `singlerun` merges all four streams into one serial queue, so concurrent ticks on the same `(symbol, virtual-minute)` cannot race to open the same slot. Each incoming tick is **base-aligned to the 1-minute boundary** before any further processing โ€” lifecycle pings may carry sub-second jitter, but Cron always reasons in whole minutes.
1342
+
1343
+ Coordination keys are built as `${name}:${alignedMs}:${symbol?}:g${generation}`. Parallel backtests that hit the same key share a single in-flight promise (mutex semantics): the first opens the slot and runs the handler, others `await` the same promise and release together. After `.finally()` the slot is removed and the next boundary creates a fresh promise. Fire-once entries additionally record a `_firedOnce` mark on success so subsequent ticks skip them โ€” a failed handler is **not** marked, so it retries on the next tick. The generation suffix isolates re-registrations: a late write from a still-in-flight handler of a previous `register()` carries the old generation and never collides with the new entry.
1344
+
1345
+ ### ๐Ÿ” How getCandles Works
1346
+
1347
+ backtest-kit uses Node.js `AsyncLocalStorage` to automatically provide
1348
+ temporal time context to your strategies.
1349
+
1350
+ <details>
1351
+ <summary>
1352
+ The Math
1353
+ </summary>
1354
+
1355
+ For a candle with:
1356
+ - `timestamp` = candle open time (openTime)
1357
+ - `stepMs` = interval duration (e.g., 60000ms for "1m")
1358
+ - Candle close time = `timestamp + stepMs`
1359
+
1360
+ **Alignment:** All timestamps are aligned down to interval boundary.
1361
+ For example, for 15m interval: 00:17 โ†’ 00:15, 00:44 โ†’ 00:30
1362
+
1363
+ **Adapter contract:**
1364
+ - First candle.timestamp must equal aligned `since`
1365
+ - Adapter must return exactly `limit` candles
1366
+ - Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
1367
+
1368
+ **How `since` is calculated from `when`:**
1369
+ - `when` = current execution context time (from AsyncLocalStorage)
1370
+ - `alignedWhen` = `Math.floor(when / stepMs) * stepMs` (aligned down to interval boundary)
1371
+ - `since` = `alignedWhen - limit * stepMs` (go back `limit` candles from aligned when)
1372
+
1373
+ **Boundary semantics (inclusive/exclusive):**
1374
+ - `since` is always **inclusive** โ€” first candle has `timestamp === since`
1375
+ - Exactly `limit` candles are returned
1376
+ - Last candle has `timestamp === since + (limit - 1) * stepMs` โ€” **inclusive**
1377
+ - For `getCandles`: `alignedWhen` is **exclusive** โ€” candle at that timestamp is NOT included (it's a pending/incomplete candle)
1378
+ - For `getRawCandles`: `eDate` is **exclusive** โ€” candle at that timestamp is NOT included (it's a pending/incomplete candle)
1379
+ - For `getNextCandles`: `alignedWhen` is **inclusive** โ€” first candle starts at `alignedWhen` (it's the current candle for backtest, already closed in historical data)
1380
+
1381
+ - `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
1382
+ - Aligns `when` down to interval boundary
1383
+ - Calculates `since = alignedWhen - limit * stepMs`
1384
+ - **since โ€” inclusive**, first candle.timestamp === since
1385
+ - **alignedWhen โ€” exclusive**, candle at alignedWhen is NOT returned
1386
+ - Range: `[since, alignedWhen)` โ€” half-open interval
1387
+ - Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before aligned when
1388
+
1389
+ - `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
1390
+ - Aligns `when` down to interval boundary
1391
+ - `since = alignedWhen` (starts from aligned when, going forward)
1392
+ - **since โ€” inclusive**, first candle.timestamp === since
1393
+ - Range: `[alignedWhen, alignedWhen + limit * stepMs)` โ€” half-open interval
1394
+ - Throws error in live mode to prevent look-ahead bias
1395
+ - Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles starting from aligned when
1396
+
1397
+ - `getRawCandles(symbol, interval, limit?, sDate?, eDate?)` - Flexible parameter combinations:
1398
+ - `(limit)` - since = alignedWhen - limit * stepMs, range `[since, alignedWhen)`
1399
+ - `(limit, sDate)` - since = align(sDate), returns `limit` candles forward, range `[since, since + limit * stepMs)`
1400
+ - `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs, **eDate โ€” exclusive**, range `[since, eDate)`
1401
+ - `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range, **sDate โ€” inclusive, eDate โ€” exclusive**, range `[sDate, eDate)`
1402
+ - `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles, **sDate โ€” inclusive**
1403
+ - All combinations respect look-ahead bias protection (eDate/endTime <= when)
1404
+
1405
+ **Persistent Cache:**
1406
+ - Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
1407
+ - Returns all candles if found, null if any missing (cache miss)
1408
+ - Cache and runtime use identical timestamp calculation logic
1409
+
1410
+ </details>
1411
+
1412
+ #### Candle Timestamp Convention:
1413
+
1414
+ According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
1415
+
1416
+ **Key principles:**
1417
+ - All timestamps are aligned down to interval boundary
1418
+ - First candle.timestamp must equal aligned `since`
1419
+ - Adapter must return exactly `limit` candles
1420
+ - Sequential timestamps: `since + i * stepMs`
1421
+
1422
+
1423
+ ### ๐Ÿ” How getOrderBook Works
1424
+
1425
+ Order book fetching uses the same temporal alignment as candles, but with a configurable time offset window instead of candle intervals.
1426
+
1427
+ <details>
1428
+ <summary>
1429
+ The Math
1430
+ </summary>
1431
+
1432
+ **Time range calculation:**
1433
+ - `when` = current execution context time (from AsyncLocalStorage)
1434
+ - `offsetMinutes` = `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` (configurable)
1435
+ - `alignedTo` = `Math.floor(when / (offsetMinutes * 60000)) * (offsetMinutes * 60000)`
1436
+ - `to` = `alignedTo` (aligned down to offset boundary)
1437
+ - `from` = `alignedTo - offsetMinutes * 60000`
1438
+
1439
+ **Adapter contract:**
1440
+ - `getOrderBook(symbol, depth, from, to, backtest)` is called on the exchange schema
1441
+ - `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
1442
+ - The `from`/`to` range represents a time window of exactly `offsetMinutes` duration
1443
+ - Schema implementation may use the time range (backtest) or ignore it (live trading)
1444
+
1445
+ **Example with CC_ORDER_BOOK_TIME_OFFSET_MINUTES = 10:**
1446
+ ```
1447
+ when = 1704067920000 // 2024-01-01 00:12:00 UTC
1448
+ offsetMinutes = 10
1449
+ offsetMs = 10 * 60000 // 600000ms
1450
+
1451
+ alignedTo = Math.floor(1704067920000 / 600000) * 600000
1452
+ = 1704067800000 // 2024-01-01 00:10:00 UTC
1453
+
1454
+ to = 1704067800000 // 00:10:00 UTC
1455
+ from = 1704067200000 // 00:00:00 UTC
1456
+ ```
1457
+ </details>
1458
+
1459
+ #### Order Book Timestamp Convention:
1460
+
1461
+ Unlike candles, most exchanges (e.g. Binance `GET /api/v3/depth`) only expose the **current** order book with no historical query support โ€” for backtest you must provide your own snapshot storage.
1462
+
1463
+ **Key principles:**
1464
+ - Time range is aligned down to `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` boundary
1465
+ - `to` = aligned timestamp, `from` = `to - offsetMinutes * 60000`
1466
+ - `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
1467
+ - Adapter receives `(symbol, depth, from, to, backtest)` โ€” may ignore `from`/`to` in live mode
1468
+
1469
+ ### ๐Ÿ” How getAggregatedTrades Works
1470
+
1471
+ Aggregated trades fetching uses the same look-ahead bias protection as candles - `to` is always aligned down to the nearest minute boundary so future trades are never visible to the strategy.
1472
+
1473
+ **Key principles:**
1474
+ - `to` is always aligned down to the 1-minute boundary โ€” prevents look-ahead bias
1475
+ - Without `limit`: returns one full window (`CC_AGGREGATED_TRADES_MAX_MINUTES`)
1476
+ - With `limit`: paginates backwards until collected, then slices to most recent `limit`
1477
+ - Adapter receives `(symbol, from, to, backtest)` โ€” may ignore `from`/`to` in live mode
1478
+
1479
+ <details>
1480
+ <summary>
1481
+ The Math
1482
+ </summary>
1483
+
1484
+ **Time range calculation:**
1485
+ - `when` = current execution context time (from AsyncLocalStorage)
1486
+ - `alignedTo` = `Math.floor(when / 60000) * 60000` (aligned down to 1-minute boundary)
1487
+ - `windowMs` = `CC_AGGREGATED_TRADES_MAX_MINUTES * 60000 โˆ’ 60000`
1488
+ - `to` = `alignedTo`, `from` = `alignedTo โˆ’ windowMs`
1489
+
1490
+ **Without `limit`:** fetches a single window and returns it as-is.
1491
+
1492
+ **With `limit`:** paginates backwards in `CC_AGGREGATED_TRADES_MAX_MINUTES` chunks until at least `limit` trades are collected, then slices to the most recent `limit` trades.
1493
+
1494
+ **Example with CC_AGGREGATED_TRADES_MAX_MINUTES = 60, limit = 200:**
1495
+ ```
1496
+ when = 1704067920000 // 2024-01-01 00:12:00 UTC
1497
+ alignedTo = 1704067800000 // 2024-01-01 00:12:00 โ†’ aligned to 00:12:00
1498
+ windowMs = 59 * 60000 // 3540000ms = 59 minutes
1499
+
1500
+ Window 1: from = 00:12:00 โˆ’ 59m = 23:13:00
1501
+ to = 00:12:00
1502
+ โ†’ got 120 trades โ€” not enough
1503
+
1504
+ Window 2: from = 23:13:00 โˆ’ 59m = 22:14:00
1505
+ to = 23:13:00
1506
+ โ†’ got 100 more โ†’ total 220 trades
1507
+
1508
+ result = last 200 of 220 (most recent)
1509
+ ```
1510
+
1511
+ **Adapter contract:**
1512
+ - `getAggregatedTrades(symbol, from, to, backtest)` is called on the exchange schema
1513
+ - `from`/`to` are `Date` objects
1514
+ - Schema implementation may use the time range (backtest) or ignore it (live trading)
1515
+
1516
+ </details>
1517
+
1518
+ #### Aggregated Trades Timestamp Convention:
1519
+
1520
+ **Compatible with:** [garch](https://www.npmjs.com/package/garch) for volatility modelling and [volume-anomaly](https://www.npmjs.com/package/volume-anomaly) for detecting abnormal trade volume โ€” both accept the same `from`/`to` time range format that `getAggregatedTrades` produces.
1521
+
1522
+ ### ๐Ÿ”ฌ Technical Details: Timestamp Alignment
1523
+
1524
+ **Why align timestamps to interval boundaries?**
1525
+
1526
+ Because candle APIs return data starting from exact interval boundaries:
1527
+
1528
+ ```typescript
1529
+ // 15-minute interval example:
1530
+ when = 1704067920000 // 00:12:00
1531
+ step = 15 // 15 minutes
1532
+ stepMs = 15 * 60000 // 900000ms
1533
+
1534
+ // Alignment: round down to nearest interval boundary
1535
+ alignedWhen = Math.floor(when / stepMs) * stepMs
1536
+ // = Math.floor(1704067920000 / 900000) * 900000
1537
+ // = 1704067200000 (00:00:00)
1538
+
1539
+ // Calculate since for 4 candles backwards:
1540
+ since = alignedWhen - 4 * stepMs
1541
+ // = 1704067200000 - 4 * 900000
1542
+ // = 1704063600000 (23:00:00 previous day)
1543
+
1544
+ // Expected candles:
1545
+ // [0] timestamp = 1704063600000 (23:00)
1546
+ // [1] timestamp = 1704064500000 (23:15)
1547
+ // [2] timestamp = 1704065400000 (23:30)
1548
+ // [3] timestamp = 1704066300000 (23:45)
1549
+ ```
1550
+
1551
+ **Pending candle exclusion:** The candle at `00:00:00` (alignedWhen) is NOT included in the result. At `when=00:12:00`, this candle covers the period `[00:00, 00:15)` and is still open (pending). Pending candles have incomplete OHLCV data that would distort technical indicators. Only fully closed candles are returned.
1552
+
1553
+ **Validation is applied consistently across:**
1554
+ - โœ… `getCandles()` - validates first timestamp and count
1555
+ - โœ… `getNextCandles()` - validates first timestamp and count
1556
+ - โœ… `getRawCandles()` - validates first timestamp and count
1557
+ - โœ… Cache read - calculates exact expected timestamps
1558
+ - โœ… Cache write - stores validated candles
1559
+
1560
+ **Result:** Deterministic candle retrieval with exact timestamp matching.
1561
+
1562
+ ### ๐Ÿ• Timezone Warning: Candle Boundaries Are UTC-Based
1563
+
1564
+ All candle timestamp alignment uses UTC (Unix epoch). For intervals like `4h`, boundaries are `00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC`. If your local timezone offset is not a multiple of the interval, the `since` timestamps will look "uneven" in local time.
1565
+
1566
+ For example, in UTC+5 the same 4h candle request logs as:
1567
+
1568
+ ```
1569
+ since: Sat Sep 20 2025 13:00:00 GMT+0500 โ† looks uneven (13:00)
1570
+ since: Sat Sep 20 2025 17:00:00 GMT+0500 โ† looks uneven (17:00)
1571
+ since: Sat Sep 20 2025 21:00:00 GMT+0500 โ† looks uneven (21:00)
1572
+ since: Sun Sep 21 2025 05:00:00 GMT+0500 โ† looks uneven (05:00)
1573
+ ```
1574
+
1575
+ But in UTC these are perfectly aligned 4h boundaries:
1576
+
1577
+ ```
1578
+ since: Sat, 20 Sep 2025 08:00:00 GMT โ† 08:00 UTC โœ“
1579
+ since: Sat, 20 Sep 2025 12:00:00 GMT โ† 12:00 UTC โœ“
1580
+ since: Sat, 20 Sep 2025 16:00:00 GMT โ† 16:00 UTC โœ“
1581
+ since: Sun, 21 Sep 2025 00:00:00 GMT โ† 00:00 UTC โœ“
1582
+ ```
1583
+
1584
+ Use `toUTCString()` or `toISOString()` in callbacks to see the actual aligned UTC times.
1585
+
1586
+ ### ๐Ÿ’ญ What this means:
1587
+ - `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`
1588
+ - Multi-timeframe data is automatically synchronized
1589
+ - **Impossible to introduce look-ahead bias** - all time boundaries are enforced
1590
+ - Same code works in both backtest and live modes
1591
+ - Boundary semantics prevent edge cases in signal generation
1592
+
1593
+
1594
+ ## ๐Ÿง  Two Ways to Run the Engine
1595
+
1596
+ Backtest Kit exposes the same runtime in two equivalent forms. Both approaches use **the same engine and guarantees** - only the consumption model differs.
1597
+
1598
+ ### 1๏ธโƒฃ Event-driven (background execution)
1599
+
1600
+ Suitable for production bots, monitoring, and long-running processes.
1601
+
1602
+ ```typescript
1603
+ Backtest.background('BTCUSDT', config);
1604
+
1605
+ listenSignalBacktest(event => { /* handle signals */ });
1606
+ listenDoneBacktest(event => { /* finalize / dump report */ });
1607
+ ```
1608
+
1609
+ ### 2๏ธโƒฃ Async Iterator (pull-based execution)
1610
+
1611
+ Suitable for research, scripting, testing, and LLM agents.
1612
+
1613
+ ```typescript
1614
+ for await (const event of Backtest.run('BTCUSDT', config)) {
1615
+ // signal | trade | progress | done
1616
+ }
1617
+ ```
1618
+
1619
+ ## โš”๏ธ Think of it as...
1620
+
1621
+ **Open-source QuantConnect/MetaTrader without the vendor lock-in**
1622
+
1623
+ Unlike cloud-based platforms, backtest-kit runs entirely in your environment. You own the entire stack from data ingestion to live execution. In addition to Ollama, you can use [neural-trader](https://www.npmjs.com/package/neural-trader) in `getSignal` function or any other third party library
1624
+
1625
+ - No C#/C++ required - pure TypeScript/JavaScript
1626
+ - Self-hosted - your code, your data, your infrastructure
1627
+ - No platform fees or hidden costs
1628
+ - Full control over execution and data sources
1629
+ - [GUI](https://npmjs.com/package/@backtest-kit/ui) for visualization and monitoring
1630
+
1631
+ ## ๐ŸŒ Ecosystem
1632
+
1633
+ The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
1634
+
1635
+
1636
+ ### @backtest-kit/cli
1637
+
1638
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** ๐Ÿ“Ÿ
1639
+
1640
+ The **@backtest-kit/cli** package is a zero-boilerplate CLI runner for backtest-kit strategies. Point it at your strategy file and run backtests, paper trading, or live bots โ€” no infrastructure code required.
1641
+
1642
+ #### Key Features
1643
+ - ๐Ÿš€ **Zero Config**: Run a backtest with one command โ€” no setup code needed
1644
+ - ๐Ÿ”„ **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
1645
+ - ๐Ÿ’พ **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
1646
+ - ๐ŸŒ **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
1647
+ - ๐Ÿ“ฌ **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
1648
+ - ๐Ÿ—‚๏ธ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
1649
+
1650
+ #### Use Case
1651
+ The fastest way to run any backtest-kit strategy from the command line. Instead of writing boilerplate for storage, notifications, candle caching, and signal logging, add one dependency and wire up your `package.json` scripts. Works equally well for a single-strategy project or a monorepo with dozens of strategies in separate subdirectories.
1652
+
1653
+ #### Get Started
1654
+ ```bash
1655
+ npx -y @backtest-kit/cli --init
1656
+ ```
1657
+
1658
+
1659
+ ### @backtest-kit/pinets
1660
+
1661
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** ๐Ÿ“œ
1662
+
1663
+ The **@backtest-kit/pinets** package lets you run TradingView Pine Script strategies directly in Node.js. Port your existing Pine Script indicators to backtest-kit with zero rewrite using the [PineTS](https://github.com/QuantForgeOrg/PineTS) runtime.
1664
+
1665
+ #### Key Features
1666
+ - ๐Ÿ“œ **Pine Script v5/v6**: Native TradingView syntax with 1:1 compatibility
1667
+ - ๐ŸŽฏ **60+ Indicators**: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Stochastic built-in
1668
+ - ๐Ÿ“ **File or Code**: Load `.pine` files or pass code strings directly
1669
+ - ๐Ÿ—บ๏ธ **Plot Extraction**: Flexible mapping from Pine `plot()` outputs to structured signals
1670
+ - โšก **Cached Execution**: Memoized file reads for repeated strategy runs
1671
+
1672
+ #### Use Case
1673
+ Perfect for traders who already have working TradingView strategies. Instead of rewriting your Pine Script logic in JavaScript, simply copy your `.pine` file and use `getSignal()` to extract trading signals. Works seamlessly with backtest-kit's temporal context - no look-ahead bias possible.
1674
+
1675
+ #### Get Started
1676
+ ```bash
1677
+ npm install @backtest-kit/pinets pinets backtest-kit
1678
+ ```
1679
+
1680
+
1681
+ ### @backtest-kit/graph
1682
+
1683
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** ๐Ÿ”—
1684
+
1685
+ The **@backtest-kit/graph** package lets you compose backtest-kit computations as a typed directed acyclic graph (DAG). Define source nodes that fetch market data and output nodes that compute derived values โ€” then resolve the whole graph in topological order with automatic parallelism.
1686
+
1687
+ #### Key Features
1688
+ - ๐Ÿ”Œ **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
1689
+ - ๐Ÿ”’ **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
1690
+ - ๐Ÿงฑ **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
1691
+ - ๐Ÿ’พ **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
1692
+ - ๐ŸŒ **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
1693
+
1694
+ #### Use Case
1695
+ Perfect for multi-timeframe strategies where multiple Pine Script or indicator computations must be combined. Instead of manually chaining async calls, define each computation as a node and let the graph resolve dependencies in parallel. Adding a new filter or timeframe requires no changes to the existing wiring.
1696
+
1697
+ #### Get Started
1698
+ ```bash
1699
+ npm install @backtest-kit/graph backtest-kit
1700
+ ```
1701
+
1702
+
1703
+ ### @backtest-kit/ui
1704
+
1705
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ui)** ๐Ÿ“Š
1706
+
1707
+ The **@backtest-kit/ui** package is a full-stack UI framework for visualizing cryptocurrency trading signals, backtests, and real-time market data. Combines a Node.js backend server with a React dashboard - all in one package.
1708
+
1709
+ #### Key Features
1710
+ - ๐Ÿ“ˆ **Interactive Charts**: Candlestick visualization with Lightweight Charts (1m, 15m, 1h timeframes)
1711
+ - ๐ŸŽฏ **Signal Tracking**: View opened, closed, scheduled, and cancelled signals with full details
1712
+ - ๐Ÿ“Š **Risk Analysis**: Monitor risk rejections and position management
1713
+ - ๐Ÿ”” **Notifications**: Real-time notification system for all trading events
1714
+ - ๐Ÿ’น **Trailing & Breakeven**: Visualize trailing stop/take and breakeven events
1715
+ - ๐ŸŽจ **Material Design**: Beautiful UI with MUI 5 and Mantine components
1716
+
1717
+ #### Use Case
1718
+ Perfect for monitoring your trading bots in production. Instead of building custom dashboards, `@backtest-kit/ui` provides a complete visualization layer out of the box. Each signal view includes detailed information forms, multi-timeframe candlestick charts, and JSON export for all data.
1719
+
1720
+ #### Get Started
1721
+ ```bash
1722
+ npm install @backtest-kit/ui backtest-kit ccxt
1723
+ ```
1724
+
1725
+
1726
+ ### @backtest-kit/mongo
1727
+
1728
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/mongo)** ๐Ÿ’พ
1729
+
1730
+ The **@backtest-kit/mongo** package replaces the default file-based `./dump/` storage with MongoDB as the source of truth and Redis as an O(1) lookup cache. All 15 `IPersist*Instance` contracts from backtest-kit are implemented โ€” strategy code stays unchanged.
1731
+
1732
+ #### Key Features
1733
+ - ๐Ÿ—„๏ธ **MongoDB Backend**: All 15 persistence adapters implemented with Mongoose and unique compound indexes
1734
+ - โšก **O(1) Reads via Redis**: Every context-key lookup goes through ioredis โ€” one `GET` + one `findById`, no B-tree scans
1735
+ - ๐Ÿ”’ **Atomic Writes**: `findOneAndUpdate` with `upsert: true` guarantees read-after-write correctness with no race conditions
1736
+ - ๐Ÿ›ก๏ธ **Look-Ahead Bias Protection**: Adapters that affect signal logic store the simulation timestamp so backtest-kit can enforce temporal correctness
1737
+ - ๐Ÿชฆ **Soft Delete**: Measure, Interval, and Memory records carry a `removed` flag instead of being physically deleted
1738
+ - ๐Ÿ”Œ **Zero Strategy Changes**: Drop `setup()` into your entry point, everything else stays the same
1739
+
1740
+ #### Use Case
1741
+ Perfect for production deployments where the default file-based storage is a bottleneck or a reliability concern. During backtests, backtest-kit performs thousands of context-keyed reads per second โ€” Redis eliminates the per-request B-tree traversal and makes repeated reads effectively free. MongoDB provides durability, atomic upserts, and a queryable signal history that survives process restarts.
1742
+
1743
+ #### Get Started
1744
+ ```bash
1745
+ npm install @backtest-kit/mongo backtest-kit mongoose ioredis
1746
+ ```
1747
+
1748
+
1749
+ ### @backtest-kit/ollama
1750
+
1751
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ollama)** ๐Ÿค–
1752
+
1753
+ The **@backtest-kit/ollama** package is a multi-provider LLM inference library that supports 10+ providers including OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, and Ollama with unified API and automatic token rotation.
1754
+
1755
+ #### Key Features
1756
+ - ๐Ÿ”Œ **10+ LLM Providers**: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama
1757
+ - ๐Ÿ”„ **Token Rotation**: Automatic API key rotation for Ollama (others throw clear errors)
1758
+ - ๐ŸŽฏ **Structured Output**: Enforced JSON schema for trading signals (position, price levels, risk notes)
1759
+ - ๐Ÿ”‘ **Flexible Auth**: Context-based API keys or environment variables
1760
+ - โšก **Unified API**: Single interface across all providers
1761
+ - ๐Ÿ“Š **Trading-First**: Built for backtest-kit with position sizing and risk management
1762
+
1763
+ #### Use Case
1764
+ Ideal for building multi-provider LLM strategies with fallback chains and ensemble predictions. The package returns structured trading signals with validated TP/SL levels, making it perfect for use in `getSignal` functions. Supports both backtest and live trading modes.
1765
+
1766
+ #### Get Started
1767
+ ```bash
1768
+ npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
1769
+ ```
1770
+
1771
+
1772
+ ### @backtest-kit/signals
1773
+
1774
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/signals)** ๐Ÿ“Š
1775
+
1776
+ The **@backtest-kit/signals** package is a technical analysis and trading signal generation library designed for AI-powered trading systems. It computes 50+ indicators across 4 timeframes and generates markdown reports optimized for LLM consumption.
1777
+
1778
+ #### Key Features
1779
+ - ๐Ÿ“ˆ **Multi-Timeframe Analysis**: 1m, 15m, 30m, 1h with synchronized indicator computation
1780
+ - ๐ŸŽฏ **50+ Technical Indicators**: RSI, MACD, Bollinger Bands, Stochastic, ADX, ATR, CCI, Fibonacci, Support/Resistance
1781
+ - ๐Ÿ“Š **Order Book Analysis**: Bid/ask depth, spread, liquidity imbalance, top 20 levels
1782
+ - ๐Ÿค– **AI-Ready Output**: Markdown reports formatted for LLM context injection
1783
+ - โšก **Performance Optimized**: Intelligent caching with configurable TTL per timeframe
1784
+
1785
+ #### Use Case
1786
+ Perfect for injecting comprehensive market context into your LLM-powered strategies. Instead of manually calculating indicators, `@backtest-kit/signals` provides a single function call that adds all technical analysis to your message context. Works seamlessly with `getSignal` function in backtest-kit strategies.
1787
+
1788
+ #### Get Started
1789
+ ```bash
1790
+ npm install @backtest-kit/signals backtest-kit
1791
+ ```
1792
+
1793
+
1794
+ ### @backtest-kit/sidekick
1795
+
1796
+ > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** ๐Ÿš€
1797
+
1798
+ The **@backtest-kit/sidekick** package scaffolds a project where **all wiring is visible and editable** in your project files โ€” exchange adapter, frame definitions, risk rules, strategy logic, and the runner script. Think of it as the **eject** of `@backtest-kit/cli --init`: instead of the boilerplate being hidden inside the CLI package, it lives directly in your project.
1799
+
1800
+ #### Key Features
1801
+ - ๐Ÿš€ **Zero Config**: Get started with one command - no setup required
1802
+ - ๐Ÿ“ฆ **Complete Template**: Includes backtest strategy, risk management, and LLM integration
1803
+ - ๐Ÿค– **AI-Powered**: Pre-configured with DeepSeek, Claude, and GPT-5 fallback chain
1804
+ - ๐Ÿ“Š **Technical Analysis**: Built-in 50+ indicators via @backtest-kit/signals
1805
+ - ๐Ÿ”‘ **Environment Setup**: Auto-generated .env with all API key placeholders
1806
+ - ๐Ÿ“ **Best Practices**: Production-ready code structure with examples
1807
+
1808
+ #### Use Case
1809
+ The fastest way to bootstrap a new trading bot project. Instead of manually setting up dependencies, configurations, and boilerplate code, simply run one command and get a working project with LLM-powered strategy, multi-timeframe technical analysis, and risk management validation.
1810
+
1811
+ #### Get Started
1812
+ ```bash
1813
+ npx -y @backtest-kit/sidekick my-trading-bot
1814
+ cd my-trading-bot
1815
+ npm start
1816
+ ```
1817
+
1818
+
1819
+ ## ๐Ÿ‘ช Community
1820
+
1821
+ ### backtest-monorepo-parallel
1822
+
1823
+ > **[Explore on GitHub](https://github.com/backtest-kit/backtest-monorepo-parallel)** ๐ŸŽ๏ธ
1824
+
1825
+ The **backtest-monorepo-parallel** repository is a TypeScript monorepo template that runs **9 symbols in parallel** in a single Node process on top of shared Mongo + Redis infrastructure, with a self-enforcement runtime that exposes the workspace DI container to `./content/` strategy files. No wiring, no bundler hooks, no strategy-author changes.
1826
+
1827
+ #### Key Features
1828
+ - โšก **~6 300ร— Real-Time Aggregate**: 9 symbols ร— ~703ร— per-symbol replay speed, ~103 events/sec in the hot `listenActivePing โ†’ commitAverageBuy` loop on a commodity i5-13420H laptop
1829
+ - ๐Ÿงต **Single-Process Concurrency**: All 9 `Backtest.background(...)` contexts share one event loop, one Mongo pool, one Redis pool โ€” no IPC, no fork overhead
1830
+ - ๐Ÿ’‰ **DI Surface**: Workspace services typed via rolled-up `types.d.ts` and reachable from strategy files at evaluation time
1831
+ - ๐Ÿ—‚๏ธ **Mode A / Mode B**: `--entry` flag toggles between parallel runner (`CC_SYMBOL_LIST` fan-out) and single-strategy CLI mode
1832
+ - ๐Ÿงฉ **Linear Scaling Recipe**: Adding a service = +1 file, +1 symbol, +1 provider, +1 ioc entry โ€” no churn under `./content/`
1833
+
1834
+ #### Use Case
1835
+ Use when you need to backtest many symbols concurrently against the same strategy without spawning subprocesses, and want a scaffold where new services, collections, and Redis caches drop in alongside existing ones without restructuring. Ideal as the starting point for a production parallel-symbol backtesting setup.
1836
+
1837
+ #### Get Started
1838
+ ```bash
1839
+ git clone https://github.com/backtest-kit/backtest-monorepo-parallel.git
1840
+ ```
1841
+
1842
+
1843
+ ### backtest-ollama-crontab
1844
+
1845
+ > **[Explore on GitHub](https://github.com/backtest-kit/backtest-ollama-crontab)** ๐Ÿ 
1846
+
1847
+ The **backtest-ollama-crontab** repository is a TypeScript monorepo template that wires a cloud/local **Ollama** into a trading-signal pipeline as a risk filter, with a **15-minute crontab** ingesting signals from any public Telegram channel. The **same code runs in both live and backtest modes** โ€” the crontab re-polls live and pulls the entire frame at startup in backtest.
1848
+
1849
+ #### Key Features
1850
+ - ๐Ÿค– **Local/Cloud LLM Risk Filter**: Per-signal verdict from local Ollama (`gpt-oss` quantized) returning `riskAction: "skip" | "follow"`, with empirical rules embedded in the system prompt and tunable without recompiling packages
1851
+ - โฐ **Crontab-Driven Ingestion**: `Cron.register(..., interval: "15m")` for live re-polling of the Telegram channel, plus a fire-once `Cron.register(...)` (no `interval`) for backtest-time bulk prepare โ€” same code path in both modes
1852
+ - ๐Ÿ“ก **Telegram MTProto Crawler**: QR-code session auth, `iterMessages` pull from any public channel into a `parser-items` Mongo collection, regex extraction of `direction / entry / targets / stoploss` into `screen-items`
1853
+ - ๐Ÿง  **Outline-Based Risk Logic**: Risk outline ingests 1m/15m candles + a pre-computed metrics packet (`avgRangePct`, `momentum24hPct`) and produces a zod-validated verdict consumed by the strategy
1854
+ - ๐Ÿ“ˆ **Reproducible Backtest Comparison**: same parsed-signal set, two backtests side-by-side โ€” **+52.22% โ†’ +68.90%** total PNL, Sharpe **+0.309 โ†’ +0.512**, winrate **68% โ†’ 82%**, profit factor **2.73 โ†’ 6.37** with the LLM gate enabled
1855
+
1856
+ #### Use Case
1857
+ Reference for integrating any local LLM into a backtest-kit pipeline as a signal filter, and for combining periodic crontab pulls (live) with one-shot bulk prepare (backtest) via the same `Cron.register` API.
1858
+
1859
+ #### Get Started
1860
+ ```bash
1861
+ git clone https://github.com/backtest-kit/backtest-ollama-crontab.git
1862
+ ```
1863
+
1864
+
1865
+ ### backtest-kit-redis-mongo-docker
1866
+
1867
+ > **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-redis-mongo-docker)** ๐Ÿณ
1868
+
1869
+ The **backtest-kit-redis-mongo-docker** repository is a production-grade integration that replaces the default file-based `./dump/` persistence with **MongoDB** as the source of truth and **Redis** as an O(1) lookup cache, packaged with `docker-compose` for one-command deploys.
1870
+
1871
+ #### Key Features
1872
+ - ๐Ÿ—‚๏ธ **15 Persist Adapters**: Full implementation of every `IPersist*Instance` contract (Candle, Signal, Schedule, Risk, Partial, Breakeven, Storage, Notification, Log, Measure, Interval, Memory, Recent, State, Session) on top of MongoDB + Redis
1873
+ - โš›๏ธ **Atomic Read-After-Write**: Single-round-trip `findOneAndUpdate` with unique compound indexes โ€” no E11000 leaks under concurrent writes
1874
+ - โšก **Redis O(1) Cache**: Per-domain `*CacheService` over `ioredis` for context-key โ†’ id lookups; cache miss falls back to Mongo and backfills automatically
1875
+ - ๐Ÿ›ก๏ธ **Look-Ahead Bias Protection**: Indexed `when: Number` column on every signal-affecting schema, fed by backtest-kit 9.0+'s `when: Date` adapter argument
1876
+ - ๐Ÿณ **Docker Compose Stack**: Separate compose files for Mongo and Redis plus a main container with networks; configurable via `CC_MONGO_CONNECTION_STRING` / `CC_REDIS_*` env vars
1877
+
1878
+ #### Use Case
1879
+ Drop-in persistence upgrade for any backtest-kit project that outgrows the default file-based `./dump/` layout โ€” strategy code, runners, and the CLI entry point stay unchanged. Use it when you need durable storage, concurrent-safe writes, fast restart recovery, or a containerized deployment for live and paper trading.
1880
+
1881
+ #### Get Started
1882
+ ```bash
1883
+ git clone https://github.com/backtest-kit/backtest-kit-redis-mongo-docker.git
1884
+ ```
1885
+
1886
+
1887
+ ### backtest-kit-skills
1888
+
1889
+ > **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-skills)** ๐Ÿค–
1890
+
1891
+ The **backtest-kit-skills** repository is a Claude Code agent skill and Mintlify documentation source for the backtest-kit framework โ€” AI-assisted strategy writing, debugging help, and full API reference in one place.
1892
+
1893
+ #### Key Features
1894
+ - ๐Ÿค– **Claude Code Skill**: Installed under `~/.claude/skills/backtest-kit/` โ€” strategy generation, debugging, and API reference
1895
+ - ๐Ÿ“– **Mintlify Docs**: Full documentation site runnable locally
1896
+ - ๐ŸŽฏ **Strategy Generation**: Complete TypeScript files with all schema registrations and runner setup
1897
+ - ๐Ÿ› **Debugging Help**: Catches common mistakes (missing `await`, wrong TP/SL direction, top-level commit calls)
1898
+ - ๐Ÿ“š **API Reference**: All schemas, commit functions, event listeners, LLM integration, graph pipelines, and persistence adapters
1899
+
1900
+ #### Use Case
1901
+ Install the skill once and get AI-assisted backtest-kit development inside Claude Code. The skill knows the full API surface โ€” schemas, commit functions, event listeners, broker adapters โ€” so you can describe what you want in plain language and get working TypeScript strategy code.
1902
+
1903
+ #### Get Started
1904
+ ```bash
1905
+ npx skills add https://github.com/backtest-kit/backtest-kit-skills
1906
+ ```
1907
+
1908
+
1909
+ ### uzse-backtest-app
1910
+
1911
+ > **[Explore on GitHub](https://github.com/backtest-kit/uzse-backtest-app)** ๐Ÿ“ˆ
1912
+
1913
+ The **uzse-backtest-app** repository is a reference implementation for running Pine Script strategies on regional stock exchanges not available on TradingView (UZSE, MSE, DSE, and others). It downloads raw trade history, builds Japanese candlesticks, and feeds them into backtest-kit via a custom MongoDB exchange adapter.
1914
+
1915
+ #### Key Features
1916
+ - ๐ŸŒ **Off-TradingView Markets**: Works with any exchange that exposes trade history โ€” no TradingView dependency
1917
+ - ๐Ÿ•ฏ๏ธ **Candle Builder**: Aggregates raw trades into 1m candles, fills intraday and non-trading day gaps, builds higher timeframes up to `1d`
1918
+ - ๐Ÿ—„๏ธ **MongoDB Backend**: Idempotent import with unique index โ€” re-runs never create duplicates
1919
+ - ๐Ÿ”Œ **Custom Exchange Adapter**: Connects MongoDB candles to backtest-kit via `addExchangeSchema`
1920
+ - ๐Ÿ“œ **Pine Script Support**: Full `@backtest-kit/pinets` integration โ€” run any Pine Script v5/v6 indicator on local market data
1921
+
1922
+ #### Use Case
1923
+ Perfect for traders working with emerging or regional markets absent from TradingView. Download trade history, build candles once, then use the full backtest-kit + Pine Script toolchain for backtesting and live signal generation โ€” with no dependency on any third-party charting platform.
1924
+
1925
+ #### Get Started
1926
+ ```bash
1927
+ git clone https://github.com/backtest-kit/uzse-backtest-app.git
1928
+ ```
1929
+
1930
+ ## ๐Ÿงฉ Strategy Examples
1931
+
1932
+ #### ๐Ÿง  Neural Network Strategy (Oct 2021)
1933
+
1934
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy)
1935
+
1936
+ Trains a feed-forward `TensorFlow` neural network (8โ†’6โ†’4โ†’1 architecture) every 8 hours to predict where the next candle will close within its high-low range. When current price is below predicted price, opens a LONG with 1% trailing take-profit.
1937
+
1938
+ #### ๐ŸŒฒ Pine Script Range Breakout (Dec 2025)
1939
+
1940
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy)
1941
+
1942
+ Runs `btc_dec2025_range.pine` on 1h candles via `@backtest-kit/pinets`, extracting Bollinger Bands, range boundaries, and volume spikes. Signals fire only on confirmed breakouts when price hasn't already moved past the signal close.
1943
+
1944
+ #### ๐Ÿ”ช Signal Inversion Strategy (Jan 2026)
1945
+
1946
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy)
1947
+
1948
+ The strategy takes published signals from a real Telegram crypto channel (Crypto Yoda), enters at the same price zone and timestamp, but **inverts the direction** and uses the liquidity of the crowd that blindly follows the recommendation regardless of the contents of the order book.
1949
+
1950
+ #### ๐Ÿ“ฐ AI News Sentiment (Feb 2026)
1951
+
1952
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy)
1953
+
1954
+ Every 4-8 hours, fetches live crypto/macro news via Tavily, passes headlines to Ollama (local LLM), and opens positions based on `bullish`/`bearish`/`wait` forecasts. Conflicting signals flip positions mid-trade. Achieved +16.99% during a -16.4% month.
1955
+
1956
+ #### ๐Ÿช‚ SHORT DCA Ladder (Mar 2026)
1957
+
1958
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy)
1959
+
1960
+ Opens a SHORT on every pending signal, then adds rungs (up to 10) whenever price spikes upward outside a ยฑ1-5% band around last entry. Closes at 0.5% blended profit.
1961
+
1962
+ #### ๐Ÿง— LONG DCA Ladder (Apr 2026)
1963
+
1964
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy)
1965
+
1966
+ Same mechanics as SHORT version but LONG-biased with 3% profit target. Deployed 2.4 entries per trade on average, achieved +67.85% PNL on deployed capital with improved percentage drawdown (-2.59% vs -3.99% without DCA).
1967
+
1968
+ #### ๐Ÿ Python EMA Crossover (Feb 2021)
1969
+
1970
+ > Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy)
1971
+
1972
+ Python-based (WASI) strategy that uses EMA(9) and EMA(21) crossover signals executed via WebAssembly. Trades trigger when fast EMA crosses slow EMA, confirmed by 4h range midpoint.
1973
+
1974
+ ## ๐Ÿ‘จโ€๐Ÿ’ผ Commercial Support
1975
+
1976
+ > **[TheOneTrade](https://theonetrade.github.io)** ๐Ÿ’ผ
1977
+
1978
+ **TheOneTrade** is the commercial vendor behind backtest-kit, providing paid support, custom strategy development, managed accounts, team training, and enterprise licensing. Contact for SLAs, private features, or quant team engagements that fall outside the MIT-licensed open-source scope.
1979
+
1980
+ ## ๐Ÿค– Are you a robot?
1981
+
1982
+ **For language models**: Read extended description in [./LLMs.md](./LLMs.md)
1983
+
1984
+ ## โœ… Tested & Reliable
1985
+
1986
+ 740+ tests cover validation, recovery, reports, and events.
1987
+
1988
+ ## ๐Ÿค Contribute
1989
+
1990
+ Fork/PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
1991
+
1992
+ ## ๐Ÿ“œ License
1993
+
1994
+ MIT ยฉ [tripolskypetr](https://github.com/tripolskypetr)
1995
+