backtest-kit 8.0.0 โ 8.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +1804 -1804
- package/build/index.cjs +55 -22
- package/build/index.mjs +55 -22
- package/package.json +86 -86
- package/types.d.ts +20 -2
package/README.md
CHANGED
|
@@ -1,1804 +1,1804 @@
|
|
|
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
|
-

|
|
8
|
-
|
|
9
|
-
[](https://deepwiki.com/tripolskypetr/backtest-kit)
|
|
10
|
-
[](https://npmjs.org/package/backtest-kit)
|
|
11
|
-
[]()
|
|
12
|
-
[](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
|
|
13
|
-
|
|
14
|
-
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/demo)** | **๐ฐ [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
|
-
### ๐ฆ Manual Installation
|
|
48
|
-
|
|
49
|
-
> **Want to see the code?** ๐ [Demo app](https://github.com/tripolskypetr/backtest-kit/tree/master/example) ๐
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
npm install backtest-kit ccxt ollama uuid
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
## โจ Why Choose Backtest Kit?
|
|
56
|
-
|
|
57
|
-
- ๐ **Production-Ready**: Seamless switch between backtest/live modes; identical code across environments.
|
|
58
|
-
- ๐พ **Crash-Safe**: Atomic persistence recovers states after crashes, preventing duplicates or losses.
|
|
59
|
-
- โ
**Validation**: Checks signals for TP/SL logic, risk/reward ratios, and portfolio limits.
|
|
60
|
-
- ๐ **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
|
|
61
|
-
- ๐ค **AI Integration**: LLM-powered strategy generation (Optimizer) with multi-timeframe analysis.
|
|
62
|
-
- ๐ **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
|
|
63
|
-
- ๐ก๏ธ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
|
|
64
|
-
- ๐ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
|
|
65
|
-
- ๐๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ exchange rejection rolls back the operation atomically.
|
|
66
|
-
- ๐งช **Tested**: 515+ unit/integration tests for validation, recovery, and events.
|
|
67
|
-
- ๐ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
|
|
68
|
-
|
|
69
|
-
## ๐ Supported Order Types
|
|
70
|
-
|
|
71
|
-
> With the calculation of PnL, Peak Profit and Max Drawdown for each Entry
|
|
72
|
-
|
|
73
|
-
- Market/Limit entries
|
|
74
|
-
- TP/SL/OCO exits
|
|
75
|
-
- Grid with auto-cancel on unmet conditions
|
|
76
|
-
- Partial profit/loss levels
|
|
77
|
-
- Trailing stop-loss
|
|
78
|
-
- Breakeven protection
|
|
79
|
-
- Stop limit entries (before OCO)
|
|
80
|
-
- Dollar cost averaging
|
|
81
|
-
- Time attack / Infinite hold
|
|
82
|
-
|
|
83
|
-
## ๐ Code Samples
|
|
84
|
-
|
|
85
|
-
### โ๏ธ Basic Configuration
|
|
86
|
-
```typescript
|
|
87
|
-
import { setLogger, setConfig } from 'backtest-kit';
|
|
88
|
-
|
|
89
|
-
// Enable logging
|
|
90
|
-
setLogger({
|
|
91
|
-
log: console.log,
|
|
92
|
-
debug: console.debug,
|
|
93
|
-
info: console.info,
|
|
94
|
-
warn: console.warn,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// Global config (optional)
|
|
98
|
-
setConfig({
|
|
99
|
-
CC_PERCENT_SLIPPAGE: 0.1, // % slippage
|
|
100
|
-
CC_PERCENT_FEE: 0.1, // % fee
|
|
101
|
-
CC_SCHEDULE_AWAIT_MINUTES: 120, // Pending signal timeout
|
|
102
|
-
});
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
### ๐ง Register Components
|
|
106
|
-
```typescript
|
|
107
|
-
import ccxt from 'ccxt';
|
|
108
|
-
import { addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema } from 'backtest-kit';
|
|
109
|
-
|
|
110
|
-
// Exchange (data source)
|
|
111
|
-
addExchangeSchema({
|
|
112
|
-
exchangeName: 'binance',
|
|
113
|
-
getCandles: async (symbol, interval, since, limit) => {
|
|
114
|
-
const exchange = new ccxt.binance();
|
|
115
|
-
const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
116
|
-
return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume }));
|
|
117
|
-
},
|
|
118
|
-
formatPrice: (symbol, price) => price.toFixed(2),
|
|
119
|
-
formatQuantity: (symbol, quantity) => quantity.toFixed(8),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Risk profile
|
|
123
|
-
addRiskSchema({
|
|
124
|
-
riskName: 'demo',
|
|
125
|
-
validations: [
|
|
126
|
-
// TP at least 1%
|
|
127
|
-
({ pendingSignal, currentPrice }) => {
|
|
128
|
-
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
|
|
129
|
-
const tpDistance = position === 'long' ? ((priceTakeProfit - priceOpen) / priceOpen) * 100 : ((priceOpen - priceTakeProfit) / priceOpen) * 100;
|
|
130
|
-
if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
|
|
131
|
-
},
|
|
132
|
-
// R/R at least 2:1
|
|
133
|
-
({ pendingSignal, currentPrice }) => {
|
|
134
|
-
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
|
|
135
|
-
const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
|
|
136
|
-
const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
|
|
137
|
-
if (reward / risk < 2) throw new Error('Poor R/R ratio');
|
|
138
|
-
},
|
|
139
|
-
],
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// Time frame
|
|
143
|
-
addFrameSchema({
|
|
144
|
-
frameName: '1d-test',
|
|
145
|
-
interval: '1m',
|
|
146
|
-
startDate: new Date('2025-12-01'),
|
|
147
|
-
endDate: new Date('2025-12-02'),
|
|
148
|
-
});
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### ๐ก Example Strategy (with LLM)
|
|
152
|
-
```typescript
|
|
153
|
-
import { v4 as uuid } from 'uuid';
|
|
154
|
-
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
|
|
155
|
-
import { json } from './utils/json.mjs'; // LLM wrapper
|
|
156
|
-
import { getMessages } from './utils/messages.mjs'; // Market data prep
|
|
157
|
-
|
|
158
|
-
addStrategySchema({
|
|
159
|
-
strategyName: 'llm-strategy',
|
|
160
|
-
interval: '5m',
|
|
161
|
-
riskName: 'demo',
|
|
162
|
-
getSignal: async (symbol) => {
|
|
163
|
-
|
|
164
|
-
const candles1h = await getCandles(symbol, "1h", 24);
|
|
165
|
-
const candles15m = await getCandles(symbol, "15m", 48);
|
|
166
|
-
const candles5m = await getCandles(symbol, "5m", 60);
|
|
167
|
-
const candles1m = await getCandles(symbol, "1m", 60);
|
|
168
|
-
|
|
169
|
-
const messages = await getMessages(symbol, {
|
|
170
|
-
candles1h,
|
|
171
|
-
candles15m,
|
|
172
|
-
candles5m,
|
|
173
|
-
candles1m,
|
|
174
|
-
}); // Calculate indicators / Fetch news
|
|
175
|
-
|
|
176
|
-
const resultId = uuid();
|
|
177
|
-
const signal = await json(messages); // LLM generates signal
|
|
178
|
-
|
|
179
|
-
await dumpAgentAnswer({
|
|
180
|
-
dumpId: "position-context",
|
|
181
|
-
bucketName: "multi-timeframe-strategy",
|
|
182
|
-
messages: messages, // pass saved messages here
|
|
183
|
-
description: "agent reasoning for this signal",
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
await dumpRecord({
|
|
187
|
-
dumpId: "position-entry",
|
|
188
|
-
bucketName: "multi-timeframe-strategy",
|
|
189
|
-
record: signal, // pass saved signal record here
|
|
190
|
-
description: "signal entry parameters",
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
return { ...signal, id: resultId };
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### ๐งช Run Backtest
|
|
199
|
-
```typescript
|
|
200
|
-
import { Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
|
|
201
|
-
|
|
202
|
-
Backtest.background('BTCUSDT', {
|
|
203
|
-
strategyName: 'llm-strategy',
|
|
204
|
-
exchangeName: 'binance',
|
|
205
|
-
frameName: '1d-test',
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
listenSignalBacktest((event) => console.log(event));
|
|
209
|
-
listenDoneBacktest(async (event) => {
|
|
210
|
-
await Backtest.dump(event.symbol, event.strategyName); // Generate report
|
|
211
|
-
});
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
### ๐ Run Live Trading
|
|
215
|
-
```typescript
|
|
216
|
-
import { Live, listenSignalLive } from 'backtest-kit';
|
|
217
|
-
|
|
218
|
-
Live.background('BTCUSDT', {
|
|
219
|
-
strategyName: 'llm-strategy',
|
|
220
|
-
exchangeName: 'binance', // Use API keys in .env
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
listenSignalLive((event) => console.log(event));
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
### ๐ก Monitoring & Events
|
|
227
|
-
|
|
228
|
-
- Use `listenRisk`, `listenError`, `listenPartialProfit/Loss` for alerts.
|
|
229
|
-
- Dump reports: `Backtest.dump()`, `Live.dump()`.
|
|
230
|
-
|
|
231
|
-
## ๐ Global Configuration
|
|
232
|
-
|
|
233
|
-
Customize via `setConfig()`:
|
|
234
|
-
|
|
235
|
-
- `CC_SCHEDULE_AWAIT_MINUTES`: Pending timeout (default: 120).
|
|
236
|
-
- `CC_AVG_PRICE_CANDLES_COUNT`: VWAP candles (default: 5).
|
|
237
|
-
|
|
238
|
-
## ๐ป Developer Note
|
|
239
|
-
|
|
240
|
-
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.
|
|
241
|
-
|
|
242
|
-
### ๐ How PNL Works
|
|
243
|
-
|
|
244
|
-
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.
|
|
245
|
-
|
|
246
|
-
**Public API:**
|
|
247
|
-
- **`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`
|
|
248
|
-
- **`commitPartialProfit`** โ closes X% of the position at a profit. Locks in gains while keeping exposure.
|
|
249
|
-
- **`commitPartialLoss`** โ closes X% of the position at a loss. Cuts exposure before the stop-loss is hit.
|
|
250
|
-
|
|
251
|
-
<details>
|
|
252
|
-
<summary>
|
|
253
|
-
The Math
|
|
254
|
-
</summary>
|
|
255
|
-
|
|
256
|
-
**Scenario:** LONG entry @ 1000, 4 DCA attempts (1 rejected), 3 partials, closed at TP.
|
|
257
|
-
`totalInvested = $400` (4 ร $100, rejected attempt not counted).
|
|
258
|
-
|
|
259
|
-
**Entries**
|
|
260
|
-
```
|
|
261
|
-
entry#1 @ 1000 โ 0.10000 coins
|
|
262
|
-
commitPartialProfit(30%) @ 1150 โ cnt=1
|
|
263
|
-
entry#2 @ 950 โ 0.10526 coins
|
|
264
|
-
entry#3 @ 880 โ 0.11364 coins
|
|
265
|
-
commitPartialLoss(20%) @ 860 โ cnt=3
|
|
266
|
-
entry#4 @ 920 โ 0.10870 coins
|
|
267
|
-
commitPartialProfit(40%) @ 1050 โ cnt=4
|
|
268
|
-
entry#5 @ 980 โ REJECTED (980 > ep3โ929.92)
|
|
269
|
-
totalInvested = $400
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
**Partial#1 โ commitPartialProfit @ 1150, 30%, cnt=1**
|
|
273
|
-
```
|
|
274
|
-
effectivePrice = hm(1000) = 1000
|
|
275
|
-
costBasis = $100
|
|
276
|
-
partialDollarValue = 30% ร 100 = $30 โ weight = 30/400 = 0.075
|
|
277
|
-
pnl = (1150โ1000)/1000 ร 100 = +15.00%
|
|
278
|
-
costBasis โ $70
|
|
279
|
-
coins sold: 0.03000 ร 1150 = $34.50
|
|
280
|
-
remaining: 0.07000
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
**DCA after Partial#1**
|
|
284
|
-
```
|
|
285
|
-
entry#2 @ 950 (950 < ep1=1000 โ accepted)
|
|
286
|
-
entry#3 @ 880 (880 < ep1=1000 โ accepted)
|
|
287
|
-
coins: 0.07000 + 0.10526 + 0.11364 = 0.28890
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
**Partial#2 โ commitPartialLoss @ 860, 20%, cnt=3**
|
|
291
|
-
```
|
|
292
|
-
costBasis = 70 + 100 + 100 = $270
|
|
293
|
-
ep2 = 270 / 0.28890 โ 934.58
|
|
294
|
-
partialDollarValue = 20% ร 270 = $54 โ weight = 54/400 = 0.135
|
|
295
|
-
pnl = (860โ934.58)/934.58 ร 100 โ โ7.98%
|
|
296
|
-
costBasis โ $216
|
|
297
|
-
coins sold: 0.05778 ร 860 = $49.69
|
|
298
|
-
remaining: 0.23112
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
**DCA after Partial#2**
|
|
302
|
-
```
|
|
303
|
-
entry#4 @ 920 (920 < ep2=934.58 โ accepted)
|
|
304
|
-
coins: 0.23112 + 0.10870 = 0.33982
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
**Partial#3 โ commitPartialProfit @ 1050, 40%, cnt=4**
|
|
308
|
-
```
|
|
309
|
-
costBasis = 216 + 100 = $316
|
|
310
|
-
ep3 = 316 / 0.33982 โ 929.92
|
|
311
|
-
partialDollarValue = 40% ร 316 = $126.4 โ weight = 126.4/400 = 0.316
|
|
312
|
-
pnl = (1050โ929.92)/929.92 ร 100 โ +12.91%
|
|
313
|
-
costBasis โ $189.6
|
|
314
|
-
coins sold: 0.13593 ร 1050 = $142.72
|
|
315
|
-
remaining: 0.20389
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
**DCA after Partial#3 โ rejected**
|
|
319
|
-
```
|
|
320
|
-
entry#5 @ 980 (980 > ep3โ929.92 โ REJECTED)
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
**Close at TP @ 1200**
|
|
324
|
-
```
|
|
325
|
-
ep_final = ep3 โ 929.92 (no new entries)
|
|
326
|
-
coins: 0.20389
|
|
327
|
-
|
|
328
|
-
remainingDollarValue = 400 โ 30 โ 54 โ 126.4 = $189.6
|
|
329
|
-
weight = 189.6/400 = 0.474
|
|
330
|
-
pnl = (1200โ929.92)/929.92 ร 100 โ +29.04%
|
|
331
|
-
coins sold: 0.20389 ร 1200 = $244.67
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
**Result (toProfitLossDto)**
|
|
335
|
-
```
|
|
336
|
-
0.075 ร (+15.00) = +1.125
|
|
337
|
-
0.135 ร (โ7.98) = โ1.077
|
|
338
|
-
0.316 ร (+12.91) = +4.080
|
|
339
|
-
0.474 ร (+29.04) = +13.765
|
|
340
|
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
341
|
-
โ +17.89%
|
|
342
|
-
|
|
343
|
-
Cross-check (coins):
|
|
344
|
-
34.50 + 49.69 + 142.72 + 244.67 = $471.58
|
|
345
|
-
(471.58 โ 400) / 400 ร 100 = +17.90% โ
|
|
346
|
-
```
|
|
347
|
-
</details>
|
|
348
|
-
|
|
349
|
-
**`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.
|
|
350
|
-
|
|
351
|
-
### ๐ How Broker Transactional Integrity Works
|
|
352
|
-
|
|
353
|
-
`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.
|
|
354
|
-
|
|
355
|
-
<details>
|
|
356
|
-
<summary>
|
|
357
|
-
The code
|
|
358
|
-
</summary>
|
|
359
|
-
|
|
360
|
-
**Spot**
|
|
361
|
-
|
|
362
|
-
```typescript
|
|
363
|
-
import ccxt from "ccxt";
|
|
364
|
-
import { singleshot, sleep } from "functools-kit";
|
|
365
|
-
import {
|
|
366
|
-
Broker,
|
|
367
|
-
IBroker,
|
|
368
|
-
BrokerSignalOpenPayload,
|
|
369
|
-
BrokerSignalClosePayload,
|
|
370
|
-
BrokerPartialProfitPayload,
|
|
371
|
-
BrokerPartialLossPayload,
|
|
372
|
-
BrokerTrailingStopPayload,
|
|
373
|
-
BrokerTrailingTakePayload,
|
|
374
|
-
BrokerBreakevenPayload,
|
|
375
|
-
BrokerAverageBuyPayload,
|
|
376
|
-
} from "backtest-kit";
|
|
377
|
-
|
|
378
|
-
const FILL_POLL_INTERVAL_MS = 10_000;
|
|
379
|
-
const FILL_POLL_ATTEMPTS = 10;
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Sleep between cancelOrder and fetchBalance to allow Binance to settle the
|
|
383
|
-
* cancellation โ reads immediately after cancel may return stale data.
|
|
384
|
-
*/
|
|
385
|
-
const CANCEL_SETTLE_MS = 2_000;
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Slippage buffer for stop_loss_limit on Spot โ limit price is set slightly
|
|
389
|
-
* below stopPrice so the order fills even on a gap down instead of hanging.
|
|
390
|
-
*/
|
|
391
|
-
const STOP_LIMIT_SLIPPAGE = 0.995;
|
|
392
|
-
|
|
393
|
-
const getSpotExchange = singleshot(async () => {
|
|
394
|
-
const exchange = new ccxt.binance({
|
|
395
|
-
apiKey: process.env.BINANCE_API_KEY,
|
|
396
|
-
secret: process.env.BINANCE_API_SECRET,
|
|
397
|
-
options: {
|
|
398
|
-
defaultType: "spot",
|
|
399
|
-
adjustForTimeDifference: true,
|
|
400
|
-
recvWindow: 60000,
|
|
401
|
-
},
|
|
402
|
-
enableRateLimit: true,
|
|
403
|
-
});
|
|
404
|
-
await exchange.loadMarkets();
|
|
405
|
-
return exchange;
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Resolve base currency from market metadata โ safe for all quote currencies (USDT, USDC, FDUSD, etc.)
|
|
410
|
-
*/
|
|
411
|
-
function getBase(exchange: ccxt.binance, symbol: string): string {
|
|
412
|
-
return exchange.markets[symbol].base;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Truncate qty to exchange precision, always rounding down.
|
|
417
|
-
* Prevents over-selling due to floating point drift from fetchBalance.
|
|
418
|
-
*/
|
|
419
|
-
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
420
|
-
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Fetch current free balance for base currency of symbol.
|
|
425
|
-
*/
|
|
426
|
-
async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
|
|
427
|
-
const balance = await exchange.fetchBalance();
|
|
428
|
-
const base = getBase(exchange, symbol);
|
|
429
|
-
return parseFloat(String(balance?.free?.[base] ?? 0));
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
434
|
-
* network blip) does not leave remaining orders uncancelled.
|
|
435
|
-
*/
|
|
436
|
-
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
437
|
-
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* Place a stop_loss_limit sell order with a slippage buffer on the limit price.
|
|
442
|
-
* stop_loss_limit requires both stopPrice (trigger) and price (limit fill).
|
|
443
|
-
* Setting them equal risks non-fill on gap down โ limit is offset by STOP_LIMIT_SLIPPAGE.
|
|
444
|
-
*/
|
|
445
|
-
async function createStopLossOrder(
|
|
446
|
-
exchange: ccxt.binance,
|
|
447
|
-
symbol: string,
|
|
448
|
-
qty: number,
|
|
449
|
-
stopPrice: number
|
|
450
|
-
): Promise<void> {
|
|
451
|
-
const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
|
|
452
|
-
await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Place a limit order and poll until filled (status === "closed").
|
|
457
|
-
* On timeout: cancel the order, settle, check partial fill and sell it via market,
|
|
458
|
-
* restore SL/TP on remaining position so it is never left unprotected, then throw.
|
|
459
|
-
*/
|
|
460
|
-
async function createLimitOrderAndWait(
|
|
461
|
-
exchange: ccxt.binance,
|
|
462
|
-
symbol: string,
|
|
463
|
-
side: "buy" | "sell",
|
|
464
|
-
qty: number,
|
|
465
|
-
price: number,
|
|
466
|
-
restore?: { tpPrice: number; slPrice: number }
|
|
467
|
-
): Promise<void> {
|
|
468
|
-
const order = await exchange.createOrder(symbol, "limit", side, qty, price);
|
|
469
|
-
|
|
470
|
-
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
471
|
-
await sleep(FILL_POLL_INTERVAL_MS);
|
|
472
|
-
const status = await exchange.fetchOrder(order.id, symbol);
|
|
473
|
-
if (status.status === "closed") {
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
await exchange.cancelOrder(order.id, symbol);
|
|
479
|
-
|
|
480
|
-
// Wait for Binance to settle the cancellation before reading filled qty
|
|
481
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
482
|
-
|
|
483
|
-
const final = await exchange.fetchOrder(order.id, symbol);
|
|
484
|
-
const filledQty = final.filled ?? 0;
|
|
485
|
-
|
|
486
|
-
if (filledQty > 0) {
|
|
487
|
-
// Sell partial fill via market to restore clean exchange state before backtest-kit retries
|
|
488
|
-
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
489
|
-
await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
493
|
-
if (restore) {
|
|
494
|
-
const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
495
|
-
if (remainingQty > 0) {
|
|
496
|
-
await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
|
|
497
|
-
await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ partial fill rolled back, backtest-kit will retry`);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
Broker.useBrokerAdapter(
|
|
505
|
-
class implements IBroker {
|
|
506
|
-
|
|
507
|
-
async waitForInit(): Promise<void> {
|
|
508
|
-
await getSpotExchange();
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
512
|
-
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
513
|
-
|
|
514
|
-
// Spot does not support short selling โ reject immediately so backtest-kit skips the mutation
|
|
515
|
-
if (position === "short") {
|
|
516
|
-
throw new Error(`SpotBrokerAdapter: short position is not supported on spot (symbol=${symbol})`);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const exchange = await getSpotExchange();
|
|
520
|
-
|
|
521
|
-
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
522
|
-
|
|
523
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
524
|
-
if (qty <= 0) {
|
|
525
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
529
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
530
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
531
|
-
|
|
532
|
-
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
533
|
-
await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
|
|
534
|
-
|
|
535
|
-
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
536
|
-
try {
|
|
537
|
-
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
538
|
-
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
539
|
-
} catch (err) {
|
|
540
|
-
await exchange.createOrder(symbol, "market", "sell", qty);
|
|
541
|
-
throw err;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
546
|
-
const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
547
|
-
const exchange = await getSpotExchange();
|
|
548
|
-
|
|
549
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
550
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
551
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
552
|
-
|
|
553
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
554
|
-
|
|
555
|
-
// Position already closed by SL/TP on exchange โ nothing to do, commit succeeds
|
|
556
|
-
if (qty === 0) {
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
561
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
562
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
563
|
-
|
|
564
|
-
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
565
|
-
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
569
|
-
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
570
|
-
const exchange = await getSpotExchange();
|
|
571
|
-
|
|
572
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
573
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
574
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
575
|
-
|
|
576
|
-
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
577
|
-
|
|
578
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
579
|
-
if (totalQty === 0) {
|
|
580
|
-
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
584
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
585
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
586
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
587
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
588
|
-
|
|
589
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
590
|
-
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
591
|
-
|
|
592
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
593
|
-
if (remainingQty > 0) {
|
|
594
|
-
try {
|
|
595
|
-
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
596
|
-
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
597
|
-
} catch (err) {
|
|
598
|
-
// Remaining position is unprotected โ close via market
|
|
599
|
-
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
600
|
-
throw err;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
606
|
-
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
607
|
-
const exchange = await getSpotExchange();
|
|
608
|
-
|
|
609
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
610
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
611
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
612
|
-
|
|
613
|
-
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
614
|
-
|
|
615
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
616
|
-
if (totalQty === 0) {
|
|
617
|
-
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
621
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
622
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
623
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
624
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
625
|
-
|
|
626
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
627
|
-
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
628
|
-
|
|
629
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
630
|
-
if (remainingQty > 0) {
|
|
631
|
-
try {
|
|
632
|
-
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
633
|
-
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
634
|
-
} catch (err) {
|
|
635
|
-
// Remaining position is unprotected โ close via market
|
|
636
|
-
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
637
|
-
throw err;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
643
|
-
const { symbol, newStopLossPrice } = payload;
|
|
644
|
-
const exchange = await getSpotExchange();
|
|
645
|
-
|
|
646
|
-
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
647
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
648
|
-
const slOrder = orders.find((o) =>
|
|
649
|
-
o.side === "sell" &&
|
|
650
|
-
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
651
|
-
) ?? null;
|
|
652
|
-
if (slOrder) {
|
|
653
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
654
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
658
|
-
|
|
659
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
660
|
-
if (qty === 0) {
|
|
661
|
-
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
665
|
-
|
|
666
|
-
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
670
|
-
const { symbol, newTakeProfitPrice } = payload;
|
|
671
|
-
const exchange = await getSpotExchange();
|
|
672
|
-
|
|
673
|
-
// Cancel existing TP order only โ Spot has no reduceOnly, filter by side + type
|
|
674
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
675
|
-
const tpOrder = orders.find((o) =>
|
|
676
|
-
o.side === "sell" &&
|
|
677
|
-
["limit", "LIMIT"].includes(o.type ?? "")
|
|
678
|
-
) ?? null;
|
|
679
|
-
if (tpOrder) {
|
|
680
|
-
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
681
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
685
|
-
|
|
686
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
687
|
-
if (qty === 0) {
|
|
688
|
-
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
692
|
-
|
|
693
|
-
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
697
|
-
const { symbol, newStopLossPrice } = payload;
|
|
698
|
-
const exchange = await getSpotExchange();
|
|
699
|
-
|
|
700
|
-
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
701
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
702
|
-
const slOrder = orders.find((o) =>
|
|
703
|
-
o.side === "sell" &&
|
|
704
|
-
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
705
|
-
) ?? null;
|
|
706
|
-
if (slOrder) {
|
|
707
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
708
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
712
|
-
|
|
713
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
714
|
-
if (qty === 0) {
|
|
715
|
-
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
719
|
-
|
|
720
|
-
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
724
|
-
const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
|
|
725
|
-
const exchange = await getSpotExchange();
|
|
726
|
-
|
|
727
|
-
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
728
|
-
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
729
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
730
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
731
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
732
|
-
|
|
733
|
-
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
734
|
-
const existing = await fetchFreeQty(exchange, symbol);
|
|
735
|
-
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
736
|
-
|
|
737
|
-
// Compare notional value rather than raw qty โ avoids float === 0 trap
|
|
738
|
-
// and correctly rejects dust balances left over from previous trades
|
|
739
|
-
if (existing * currentPrice < minNotional) {
|
|
740
|
-
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
744
|
-
|
|
745
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
746
|
-
if (qty <= 0) {
|
|
747
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
751
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
752
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
753
|
-
|
|
754
|
-
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
755
|
-
await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
|
|
756
|
-
|
|
757
|
-
// Refetch balance after fill โ existing snapshot is stale after cancel + fill
|
|
758
|
-
const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
759
|
-
|
|
760
|
-
// Recreate SL/TP on fresh total qty after successful fill
|
|
761
|
-
try {
|
|
762
|
-
await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
|
|
763
|
-
await createStopLossOrder(exchange, symbol, totalQty, slPrice);
|
|
764
|
-
} catch (err) {
|
|
765
|
-
// Total position is unprotected โ close via market
|
|
766
|
-
await exchange.createOrder(symbol, "market", "sell", totalQty);
|
|
767
|
-
throw err;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
);
|
|
772
|
-
|
|
773
|
-
Broker.enable();
|
|
774
|
-
```
|
|
775
|
-
|
|
776
|
-
**Futures**
|
|
777
|
-
|
|
778
|
-
```typescript
|
|
779
|
-
import ccxt from "ccxt";
|
|
780
|
-
import { singleshot, sleep } from "functools-kit";
|
|
781
|
-
import {
|
|
782
|
-
Broker,
|
|
783
|
-
IBroker,
|
|
784
|
-
BrokerSignalOpenPayload,
|
|
785
|
-
BrokerSignalClosePayload,
|
|
786
|
-
BrokerPartialProfitPayload,
|
|
787
|
-
BrokerPartialLossPayload,
|
|
788
|
-
BrokerTrailingStopPayload,
|
|
789
|
-
BrokerTrailingTakePayload,
|
|
790
|
-
BrokerBreakevenPayload,
|
|
791
|
-
BrokerAverageBuyPayload,
|
|
792
|
-
} from "backtest-kit";
|
|
793
|
-
|
|
794
|
-
const FILL_POLL_INTERVAL_MS = 10_000;
|
|
795
|
-
const FILL_POLL_ATTEMPTS = 10;
|
|
796
|
-
|
|
797
|
-
/**
|
|
798
|
-
* Sleep between cancelOrder and fetchPositions to allow Binance to settle the
|
|
799
|
-
* cancellation โ reads immediately after cancel may return stale data.
|
|
800
|
-
*/
|
|
801
|
-
const CANCEL_SETTLE_MS = 2_000;
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* 3x leverage โ conservative choice for $1000 total fiat.
|
|
805
|
-
* Enough to matter, not enough to liquidate on normal volatility.
|
|
806
|
-
* Applied per-symbol on first open via setLeverage.
|
|
807
|
-
*/
|
|
808
|
-
const FUTURES_LEVERAGE = 3;
|
|
809
|
-
|
|
810
|
-
const getFuturesExchange = singleshot(async () => {
|
|
811
|
-
const exchange = new ccxt.binance({
|
|
812
|
-
apiKey: process.env.BINANCE_API_KEY,
|
|
813
|
-
secret: process.env.BINANCE_API_SECRET,
|
|
814
|
-
options: {
|
|
815
|
-
defaultType: "future",
|
|
816
|
-
adjustForTimeDifference: true,
|
|
817
|
-
recvWindow: 60000,
|
|
818
|
-
},
|
|
819
|
-
enableRateLimit: true,
|
|
820
|
-
});
|
|
821
|
-
await exchange.loadMarkets();
|
|
822
|
-
return exchange;
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Truncate qty to exchange precision, always rounding down.
|
|
827
|
-
* Prevents over-selling due to floating point drift from fetchPositions.
|
|
828
|
-
*/
|
|
829
|
-
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
830
|
-
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* Resolve position for symbol filtered by side โ safe in both one-way and hedge mode.
|
|
835
|
-
*/
|
|
836
|
-
function findPosition(positions: ccxt.Position[], symbol: string, side: "long" | "short") {
|
|
837
|
-
// Hedge mode: positions have explicit side field
|
|
838
|
-
const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
|
|
839
|
-
if (hedged) {
|
|
840
|
-
return hedged;
|
|
841
|
-
}
|
|
842
|
-
// One-way mode: single position per symbol, side field may be undefined or mismatched
|
|
843
|
-
const pos = positions.find((p) => p.symbol === symbol) ?? null;
|
|
844
|
-
if (pos && pos.side && pos.side !== side) {
|
|
845
|
-
console.warn(`findPosition: expected side="${side}" but exchange returned side="${pos.side}" for ${symbol} โ possible one-way/hedge mode mismatch`);
|
|
846
|
-
}
|
|
847
|
-
return pos;
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
/**
|
|
851
|
-
* Fetch current contracts qty for symbol/side.
|
|
852
|
-
*/
|
|
853
|
-
async function fetchContractsQty(
|
|
854
|
-
exchange: ccxt.binance,
|
|
855
|
-
symbol: string,
|
|
856
|
-
side: "long" | "short"
|
|
857
|
-
): Promise<number> {
|
|
858
|
-
const positions = await exchange.fetchPositions([symbol]);
|
|
859
|
-
const pos = findPosition(positions, symbol, side);
|
|
860
|
-
return Math.abs(parseFloat(String(pos?.contracts ?? 0)));
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
/**
|
|
864
|
-
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
865
|
-
* network blip) does not leave remaining orders uncancelled.
|
|
866
|
-
*/
|
|
867
|
-
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
868
|
-
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* Resolve Binance positionSide string from position direction.
|
|
873
|
-
* Required in hedge mode to correctly route orders; ignored in one-way mode.
|
|
874
|
-
*/
|
|
875
|
-
function toPositionSide(position: "long" | "short"): "LONG" | "SHORT" {
|
|
876
|
-
return position === "long" ? "LONG" : "SHORT";
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* Place a limit order and poll until filled (status === "closed").
|
|
881
|
-
* On timeout: cancel the order, settle, check partial fill and close it via market,
|
|
882
|
-
* restore SL/TP on remaining position so it is never left unprotected, then throw.
|
|
883
|
-
*
|
|
884
|
-
* positionSide is forwarded into rollback market order so hedge mode accounts
|
|
885
|
-
* correctly route the close without -4061 error.
|
|
886
|
-
*/
|
|
887
|
-
async function createLimitOrderAndWait(
|
|
888
|
-
exchange: ccxt.binance,
|
|
889
|
-
symbol: string,
|
|
890
|
-
side: "buy" | "sell",
|
|
891
|
-
qty: number,
|
|
892
|
-
price: number,
|
|
893
|
-
params: Record<string, unknown> = {},
|
|
894
|
-
restore?: { exitSide: "buy" | "sell"; tpPrice: number; slPrice: number; positionSide: "long" | "short" }
|
|
895
|
-
): Promise<void> {
|
|
896
|
-
const order = await exchange.createOrder(symbol, "limit", side, qty, price, params);
|
|
897
|
-
|
|
898
|
-
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
899
|
-
await sleep(FILL_POLL_INTERVAL_MS);
|
|
900
|
-
const status = await exchange.fetchOrder(order.id, symbol);
|
|
901
|
-
if (status.status === "closed") {
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
await exchange.cancelOrder(order.id, symbol);
|
|
907
|
-
|
|
908
|
-
// Wait for Binance to settle the cancellation before reading filled qty
|
|
909
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
910
|
-
|
|
911
|
-
const final = await exchange.fetchOrder(order.id, symbol);
|
|
912
|
-
const filledQty = final.filled ?? 0;
|
|
913
|
-
|
|
914
|
-
if (filledQty > 0) {
|
|
915
|
-
// Close partial fill via market โ positionSide required in hedge mode (-4061 without it)
|
|
916
|
-
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
917
|
-
const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
|
|
918
|
-
await exchange.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
|
|
919
|
-
reduceOnly: true,
|
|
920
|
-
...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
925
|
-
if (restore) {
|
|
926
|
-
const remainingQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, restore.positionSide));
|
|
927
|
-
if (remainingQty > 0) {
|
|
928
|
-
await exchange.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
|
|
929
|
-
await exchange.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ partial fill rolled back, backtest-kit will retry`);
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
Broker.useBrokerAdapter(
|
|
937
|
-
class implements IBroker {
|
|
938
|
-
|
|
939
|
-
async waitForInit(): Promise<void> {
|
|
940
|
-
await getFuturesExchange();
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
944
|
-
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
945
|
-
const exchange = await getFuturesExchange();
|
|
946
|
-
|
|
947
|
-
// Set leverage before entry โ ensures consistent leverage regardless of previous session state
|
|
948
|
-
await exchange.setLeverage(FUTURES_LEVERAGE, symbol);
|
|
949
|
-
|
|
950
|
-
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
951
|
-
|
|
952
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
953
|
-
if (qty <= 0) {
|
|
954
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
958
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
959
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
960
|
-
const entrySide = position === "long" ? "buy" : "sell";
|
|
961
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
962
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
963
|
-
const positionSide = toPositionSide(position);
|
|
964
|
-
|
|
965
|
-
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
966
|
-
await createLimitOrderAndWait(exchange, symbol, entrySide, qty, openPrice, { positionSide });
|
|
967
|
-
|
|
968
|
-
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
969
|
-
try {
|
|
970
|
-
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
971
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
972
|
-
} catch (err) {
|
|
973
|
-
await exchange.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
|
|
974
|
-
throw err;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
979
|
-
const { symbol, position, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
980
|
-
const exchange = await getFuturesExchange();
|
|
981
|
-
|
|
982
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
983
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
984
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
985
|
-
|
|
986
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
987
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
988
|
-
|
|
989
|
-
// Position already closed by SL/TP on exchange โ throw so backtest-kit can reconcile
|
|
990
|
-
// the close price via its own mechanism rather than assuming a successful manual close
|
|
991
|
-
if (qty === 0) {
|
|
992
|
-
throw new Error(`SignalClose skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
996
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
997
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
998
|
-
|
|
999
|
-
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1000
|
-
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
1001
|
-
await createLimitOrderAndWait(
|
|
1002
|
-
exchange, symbol, exitSide, qty, closePrice,
|
|
1003
|
-
{ reduceOnly: true },
|
|
1004
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1005
|
-
);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
1009
|
-
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1010
|
-
const exchange = await getFuturesExchange();
|
|
1011
|
-
|
|
1012
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1013
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1014
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1015
|
-
|
|
1016
|
-
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1017
|
-
|
|
1018
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1019
|
-
if (totalQty === 0) {
|
|
1020
|
-
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1024
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1025
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1026
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1027
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1028
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1029
|
-
const positionSide = toPositionSide(position);
|
|
1030
|
-
|
|
1031
|
-
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1032
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1033
|
-
await createLimitOrderAndWait(
|
|
1034
|
-
exchange, symbol, exitSide, qty, closePrice,
|
|
1035
|
-
{ reduceOnly: true },
|
|
1036
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1037
|
-
);
|
|
1038
|
-
|
|
1039
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
1040
|
-
if (remainingQty > 0) {
|
|
1041
|
-
try {
|
|
1042
|
-
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1043
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1044
|
-
} catch (err) {
|
|
1045
|
-
// Remaining position is unprotected โ close via market
|
|
1046
|
-
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1047
|
-
throw err;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
1053
|
-
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1054
|
-
const exchange = await getFuturesExchange();
|
|
1055
|
-
|
|
1056
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1057
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1058
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1059
|
-
|
|
1060
|
-
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1061
|
-
|
|
1062
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1063
|
-
if (totalQty === 0) {
|
|
1064
|
-
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1068
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1069
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1070
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1071
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1072
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1073
|
-
const positionSide = toPositionSide(position);
|
|
1074
|
-
|
|
1075
|
-
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1076
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1077
|
-
await createLimitOrderAndWait(
|
|
1078
|
-
exchange, symbol, exitSide, qty, closePrice,
|
|
1079
|
-
{ reduceOnly: true },
|
|
1080
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1081
|
-
);
|
|
1082
|
-
|
|
1083
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
1084
|
-
if (remainingQty > 0) {
|
|
1085
|
-
try {
|
|
1086
|
-
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1087
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1088
|
-
} catch (err) {
|
|
1089
|
-
// Remaining position is unprotected โ close via market
|
|
1090
|
-
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1091
|
-
throw err;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
1097
|
-
const { symbol, newStopLossPrice, position } = payload;
|
|
1098
|
-
const exchange = await getFuturesExchange();
|
|
1099
|
-
|
|
1100
|
-
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1101
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1102
|
-
const slOrder = orders.find((o) =>
|
|
1103
|
-
!!o.reduceOnly &&
|
|
1104
|
-
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1105
|
-
) ?? null;
|
|
1106
|
-
if (slOrder) {
|
|
1107
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1108
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1112
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1113
|
-
|
|
1114
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1115
|
-
if (qty === 0) {
|
|
1116
|
-
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1120
|
-
const positionSide = toPositionSide(position);
|
|
1121
|
-
|
|
1122
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1123
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
1127
|
-
const { symbol, newTakeProfitPrice, position } = payload;
|
|
1128
|
-
const exchange = await getFuturesExchange();
|
|
1129
|
-
|
|
1130
|
-
// Cancel existing TP order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1131
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1132
|
-
const tpOrder = orders.find((o) =>
|
|
1133
|
-
!!o.reduceOnly &&
|
|
1134
|
-
["limit", "LIMIT"].includes(o.type ?? "")
|
|
1135
|
-
) ?? null;
|
|
1136
|
-
if (tpOrder) {
|
|
1137
|
-
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
1138
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1142
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1143
|
-
|
|
1144
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1145
|
-
if (qty === 0) {
|
|
1146
|
-
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
1150
|
-
const positionSide = toPositionSide(position);
|
|
1151
|
-
|
|
1152
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1153
|
-
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
1157
|
-
const { symbol, newStopLossPrice, position } = payload;
|
|
1158
|
-
const exchange = await getFuturesExchange();
|
|
1159
|
-
|
|
1160
|
-
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1161
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1162
|
-
const slOrder = orders.find((o) =>
|
|
1163
|
-
!!o.reduceOnly &&
|
|
1164
|
-
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1165
|
-
) ?? null;
|
|
1166
|
-
if (slOrder) {
|
|
1167
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1168
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1172
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1173
|
-
|
|
1174
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1175
|
-
if (qty === 0) {
|
|
1176
|
-
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1180
|
-
const positionSide = toPositionSide(position);
|
|
1181
|
-
|
|
1182
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1183
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
1187
|
-
const { symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1188
|
-
const exchange = await getFuturesExchange();
|
|
1189
|
-
|
|
1190
|
-
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
1191
|
-
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
1192
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1193
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1194
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1195
|
-
|
|
1196
|
-
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
1197
|
-
const existing = await fetchContractsQty(exchange, symbol, position);
|
|
1198
|
-
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
1199
|
-
|
|
1200
|
-
// Compare notional value rather than raw contracts โ avoids float === 0 trap
|
|
1201
|
-
// and correctly rejects dust positions left over from previous trades
|
|
1202
|
-
if (existing * currentPrice < minNotional) {
|
|
1203
|
-
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
1207
|
-
|
|
1208
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
1209
|
-
if (qty <= 0) {
|
|
1210
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1214
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1215
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1216
|
-
// positionSide required in hedge mode to add to correct side; ignored in one-way mode
|
|
1217
|
-
const positionSide = toPositionSide(position);
|
|
1218
|
-
const entrySide = position === "long" ? "buy" : "sell";
|
|
1219
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1220
|
-
|
|
1221
|
-
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
1222
|
-
await createLimitOrderAndWait(
|
|
1223
|
-
exchange, symbol, entrySide, qty, entryPrice,
|
|
1224
|
-
{ positionSide },
|
|
1225
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1226
|
-
);
|
|
1227
|
-
|
|
1228
|
-
// Refetch contracts after fill โ existing snapshot is stale after cancel + fill
|
|
1229
|
-
const totalQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1230
|
-
|
|
1231
|
-
// Recreate SL/TP on fresh total qty after successful fill
|
|
1232
|
-
try {
|
|
1233
|
-
await exchange.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1234
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1235
|
-
} catch (err) {
|
|
1236
|
-
// Total position is unprotected โ close via market
|
|
1237
|
-
await exchange.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
|
|
1238
|
-
throw err;
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
);
|
|
1243
|
-
|
|
1244
|
-
Broker.enable();
|
|
1245
|
-
```
|
|
1246
|
-
|
|
1247
|
-
</details>
|
|
1248
|
-
|
|
1249
|
-
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.
|
|
1250
|
-
|
|
1251
|
-
### ๐ How getCandles Works
|
|
1252
|
-
|
|
1253
|
-
backtest-kit uses Node.js `AsyncLocalStorage` to automatically provide
|
|
1254
|
-
temporal time context to your strategies.
|
|
1255
|
-
|
|
1256
|
-
<details>
|
|
1257
|
-
<summary>
|
|
1258
|
-
The Math
|
|
1259
|
-
</summary>
|
|
1260
|
-
|
|
1261
|
-
For a candle with:
|
|
1262
|
-
- `timestamp` = candle open time (openTime)
|
|
1263
|
-
- `stepMs` = interval duration (e.g., 60000ms for "1m")
|
|
1264
|
-
- Candle close time = `timestamp + stepMs`
|
|
1265
|
-
|
|
1266
|
-
**Alignment:** All timestamps are aligned down to interval boundary.
|
|
1267
|
-
For example, for 15m interval: 00:17 โ 00:15, 00:44 โ 00:30
|
|
1268
|
-
|
|
1269
|
-
**Adapter contract:**
|
|
1270
|
-
- First candle.timestamp must equal aligned `since`
|
|
1271
|
-
- Adapter must return exactly `limit` candles
|
|
1272
|
-
- Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
|
|
1273
|
-
|
|
1274
|
-
**How `since` is calculated from `when`:**
|
|
1275
|
-
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1276
|
-
- `alignedWhen` = `Math.floor(when / stepMs) * stepMs` (aligned down to interval boundary)
|
|
1277
|
-
- `since` = `alignedWhen - limit * stepMs` (go back `limit` candles from aligned when)
|
|
1278
|
-
|
|
1279
|
-
**Boundary semantics (inclusive/exclusive):**
|
|
1280
|
-
- `since` is always **inclusive** โ first candle has `timestamp === since`
|
|
1281
|
-
- Exactly `limit` candles are returned
|
|
1282
|
-
- Last candle has `timestamp === since + (limit - 1) * stepMs` โ **inclusive**
|
|
1283
|
-
- For `getCandles`: `alignedWhen` is **exclusive** โ candle at that timestamp is NOT included (it's a pending/incomplete candle)
|
|
1284
|
-
- For `getRawCandles`: `eDate` is **exclusive** โ candle at that timestamp is NOT included (it's a pending/incomplete candle)
|
|
1285
|
-
- For `getNextCandles`: `alignedWhen` is **inclusive** โ first candle starts at `alignedWhen` (it's the current candle for backtest, already closed in historical data)
|
|
1286
|
-
|
|
1287
|
-
- `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
|
|
1288
|
-
- Aligns `when` down to interval boundary
|
|
1289
|
-
- Calculates `since = alignedWhen - limit * stepMs`
|
|
1290
|
-
- **since โ inclusive**, first candle.timestamp === since
|
|
1291
|
-
- **alignedWhen โ exclusive**, candle at alignedWhen is NOT returned
|
|
1292
|
-
- Range: `[since, alignedWhen)` โ half-open interval
|
|
1293
|
-
- Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before aligned when
|
|
1294
|
-
|
|
1295
|
-
- `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
|
|
1296
|
-
- Aligns `when` down to interval boundary
|
|
1297
|
-
- `since = alignedWhen` (starts from aligned when, going forward)
|
|
1298
|
-
- **since โ inclusive**, first candle.timestamp === since
|
|
1299
|
-
- Range: `[alignedWhen, alignedWhen + limit * stepMs)` โ half-open interval
|
|
1300
|
-
- Throws error in live mode to prevent look-ahead bias
|
|
1301
|
-
- Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles starting from aligned when
|
|
1302
|
-
|
|
1303
|
-
- `getRawCandles(symbol, interval, limit?, sDate?, eDate?)` - Flexible parameter combinations:
|
|
1304
|
-
- `(limit)` - since = alignedWhen - limit * stepMs, range `[since, alignedWhen)`
|
|
1305
|
-
- `(limit, sDate)` - since = align(sDate), returns `limit` candles forward, range `[since, since + limit * stepMs)`
|
|
1306
|
-
- `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs, **eDate โ exclusive**, range `[since, eDate)`
|
|
1307
|
-
- `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range, **sDate โ inclusive, eDate โ exclusive**, range `[sDate, eDate)`
|
|
1308
|
-
- `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles, **sDate โ inclusive**
|
|
1309
|
-
- All combinations respect look-ahead bias protection (eDate/endTime <= when)
|
|
1310
|
-
|
|
1311
|
-
**Persistent Cache:**
|
|
1312
|
-
- Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
|
|
1313
|
-
- Returns all candles if found, null if any missing (cache miss)
|
|
1314
|
-
- Cache and runtime use identical timestamp calculation logic
|
|
1315
|
-
|
|
1316
|
-
</details>
|
|
1317
|
-
|
|
1318
|
-
#### Candle Timestamp Convention:
|
|
1319
|
-
|
|
1320
|
-
According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
|
|
1321
|
-
|
|
1322
|
-
**Key principles:**
|
|
1323
|
-
- All timestamps are aligned down to interval boundary
|
|
1324
|
-
- First candle.timestamp must equal aligned `since`
|
|
1325
|
-
- Adapter must return exactly `limit` candles
|
|
1326
|
-
- Sequential timestamps: `since + i * stepMs`
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
### ๐ How getOrderBook Works
|
|
1330
|
-
|
|
1331
|
-
Order book fetching uses the same temporal alignment as candles, but with a configurable time offset window instead of candle intervals.
|
|
1332
|
-
|
|
1333
|
-
<details>
|
|
1334
|
-
<summary>
|
|
1335
|
-
The Math
|
|
1336
|
-
</summary>
|
|
1337
|
-
|
|
1338
|
-
**Time range calculation:**
|
|
1339
|
-
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1340
|
-
- `offsetMinutes` = `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` (configurable)
|
|
1341
|
-
- `alignedTo` = `Math.floor(when / (offsetMinutes * 60000)) * (offsetMinutes * 60000)`
|
|
1342
|
-
- `to` = `alignedTo` (aligned down to offset boundary)
|
|
1343
|
-
- `from` = `alignedTo - offsetMinutes * 60000`
|
|
1344
|
-
|
|
1345
|
-
**Adapter contract:**
|
|
1346
|
-
- `getOrderBook(symbol, depth, from, to, backtest)` is called on the exchange schema
|
|
1347
|
-
- `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
|
|
1348
|
-
- The `from`/`to` range represents a time window of exactly `offsetMinutes` duration
|
|
1349
|
-
- Schema implementation may use the time range (backtest) or ignore it (live trading)
|
|
1350
|
-
|
|
1351
|
-
**Example with CC_ORDER_BOOK_TIME_OFFSET_MINUTES = 10:**
|
|
1352
|
-
```
|
|
1353
|
-
when = 1704067920000 // 2024-01-01 00:12:00 UTC
|
|
1354
|
-
offsetMinutes = 10
|
|
1355
|
-
offsetMs = 10 * 60000 // 600000ms
|
|
1356
|
-
|
|
1357
|
-
alignedTo = Math.floor(1704067920000 / 600000) * 600000
|
|
1358
|
-
= 1704067800000 // 2024-01-01 00:10:00 UTC
|
|
1359
|
-
|
|
1360
|
-
to = 1704067800000 // 00:10:00 UTC
|
|
1361
|
-
from = 1704067200000 // 00:00:00 UTC
|
|
1362
|
-
```
|
|
1363
|
-
</details>
|
|
1364
|
-
|
|
1365
|
-
#### Order Book Timestamp Convention:
|
|
1366
|
-
|
|
1367
|
-
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.
|
|
1368
|
-
|
|
1369
|
-
**Key principles:**
|
|
1370
|
-
- Time range is aligned down to `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` boundary
|
|
1371
|
-
- `to` = aligned timestamp, `from` = `to - offsetMinutes * 60000`
|
|
1372
|
-
- `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
|
|
1373
|
-
- Adapter receives `(symbol, depth, from, to, backtest)` โ may ignore `from`/`to` in live mode
|
|
1374
|
-
|
|
1375
|
-
### ๐ How getAggregatedTrades Works
|
|
1376
|
-
|
|
1377
|
-
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.
|
|
1378
|
-
|
|
1379
|
-
**Key principles:**
|
|
1380
|
-
- `to` is always aligned down to the 1-minute boundary โ prevents look-ahead bias
|
|
1381
|
-
- Without `limit`: returns one full window (`CC_AGGREGATED_TRADES_MAX_MINUTES`)
|
|
1382
|
-
- With `limit`: paginates backwards until collected, then slices to most recent `limit`
|
|
1383
|
-
- Adapter receives `(symbol, from, to, backtest)` โ may ignore `from`/`to` in live mode
|
|
1384
|
-
|
|
1385
|
-
<details>
|
|
1386
|
-
<summary>
|
|
1387
|
-
The Math
|
|
1388
|
-
</summary>
|
|
1389
|
-
|
|
1390
|
-
**Time range calculation:**
|
|
1391
|
-
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1392
|
-
- `alignedTo` = `Math.floor(when / 60000) * 60000` (aligned down to 1-minute boundary)
|
|
1393
|
-
- `windowMs` = `CC_AGGREGATED_TRADES_MAX_MINUTES * 60000 โ 60000`
|
|
1394
|
-
- `to` = `alignedTo`, `from` = `alignedTo โ windowMs`
|
|
1395
|
-
|
|
1396
|
-
**Without `limit`:** fetches a single window and returns it as-is.
|
|
1397
|
-
|
|
1398
|
-
**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.
|
|
1399
|
-
|
|
1400
|
-
**Example with CC_AGGREGATED_TRADES_MAX_MINUTES = 60, limit = 200:**
|
|
1401
|
-
```
|
|
1402
|
-
when = 1704067920000 // 2024-01-01 00:12:00 UTC
|
|
1403
|
-
alignedTo = 1704067800000 // 2024-01-01 00:12:00 โ aligned to 00:12:00
|
|
1404
|
-
windowMs = 59 * 60000 // 3540000ms = 59 minutes
|
|
1405
|
-
|
|
1406
|
-
Window 1: from = 00:12:00 โ 59m = 23:13:00
|
|
1407
|
-
to = 00:12:00
|
|
1408
|
-
โ got 120 trades โ not enough
|
|
1409
|
-
|
|
1410
|
-
Window 2: from = 23:13:00 โ 59m = 22:14:00
|
|
1411
|
-
to = 23:13:00
|
|
1412
|
-
โ got 100 more โ total 220 trades
|
|
1413
|
-
|
|
1414
|
-
result = last 200 of 220 (most recent)
|
|
1415
|
-
```
|
|
1416
|
-
|
|
1417
|
-
**Adapter contract:**
|
|
1418
|
-
- `getAggregatedTrades(symbol, from, to, backtest)` is called on the exchange schema
|
|
1419
|
-
- `from`/`to` are `Date` objects
|
|
1420
|
-
- Schema implementation may use the time range (backtest) or ignore it (live trading)
|
|
1421
|
-
|
|
1422
|
-
</details>
|
|
1423
|
-
|
|
1424
|
-
**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.
|
|
1425
|
-
|
|
1426
|
-
### ๐ฌ Technical Details: Timestamp Alignment
|
|
1427
|
-
|
|
1428
|
-
**Why align timestamps to interval boundaries?**
|
|
1429
|
-
|
|
1430
|
-
Because candle APIs return data starting from exact interval boundaries:
|
|
1431
|
-
|
|
1432
|
-
```typescript
|
|
1433
|
-
// 15-minute interval example:
|
|
1434
|
-
when = 1704067920000 // 00:12:00
|
|
1435
|
-
step = 15 // 15 minutes
|
|
1436
|
-
stepMs = 15 * 60000 // 900000ms
|
|
1437
|
-
|
|
1438
|
-
// Alignment: round down to nearest interval boundary
|
|
1439
|
-
alignedWhen = Math.floor(when / stepMs) * stepMs
|
|
1440
|
-
// = Math.floor(1704067920000 / 900000) * 900000
|
|
1441
|
-
// = 1704067200000 (00:00:00)
|
|
1442
|
-
|
|
1443
|
-
// Calculate since for 4 candles backwards:
|
|
1444
|
-
since = alignedWhen - 4 * stepMs
|
|
1445
|
-
// = 1704067200000 - 4 * 900000
|
|
1446
|
-
// = 1704063600000 (23:00:00 previous day)
|
|
1447
|
-
|
|
1448
|
-
// Expected candles:
|
|
1449
|
-
// [0] timestamp = 1704063600000 (23:00)
|
|
1450
|
-
// [1] timestamp = 1704064500000 (23:15)
|
|
1451
|
-
// [2] timestamp = 1704065400000 (23:30)
|
|
1452
|
-
// [3] timestamp = 1704066300000 (23:45)
|
|
1453
|
-
```
|
|
1454
|
-
|
|
1455
|
-
**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.
|
|
1456
|
-
|
|
1457
|
-
**Validation is applied consistently across:**
|
|
1458
|
-
- โ
`getCandles()` - validates first timestamp and count
|
|
1459
|
-
- โ
`getNextCandles()` - validates first timestamp and count
|
|
1460
|
-
- โ
`getRawCandles()` - validates first timestamp and count
|
|
1461
|
-
- โ
Cache read - calculates exact expected timestamps
|
|
1462
|
-
- โ
Cache write - stores validated candles
|
|
1463
|
-
|
|
1464
|
-
**Result:** Deterministic candle retrieval with exact timestamp matching.
|
|
1465
|
-
|
|
1466
|
-
### ๐ Timezone Warning: Candle Boundaries Are UTC-Based
|
|
1467
|
-
|
|
1468
|
-
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.
|
|
1469
|
-
|
|
1470
|
-
For example, in UTC+5 the same 4h candle request logs as:
|
|
1471
|
-
|
|
1472
|
-
```
|
|
1473
|
-
since: Sat Sep 20 2025 13:00:00 GMT+0500 โ looks uneven (13:00)
|
|
1474
|
-
since: Sat Sep 20 2025 17:00:00 GMT+0500 โ looks uneven (17:00)
|
|
1475
|
-
since: Sat Sep 20 2025 21:00:00 GMT+0500 โ looks uneven (21:00)
|
|
1476
|
-
since: Sun Sep 21 2025 05:00:00 GMT+0500 โ looks uneven (05:00)
|
|
1477
|
-
```
|
|
1478
|
-
|
|
1479
|
-
But in UTC these are perfectly aligned 4h boundaries:
|
|
1480
|
-
|
|
1481
|
-
```
|
|
1482
|
-
since: Sat, 20 Sep 2025 08:00:00 GMT โ 08:00 UTC โ
|
|
1483
|
-
since: Sat, 20 Sep 2025 12:00:00 GMT โ 12:00 UTC โ
|
|
1484
|
-
since: Sat, 20 Sep 2025 16:00:00 GMT โ 16:00 UTC โ
|
|
1485
|
-
since: Sun, 21 Sep 2025 00:00:00 GMT โ 00:00 UTC โ
|
|
1486
|
-
```
|
|
1487
|
-
|
|
1488
|
-
Use `toUTCString()` or `toISOString()` in callbacks to see the actual aligned UTC times.
|
|
1489
|
-
|
|
1490
|
-
### ๐ญ What this means:
|
|
1491
|
-
- `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`
|
|
1492
|
-
- Multi-timeframe data is automatically synchronized
|
|
1493
|
-
- **Impossible to introduce look-ahead bias** - all time boundaries are enforced
|
|
1494
|
-
- Same code works in both backtest and live modes
|
|
1495
|
-
- Boundary semantics prevent edge cases in signal generation
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
## ๐ง Two Ways to Run the Engine
|
|
1499
|
-
|
|
1500
|
-
Backtest Kit exposes the same runtime in two equivalent forms. Both approaches use **the same engine and guarantees** - only the consumption model differs.
|
|
1501
|
-
|
|
1502
|
-
### 1๏ธโฃ Event-driven (background execution)
|
|
1503
|
-
|
|
1504
|
-
Suitable for production bots, monitoring, and long-running processes.
|
|
1505
|
-
|
|
1506
|
-
```typescript
|
|
1507
|
-
Backtest.background('BTCUSDT', config);
|
|
1508
|
-
|
|
1509
|
-
listenSignalBacktest(event => { /* handle signals */ });
|
|
1510
|
-
listenDoneBacktest(event => { /* finalize / dump report */ });
|
|
1511
|
-
```
|
|
1512
|
-
|
|
1513
|
-
### 2๏ธโฃ Async Iterator (pull-based execution)
|
|
1514
|
-
|
|
1515
|
-
Suitable for research, scripting, testing, and LLM agents.
|
|
1516
|
-
|
|
1517
|
-
```typescript
|
|
1518
|
-
for await (const event of Backtest.run('BTCUSDT', config)) {
|
|
1519
|
-
// signal | trade | progress | done
|
|
1520
|
-
}
|
|
1521
|
-
```
|
|
1522
|
-
|
|
1523
|
-
## โ๏ธ Think of it as...
|
|
1524
|
-
|
|
1525
|
-
**Open-source QuantConnect/MetaTrader without the vendor lock-in**
|
|
1526
|
-
|
|
1527
|
-
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
|
|
1528
|
-
|
|
1529
|
-
- No C#/C++ required - pure TypeScript/JavaScript
|
|
1530
|
-
- Self-hosted - your code, your data, your infrastructure
|
|
1531
|
-
- No platform fees or hidden costs
|
|
1532
|
-
- Full control over execution and data sources
|
|
1533
|
-
- [GUI](https://npmjs.com/package/@backtest-kit/ui) for visualization and monitoring
|
|
1534
|
-
|
|
1535
|
-
## ๐ Ecosystem
|
|
1536
|
-
|
|
1537
|
-
The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
### @backtest-kit/cli
|
|
1541
|
-
|
|
1542
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** ๐
|
|
1543
|
-
|
|
1544
|
-
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.
|
|
1545
|
-
|
|
1546
|
-
#### Key Features
|
|
1547
|
-
- ๐ **Zero Config**: Run a backtest with one command โ no setup code needed
|
|
1548
|
-
- ๐ **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
|
|
1549
|
-
- ๐พ **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
|
|
1550
|
-
- ๐ **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
|
|
1551
|
-
- ๐ฌ **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
|
|
1552
|
-
- ๐๏ธ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
|
|
1553
|
-
|
|
1554
|
-
#### Use Case
|
|
1555
|
-
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.
|
|
1556
|
-
|
|
1557
|
-
#### Get Started
|
|
1558
|
-
```bash
|
|
1559
|
-
npx -y @backtest-kit/cli --init
|
|
1560
|
-
```
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
### @backtest-kit/pinets
|
|
1564
|
-
|
|
1565
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** ๐
|
|
1566
|
-
|
|
1567
|
-
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.
|
|
1568
|
-
|
|
1569
|
-
#### Key Features
|
|
1570
|
-
- ๐ **Pine Script v5/v6**: Native TradingView syntax with 1:1 compatibility
|
|
1571
|
-
- ๐ฏ **60+ Indicators**: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Stochastic built-in
|
|
1572
|
-
- ๐ **File or Code**: Load `.pine` files or pass code strings directly
|
|
1573
|
-
- ๐บ๏ธ **Plot Extraction**: Flexible mapping from Pine `plot()` outputs to structured signals
|
|
1574
|
-
- โก **Cached Execution**: Memoized file reads for repeated strategy runs
|
|
1575
|
-
|
|
1576
|
-
#### Use Case
|
|
1577
|
-
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.
|
|
1578
|
-
|
|
1579
|
-
#### Get Started
|
|
1580
|
-
```bash
|
|
1581
|
-
npm install @backtest-kit/pinets pinets backtest-kit
|
|
1582
|
-
```
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
### @backtest-kit/graph
|
|
1586
|
-
|
|
1587
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** ๐
|
|
1588
|
-
|
|
1589
|
-
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.
|
|
1590
|
-
|
|
1591
|
-
#### Key Features
|
|
1592
|
-
- ๐ **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
|
|
1593
|
-
- ๐ **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
|
|
1594
|
-
- ๐งฑ **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
|
|
1595
|
-
- ๐พ **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
|
|
1596
|
-
- ๐ **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
|
|
1597
|
-
|
|
1598
|
-
#### Use Case
|
|
1599
|
-
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.
|
|
1600
|
-
|
|
1601
|
-
#### Get Started
|
|
1602
|
-
```bash
|
|
1603
|
-
npm install @backtest-kit/graph backtest-kit
|
|
1604
|
-
```
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
### @backtest-kit/ui
|
|
1608
|
-
|
|
1609
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ui)** ๐
|
|
1610
|
-
|
|
1611
|
-
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.
|
|
1612
|
-
|
|
1613
|
-
#### Key Features
|
|
1614
|
-
- ๐ **Interactive Charts**: Candlestick visualization with Lightweight Charts (1m, 15m, 1h timeframes)
|
|
1615
|
-
- ๐ฏ **Signal Tracking**: View opened, closed, scheduled, and cancelled signals with full details
|
|
1616
|
-
- ๐ **Risk Analysis**: Monitor risk rejections and position management
|
|
1617
|
-
- ๐ **Notifications**: Real-time notification system for all trading events
|
|
1618
|
-
- ๐น **Trailing & Breakeven**: Visualize trailing stop/take and breakeven events
|
|
1619
|
-
- ๐จ **Material Design**: Beautiful UI with MUI 5 and Mantine components
|
|
1620
|
-
|
|
1621
|
-
#### Use Case
|
|
1622
|
-
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.
|
|
1623
|
-
|
|
1624
|
-
#### Get Started
|
|
1625
|
-
```bash
|
|
1626
|
-
npm install @backtest-kit/ui backtest-kit ccxt
|
|
1627
|
-
```
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
### @backtest-kit/ollama
|
|
1631
|
-
|
|
1632
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ollama)** ๐ค
|
|
1633
|
-
|
|
1634
|
-
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.
|
|
1635
|
-
|
|
1636
|
-
#### Key Features
|
|
1637
|
-
- ๐ **10+ LLM Providers**: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama
|
|
1638
|
-
- ๐ **Token Rotation**: Automatic API key rotation for Ollama (others throw clear errors)
|
|
1639
|
-
- ๐ฏ **Structured Output**: Enforced JSON schema for trading signals (position, price levels, risk notes)
|
|
1640
|
-
- ๐ **Flexible Auth**: Context-based API keys or environment variables
|
|
1641
|
-
- โก **Unified API**: Single interface across all providers
|
|
1642
|
-
- ๐ **Trading-First**: Built for backtest-kit with position sizing and risk management
|
|
1643
|
-
|
|
1644
|
-
#### Use Case
|
|
1645
|
-
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.
|
|
1646
|
-
|
|
1647
|
-
#### Get Started
|
|
1648
|
-
```bash
|
|
1649
|
-
npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
|
|
1650
|
-
```
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
### @backtest-kit/signals
|
|
1654
|
-
|
|
1655
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/signals)** ๐
|
|
1656
|
-
|
|
1657
|
-
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.
|
|
1658
|
-
|
|
1659
|
-
#### Key Features
|
|
1660
|
-
- ๐ **Multi-Timeframe Analysis**: 1m, 15m, 30m, 1h with synchronized indicator computation
|
|
1661
|
-
- ๐ฏ **50+ Technical Indicators**: RSI, MACD, Bollinger Bands, Stochastic, ADX, ATR, CCI, Fibonacci, Support/Resistance
|
|
1662
|
-
- ๐ **Order Book Analysis**: Bid/ask depth, spread, liquidity imbalance, top 20 levels
|
|
1663
|
-
- ๐ค **AI-Ready Output**: Markdown reports formatted for LLM context injection
|
|
1664
|
-
- โก **Performance Optimized**: Intelligent caching with configurable TTL per timeframe
|
|
1665
|
-
|
|
1666
|
-
#### Use Case
|
|
1667
|
-
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.
|
|
1668
|
-
|
|
1669
|
-
#### Get Started
|
|
1670
|
-
```bash
|
|
1671
|
-
npm install @backtest-kit/signals backtest-kit
|
|
1672
|
-
```
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
### @backtest-kit/sidekick
|
|
1676
|
-
|
|
1677
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** ๐
|
|
1678
|
-
|
|
1679
|
-
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.
|
|
1680
|
-
|
|
1681
|
-
#### Key Features
|
|
1682
|
-
- ๐ **Zero Config**: Get started with one command - no setup required
|
|
1683
|
-
- ๐ฆ **Complete Template**: Includes backtest strategy, risk management, and LLM integration
|
|
1684
|
-
- ๐ค **AI-Powered**: Pre-configured with DeepSeek, Claude, and GPT-5 fallback chain
|
|
1685
|
-
- ๐ **Technical Analysis**: Built-in 50+ indicators via @backtest-kit/signals
|
|
1686
|
-
- ๐ **Environment Setup**: Auto-generated .env with all API key placeholders
|
|
1687
|
-
- ๐ **Best Practices**: Production-ready code structure with examples
|
|
1688
|
-
|
|
1689
|
-
#### Use Case
|
|
1690
|
-
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.
|
|
1691
|
-
|
|
1692
|
-
#### Get Started
|
|
1693
|
-
```bash
|
|
1694
|
-
npx -y @backtest-kit/sidekick my-trading-bot
|
|
1695
|
-
cd my-trading-bot
|
|
1696
|
-
npm start
|
|
1697
|
-
```
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
## ๐ช Community
|
|
1701
|
-
|
|
1702
|
-
### uzse-backtest-app
|
|
1703
|
-
|
|
1704
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/uzse-backtest-app)** ๐
|
|
1705
|
-
|
|
1706
|
-
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.
|
|
1707
|
-
|
|
1708
|
-
#### Key Features
|
|
1709
|
-
- ๐ **Off-TradingView Markets**: Works with any exchange that exposes trade history โ no TradingView dependency
|
|
1710
|
-
- ๐ฏ๏ธ **Candle Builder**: Aggregates raw trades into 1m candles, fills intraday and non-trading day gaps, builds higher timeframes up to `1d`
|
|
1711
|
-
- ๐๏ธ **MongoDB Backend**: Idempotent import with unique index โ re-runs never create duplicates
|
|
1712
|
-
- ๐ **Custom Exchange Adapter**: Connects MongoDB candles to backtest-kit via `addExchangeSchema`
|
|
1713
|
-
- ๐ **Pine Script Support**: Full `@backtest-kit/pinets` integration โ run any Pine Script v5/v6 indicator on local market data
|
|
1714
|
-
|
|
1715
|
-
#### Use Case
|
|
1716
|
-
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.
|
|
1717
|
-
|
|
1718
|
-
#### Get Started
|
|
1719
|
-
```bash
|
|
1720
|
-
git clone https://github.com/backtest-kit/uzse-backtest-app.git
|
|
1721
|
-
```
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
### backtest-kit-skills
|
|
1725
|
-
|
|
1726
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-skills)** ๐ค
|
|
1727
|
-
|
|
1728
|
-
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.
|
|
1729
|
-
|
|
1730
|
-
#### Key Features
|
|
1731
|
-
- ๐ค **Claude Code Skill**: Installed under `~/.claude/skills/backtest-kit/` โ strategy generation, debugging, and API reference
|
|
1732
|
-
- ๐ **Mintlify Docs**: Full documentation site runnable locally
|
|
1733
|
-
- ๐ฏ **Strategy Generation**: Complete TypeScript files with all schema registrations and runner setup
|
|
1734
|
-
- ๐ **Debugging Help**: Catches common mistakes (missing `await`, wrong TP/SL direction, top-level commit calls)
|
|
1735
|
-
- ๐ **API Reference**: All schemas, commit functions, event listeners, LLM integration, graph pipelines, and persistence adapters
|
|
1736
|
-
|
|
1737
|
-
#### Use Case
|
|
1738
|
-
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.
|
|
1739
|
-
|
|
1740
|
-
#### Get Started
|
|
1741
|
-
```bash
|
|
1742
|
-
npx skills add https://github.com/backtest-kit/backtest-kit-skills
|
|
1743
|
-
```
|
|
1744
|
-
|
|
1745
|
-
## ๐งฉ Strategy Examples
|
|
1746
|
-
|
|
1747
|
-
#### ๐ง Neural Network Strategy (Oct 2021)
|
|
1748
|
-
|
|
1749
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy)
|
|
1750
|
-
|
|
1751
|
-
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.
|
|
1752
|
-
|
|
1753
|
-
#### ๐ฒ Pine Script Range Breakout (Dec 2025)
|
|
1754
|
-
|
|
1755
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy)
|
|
1756
|
-
|
|
1757
|
-
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.
|
|
1758
|
-
|
|
1759
|
-
#### ๐ช Signal Inversion Strategy (Jan 2026)
|
|
1760
|
-
|
|
1761
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy)
|
|
1762
|
-
|
|
1763
|
-
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.
|
|
1764
|
-
|
|
1765
|
-
#### ๐ฐ AI News Sentiment (Feb 2026)
|
|
1766
|
-
|
|
1767
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy)
|
|
1768
|
-
|
|
1769
|
-
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.
|
|
1770
|
-
|
|
1771
|
-
#### ๐ช SHORT DCA Ladder (Mar 2026)
|
|
1772
|
-
|
|
1773
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy)
|
|
1774
|
-
|
|
1775
|
-
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.
|
|
1776
|
-
|
|
1777
|
-
#### ๐ง LONG DCA Ladder (Apr 2026)
|
|
1778
|
-
|
|
1779
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy)
|
|
1780
|
-
|
|
1781
|
-
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).
|
|
1782
|
-
|
|
1783
|
-
#### ๐ Python EMA Crossover (Feb 2021)
|
|
1784
|
-
|
|
1785
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy)
|
|
1786
|
-
|
|
1787
|
-
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.
|
|
1788
|
-
|
|
1789
|
-
## ๐ค Are you a robot?
|
|
1790
|
-
|
|
1791
|
-
**For language models**: Read extended description in [./LLMs.md](./LLMs.md)
|
|
1792
|
-
|
|
1793
|
-
## โ
Tested & Reliable
|
|
1794
|
-
|
|
1795
|
-
515+ tests cover validation, recovery, reports, and events.
|
|
1796
|
-
|
|
1797
|
-
## ๐ค Contribute
|
|
1798
|
-
|
|
1799
|
-
Fork/PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
|
|
1800
|
-
|
|
1801
|
-
## ๐ License
|
|
1802
|
-
|
|
1803
|
-
MIT ยฉ [tripolskypetr](https://github.com/tripolskypetr)
|
|
1804
|
-
|
|
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
|
+

|
|
8
|
+
|
|
9
|
+
[](https://deepwiki.com/tripolskypetr/backtest-kit)
|
|
10
|
+
[](https://npmjs.org/package/backtest-kit)
|
|
11
|
+
[]()
|
|
12
|
+
[](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
|
|
13
|
+
|
|
14
|
+
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/demo)** | **๐ฐ [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
|
+
### ๐ฆ Manual Installation
|
|
48
|
+
|
|
49
|
+
> **Want to see the code?** ๐ [Demo app](https://github.com/tripolskypetr/backtest-kit/tree/master/example) ๐
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm install backtest-kit ccxt ollama uuid
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## โจ Why Choose Backtest Kit?
|
|
56
|
+
|
|
57
|
+
- ๐ **Production-Ready**: Seamless switch between backtest/live modes; identical code across environments.
|
|
58
|
+
- ๐พ **Crash-Safe**: Atomic persistence recovers states after crashes, preventing duplicates or losses.
|
|
59
|
+
- โ
**Validation**: Checks signals for TP/SL logic, risk/reward ratios, and portfolio limits.
|
|
60
|
+
- ๐ **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
|
|
61
|
+
- ๐ค **AI Integration**: LLM-powered strategy generation (Optimizer) with multi-timeframe analysis.
|
|
62
|
+
- ๐ **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
|
|
63
|
+
- ๐ก๏ธ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
|
|
64
|
+
- ๐ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
|
|
65
|
+
- ๐๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ exchange rejection rolls back the operation atomically.
|
|
66
|
+
- ๐งช **Tested**: 515+ unit/integration tests for validation, recovery, and events.
|
|
67
|
+
- ๐ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
|
|
68
|
+
|
|
69
|
+
## ๐ Supported Order Types
|
|
70
|
+
|
|
71
|
+
> With the calculation of PnL, Peak Profit and Max Drawdown for each Entry
|
|
72
|
+
|
|
73
|
+
- Market/Limit entries
|
|
74
|
+
- TP/SL/OCO exits
|
|
75
|
+
- Grid with auto-cancel on unmet conditions
|
|
76
|
+
- Partial profit/loss levels
|
|
77
|
+
- Trailing stop-loss
|
|
78
|
+
- Breakeven protection
|
|
79
|
+
- Stop limit entries (before OCO)
|
|
80
|
+
- Dollar cost averaging
|
|
81
|
+
- Time attack / Infinite hold
|
|
82
|
+
|
|
83
|
+
## ๐ Code Samples
|
|
84
|
+
|
|
85
|
+
### โ๏ธ Basic Configuration
|
|
86
|
+
```typescript
|
|
87
|
+
import { setLogger, setConfig } from 'backtest-kit';
|
|
88
|
+
|
|
89
|
+
// Enable logging
|
|
90
|
+
setLogger({
|
|
91
|
+
log: console.log,
|
|
92
|
+
debug: console.debug,
|
|
93
|
+
info: console.info,
|
|
94
|
+
warn: console.warn,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Global config (optional)
|
|
98
|
+
setConfig({
|
|
99
|
+
CC_PERCENT_SLIPPAGE: 0.1, // % slippage
|
|
100
|
+
CC_PERCENT_FEE: 0.1, // % fee
|
|
101
|
+
CC_SCHEDULE_AWAIT_MINUTES: 120, // Pending signal timeout
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### ๐ง Register Components
|
|
106
|
+
```typescript
|
|
107
|
+
import ccxt from 'ccxt';
|
|
108
|
+
import { addExchangeSchema, addStrategySchema, addFrameSchema, addRiskSchema } from 'backtest-kit';
|
|
109
|
+
|
|
110
|
+
// Exchange (data source)
|
|
111
|
+
addExchangeSchema({
|
|
112
|
+
exchangeName: 'binance',
|
|
113
|
+
getCandles: async (symbol, interval, since, limit) => {
|
|
114
|
+
const exchange = new ccxt.binance();
|
|
115
|
+
const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
116
|
+
return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ timestamp, open, high, low, close, volume }));
|
|
117
|
+
},
|
|
118
|
+
formatPrice: (symbol, price) => price.toFixed(2),
|
|
119
|
+
formatQuantity: (symbol, quantity) => quantity.toFixed(8),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Risk profile
|
|
123
|
+
addRiskSchema({
|
|
124
|
+
riskName: 'demo',
|
|
125
|
+
validations: [
|
|
126
|
+
// TP at least 1%
|
|
127
|
+
({ pendingSignal, currentPrice }) => {
|
|
128
|
+
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
|
|
129
|
+
const tpDistance = position === 'long' ? ((priceTakeProfit - priceOpen) / priceOpen) * 100 : ((priceOpen - priceTakeProfit) / priceOpen) * 100;
|
|
130
|
+
if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
|
|
131
|
+
},
|
|
132
|
+
// R/R at least 2:1
|
|
133
|
+
({ pendingSignal, currentPrice }) => {
|
|
134
|
+
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
|
|
135
|
+
const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
|
|
136
|
+
const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
|
|
137
|
+
if (reward / risk < 2) throw new Error('Poor R/R ratio');
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Time frame
|
|
143
|
+
addFrameSchema({
|
|
144
|
+
frameName: '1d-test',
|
|
145
|
+
interval: '1m',
|
|
146
|
+
startDate: new Date('2025-12-01'),
|
|
147
|
+
endDate: new Date('2025-12-02'),
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### ๐ก Example Strategy (with LLM)
|
|
152
|
+
```typescript
|
|
153
|
+
import { v4 as uuid } from 'uuid';
|
|
154
|
+
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
|
|
155
|
+
import { json } from './utils/json.mjs'; // LLM wrapper
|
|
156
|
+
import { getMessages } from './utils/messages.mjs'; // Market data prep
|
|
157
|
+
|
|
158
|
+
addStrategySchema({
|
|
159
|
+
strategyName: 'llm-strategy',
|
|
160
|
+
interval: '5m',
|
|
161
|
+
riskName: 'demo',
|
|
162
|
+
getSignal: async (symbol) => {
|
|
163
|
+
|
|
164
|
+
const candles1h = await getCandles(symbol, "1h", 24);
|
|
165
|
+
const candles15m = await getCandles(symbol, "15m", 48);
|
|
166
|
+
const candles5m = await getCandles(symbol, "5m", 60);
|
|
167
|
+
const candles1m = await getCandles(symbol, "1m", 60);
|
|
168
|
+
|
|
169
|
+
const messages = await getMessages(symbol, {
|
|
170
|
+
candles1h,
|
|
171
|
+
candles15m,
|
|
172
|
+
candles5m,
|
|
173
|
+
candles1m,
|
|
174
|
+
}); // Calculate indicators / Fetch news
|
|
175
|
+
|
|
176
|
+
const resultId = uuid();
|
|
177
|
+
const signal = await json(messages); // LLM generates signal
|
|
178
|
+
|
|
179
|
+
await dumpAgentAnswer({
|
|
180
|
+
dumpId: "position-context",
|
|
181
|
+
bucketName: "multi-timeframe-strategy",
|
|
182
|
+
messages: messages, // pass saved messages here
|
|
183
|
+
description: "agent reasoning for this signal",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await dumpRecord({
|
|
187
|
+
dumpId: "position-entry",
|
|
188
|
+
bucketName: "multi-timeframe-strategy",
|
|
189
|
+
record: signal, // pass saved signal record here
|
|
190
|
+
description: "signal entry parameters",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { ...signal, id: resultId };
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### ๐งช Run Backtest
|
|
199
|
+
```typescript
|
|
200
|
+
import { Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
|
|
201
|
+
|
|
202
|
+
Backtest.background('BTCUSDT', {
|
|
203
|
+
strategyName: 'llm-strategy',
|
|
204
|
+
exchangeName: 'binance',
|
|
205
|
+
frameName: '1d-test',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
listenSignalBacktest((event) => console.log(event));
|
|
209
|
+
listenDoneBacktest(async (event) => {
|
|
210
|
+
await Backtest.dump(event.symbol, event.strategyName); // Generate report
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### ๐ Run Live Trading
|
|
215
|
+
```typescript
|
|
216
|
+
import { Live, listenSignalLive } from 'backtest-kit';
|
|
217
|
+
|
|
218
|
+
Live.background('BTCUSDT', {
|
|
219
|
+
strategyName: 'llm-strategy',
|
|
220
|
+
exchangeName: 'binance', // Use API keys in .env
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
listenSignalLive((event) => console.log(event));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### ๐ก Monitoring & Events
|
|
227
|
+
|
|
228
|
+
- Use `listenRisk`, `listenError`, `listenPartialProfit/Loss` for alerts.
|
|
229
|
+
- Dump reports: `Backtest.dump()`, `Live.dump()`.
|
|
230
|
+
|
|
231
|
+
## ๐ Global Configuration
|
|
232
|
+
|
|
233
|
+
Customize via `setConfig()`:
|
|
234
|
+
|
|
235
|
+
- `CC_SCHEDULE_AWAIT_MINUTES`: Pending timeout (default: 120).
|
|
236
|
+
- `CC_AVG_PRICE_CANDLES_COUNT`: VWAP candles (default: 5).
|
|
237
|
+
|
|
238
|
+
## ๐ป Developer Note
|
|
239
|
+
|
|
240
|
+
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.
|
|
241
|
+
|
|
242
|
+
### ๐ How PNL Works
|
|
243
|
+
|
|
244
|
+
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.
|
|
245
|
+
|
|
246
|
+
**Public API:**
|
|
247
|
+
- **`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`
|
|
248
|
+
- **`commitPartialProfit`** โ closes X% of the position at a profit. Locks in gains while keeping exposure.
|
|
249
|
+
- **`commitPartialLoss`** โ closes X% of the position at a loss. Cuts exposure before the stop-loss is hit.
|
|
250
|
+
|
|
251
|
+
<details>
|
|
252
|
+
<summary>
|
|
253
|
+
The Math
|
|
254
|
+
</summary>
|
|
255
|
+
|
|
256
|
+
**Scenario:** LONG entry @ 1000, 4 DCA attempts (1 rejected), 3 partials, closed at TP.
|
|
257
|
+
`totalInvested = $400` (4 ร $100, rejected attempt not counted).
|
|
258
|
+
|
|
259
|
+
**Entries**
|
|
260
|
+
```
|
|
261
|
+
entry#1 @ 1000 โ 0.10000 coins
|
|
262
|
+
commitPartialProfit(30%) @ 1150 โ cnt=1
|
|
263
|
+
entry#2 @ 950 โ 0.10526 coins
|
|
264
|
+
entry#3 @ 880 โ 0.11364 coins
|
|
265
|
+
commitPartialLoss(20%) @ 860 โ cnt=3
|
|
266
|
+
entry#4 @ 920 โ 0.10870 coins
|
|
267
|
+
commitPartialProfit(40%) @ 1050 โ cnt=4
|
|
268
|
+
entry#5 @ 980 โ REJECTED (980 > ep3โ929.92)
|
|
269
|
+
totalInvested = $400
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Partial#1 โ commitPartialProfit @ 1150, 30%, cnt=1**
|
|
273
|
+
```
|
|
274
|
+
effectivePrice = hm(1000) = 1000
|
|
275
|
+
costBasis = $100
|
|
276
|
+
partialDollarValue = 30% ร 100 = $30 โ weight = 30/400 = 0.075
|
|
277
|
+
pnl = (1150โ1000)/1000 ร 100 = +15.00%
|
|
278
|
+
costBasis โ $70
|
|
279
|
+
coins sold: 0.03000 ร 1150 = $34.50
|
|
280
|
+
remaining: 0.07000
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**DCA after Partial#1**
|
|
284
|
+
```
|
|
285
|
+
entry#2 @ 950 (950 < ep1=1000 โ accepted)
|
|
286
|
+
entry#3 @ 880 (880 < ep1=1000 โ accepted)
|
|
287
|
+
coins: 0.07000 + 0.10526 + 0.11364 = 0.28890
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
**Partial#2 โ commitPartialLoss @ 860, 20%, cnt=3**
|
|
291
|
+
```
|
|
292
|
+
costBasis = 70 + 100 + 100 = $270
|
|
293
|
+
ep2 = 270 / 0.28890 โ 934.58
|
|
294
|
+
partialDollarValue = 20% ร 270 = $54 โ weight = 54/400 = 0.135
|
|
295
|
+
pnl = (860โ934.58)/934.58 ร 100 โ โ7.98%
|
|
296
|
+
costBasis โ $216
|
|
297
|
+
coins sold: 0.05778 ร 860 = $49.69
|
|
298
|
+
remaining: 0.23112
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
**DCA after Partial#2**
|
|
302
|
+
```
|
|
303
|
+
entry#4 @ 920 (920 < ep2=934.58 โ accepted)
|
|
304
|
+
coins: 0.23112 + 0.10870 = 0.33982
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Partial#3 โ commitPartialProfit @ 1050, 40%, cnt=4**
|
|
308
|
+
```
|
|
309
|
+
costBasis = 216 + 100 = $316
|
|
310
|
+
ep3 = 316 / 0.33982 โ 929.92
|
|
311
|
+
partialDollarValue = 40% ร 316 = $126.4 โ weight = 126.4/400 = 0.316
|
|
312
|
+
pnl = (1050โ929.92)/929.92 ร 100 โ +12.91%
|
|
313
|
+
costBasis โ $189.6
|
|
314
|
+
coins sold: 0.13593 ร 1050 = $142.72
|
|
315
|
+
remaining: 0.20389
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**DCA after Partial#3 โ rejected**
|
|
319
|
+
```
|
|
320
|
+
entry#5 @ 980 (980 > ep3โ929.92 โ REJECTED)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Close at TP @ 1200**
|
|
324
|
+
```
|
|
325
|
+
ep_final = ep3 โ 929.92 (no new entries)
|
|
326
|
+
coins: 0.20389
|
|
327
|
+
|
|
328
|
+
remainingDollarValue = 400 โ 30 โ 54 โ 126.4 = $189.6
|
|
329
|
+
weight = 189.6/400 = 0.474
|
|
330
|
+
pnl = (1200โ929.92)/929.92 ร 100 โ +29.04%
|
|
331
|
+
coins sold: 0.20389 ร 1200 = $244.67
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Result (toProfitLossDto)**
|
|
335
|
+
```
|
|
336
|
+
0.075 ร (+15.00) = +1.125
|
|
337
|
+
0.135 ร (โ7.98) = โ1.077
|
|
338
|
+
0.316 ร (+12.91) = +4.080
|
|
339
|
+
0.474 ร (+29.04) = +13.765
|
|
340
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
341
|
+
โ +17.89%
|
|
342
|
+
|
|
343
|
+
Cross-check (coins):
|
|
344
|
+
34.50 + 49.69 + 142.72 + 244.67 = $471.58
|
|
345
|
+
(471.58 โ 400) / 400 ร 100 = +17.90% โ
|
|
346
|
+
```
|
|
347
|
+
</details>
|
|
348
|
+
|
|
349
|
+
**`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.
|
|
350
|
+
|
|
351
|
+
### ๐ How Broker Transactional Integrity Works
|
|
352
|
+
|
|
353
|
+
`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.
|
|
354
|
+
|
|
355
|
+
<details>
|
|
356
|
+
<summary>
|
|
357
|
+
The code
|
|
358
|
+
</summary>
|
|
359
|
+
|
|
360
|
+
**Spot**
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
import ccxt from "ccxt";
|
|
364
|
+
import { singleshot, sleep } from "functools-kit";
|
|
365
|
+
import {
|
|
366
|
+
Broker,
|
|
367
|
+
IBroker,
|
|
368
|
+
BrokerSignalOpenPayload,
|
|
369
|
+
BrokerSignalClosePayload,
|
|
370
|
+
BrokerPartialProfitPayload,
|
|
371
|
+
BrokerPartialLossPayload,
|
|
372
|
+
BrokerTrailingStopPayload,
|
|
373
|
+
BrokerTrailingTakePayload,
|
|
374
|
+
BrokerBreakevenPayload,
|
|
375
|
+
BrokerAverageBuyPayload,
|
|
376
|
+
} from "backtest-kit";
|
|
377
|
+
|
|
378
|
+
const FILL_POLL_INTERVAL_MS = 10_000;
|
|
379
|
+
const FILL_POLL_ATTEMPTS = 10;
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Sleep between cancelOrder and fetchBalance to allow Binance to settle the
|
|
383
|
+
* cancellation โ reads immediately after cancel may return stale data.
|
|
384
|
+
*/
|
|
385
|
+
const CANCEL_SETTLE_MS = 2_000;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Slippage buffer for stop_loss_limit on Spot โ limit price is set slightly
|
|
389
|
+
* below stopPrice so the order fills even on a gap down instead of hanging.
|
|
390
|
+
*/
|
|
391
|
+
const STOP_LIMIT_SLIPPAGE = 0.995;
|
|
392
|
+
|
|
393
|
+
const getSpotExchange = singleshot(async () => {
|
|
394
|
+
const exchange = new ccxt.binance({
|
|
395
|
+
apiKey: process.env.BINANCE_API_KEY,
|
|
396
|
+
secret: process.env.BINANCE_API_SECRET,
|
|
397
|
+
options: {
|
|
398
|
+
defaultType: "spot",
|
|
399
|
+
adjustForTimeDifference: true,
|
|
400
|
+
recvWindow: 60000,
|
|
401
|
+
},
|
|
402
|
+
enableRateLimit: true,
|
|
403
|
+
});
|
|
404
|
+
await exchange.loadMarkets();
|
|
405
|
+
return exchange;
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Resolve base currency from market metadata โ safe for all quote currencies (USDT, USDC, FDUSD, etc.)
|
|
410
|
+
*/
|
|
411
|
+
function getBase(exchange: ccxt.binance, symbol: string): string {
|
|
412
|
+
return exchange.markets[symbol].base;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Truncate qty to exchange precision, always rounding down.
|
|
417
|
+
* Prevents over-selling due to floating point drift from fetchBalance.
|
|
418
|
+
*/
|
|
419
|
+
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
420
|
+
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Fetch current free balance for base currency of symbol.
|
|
425
|
+
*/
|
|
426
|
+
async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
|
|
427
|
+
const balance = await exchange.fetchBalance();
|
|
428
|
+
const base = getBase(exchange, symbol);
|
|
429
|
+
return parseFloat(String(balance?.free?.[base] ?? 0));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
434
|
+
* network blip) does not leave remaining orders uncancelled.
|
|
435
|
+
*/
|
|
436
|
+
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
437
|
+
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Place a stop_loss_limit sell order with a slippage buffer on the limit price.
|
|
442
|
+
* stop_loss_limit requires both stopPrice (trigger) and price (limit fill).
|
|
443
|
+
* Setting them equal risks non-fill on gap down โ limit is offset by STOP_LIMIT_SLIPPAGE.
|
|
444
|
+
*/
|
|
445
|
+
async function createStopLossOrder(
|
|
446
|
+
exchange: ccxt.binance,
|
|
447
|
+
symbol: string,
|
|
448
|
+
qty: number,
|
|
449
|
+
stopPrice: number
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
|
|
452
|
+
await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Place a limit order and poll until filled (status === "closed").
|
|
457
|
+
* On timeout: cancel the order, settle, check partial fill and sell it via market,
|
|
458
|
+
* restore SL/TP on remaining position so it is never left unprotected, then throw.
|
|
459
|
+
*/
|
|
460
|
+
async function createLimitOrderAndWait(
|
|
461
|
+
exchange: ccxt.binance,
|
|
462
|
+
symbol: string,
|
|
463
|
+
side: "buy" | "sell",
|
|
464
|
+
qty: number,
|
|
465
|
+
price: number,
|
|
466
|
+
restore?: { tpPrice: number; slPrice: number }
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
const order = await exchange.createOrder(symbol, "limit", side, qty, price);
|
|
469
|
+
|
|
470
|
+
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
471
|
+
await sleep(FILL_POLL_INTERVAL_MS);
|
|
472
|
+
const status = await exchange.fetchOrder(order.id, symbol);
|
|
473
|
+
if (status.status === "closed") {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await exchange.cancelOrder(order.id, symbol);
|
|
479
|
+
|
|
480
|
+
// Wait for Binance to settle the cancellation before reading filled qty
|
|
481
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
482
|
+
|
|
483
|
+
const final = await exchange.fetchOrder(order.id, symbol);
|
|
484
|
+
const filledQty = final.filled ?? 0;
|
|
485
|
+
|
|
486
|
+
if (filledQty > 0) {
|
|
487
|
+
// Sell partial fill via market to restore clean exchange state before backtest-kit retries
|
|
488
|
+
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
489
|
+
await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
493
|
+
if (restore) {
|
|
494
|
+
const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
495
|
+
if (remainingQty > 0) {
|
|
496
|
+
await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
|
|
497
|
+
await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ partial fill rolled back, backtest-kit will retry`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
Broker.useBrokerAdapter(
|
|
505
|
+
class implements IBroker {
|
|
506
|
+
|
|
507
|
+
async waitForInit(): Promise<void> {
|
|
508
|
+
await getSpotExchange();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
512
|
+
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
513
|
+
|
|
514
|
+
// Spot does not support short selling โ reject immediately so backtest-kit skips the mutation
|
|
515
|
+
if (position === "short") {
|
|
516
|
+
throw new Error(`SpotBrokerAdapter: short position is not supported on spot (symbol=${symbol})`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const exchange = await getSpotExchange();
|
|
520
|
+
|
|
521
|
+
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
522
|
+
|
|
523
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
524
|
+
if (qty <= 0) {
|
|
525
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
529
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
530
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
531
|
+
|
|
532
|
+
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
533
|
+
await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
|
|
534
|
+
|
|
535
|
+
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
536
|
+
try {
|
|
537
|
+
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
538
|
+
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
539
|
+
} catch (err) {
|
|
540
|
+
await exchange.createOrder(symbol, "market", "sell", qty);
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
546
|
+
const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
547
|
+
const exchange = await getSpotExchange();
|
|
548
|
+
|
|
549
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
550
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
551
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
552
|
+
|
|
553
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
554
|
+
|
|
555
|
+
// Position already closed by SL/TP on exchange โ nothing to do, commit succeeds
|
|
556
|
+
if (qty === 0) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
561
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
562
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
563
|
+
|
|
564
|
+
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
565
|
+
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
569
|
+
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
570
|
+
const exchange = await getSpotExchange();
|
|
571
|
+
|
|
572
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
573
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
574
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
575
|
+
|
|
576
|
+
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
577
|
+
|
|
578
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
579
|
+
if (totalQty === 0) {
|
|
580
|
+
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
584
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
585
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
586
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
587
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
588
|
+
|
|
589
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
590
|
+
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
591
|
+
|
|
592
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
593
|
+
if (remainingQty > 0) {
|
|
594
|
+
try {
|
|
595
|
+
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
596
|
+
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
// Remaining position is unprotected โ close via market
|
|
599
|
+
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
600
|
+
throw err;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
606
|
+
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
607
|
+
const exchange = await getSpotExchange();
|
|
608
|
+
|
|
609
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
610
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
611
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
612
|
+
|
|
613
|
+
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
614
|
+
|
|
615
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
616
|
+
if (totalQty === 0) {
|
|
617
|
+
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
621
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
622
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
623
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
624
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
625
|
+
|
|
626
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
627
|
+
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
628
|
+
|
|
629
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
630
|
+
if (remainingQty > 0) {
|
|
631
|
+
try {
|
|
632
|
+
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
633
|
+
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
// Remaining position is unprotected โ close via market
|
|
636
|
+
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
643
|
+
const { symbol, newStopLossPrice } = payload;
|
|
644
|
+
const exchange = await getSpotExchange();
|
|
645
|
+
|
|
646
|
+
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
647
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
648
|
+
const slOrder = orders.find((o) =>
|
|
649
|
+
o.side === "sell" &&
|
|
650
|
+
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
651
|
+
) ?? null;
|
|
652
|
+
if (slOrder) {
|
|
653
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
654
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
658
|
+
|
|
659
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
660
|
+
if (qty === 0) {
|
|
661
|
+
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
665
|
+
|
|
666
|
+
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
670
|
+
const { symbol, newTakeProfitPrice } = payload;
|
|
671
|
+
const exchange = await getSpotExchange();
|
|
672
|
+
|
|
673
|
+
// Cancel existing TP order only โ Spot has no reduceOnly, filter by side + type
|
|
674
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
675
|
+
const tpOrder = orders.find((o) =>
|
|
676
|
+
o.side === "sell" &&
|
|
677
|
+
["limit", "LIMIT"].includes(o.type ?? "")
|
|
678
|
+
) ?? null;
|
|
679
|
+
if (tpOrder) {
|
|
680
|
+
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
681
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
685
|
+
|
|
686
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
687
|
+
if (qty === 0) {
|
|
688
|
+
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
692
|
+
|
|
693
|
+
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
697
|
+
const { symbol, newStopLossPrice } = payload;
|
|
698
|
+
const exchange = await getSpotExchange();
|
|
699
|
+
|
|
700
|
+
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
701
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
702
|
+
const slOrder = orders.find((o) =>
|
|
703
|
+
o.side === "sell" &&
|
|
704
|
+
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
705
|
+
) ?? null;
|
|
706
|
+
if (slOrder) {
|
|
707
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
708
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
712
|
+
|
|
713
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
714
|
+
if (qty === 0) {
|
|
715
|
+
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
719
|
+
|
|
720
|
+
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
724
|
+
const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
|
|
725
|
+
const exchange = await getSpotExchange();
|
|
726
|
+
|
|
727
|
+
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
728
|
+
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
729
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
730
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
731
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
732
|
+
|
|
733
|
+
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
734
|
+
const existing = await fetchFreeQty(exchange, symbol);
|
|
735
|
+
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
736
|
+
|
|
737
|
+
// Compare notional value rather than raw qty โ avoids float === 0 trap
|
|
738
|
+
// and correctly rejects dust balances left over from previous trades
|
|
739
|
+
if (existing * currentPrice < minNotional) {
|
|
740
|
+
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
744
|
+
|
|
745
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
746
|
+
if (qty <= 0) {
|
|
747
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
751
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
752
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
753
|
+
|
|
754
|
+
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
755
|
+
await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
|
|
756
|
+
|
|
757
|
+
// Refetch balance after fill โ existing snapshot is stale after cancel + fill
|
|
758
|
+
const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
759
|
+
|
|
760
|
+
// Recreate SL/TP on fresh total qty after successful fill
|
|
761
|
+
try {
|
|
762
|
+
await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
|
|
763
|
+
await createStopLossOrder(exchange, symbol, totalQty, slPrice);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
// Total position is unprotected โ close via market
|
|
766
|
+
await exchange.createOrder(symbol, "market", "sell", totalQty);
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
Broker.enable();
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
**Futures**
|
|
777
|
+
|
|
778
|
+
```typescript
|
|
779
|
+
import ccxt from "ccxt";
|
|
780
|
+
import { singleshot, sleep } from "functools-kit";
|
|
781
|
+
import {
|
|
782
|
+
Broker,
|
|
783
|
+
IBroker,
|
|
784
|
+
BrokerSignalOpenPayload,
|
|
785
|
+
BrokerSignalClosePayload,
|
|
786
|
+
BrokerPartialProfitPayload,
|
|
787
|
+
BrokerPartialLossPayload,
|
|
788
|
+
BrokerTrailingStopPayload,
|
|
789
|
+
BrokerTrailingTakePayload,
|
|
790
|
+
BrokerBreakevenPayload,
|
|
791
|
+
BrokerAverageBuyPayload,
|
|
792
|
+
} from "backtest-kit";
|
|
793
|
+
|
|
794
|
+
const FILL_POLL_INTERVAL_MS = 10_000;
|
|
795
|
+
const FILL_POLL_ATTEMPTS = 10;
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Sleep between cancelOrder and fetchPositions to allow Binance to settle the
|
|
799
|
+
* cancellation โ reads immediately after cancel may return stale data.
|
|
800
|
+
*/
|
|
801
|
+
const CANCEL_SETTLE_MS = 2_000;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* 3x leverage โ conservative choice for $1000 total fiat.
|
|
805
|
+
* Enough to matter, not enough to liquidate on normal volatility.
|
|
806
|
+
* Applied per-symbol on first open via setLeverage.
|
|
807
|
+
*/
|
|
808
|
+
const FUTURES_LEVERAGE = 3;
|
|
809
|
+
|
|
810
|
+
const getFuturesExchange = singleshot(async () => {
|
|
811
|
+
const exchange = new ccxt.binance({
|
|
812
|
+
apiKey: process.env.BINANCE_API_KEY,
|
|
813
|
+
secret: process.env.BINANCE_API_SECRET,
|
|
814
|
+
options: {
|
|
815
|
+
defaultType: "future",
|
|
816
|
+
adjustForTimeDifference: true,
|
|
817
|
+
recvWindow: 60000,
|
|
818
|
+
},
|
|
819
|
+
enableRateLimit: true,
|
|
820
|
+
});
|
|
821
|
+
await exchange.loadMarkets();
|
|
822
|
+
return exchange;
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Truncate qty to exchange precision, always rounding down.
|
|
827
|
+
* Prevents over-selling due to floating point drift from fetchPositions.
|
|
828
|
+
*/
|
|
829
|
+
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
830
|
+
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Resolve position for symbol filtered by side โ safe in both one-way and hedge mode.
|
|
835
|
+
*/
|
|
836
|
+
function findPosition(positions: ccxt.Position[], symbol: string, side: "long" | "short") {
|
|
837
|
+
// Hedge mode: positions have explicit side field
|
|
838
|
+
const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
|
|
839
|
+
if (hedged) {
|
|
840
|
+
return hedged;
|
|
841
|
+
}
|
|
842
|
+
// One-way mode: single position per symbol, side field may be undefined or mismatched
|
|
843
|
+
const pos = positions.find((p) => p.symbol === symbol) ?? null;
|
|
844
|
+
if (pos && pos.side && pos.side !== side) {
|
|
845
|
+
console.warn(`findPosition: expected side="${side}" but exchange returned side="${pos.side}" for ${symbol} โ possible one-way/hedge mode mismatch`);
|
|
846
|
+
}
|
|
847
|
+
return pos;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Fetch current contracts qty for symbol/side.
|
|
852
|
+
*/
|
|
853
|
+
async function fetchContractsQty(
|
|
854
|
+
exchange: ccxt.binance,
|
|
855
|
+
symbol: string,
|
|
856
|
+
side: "long" | "short"
|
|
857
|
+
): Promise<number> {
|
|
858
|
+
const positions = await exchange.fetchPositions([symbol]);
|
|
859
|
+
const pos = findPosition(positions, symbol, side);
|
|
860
|
+
return Math.abs(parseFloat(String(pos?.contracts ?? 0)));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
865
|
+
* network blip) does not leave remaining orders uncancelled.
|
|
866
|
+
*/
|
|
867
|
+
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
868
|
+
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Resolve Binance positionSide string from position direction.
|
|
873
|
+
* Required in hedge mode to correctly route orders; ignored in one-way mode.
|
|
874
|
+
*/
|
|
875
|
+
function toPositionSide(position: "long" | "short"): "LONG" | "SHORT" {
|
|
876
|
+
return position === "long" ? "LONG" : "SHORT";
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Place a limit order and poll until filled (status === "closed").
|
|
881
|
+
* On timeout: cancel the order, settle, check partial fill and close it via market,
|
|
882
|
+
* restore SL/TP on remaining position so it is never left unprotected, then throw.
|
|
883
|
+
*
|
|
884
|
+
* positionSide is forwarded into rollback market order so hedge mode accounts
|
|
885
|
+
* correctly route the close without -4061 error.
|
|
886
|
+
*/
|
|
887
|
+
async function createLimitOrderAndWait(
|
|
888
|
+
exchange: ccxt.binance,
|
|
889
|
+
symbol: string,
|
|
890
|
+
side: "buy" | "sell",
|
|
891
|
+
qty: number,
|
|
892
|
+
price: number,
|
|
893
|
+
params: Record<string, unknown> = {},
|
|
894
|
+
restore?: { exitSide: "buy" | "sell"; tpPrice: number; slPrice: number; positionSide: "long" | "short" }
|
|
895
|
+
): Promise<void> {
|
|
896
|
+
const order = await exchange.createOrder(symbol, "limit", side, qty, price, params);
|
|
897
|
+
|
|
898
|
+
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
899
|
+
await sleep(FILL_POLL_INTERVAL_MS);
|
|
900
|
+
const status = await exchange.fetchOrder(order.id, symbol);
|
|
901
|
+
if (status.status === "closed") {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
await exchange.cancelOrder(order.id, symbol);
|
|
907
|
+
|
|
908
|
+
// Wait for Binance to settle the cancellation before reading filled qty
|
|
909
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
910
|
+
|
|
911
|
+
const final = await exchange.fetchOrder(order.id, symbol);
|
|
912
|
+
const filledQty = final.filled ?? 0;
|
|
913
|
+
|
|
914
|
+
if (filledQty > 0) {
|
|
915
|
+
// Close partial fill via market โ positionSide required in hedge mode (-4061 without it)
|
|
916
|
+
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
917
|
+
const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
|
|
918
|
+
await exchange.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
|
|
919
|
+
reduceOnly: true,
|
|
920
|
+
...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
925
|
+
if (restore) {
|
|
926
|
+
const remainingQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, restore.positionSide));
|
|
927
|
+
if (remainingQty > 0) {
|
|
928
|
+
await exchange.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
|
|
929
|
+
await exchange.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ partial fill rolled back, backtest-kit will retry`);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
Broker.useBrokerAdapter(
|
|
937
|
+
class implements IBroker {
|
|
938
|
+
|
|
939
|
+
async waitForInit(): Promise<void> {
|
|
940
|
+
await getFuturesExchange();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
944
|
+
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
945
|
+
const exchange = await getFuturesExchange();
|
|
946
|
+
|
|
947
|
+
// Set leverage before entry โ ensures consistent leverage regardless of previous session state
|
|
948
|
+
await exchange.setLeverage(FUTURES_LEVERAGE, symbol);
|
|
949
|
+
|
|
950
|
+
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
951
|
+
|
|
952
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
953
|
+
if (qty <= 0) {
|
|
954
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
958
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
959
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
960
|
+
const entrySide = position === "long" ? "buy" : "sell";
|
|
961
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
962
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
963
|
+
const positionSide = toPositionSide(position);
|
|
964
|
+
|
|
965
|
+
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
966
|
+
await createLimitOrderAndWait(exchange, symbol, entrySide, qty, openPrice, { positionSide });
|
|
967
|
+
|
|
968
|
+
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
969
|
+
try {
|
|
970
|
+
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
971
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
972
|
+
} catch (err) {
|
|
973
|
+
await exchange.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
|
|
974
|
+
throw err;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
979
|
+
const { symbol, position, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
980
|
+
const exchange = await getFuturesExchange();
|
|
981
|
+
|
|
982
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
983
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
984
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
985
|
+
|
|
986
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
987
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
988
|
+
|
|
989
|
+
// Position already closed by SL/TP on exchange โ throw so backtest-kit can reconcile
|
|
990
|
+
// the close price via its own mechanism rather than assuming a successful manual close
|
|
991
|
+
if (qty === 0) {
|
|
992
|
+
throw new Error(`SignalClose skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
996
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
997
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
998
|
+
|
|
999
|
+
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1000
|
+
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
1001
|
+
await createLimitOrderAndWait(
|
|
1002
|
+
exchange, symbol, exitSide, qty, closePrice,
|
|
1003
|
+
{ reduceOnly: true },
|
|
1004
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
1009
|
+
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1010
|
+
const exchange = await getFuturesExchange();
|
|
1011
|
+
|
|
1012
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1013
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1014
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1015
|
+
|
|
1016
|
+
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1017
|
+
|
|
1018
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1019
|
+
if (totalQty === 0) {
|
|
1020
|
+
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1024
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1025
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1026
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1027
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1028
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1029
|
+
const positionSide = toPositionSide(position);
|
|
1030
|
+
|
|
1031
|
+
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1032
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1033
|
+
await createLimitOrderAndWait(
|
|
1034
|
+
exchange, symbol, exitSide, qty, closePrice,
|
|
1035
|
+
{ reduceOnly: true },
|
|
1036
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
1040
|
+
if (remainingQty > 0) {
|
|
1041
|
+
try {
|
|
1042
|
+
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1043
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
// Remaining position is unprotected โ close via market
|
|
1046
|
+
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1047
|
+
throw err;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
1053
|
+
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1054
|
+
const exchange = await getFuturesExchange();
|
|
1055
|
+
|
|
1056
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1057
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1058
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1059
|
+
|
|
1060
|
+
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1061
|
+
|
|
1062
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1063
|
+
if (totalQty === 0) {
|
|
1064
|
+
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1068
|
+
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1069
|
+
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1070
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1071
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1072
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1073
|
+
const positionSide = toPositionSide(position);
|
|
1074
|
+
|
|
1075
|
+
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1076
|
+
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1077
|
+
await createLimitOrderAndWait(
|
|
1078
|
+
exchange, symbol, exitSide, qty, closePrice,
|
|
1079
|
+
{ reduceOnly: true },
|
|
1080
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
// Restore SL/TP on remaining qty after successful partial close
|
|
1084
|
+
if (remainingQty > 0) {
|
|
1085
|
+
try {
|
|
1086
|
+
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1087
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
// Remaining position is unprotected โ close via market
|
|
1090
|
+
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1091
|
+
throw err;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
1097
|
+
const { symbol, newStopLossPrice, position } = payload;
|
|
1098
|
+
const exchange = await getFuturesExchange();
|
|
1099
|
+
|
|
1100
|
+
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1101
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1102
|
+
const slOrder = orders.find((o) =>
|
|
1103
|
+
!!o.reduceOnly &&
|
|
1104
|
+
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1105
|
+
) ?? null;
|
|
1106
|
+
if (slOrder) {
|
|
1107
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1108
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1112
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1113
|
+
|
|
1114
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1115
|
+
if (qty === 0) {
|
|
1116
|
+
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1120
|
+
const positionSide = toPositionSide(position);
|
|
1121
|
+
|
|
1122
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1123
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
1127
|
+
const { symbol, newTakeProfitPrice, position } = payload;
|
|
1128
|
+
const exchange = await getFuturesExchange();
|
|
1129
|
+
|
|
1130
|
+
// Cancel existing TP order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1131
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1132
|
+
const tpOrder = orders.find((o) =>
|
|
1133
|
+
!!o.reduceOnly &&
|
|
1134
|
+
["limit", "LIMIT"].includes(o.type ?? "")
|
|
1135
|
+
) ?? null;
|
|
1136
|
+
if (tpOrder) {
|
|
1137
|
+
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
1138
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1142
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1143
|
+
|
|
1144
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1145
|
+
if (qty === 0) {
|
|
1146
|
+
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
1150
|
+
const positionSide = toPositionSide(position);
|
|
1151
|
+
|
|
1152
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1153
|
+
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
1157
|
+
const { symbol, newStopLossPrice, position } = payload;
|
|
1158
|
+
const exchange = await getFuturesExchange();
|
|
1159
|
+
|
|
1160
|
+
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1161
|
+
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1162
|
+
const slOrder = orders.find((o) =>
|
|
1163
|
+
!!o.reduceOnly &&
|
|
1164
|
+
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1165
|
+
) ?? null;
|
|
1166
|
+
if (slOrder) {
|
|
1167
|
+
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1168
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1172
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1173
|
+
|
|
1174
|
+
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1175
|
+
if (qty === 0) {
|
|
1176
|
+
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1180
|
+
const positionSide = toPositionSide(position);
|
|
1181
|
+
|
|
1182
|
+
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1183
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
1187
|
+
const { symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1188
|
+
const exchange = await getFuturesExchange();
|
|
1189
|
+
|
|
1190
|
+
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
1191
|
+
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
1192
|
+
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1193
|
+
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1194
|
+
await sleep(CANCEL_SETTLE_MS);
|
|
1195
|
+
|
|
1196
|
+
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
1197
|
+
const existing = await fetchContractsQty(exchange, symbol, position);
|
|
1198
|
+
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
1199
|
+
|
|
1200
|
+
// Compare notional value rather than raw contracts โ avoids float === 0 trap
|
|
1201
|
+
// and correctly rejects dust positions left over from previous trades
|
|
1202
|
+
if (existing * currentPrice < minNotional) {
|
|
1203
|
+
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
1207
|
+
|
|
1208
|
+
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
1209
|
+
if (qty <= 0) {
|
|
1210
|
+
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1214
|
+
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1215
|
+
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1216
|
+
// positionSide required in hedge mode to add to correct side; ignored in one-way mode
|
|
1217
|
+
const positionSide = toPositionSide(position);
|
|
1218
|
+
const entrySide = position === "long" ? "buy" : "sell";
|
|
1219
|
+
const exitSide = position === "long" ? "sell" : "buy";
|
|
1220
|
+
|
|
1221
|
+
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
1222
|
+
await createLimitOrderAndWait(
|
|
1223
|
+
exchange, symbol, entrySide, qty, entryPrice,
|
|
1224
|
+
{ positionSide },
|
|
1225
|
+
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
// Refetch contracts after fill โ existing snapshot is stale after cancel + fill
|
|
1229
|
+
const totalQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1230
|
+
|
|
1231
|
+
// Recreate SL/TP on fresh total qty after successful fill
|
|
1232
|
+
try {
|
|
1233
|
+
await exchange.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1234
|
+
await exchange.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
// Total position is unprotected โ close via market
|
|
1237
|
+
await exchange.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
|
|
1238
|
+
throw err;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
Broker.enable();
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
</details>
|
|
1248
|
+
|
|
1249
|
+
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.
|
|
1250
|
+
|
|
1251
|
+
### ๐ How getCandles Works
|
|
1252
|
+
|
|
1253
|
+
backtest-kit uses Node.js `AsyncLocalStorage` to automatically provide
|
|
1254
|
+
temporal time context to your strategies.
|
|
1255
|
+
|
|
1256
|
+
<details>
|
|
1257
|
+
<summary>
|
|
1258
|
+
The Math
|
|
1259
|
+
</summary>
|
|
1260
|
+
|
|
1261
|
+
For a candle with:
|
|
1262
|
+
- `timestamp` = candle open time (openTime)
|
|
1263
|
+
- `stepMs` = interval duration (e.g., 60000ms for "1m")
|
|
1264
|
+
- Candle close time = `timestamp + stepMs`
|
|
1265
|
+
|
|
1266
|
+
**Alignment:** All timestamps are aligned down to interval boundary.
|
|
1267
|
+
For example, for 15m interval: 00:17 โ 00:15, 00:44 โ 00:30
|
|
1268
|
+
|
|
1269
|
+
**Adapter contract:**
|
|
1270
|
+
- First candle.timestamp must equal aligned `since`
|
|
1271
|
+
- Adapter must return exactly `limit` candles
|
|
1272
|
+
- Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
|
|
1273
|
+
|
|
1274
|
+
**How `since` is calculated from `when`:**
|
|
1275
|
+
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1276
|
+
- `alignedWhen` = `Math.floor(when / stepMs) * stepMs` (aligned down to interval boundary)
|
|
1277
|
+
- `since` = `alignedWhen - limit * stepMs` (go back `limit` candles from aligned when)
|
|
1278
|
+
|
|
1279
|
+
**Boundary semantics (inclusive/exclusive):**
|
|
1280
|
+
- `since` is always **inclusive** โ first candle has `timestamp === since`
|
|
1281
|
+
- Exactly `limit` candles are returned
|
|
1282
|
+
- Last candle has `timestamp === since + (limit - 1) * stepMs` โ **inclusive**
|
|
1283
|
+
- For `getCandles`: `alignedWhen` is **exclusive** โ candle at that timestamp is NOT included (it's a pending/incomplete candle)
|
|
1284
|
+
- For `getRawCandles`: `eDate` is **exclusive** โ candle at that timestamp is NOT included (it's a pending/incomplete candle)
|
|
1285
|
+
- For `getNextCandles`: `alignedWhen` is **inclusive** โ first candle starts at `alignedWhen` (it's the current candle for backtest, already closed in historical data)
|
|
1286
|
+
|
|
1287
|
+
- `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
|
|
1288
|
+
- Aligns `when` down to interval boundary
|
|
1289
|
+
- Calculates `since = alignedWhen - limit * stepMs`
|
|
1290
|
+
- **since โ inclusive**, first candle.timestamp === since
|
|
1291
|
+
- **alignedWhen โ exclusive**, candle at alignedWhen is NOT returned
|
|
1292
|
+
- Range: `[since, alignedWhen)` โ half-open interval
|
|
1293
|
+
- Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before aligned when
|
|
1294
|
+
|
|
1295
|
+
- `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
|
|
1296
|
+
- Aligns `when` down to interval boundary
|
|
1297
|
+
- `since = alignedWhen` (starts from aligned when, going forward)
|
|
1298
|
+
- **since โ inclusive**, first candle.timestamp === since
|
|
1299
|
+
- Range: `[alignedWhen, alignedWhen + limit * stepMs)` โ half-open interval
|
|
1300
|
+
- Throws error in live mode to prevent look-ahead bias
|
|
1301
|
+
- Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles starting from aligned when
|
|
1302
|
+
|
|
1303
|
+
- `getRawCandles(symbol, interval, limit?, sDate?, eDate?)` - Flexible parameter combinations:
|
|
1304
|
+
- `(limit)` - since = alignedWhen - limit * stepMs, range `[since, alignedWhen)`
|
|
1305
|
+
- `(limit, sDate)` - since = align(sDate), returns `limit` candles forward, range `[since, since + limit * stepMs)`
|
|
1306
|
+
- `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs, **eDate โ exclusive**, range `[since, eDate)`
|
|
1307
|
+
- `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range, **sDate โ inclusive, eDate โ exclusive**, range `[sDate, eDate)`
|
|
1308
|
+
- `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles, **sDate โ inclusive**
|
|
1309
|
+
- All combinations respect look-ahead bias protection (eDate/endTime <= when)
|
|
1310
|
+
|
|
1311
|
+
**Persistent Cache:**
|
|
1312
|
+
- Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
|
|
1313
|
+
- Returns all candles if found, null if any missing (cache miss)
|
|
1314
|
+
- Cache and runtime use identical timestamp calculation logic
|
|
1315
|
+
|
|
1316
|
+
</details>
|
|
1317
|
+
|
|
1318
|
+
#### Candle Timestamp Convention:
|
|
1319
|
+
|
|
1320
|
+
According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
|
|
1321
|
+
|
|
1322
|
+
**Key principles:**
|
|
1323
|
+
- All timestamps are aligned down to interval boundary
|
|
1324
|
+
- First candle.timestamp must equal aligned `since`
|
|
1325
|
+
- Adapter must return exactly `limit` candles
|
|
1326
|
+
- Sequential timestamps: `since + i * stepMs`
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
### ๐ How getOrderBook Works
|
|
1330
|
+
|
|
1331
|
+
Order book fetching uses the same temporal alignment as candles, but with a configurable time offset window instead of candle intervals.
|
|
1332
|
+
|
|
1333
|
+
<details>
|
|
1334
|
+
<summary>
|
|
1335
|
+
The Math
|
|
1336
|
+
</summary>
|
|
1337
|
+
|
|
1338
|
+
**Time range calculation:**
|
|
1339
|
+
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1340
|
+
- `offsetMinutes` = `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` (configurable)
|
|
1341
|
+
- `alignedTo` = `Math.floor(when / (offsetMinutes * 60000)) * (offsetMinutes * 60000)`
|
|
1342
|
+
- `to` = `alignedTo` (aligned down to offset boundary)
|
|
1343
|
+
- `from` = `alignedTo - offsetMinutes * 60000`
|
|
1344
|
+
|
|
1345
|
+
**Adapter contract:**
|
|
1346
|
+
- `getOrderBook(symbol, depth, from, to, backtest)` is called on the exchange schema
|
|
1347
|
+
- `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
|
|
1348
|
+
- The `from`/`to` range represents a time window of exactly `offsetMinutes` duration
|
|
1349
|
+
- Schema implementation may use the time range (backtest) or ignore it (live trading)
|
|
1350
|
+
|
|
1351
|
+
**Example with CC_ORDER_BOOK_TIME_OFFSET_MINUTES = 10:**
|
|
1352
|
+
```
|
|
1353
|
+
when = 1704067920000 // 2024-01-01 00:12:00 UTC
|
|
1354
|
+
offsetMinutes = 10
|
|
1355
|
+
offsetMs = 10 * 60000 // 600000ms
|
|
1356
|
+
|
|
1357
|
+
alignedTo = Math.floor(1704067920000 / 600000) * 600000
|
|
1358
|
+
= 1704067800000 // 2024-01-01 00:10:00 UTC
|
|
1359
|
+
|
|
1360
|
+
to = 1704067800000 // 00:10:00 UTC
|
|
1361
|
+
from = 1704067200000 // 00:00:00 UTC
|
|
1362
|
+
```
|
|
1363
|
+
</details>
|
|
1364
|
+
|
|
1365
|
+
#### Order Book Timestamp Convention:
|
|
1366
|
+
|
|
1367
|
+
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.
|
|
1368
|
+
|
|
1369
|
+
**Key principles:**
|
|
1370
|
+
- Time range is aligned down to `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` boundary
|
|
1371
|
+
- `to` = aligned timestamp, `from` = `to - offsetMinutes * 60000`
|
|
1372
|
+
- `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
|
|
1373
|
+
- Adapter receives `(symbol, depth, from, to, backtest)` โ may ignore `from`/`to` in live mode
|
|
1374
|
+
|
|
1375
|
+
### ๐ How getAggregatedTrades Works
|
|
1376
|
+
|
|
1377
|
+
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.
|
|
1378
|
+
|
|
1379
|
+
**Key principles:**
|
|
1380
|
+
- `to` is always aligned down to the 1-minute boundary โ prevents look-ahead bias
|
|
1381
|
+
- Without `limit`: returns one full window (`CC_AGGREGATED_TRADES_MAX_MINUTES`)
|
|
1382
|
+
- With `limit`: paginates backwards until collected, then slices to most recent `limit`
|
|
1383
|
+
- Adapter receives `(symbol, from, to, backtest)` โ may ignore `from`/`to` in live mode
|
|
1384
|
+
|
|
1385
|
+
<details>
|
|
1386
|
+
<summary>
|
|
1387
|
+
The Math
|
|
1388
|
+
</summary>
|
|
1389
|
+
|
|
1390
|
+
**Time range calculation:**
|
|
1391
|
+
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1392
|
+
- `alignedTo` = `Math.floor(when / 60000) * 60000` (aligned down to 1-minute boundary)
|
|
1393
|
+
- `windowMs` = `CC_AGGREGATED_TRADES_MAX_MINUTES * 60000 โ 60000`
|
|
1394
|
+
- `to` = `alignedTo`, `from` = `alignedTo โ windowMs`
|
|
1395
|
+
|
|
1396
|
+
**Without `limit`:** fetches a single window and returns it as-is.
|
|
1397
|
+
|
|
1398
|
+
**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.
|
|
1399
|
+
|
|
1400
|
+
**Example with CC_AGGREGATED_TRADES_MAX_MINUTES = 60, limit = 200:**
|
|
1401
|
+
```
|
|
1402
|
+
when = 1704067920000 // 2024-01-01 00:12:00 UTC
|
|
1403
|
+
alignedTo = 1704067800000 // 2024-01-01 00:12:00 โ aligned to 00:12:00
|
|
1404
|
+
windowMs = 59 * 60000 // 3540000ms = 59 minutes
|
|
1405
|
+
|
|
1406
|
+
Window 1: from = 00:12:00 โ 59m = 23:13:00
|
|
1407
|
+
to = 00:12:00
|
|
1408
|
+
โ got 120 trades โ not enough
|
|
1409
|
+
|
|
1410
|
+
Window 2: from = 23:13:00 โ 59m = 22:14:00
|
|
1411
|
+
to = 23:13:00
|
|
1412
|
+
โ got 100 more โ total 220 trades
|
|
1413
|
+
|
|
1414
|
+
result = last 200 of 220 (most recent)
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
**Adapter contract:**
|
|
1418
|
+
- `getAggregatedTrades(symbol, from, to, backtest)` is called on the exchange schema
|
|
1419
|
+
- `from`/`to` are `Date` objects
|
|
1420
|
+
- Schema implementation may use the time range (backtest) or ignore it (live trading)
|
|
1421
|
+
|
|
1422
|
+
</details>
|
|
1423
|
+
|
|
1424
|
+
**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.
|
|
1425
|
+
|
|
1426
|
+
### ๐ฌ Technical Details: Timestamp Alignment
|
|
1427
|
+
|
|
1428
|
+
**Why align timestamps to interval boundaries?**
|
|
1429
|
+
|
|
1430
|
+
Because candle APIs return data starting from exact interval boundaries:
|
|
1431
|
+
|
|
1432
|
+
```typescript
|
|
1433
|
+
// 15-minute interval example:
|
|
1434
|
+
when = 1704067920000 // 00:12:00
|
|
1435
|
+
step = 15 // 15 minutes
|
|
1436
|
+
stepMs = 15 * 60000 // 900000ms
|
|
1437
|
+
|
|
1438
|
+
// Alignment: round down to nearest interval boundary
|
|
1439
|
+
alignedWhen = Math.floor(when / stepMs) * stepMs
|
|
1440
|
+
// = Math.floor(1704067920000 / 900000) * 900000
|
|
1441
|
+
// = 1704067200000 (00:00:00)
|
|
1442
|
+
|
|
1443
|
+
// Calculate since for 4 candles backwards:
|
|
1444
|
+
since = alignedWhen - 4 * stepMs
|
|
1445
|
+
// = 1704067200000 - 4 * 900000
|
|
1446
|
+
// = 1704063600000 (23:00:00 previous day)
|
|
1447
|
+
|
|
1448
|
+
// Expected candles:
|
|
1449
|
+
// [0] timestamp = 1704063600000 (23:00)
|
|
1450
|
+
// [1] timestamp = 1704064500000 (23:15)
|
|
1451
|
+
// [2] timestamp = 1704065400000 (23:30)
|
|
1452
|
+
// [3] timestamp = 1704066300000 (23:45)
|
|
1453
|
+
```
|
|
1454
|
+
|
|
1455
|
+
**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.
|
|
1456
|
+
|
|
1457
|
+
**Validation is applied consistently across:**
|
|
1458
|
+
- โ
`getCandles()` - validates first timestamp and count
|
|
1459
|
+
- โ
`getNextCandles()` - validates first timestamp and count
|
|
1460
|
+
- โ
`getRawCandles()` - validates first timestamp and count
|
|
1461
|
+
- โ
Cache read - calculates exact expected timestamps
|
|
1462
|
+
- โ
Cache write - stores validated candles
|
|
1463
|
+
|
|
1464
|
+
**Result:** Deterministic candle retrieval with exact timestamp matching.
|
|
1465
|
+
|
|
1466
|
+
### ๐ Timezone Warning: Candle Boundaries Are UTC-Based
|
|
1467
|
+
|
|
1468
|
+
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.
|
|
1469
|
+
|
|
1470
|
+
For example, in UTC+5 the same 4h candle request logs as:
|
|
1471
|
+
|
|
1472
|
+
```
|
|
1473
|
+
since: Sat Sep 20 2025 13:00:00 GMT+0500 โ looks uneven (13:00)
|
|
1474
|
+
since: Sat Sep 20 2025 17:00:00 GMT+0500 โ looks uneven (17:00)
|
|
1475
|
+
since: Sat Sep 20 2025 21:00:00 GMT+0500 โ looks uneven (21:00)
|
|
1476
|
+
since: Sun Sep 21 2025 05:00:00 GMT+0500 โ looks uneven (05:00)
|
|
1477
|
+
```
|
|
1478
|
+
|
|
1479
|
+
But in UTC these are perfectly aligned 4h boundaries:
|
|
1480
|
+
|
|
1481
|
+
```
|
|
1482
|
+
since: Sat, 20 Sep 2025 08:00:00 GMT โ 08:00 UTC โ
|
|
1483
|
+
since: Sat, 20 Sep 2025 12:00:00 GMT โ 12:00 UTC โ
|
|
1484
|
+
since: Sat, 20 Sep 2025 16:00:00 GMT โ 16:00 UTC โ
|
|
1485
|
+
since: Sun, 21 Sep 2025 00:00:00 GMT โ 00:00 UTC โ
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
Use `toUTCString()` or `toISOString()` in callbacks to see the actual aligned UTC times.
|
|
1489
|
+
|
|
1490
|
+
### ๐ญ What this means:
|
|
1491
|
+
- `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`
|
|
1492
|
+
- Multi-timeframe data is automatically synchronized
|
|
1493
|
+
- **Impossible to introduce look-ahead bias** - all time boundaries are enforced
|
|
1494
|
+
- Same code works in both backtest and live modes
|
|
1495
|
+
- Boundary semantics prevent edge cases in signal generation
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
## ๐ง Two Ways to Run the Engine
|
|
1499
|
+
|
|
1500
|
+
Backtest Kit exposes the same runtime in two equivalent forms. Both approaches use **the same engine and guarantees** - only the consumption model differs.
|
|
1501
|
+
|
|
1502
|
+
### 1๏ธโฃ Event-driven (background execution)
|
|
1503
|
+
|
|
1504
|
+
Suitable for production bots, monitoring, and long-running processes.
|
|
1505
|
+
|
|
1506
|
+
```typescript
|
|
1507
|
+
Backtest.background('BTCUSDT', config);
|
|
1508
|
+
|
|
1509
|
+
listenSignalBacktest(event => { /* handle signals */ });
|
|
1510
|
+
listenDoneBacktest(event => { /* finalize / dump report */ });
|
|
1511
|
+
```
|
|
1512
|
+
|
|
1513
|
+
### 2๏ธโฃ Async Iterator (pull-based execution)
|
|
1514
|
+
|
|
1515
|
+
Suitable for research, scripting, testing, and LLM agents.
|
|
1516
|
+
|
|
1517
|
+
```typescript
|
|
1518
|
+
for await (const event of Backtest.run('BTCUSDT', config)) {
|
|
1519
|
+
// signal | trade | progress | done
|
|
1520
|
+
}
|
|
1521
|
+
```
|
|
1522
|
+
|
|
1523
|
+
## โ๏ธ Think of it as...
|
|
1524
|
+
|
|
1525
|
+
**Open-source QuantConnect/MetaTrader without the vendor lock-in**
|
|
1526
|
+
|
|
1527
|
+
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
|
|
1528
|
+
|
|
1529
|
+
- No C#/C++ required - pure TypeScript/JavaScript
|
|
1530
|
+
- Self-hosted - your code, your data, your infrastructure
|
|
1531
|
+
- No platform fees or hidden costs
|
|
1532
|
+
- Full control over execution and data sources
|
|
1533
|
+
- [GUI](https://npmjs.com/package/@backtest-kit/ui) for visualization and monitoring
|
|
1534
|
+
|
|
1535
|
+
## ๐ Ecosystem
|
|
1536
|
+
|
|
1537
|
+
The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
### @backtest-kit/cli
|
|
1541
|
+
|
|
1542
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** ๐
|
|
1543
|
+
|
|
1544
|
+
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.
|
|
1545
|
+
|
|
1546
|
+
#### Key Features
|
|
1547
|
+
- ๐ **Zero Config**: Run a backtest with one command โ no setup code needed
|
|
1548
|
+
- ๐ **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
|
|
1549
|
+
- ๐พ **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
|
|
1550
|
+
- ๐ **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
|
|
1551
|
+
- ๐ฌ **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
|
|
1552
|
+
- ๐๏ธ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
|
|
1553
|
+
|
|
1554
|
+
#### Use Case
|
|
1555
|
+
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.
|
|
1556
|
+
|
|
1557
|
+
#### Get Started
|
|
1558
|
+
```bash
|
|
1559
|
+
npx -y @backtest-kit/cli --init
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
### @backtest-kit/pinets
|
|
1564
|
+
|
|
1565
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** ๐
|
|
1566
|
+
|
|
1567
|
+
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.
|
|
1568
|
+
|
|
1569
|
+
#### Key Features
|
|
1570
|
+
- ๐ **Pine Script v5/v6**: Native TradingView syntax with 1:1 compatibility
|
|
1571
|
+
- ๐ฏ **60+ Indicators**: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Stochastic built-in
|
|
1572
|
+
- ๐ **File or Code**: Load `.pine` files or pass code strings directly
|
|
1573
|
+
- ๐บ๏ธ **Plot Extraction**: Flexible mapping from Pine `plot()` outputs to structured signals
|
|
1574
|
+
- โก **Cached Execution**: Memoized file reads for repeated strategy runs
|
|
1575
|
+
|
|
1576
|
+
#### Use Case
|
|
1577
|
+
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.
|
|
1578
|
+
|
|
1579
|
+
#### Get Started
|
|
1580
|
+
```bash
|
|
1581
|
+
npm install @backtest-kit/pinets pinets backtest-kit
|
|
1582
|
+
```
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
### @backtest-kit/graph
|
|
1586
|
+
|
|
1587
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** ๐
|
|
1588
|
+
|
|
1589
|
+
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.
|
|
1590
|
+
|
|
1591
|
+
#### Key Features
|
|
1592
|
+
- ๐ **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
|
|
1593
|
+
- ๐ **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
|
|
1594
|
+
- ๐งฑ **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
|
|
1595
|
+
- ๐พ **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
|
|
1596
|
+
- ๐ **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
|
|
1597
|
+
|
|
1598
|
+
#### Use Case
|
|
1599
|
+
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.
|
|
1600
|
+
|
|
1601
|
+
#### Get Started
|
|
1602
|
+
```bash
|
|
1603
|
+
npm install @backtest-kit/graph backtest-kit
|
|
1604
|
+
```
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
### @backtest-kit/ui
|
|
1608
|
+
|
|
1609
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ui)** ๐
|
|
1610
|
+
|
|
1611
|
+
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.
|
|
1612
|
+
|
|
1613
|
+
#### Key Features
|
|
1614
|
+
- ๐ **Interactive Charts**: Candlestick visualization with Lightweight Charts (1m, 15m, 1h timeframes)
|
|
1615
|
+
- ๐ฏ **Signal Tracking**: View opened, closed, scheduled, and cancelled signals with full details
|
|
1616
|
+
- ๐ **Risk Analysis**: Monitor risk rejections and position management
|
|
1617
|
+
- ๐ **Notifications**: Real-time notification system for all trading events
|
|
1618
|
+
- ๐น **Trailing & Breakeven**: Visualize trailing stop/take and breakeven events
|
|
1619
|
+
- ๐จ **Material Design**: Beautiful UI with MUI 5 and Mantine components
|
|
1620
|
+
|
|
1621
|
+
#### Use Case
|
|
1622
|
+
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.
|
|
1623
|
+
|
|
1624
|
+
#### Get Started
|
|
1625
|
+
```bash
|
|
1626
|
+
npm install @backtest-kit/ui backtest-kit ccxt
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
### @backtest-kit/ollama
|
|
1631
|
+
|
|
1632
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ollama)** ๐ค
|
|
1633
|
+
|
|
1634
|
+
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.
|
|
1635
|
+
|
|
1636
|
+
#### Key Features
|
|
1637
|
+
- ๐ **10+ LLM Providers**: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama
|
|
1638
|
+
- ๐ **Token Rotation**: Automatic API key rotation for Ollama (others throw clear errors)
|
|
1639
|
+
- ๐ฏ **Structured Output**: Enforced JSON schema for trading signals (position, price levels, risk notes)
|
|
1640
|
+
- ๐ **Flexible Auth**: Context-based API keys or environment variables
|
|
1641
|
+
- โก **Unified API**: Single interface across all providers
|
|
1642
|
+
- ๐ **Trading-First**: Built for backtest-kit with position sizing and risk management
|
|
1643
|
+
|
|
1644
|
+
#### Use Case
|
|
1645
|
+
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.
|
|
1646
|
+
|
|
1647
|
+
#### Get Started
|
|
1648
|
+
```bash
|
|
1649
|
+
npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
|
|
1650
|
+
```
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
### @backtest-kit/signals
|
|
1654
|
+
|
|
1655
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/signals)** ๐
|
|
1656
|
+
|
|
1657
|
+
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.
|
|
1658
|
+
|
|
1659
|
+
#### Key Features
|
|
1660
|
+
- ๐ **Multi-Timeframe Analysis**: 1m, 15m, 30m, 1h with synchronized indicator computation
|
|
1661
|
+
- ๐ฏ **50+ Technical Indicators**: RSI, MACD, Bollinger Bands, Stochastic, ADX, ATR, CCI, Fibonacci, Support/Resistance
|
|
1662
|
+
- ๐ **Order Book Analysis**: Bid/ask depth, spread, liquidity imbalance, top 20 levels
|
|
1663
|
+
- ๐ค **AI-Ready Output**: Markdown reports formatted for LLM context injection
|
|
1664
|
+
- โก **Performance Optimized**: Intelligent caching with configurable TTL per timeframe
|
|
1665
|
+
|
|
1666
|
+
#### Use Case
|
|
1667
|
+
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.
|
|
1668
|
+
|
|
1669
|
+
#### Get Started
|
|
1670
|
+
```bash
|
|
1671
|
+
npm install @backtest-kit/signals backtest-kit
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
### @backtest-kit/sidekick
|
|
1676
|
+
|
|
1677
|
+
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** ๐
|
|
1678
|
+
|
|
1679
|
+
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.
|
|
1680
|
+
|
|
1681
|
+
#### Key Features
|
|
1682
|
+
- ๐ **Zero Config**: Get started with one command - no setup required
|
|
1683
|
+
- ๐ฆ **Complete Template**: Includes backtest strategy, risk management, and LLM integration
|
|
1684
|
+
- ๐ค **AI-Powered**: Pre-configured with DeepSeek, Claude, and GPT-5 fallback chain
|
|
1685
|
+
- ๐ **Technical Analysis**: Built-in 50+ indicators via @backtest-kit/signals
|
|
1686
|
+
- ๐ **Environment Setup**: Auto-generated .env with all API key placeholders
|
|
1687
|
+
- ๐ **Best Practices**: Production-ready code structure with examples
|
|
1688
|
+
|
|
1689
|
+
#### Use Case
|
|
1690
|
+
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.
|
|
1691
|
+
|
|
1692
|
+
#### Get Started
|
|
1693
|
+
```bash
|
|
1694
|
+
npx -y @backtest-kit/sidekick my-trading-bot
|
|
1695
|
+
cd my-trading-bot
|
|
1696
|
+
npm start
|
|
1697
|
+
```
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
## ๐ช Community
|
|
1701
|
+
|
|
1702
|
+
### uzse-backtest-app
|
|
1703
|
+
|
|
1704
|
+
> **[Explore on GitHub](https://github.com/backtest-kit/uzse-backtest-app)** ๐
|
|
1705
|
+
|
|
1706
|
+
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.
|
|
1707
|
+
|
|
1708
|
+
#### Key Features
|
|
1709
|
+
- ๐ **Off-TradingView Markets**: Works with any exchange that exposes trade history โ no TradingView dependency
|
|
1710
|
+
- ๐ฏ๏ธ **Candle Builder**: Aggregates raw trades into 1m candles, fills intraday and non-trading day gaps, builds higher timeframes up to `1d`
|
|
1711
|
+
- ๐๏ธ **MongoDB Backend**: Idempotent import with unique index โ re-runs never create duplicates
|
|
1712
|
+
- ๐ **Custom Exchange Adapter**: Connects MongoDB candles to backtest-kit via `addExchangeSchema`
|
|
1713
|
+
- ๐ **Pine Script Support**: Full `@backtest-kit/pinets` integration โ run any Pine Script v5/v6 indicator on local market data
|
|
1714
|
+
|
|
1715
|
+
#### Use Case
|
|
1716
|
+
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.
|
|
1717
|
+
|
|
1718
|
+
#### Get Started
|
|
1719
|
+
```bash
|
|
1720
|
+
git clone https://github.com/backtest-kit/uzse-backtest-app.git
|
|
1721
|
+
```
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
### backtest-kit-skills
|
|
1725
|
+
|
|
1726
|
+
> **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-skills)** ๐ค
|
|
1727
|
+
|
|
1728
|
+
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.
|
|
1729
|
+
|
|
1730
|
+
#### Key Features
|
|
1731
|
+
- ๐ค **Claude Code Skill**: Installed under `~/.claude/skills/backtest-kit/` โ strategy generation, debugging, and API reference
|
|
1732
|
+
- ๐ **Mintlify Docs**: Full documentation site runnable locally
|
|
1733
|
+
- ๐ฏ **Strategy Generation**: Complete TypeScript files with all schema registrations and runner setup
|
|
1734
|
+
- ๐ **Debugging Help**: Catches common mistakes (missing `await`, wrong TP/SL direction, top-level commit calls)
|
|
1735
|
+
- ๐ **API Reference**: All schemas, commit functions, event listeners, LLM integration, graph pipelines, and persistence adapters
|
|
1736
|
+
|
|
1737
|
+
#### Use Case
|
|
1738
|
+
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.
|
|
1739
|
+
|
|
1740
|
+
#### Get Started
|
|
1741
|
+
```bash
|
|
1742
|
+
npx skills add https://github.com/backtest-kit/backtest-kit-skills
|
|
1743
|
+
```
|
|
1744
|
+
|
|
1745
|
+
## ๐งฉ Strategy Examples
|
|
1746
|
+
|
|
1747
|
+
#### ๐ง Neural Network Strategy (Oct 2021)
|
|
1748
|
+
|
|
1749
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy)
|
|
1750
|
+
|
|
1751
|
+
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.
|
|
1752
|
+
|
|
1753
|
+
#### ๐ฒ Pine Script Range Breakout (Dec 2025)
|
|
1754
|
+
|
|
1755
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy)
|
|
1756
|
+
|
|
1757
|
+
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.
|
|
1758
|
+
|
|
1759
|
+
#### ๐ช Signal Inversion Strategy (Jan 2026)
|
|
1760
|
+
|
|
1761
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy)
|
|
1762
|
+
|
|
1763
|
+
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.
|
|
1764
|
+
|
|
1765
|
+
#### ๐ฐ AI News Sentiment (Feb 2026)
|
|
1766
|
+
|
|
1767
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy)
|
|
1768
|
+
|
|
1769
|
+
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.
|
|
1770
|
+
|
|
1771
|
+
#### ๐ช SHORT DCA Ladder (Mar 2026)
|
|
1772
|
+
|
|
1773
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy)
|
|
1774
|
+
|
|
1775
|
+
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.
|
|
1776
|
+
|
|
1777
|
+
#### ๐ง LONG DCA Ladder (Apr 2026)
|
|
1778
|
+
|
|
1779
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy)
|
|
1780
|
+
|
|
1781
|
+
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).
|
|
1782
|
+
|
|
1783
|
+
#### ๐ Python EMA Crossover (Feb 2021)
|
|
1784
|
+
|
|
1785
|
+
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy)
|
|
1786
|
+
|
|
1787
|
+
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.
|
|
1788
|
+
|
|
1789
|
+
## ๐ค Are you a robot?
|
|
1790
|
+
|
|
1791
|
+
**For language models**: Read extended description in [./LLMs.md](./LLMs.md)
|
|
1792
|
+
|
|
1793
|
+
## โ
Tested & Reliable
|
|
1794
|
+
|
|
1795
|
+
515+ tests cover validation, recovery, reports, and events.
|
|
1796
|
+
|
|
1797
|
+
## ๐ค Contribute
|
|
1798
|
+
|
|
1799
|
+
Fork/PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
|
|
1800
|
+
|
|
1801
|
+
## ๐ License
|
|
1802
|
+
|
|
1803
|
+
MIT ยฉ [tripolskypetr](https://github.com/tripolskypetr)
|
|
1804
|
+
|