backtest-kit 13.6.0 โ 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +524 -1723
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# ๐งฟ Backtest Kit
|
|
4
4
|
|
|
5
|
-
> A TypeScript
|
|
5
|
+
> A TypeScript engine for backtesting **and** live-trading strategies โ crypto, forex, DEX, spot or futures โ where the code you test is the code you ship.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
@@ -11,1995 +11,796 @@
|
|
|
11
11
|
[]()
|
|
12
12
|
[](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Most trading bots don't die because the strategy was wrong. They die because the backtest quietly read tomorrow's candle, because the process crashed mid-fill and opened the position twice, because the exchange rejected an order and the bot kept trading a ghost. The strategy was never the hard part โ the *plumbing* was.
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
`backtest-kit` is that plumbing, closed off one failure at a time over a year of live trading and running real money in production at [TheOneTrade](https://theonetrade.github.io). This page walks the failures that kill bots and shows how each one is designed out of the default path โ not "discouraged," not "documented," but structurally unavailable unless you go out of your way to defeat the engine. Every claim opens into **The Code / The Math / The Proof** so you (or the model reading this for you) can check the work instead of trusting the pitch.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
๐ **[API Reference](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** ยท ๐ **[Reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** ยท ๐ฐ **[Article series](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)**
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
---
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
## Start here
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Three on-ramps, one engine. Casual keeps the boilerplate inside the CLI; Sidekick ejects every wire into your repo; Docker gives you a restart-safe box.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
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:**
|
|
26
|
+
<details>
|
|
27
|
+
<summary>The Code</summary>
|
|
38
28
|
|
|
39
29
|
```bash
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
npm start
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Sidekick generates a project where the exchange adapter, frame definitions, risk rules, strategy logic, and runner script all live as editable source files inside the project. Use it when you need full visibility and control over every part of the setup.
|
|
46
|
-
|
|
47
|
-
### ๐ณ Running in Docker
|
|
30
|
+
# Casual โ your repo holds only strategy files; docs auto-fetched into docs/lib/
|
|
31
|
+
npx @backtest-kit/cli --init --output backtest-kit-project
|
|
32
|
+
cd backtest-kit-project && npm install && npm start
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
# Full control โ exchange/frames/risk/runner all editable in your project
|
|
35
|
+
npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
|
|
50
36
|
|
|
51
|
-
|
|
52
|
-
npx @backtest-kit/cli --docker
|
|
53
|
-
cd backtest-kit-docker
|
|
37
|
+
# Docker โ zero-downtime live trading
|
|
38
|
+
npx @backtest-kit/cli --docker && cd backtest-kit-docker
|
|
54
39
|
MODE=live SYMBOL=TRXUSDT STRATEGY_FILE=./content/feb_2026/feb_2026.strategy.ts docker-compose up -d
|
|
55
|
-
docker-compose logs -f
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
CLI can create a ready-to-use Docker workspace: self-contained directory with `docker-compose.yaml` and a strategy entry point. CLI supports [Multiple Symbol in Parallel](https://www.npmjs.com/package/@backtest-kit/cli#-multiple-symbol-parallel) for powerusers.
|
|
59
|
-
|
|
60
|
-
### ๐ฆ Manual Installation
|
|
61
|
-
|
|
62
|
-
> **Want to see the code?** ๐ [Demo app](https://github.com/tripolskypetr/backtest-kit/tree/master/example) ๐
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
npm install backtest-kit ccxt ollama uuid
|
|
66
40
|
```
|
|
67
41
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
## โจ Why Choose Backtest Kit?
|
|
71
|
-
|
|
72
|
-
- ๐ **Production-Ready**: Seamless switch between backtest/live modes; identical code across environments.
|
|
73
|
-
- ๐พ **Crash-Safe**: Atomic persistence recovers states after crashes, preventing duplicates or losses.
|
|
74
|
-
- โ
**Validation**: Checks signals for TP/SL logic, risk/reward ratios, whipsaw protection and portfolio limits.
|
|
75
|
-
- ๐ **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
|
|
76
|
-
- ๐ช **Path-aware Exit**: Exit based on OHLC replay, not close-to-close
|
|
77
|
-
- ๐ค **AI Integration**: LLM-powered strategy generation (Optimizer) with multi-timeframe analysis.
|
|
78
|
-
- ๐ **Reports & Metrics**: Auto Markdown reports with PNL, Sharpe Ratio, win rate, and more.
|
|
79
|
-
- ๐ **Portfolio Heatmap**: Cross-symbol portfolio with Pooled Sharpe, Sortino & Calmar Ratio, Recovery Factor, Expectancy and other measures
|
|
80
|
-
- ๐ก๏ธ **Risk Management**: Custom rules for position limits, time windows, and multi-strategy coordination.
|
|
81
|
-
- ๐ **Pluggable**: Custom data sources (CCXT), persistence (file/Redis), and sizing calculators.
|
|
82
|
-
- ๐๏ธ **Transactional Live Orders**: Broker adapter intercepts every trade mutation before internal state changes โ exchange rejection rolls back the operation atomically.
|
|
83
|
-
- โฐ **Built-in Crontab**: Register periodic or fire-once jobs that fire on virtual-time boundaries with singleshot coordination across parallel backtests โ one handler invocation per boundary, no double-fires.
|
|
84
|
-
- ๐งช **Tested**: 775+ unit/integration tests for validation, recovery, and events.
|
|
85
|
-
- ๐ **Self hosted**: Zero dependency on third-party node_modules or platforms; run entirely in your own environment.
|
|
86
|
-
|
|
87
|
-
## ๐ Supported Order Types
|
|
88
|
-
|
|
89
|
-
> With the calculation of PnL, Peak Profit and Max Drawdown for each Entry
|
|
90
|
-
|
|
91
|
-
- Market/Limit entries
|
|
92
|
-
- TP/SL/OCO exits
|
|
93
|
-
- Grid with auto-cancel on unmet conditions
|
|
94
|
-
- Partial profit/loss levels
|
|
95
|
-
- Trailing take-profit / stop-loss
|
|
96
|
-
- Breakeven protection
|
|
97
|
-
- Stop limit entries (before OCO)
|
|
98
|
-
- Dollar cost averaging
|
|
99
|
-
- Time attack / Infinite hold
|
|
100
|
-
|
|
101
|
-
## ๐ Code Samples
|
|
102
|
-
|
|
103
|
-
### โ๏ธ Basic Configuration
|
|
104
|
-
```typescript
|
|
105
|
-
import { setLogger, setConfig } from 'backtest-kit';
|
|
106
|
-
|
|
107
|
-
// Enable logging
|
|
108
|
-
setLogger({
|
|
109
|
-
log: console.log,
|
|
110
|
-
debug: console.debug,
|
|
111
|
-
info: console.info,
|
|
112
|
-
warn: console.warn,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// Global config (optional)
|
|
116
|
-
setConfig({
|
|
117
|
-
CC_PERCENT_SLIPPAGE: 0.1, // % slippage
|
|
118
|
-
CC_PERCENT_FEE: 0.1, // % fee
|
|
119
|
-
CC_SCHEDULE_AWAIT_MINUTES: 120, // Pending signal timeout
|
|
120
|
-
});
|
|
121
|
-
```
|
|
42
|
+
A whole strategy is three registrations and a run call. No bootstrap, no DI container to learn:
|
|
122
43
|
|
|
123
|
-
### ๐ง Register Components
|
|
124
44
|
```typescript
|
|
125
45
|
import ccxt from 'ccxt';
|
|
126
|
-
import { addExchangeSchema, addStrategySchema, addFrameSchema,
|
|
46
|
+
import { addExchangeSchema, addStrategySchema, addFrameSchema, Position,
|
|
47
|
+
Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
|
|
127
48
|
|
|
128
|
-
// Exchange (data source)
|
|
129
49
|
addExchangeSchema({
|
|
130
50
|
exchangeName: 'binance',
|
|
131
51
|
getCandles: async (symbol, interval, since, limit) => {
|
|
132
|
-
const
|
|
133
|
-
const ohlcv = await
|
|
134
|
-
return ohlcv.map(([timestamp, open, high, low, close, volume]) =>
|
|
52
|
+
const ex = new ccxt.binance();
|
|
53
|
+
const ohlcv = await ex.fetchOHLCV(symbol, interval, since.getTime(), limit);
|
|
54
|
+
return ohlcv.map(([timestamp, open, high, low, close, volume]) =>
|
|
55
|
+
({ timestamp, open, high, low, close, volume }));
|
|
135
56
|
},
|
|
136
|
-
formatPrice: (
|
|
137
|
-
formatQuantity: (symbol, quantity) => quantity.toFixed(8),
|
|
57
|
+
formatPrice: (s, p) => p.toFixed(2), formatQuantity: (s, q) => q.toFixed(8),
|
|
138
58
|
});
|
|
139
59
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
riskName: 'demo',
|
|
143
|
-
validations: [
|
|
144
|
-
// TP at least 1%
|
|
145
|
-
({ pendingSignal, currentPrice }) => {
|
|
146
|
-
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
|
|
147
|
-
const tpDistance = position === 'long' ? ((priceTakeProfit - priceOpen) / priceOpen) * 100 : ((priceOpen - priceTakeProfit) / priceOpen) * 100;
|
|
148
|
-
if (tpDistance < 1) throw new Error(`TP too close: ${tpDistance.toFixed(2)}%`);
|
|
149
|
-
},
|
|
150
|
-
// R/R at least 2:1
|
|
151
|
-
({ pendingSignal, currentPrice }) => {
|
|
152
|
-
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
|
|
153
|
-
const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
|
|
154
|
-
const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
|
|
155
|
-
if (reward / risk < 2) throw new Error('Poor R/R ratio');
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
});
|
|
60
|
+
addFrameSchema({ frameName: 'feb-2026', interval: '1m',
|
|
61
|
+
startDate: new Date('2026-02-01'), endDate: new Date('2026-02-28') });
|
|
159
62
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
63
|
+
addStrategySchema({
|
|
64
|
+
strategyName: 'my-strategy', interval: '15m',
|
|
65
|
+
getSignal: async (symbol, when, currentPrice) => ({
|
|
66
|
+
position: 'long',
|
|
67
|
+
...Position.bracket({ position: 'long', currentPrice, percentTakeProfit: 2, percentStopLoss: 1 }),
|
|
68
|
+
minuteEstimatedTime: 60 * 24, cost: 100,
|
|
69
|
+
}),
|
|
166
70
|
});
|
|
71
|
+
|
|
72
|
+
Backtest.background('BTCUSDT', { strategyName: 'my-strategy', exchangeName: 'binance', frameName: 'feb-2026' });
|
|
73
|
+
listenSignalBacktest(console.log);
|
|
74
|
+
listenDoneBacktest(async (e) => { await Backtest.dump(e.symbol, e.strategyName); });
|
|
167
75
|
```
|
|
168
76
|
|
|
169
|
-
|
|
170
|
-
```typescript
|
|
171
|
-
import { v4 as uuid } from 'uuid';
|
|
172
|
-
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
|
|
173
|
-
import { json } from './utils/json.mjs'; // LLM wrapper
|
|
174
|
-
import { getMessages } from './utils/messages.mjs'; // Market data prep
|
|
77
|
+
</details>
|
|
175
78
|
|
|
176
|
-
|
|
177
|
-
strategyName: 'llm-strategy',
|
|
178
|
-
interval: '5m',
|
|
179
|
-
riskName: 'demo',
|
|
180
|
-
getSignal: async (symbol) => {
|
|
79
|
+
---
|
|
181
80
|
|
|
182
|
-
|
|
183
|
-
const candles15m = await getCandles(symbol, "15m", 48);
|
|
184
|
-
const candles5m = await getCandles(symbol, "5m", 60);
|
|
185
|
-
const candles1m = await getCandles(symbol, "1m", 60);
|
|
81
|
+
## The rakes โ and where they went
|
|
186
82
|
|
|
187
|
-
|
|
188
|
-
candles1h,
|
|
189
|
-
candles15m,
|
|
190
|
-
candles5m,
|
|
191
|
-
candles1m,
|
|
192
|
-
}); // Calculate indicators / Fetch news
|
|
83
|
+
What follows isn't a feature list. It's the set of mistakes that quietly drain accounts, each one paired with the design decision that took it off the table. If you've shipped a bot before, you've stepped on at least three of these.
|
|
193
84
|
|
|
194
|
-
|
|
195
|
-
const signal = await json(messages); // LLM generates signal
|
|
85
|
+
### 1. Your backtest lied to you, and you'll only find out with real money
|
|
196
86
|
|
|
197
|
-
|
|
198
|
-
dumpId: "position-context",
|
|
199
|
-
bucketName: "multi-timeframe-strategy",
|
|
200
|
-
messages: messages, // pass saved messages here
|
|
201
|
-
description: "agent reasoning for this signal",
|
|
202
|
-
});
|
|
87
|
+
Look-ahead bias is the assassin of algo trading: a single line that touches a future candle, an indicator loaded without a timestamp filter, one forgotten `<=`. The backtest prints a beautiful equity curve that can *never* be reproduced live, and you deploy straight into a drawdown.
|
|
203
88
|
|
|
204
|
-
|
|
205
|
-
dumpId: "position-entry",
|
|
206
|
-
bucketName: "multi-timeframe-strategy",
|
|
207
|
-
record: signal, // pass saved signal record here
|
|
208
|
-
description: "signal entry parameters",
|
|
209
|
-
});
|
|
89
|
+
The usual defense is "be careful." Careful doesn't survive a 2,000-line strategy or a refactor at 1 a.m. So the cure here isn't discipline โ it's removal of the failure surface. There is no timestamp parameter to forget. An ambient temporal context flows through every async call via Node's `AsyncLocalStorage`, and the data layer physically refuses to hand you a candle past "now." The pending (still-forming) candle is never returned, because its half-finished OHLC would poison every indicator.
|
|
210
90
|
|
|
211
|
-
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
```
|
|
91
|
+
The one rule this rests on: that context is live for the whole `await` chain of your `getSignal` and every `listen*` callback โ including across `Promise.all`, which is where strategy code actually runs. It is not sorcery over execution you deliberately detach from that chain. A bare timer, an `EventEmitter`, a forked process, or the web dashboard reads engine state by **identifier** (signal id / symbol), not by inheriting the ambient clock โ that explicit, id-based interop is exactly how the frontend talks to a running backtest. Inside the hooks the guarantee holds; step outside them on purpose and you address the engine deliberately rather than by accident.
|
|
215
92
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
import { Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
|
|
93
|
+
<details>
|
|
94
|
+
<summary>The Math</summary>
|
|
219
95
|
|
|
220
|
-
|
|
221
|
-
strategyName: 'llm-strategy',
|
|
222
|
-
exchangeName: 'binance',
|
|
223
|
-
frameName: '1d-test',
|
|
224
|
-
});
|
|
96
|
+
Every request resolves "now" from the ambient context, aligns down to the interval boundary, and treats the pending candle as exclusive:
|
|
225
97
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
98
|
+
```
|
|
99
|
+
when = current execution-context time (AsyncLocalStorage)
|
|
100
|
+
stepMs = interval duration (1m โ 60000)
|
|
101
|
+
alignedWhen = Math.floor(when / stepMs) * stepMs // round down to boundary
|
|
102
|
+
since = alignedWhen โ limit * stepMs // go back `limit` candles
|
|
230
103
|
```
|
|
231
104
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
105
|
+
- `since` is **inclusive** โ first candle has `timestamp === since`.
|
|
106
|
+
- `alignedWhen` is **exclusive** โ the candle covering `[alignedWhen, alignedWhen+stepMs)` is still open and is never returned.
|
|
107
|
+
- Range is the half-open `[since, alignedWhen)`; exactly `limit` candles return; timestamps are `since + iยทstepMs`.
|
|
235
108
|
|
|
236
|
-
|
|
237
|
-
strategyName: 'llm-strategy',
|
|
238
|
-
exchangeName: 'binance', // Use API keys in .env
|
|
239
|
-
});
|
|
109
|
+
`getNextCandles()` is backtest-only and **throws in live mode** โ there is no future to look at when "now" is wall-clock. `getRawCandles(limit?, sDate?, eDate?)` supports flexible windows, all clamped to `eDate โค when`. Order books and aggregated trades use the same alignment (trades always to a 1-minute boundary). All boundaries are **UTC**: a 4h candle aligns to `00/04/08/12/16/20 UTC` regardless of your local offset โ so `since` values that look "uneven" in local time are exact in UTC. Because `since` is derived from the ambient `when`, multi-timeframe pulls inside one `getSignal` are automatically synchronized, and runtime and the persistent cache compute identical keys โ deterministic, exact-timestamp retrieval.
|
|
240
110
|
|
|
241
|
-
|
|
242
|
-
```
|
|
111
|
+
</details>
|
|
243
112
|
|
|
244
|
-
|
|
113
|
+
<details>
|
|
114
|
+
<summary>The Code</summary>
|
|
245
115
|
|
|
246
|
-
|
|
247
|
-
|
|
116
|
+
```typescript
|
|
117
|
+
getSignal: async (symbol) => {
|
|
118
|
+
// No timestamps anywhere. Context flows even through Promise.all โ
|
|
119
|
+
// all four timeframes are pinned to the same tick automatically.
|
|
120
|
+
const [c1h, c15m, c5m, c1m] = await Promise.all([
|
|
121
|
+
getCandles(symbol, '1h', 24),
|
|
122
|
+
getCandles(symbol, '15m', 48),
|
|
123
|
+
getCandles(symbol, '5m', 60),
|
|
124
|
+
getCandles(symbol, '1m', 60),
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
```
|
|
248
128
|
|
|
249
|
-
|
|
129
|
+
The bias you can't introduce by hand is the bias you'll never debug in production.
|
|
250
130
|
|
|
251
|
-
|
|
131
|
+
</details>
|
|
252
132
|
|
|
253
|
-
|
|
254
|
-
- `CC_AVG_PRICE_CANDLES_COUNT`: VWAP candles (default: 5).
|
|
133
|
+
### 2. "It worked in the backtest" means nothing if live runs different code
|
|
255
134
|
|
|
256
|
-
|
|
135
|
+
The standard path productionizes a strategy by rewriting it: the research notebook becomes a second, hand-built live system with its own order logic, its own bugs, its own divergence. Now you have two strategies that *look* identical and behave differently exactly when it matters.
|
|
257
136
|
|
|
258
|
-
|
|
137
|
+
Here there is one code path. The `getSignal` you backtested is the `getSignal` that trades. Backtest mode feeds it historical timestamps; live mode feeds it `Date.now()`. The business logic โ entries, validation, scheduled activation, TP/SL/timeout, partial closes โ is byte-for-byte the same in both. The only differences are infrastructural: where the data comes from, not what you do with it.
|
|
259
138
|
|
|
260
|
-
|
|
139
|
+
<details>
|
|
140
|
+
<summary>The Code</summary>
|
|
261
141
|
|
|
262
|
-
|
|
142
|
+
```typescript
|
|
143
|
+
// Backtest โ a historical frame drives the clock
|
|
144
|
+
Backtest.background('BTCUSDT', { strategyName, exchangeName, frameName });
|
|
263
145
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
- **`commitPartialLoss`** โ closes X% of the position at a loss. Cuts exposure before the stop-loss is hit.
|
|
146
|
+
// Live โ wall-clock drives the clock; the strategy file is untouched
|
|
147
|
+
Live.background('BTCUSDT', { strategyName, exchangeName }); // keys via .env
|
|
148
|
+
listenSignalLive(async (e) => { if (e.action === 'closed') await Live.dump(e.symbol, e.strategyName); });
|
|
268
149
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
The Math
|
|
272
|
-
</summary>
|
|
273
|
-
|
|
274
|
-
**Scenario:** LONG entry @ 1000, 4 DCA attempts (1 rejected), 3 partials, closed at TP.
|
|
275
|
-
`totalInvested = $400` (4 ร $100, rejected attempt not counted).
|
|
276
|
-
|
|
277
|
-
**Entries**
|
|
278
|
-
```
|
|
279
|
-
entry#1 @ 1000 โ 0.10000 coins
|
|
280
|
-
commitPartialProfit(30%) @ 1150 โ cnt=1
|
|
281
|
-
entry#2 @ 950 โ 0.10526 coins
|
|
282
|
-
entry#3 @ 880 โ 0.11364 coins
|
|
283
|
-
commitPartialLoss(20%) @ 860 โ cnt=3
|
|
284
|
-
entry#4 @ 920 โ 0.10870 coins
|
|
285
|
-
commitPartialProfit(40%) @ 1050 โ cnt=4
|
|
286
|
-
entry#5 @ 980 โ REJECTED (980 > ep3โ929.92)
|
|
287
|
-
totalInvested = $400
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
**Partial#1 โ commitPartialProfit @ 1150, 30%, cnt=1**
|
|
291
|
-
```
|
|
292
|
-
effectivePrice = hm(1000) = 1000
|
|
293
|
-
costBasis = $100
|
|
294
|
-
partialDollarValue = 30% ร 100 = $30 โ weight = 30/400 = 0.075
|
|
295
|
-
pnl = (1150โ1000)/1000 ร 100 = +15.00%
|
|
296
|
-
costBasis โ $70
|
|
297
|
-
coins sold: 0.03000 ร 1150 = $34.50
|
|
298
|
-
remaining: 0.07000
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
**DCA after Partial#1**
|
|
302
|
-
```
|
|
303
|
-
entry#2 @ 950 (950 < ep1=1000 โ accepted)
|
|
304
|
-
entry#3 @ 880 (880 < ep1=1000 โ accepted)
|
|
305
|
-
coins: 0.07000 + 0.10526 + 0.11364 = 0.28890
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
**Partial#2 โ commitPartialLoss @ 860, 20%, cnt=3**
|
|
309
|
-
```
|
|
310
|
-
costBasis = 70 + 100 + 100 = $270
|
|
311
|
-
ep2 = 270 / 0.28890 โ 934.58
|
|
312
|
-
partialDollarValue = 20% ร 270 = $54 โ weight = 54/400 = 0.135
|
|
313
|
-
pnl = (860โ934.58)/934.58 ร 100 โ โ7.98%
|
|
314
|
-
costBasis โ $216
|
|
315
|
-
coins sold: 0.05778 ร 860 = $49.69
|
|
316
|
-
remaining: 0.23112
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
**DCA after Partial#2**
|
|
320
|
-
```
|
|
321
|
-
entry#4 @ 920 (920 < ep2=934.58 โ accepted)
|
|
322
|
-
coins: 0.23112 + 0.10870 = 0.33982
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
**Partial#3 โ commitPartialProfit @ 1050, 40%, cnt=4**
|
|
326
|
-
```
|
|
327
|
-
costBasis = 216 + 100 = $316
|
|
328
|
-
ep3 = 316 / 0.33982 โ 929.92
|
|
329
|
-
partialDollarValue = 40% ร 316 = $126.4 โ weight = 126.4/400 = 0.316
|
|
330
|
-
pnl = (1050โ929.92)/929.92 ร 100 โ +12.91%
|
|
331
|
-
costBasis โ $189.6
|
|
332
|
-
coins sold: 0.13593 ร 1050 = $142.72
|
|
333
|
-
remaining: 0.20389
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
**DCA after Partial#3 โ rejected**
|
|
337
|
-
```
|
|
338
|
-
entry#5 @ 980 (980 > ep3โ929.92 โ REJECTED)
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
**Close at TP @ 1200**
|
|
342
|
-
```
|
|
343
|
-
ep_final = ep3 โ 929.92 (no new entries)
|
|
344
|
-
coins: 0.20389
|
|
345
|
-
|
|
346
|
-
remainingDollarValue = 400 โ 30 โ 54 โ 126.4 = $189.6
|
|
347
|
-
weight = 189.6/400 = 0.474
|
|
348
|
-
pnl = (1200โ929.92)/929.92 ร 100 โ +29.04%
|
|
349
|
-
coins sold: 0.20389 ร 1200 = $244.67
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
**Result (toProfitLossDto)**
|
|
353
|
-
```
|
|
354
|
-
0.075 ร (+15.00) = +1.125
|
|
355
|
-
0.135 ร (โ7.98) = โ1.077
|
|
356
|
-
0.316 ร (+12.91) = +4.080
|
|
357
|
-
0.474 ร (+29.04) = +13.765
|
|
358
|
-
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
359
|
-
โ +17.89%
|
|
360
|
-
|
|
361
|
-
Cross-check (coins):
|
|
362
|
-
34.50 + 49.69 + 142.72 + 244.67 = $471.58
|
|
363
|
-
(471.58 โ 400) / 400 ร 100 = +17.90% โ
|
|
364
|
-
```
|
|
365
|
-
</details>
|
|
150
|
+
// Paper โ live prices, no real orders, identical path. Validate here before risking capital.
|
|
151
|
+
```
|
|
366
152
|
|
|
367
|
-
|
|
153
|
+
And one engine, two ways to consume it โ pick by use case, not by capability:
|
|
368
154
|
|
|
369
|
-
|
|
155
|
+
```typescript
|
|
156
|
+
// Event-driven (production bots, monitoring)
|
|
157
|
+
Backtest.background('BTCUSDT', config);
|
|
158
|
+
listenSignalBacktest(e => {/* โฆ */});
|
|
370
159
|
|
|
371
|
-
|
|
160
|
+
// Async iterator (research, scripts, LLM agents)
|
|
161
|
+
for await (const event of Backtest.run('BTCUSDT', config)) { /* signal | progress | done */ }
|
|
162
|
+
```
|
|
372
163
|
|
|
373
|
-
|
|
164
|
+
</details>
|
|
374
165
|
|
|
375
166
|
<details>
|
|
376
|
-
|
|
377
|
-
The code
|
|
378
|
-
</summary>
|
|
379
|
-
|
|
380
|
-
**Spot**
|
|
167
|
+
<summary>The Proof</summary>
|
|
381
168
|
|
|
382
|
-
|
|
383
|
-
import ccxt from "ccxt";
|
|
384
|
-
import { singleshot, sleep } from "functools-kit";
|
|
385
|
-
import {
|
|
386
|
-
Broker,
|
|
387
|
-
IBroker,
|
|
388
|
-
BrokerSignalOpenPayload,
|
|
389
|
-
BrokerSignalClosePayload,
|
|
390
|
-
BrokerPartialProfitPayload,
|
|
391
|
-
BrokerPartialLossPayload,
|
|
392
|
-
BrokerTrailingStopPayload,
|
|
393
|
-
BrokerTrailingTakePayload,
|
|
394
|
-
BrokerBreakevenPayload,
|
|
395
|
-
BrokerAverageBuyPayload,
|
|
396
|
-
} from "backtest-kit";
|
|
169
|
+
This is the property the test suite exists to defend, and the line in the sand for the whole project: **business logic is 100% synchronous across backtest and live.** Signal validation is identical in both modes; immediate activation behaves identically; scheduled-signal logic is fully synchronized; TP / SL / timeout checks do not differ. The only divergence is infrastructural โ how candles, order books, and time are sourced. `validation.test.mjs`, `backtest.test.mjs`, and `callbacks.test.mjs` pin this behavior; `event.test.mjs` pins the live path against the same expectations. If the two ever drift, a test goes red before you do.
|
|
397
170
|
|
|
398
|
-
|
|
399
|
-
const FILL_POLL_ATTEMPTS = 10;
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Sleep between cancelOrder and fetchBalance to allow Binance to settle the
|
|
403
|
-
* cancellation โ reads immediately after cancel may return stale data.
|
|
404
|
-
*/
|
|
405
|
-
const CANCEL_SETTLE_MS = 2_000;
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Slippage buffer for stop_loss_limit on Spot โ limit price is set slightly
|
|
409
|
-
* below stopPrice so the order fills even on a gap down instead of hanging.
|
|
410
|
-
*/
|
|
411
|
-
const STOP_LIMIT_SLIPPAGE = 0.995;
|
|
412
|
-
|
|
413
|
-
const getSpotExchange = singleshot(async () => {
|
|
414
|
-
const exchange = new ccxt.binance({
|
|
415
|
-
apiKey: process.env.BINANCE_API_KEY,
|
|
416
|
-
secret: process.env.BINANCE_API_SECRET,
|
|
417
|
-
options: {
|
|
418
|
-
defaultType: "spot",
|
|
419
|
-
adjustForTimeDifference: true,
|
|
420
|
-
recvWindow: 60000,
|
|
421
|
-
},
|
|
422
|
-
enableRateLimit: true,
|
|
423
|
-
});
|
|
424
|
-
await exchange.loadMarkets();
|
|
425
|
-
return exchange;
|
|
426
|
-
});
|
|
171
|
+
</details>
|
|
427
172
|
|
|
428
|
-
|
|
429
|
-
* Resolve base currency from market metadata โ safe for all quote currencies (USDT, USDC, FDUSD, etc.)
|
|
430
|
-
*/
|
|
431
|
-
function getBase(exchange: ccxt.binance, symbol: string): string {
|
|
432
|
-
return exchange.markets[symbol].base;
|
|
433
|
-
}
|
|
173
|
+
### 3. The crash that opens your position twice
|
|
434
174
|
|
|
435
|
-
|
|
436
|
-
* Truncate qty to exchange precision, always rounding down.
|
|
437
|
-
* Prevents over-selling due to floating point drift from fetchBalance.
|
|
438
|
-
*/
|
|
439
|
-
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
440
|
-
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
441
|
-
}
|
|
175
|
+
A bot updating a position when the process dies โ OOM, deploy, power blip โ usually wakes up to corrupted state: a half-opened position, a cost basis that's wrong, an exit that never registered. Recovery by hand is where money leaks.
|
|
442
176
|
|
|
443
|
-
|
|
444
|
-
* Fetch current free balance for base currency of symbol.
|
|
445
|
-
*/
|
|
446
|
-
async function fetchFreeQty(exchange: ccxt.binance, symbol: string): Promise<number> {
|
|
447
|
-
const balance = await exchange.fetchBalance();
|
|
448
|
-
const base = getBase(exchange, symbol);
|
|
449
|
-
return parseFloat(String(balance?.free?.[base] ?? 0));
|
|
450
|
-
}
|
|
177
|
+
Every state mutation is written atomically to disk *before* it counts as done (write-temp-then-rename), and on restart the engine reloads to the last consistent state. Live runs reload persisted signal state on every start, and `Live.background()` shuts down gracefully โ it waits for open positions to reach `closed` before stopping, so a deploy never severs a live trade mid-flight.
|
|
451
178
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
* network blip) does not leave remaining orders uncancelled.
|
|
455
|
-
*/
|
|
456
|
-
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
457
|
-
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
458
|
-
}
|
|
179
|
+
<details>
|
|
180
|
+
<summary>The Proof</summary>
|
|
459
181
|
|
|
460
|
-
|
|
461
|
-
* Place a stop_loss_limit sell order with a slippage buffer on the limit price.
|
|
462
|
-
* stop_loss_limit requires both stopPrice (trigger) and price (limit fill).
|
|
463
|
-
* Setting them equal risks non-fill on gap down โ limit is offset by STOP_LIMIT_SLIPPAGE.
|
|
464
|
-
*/
|
|
465
|
-
async function createStopLossOrder(
|
|
466
|
-
exchange: ccxt.binance,
|
|
467
|
-
symbol: string,
|
|
468
|
-
qty: number,
|
|
469
|
-
stopPrice: number
|
|
470
|
-
): Promise<void> {
|
|
471
|
-
const limitPrice = parseFloat(exchange.priceToPrecision(symbol, stopPrice * STOP_LIMIT_SLIPPAGE));
|
|
472
|
-
await exchange.createOrder(symbol, "stop_loss_limit", "sell", qty, limitPrice, { stopPrice });
|
|
473
|
-
}
|
|
182
|
+
Recovery is structural, not a feature you remember to enable. `PersistBase` does atomic write-to-temp + rename, repairs corrupted files, and verifies integrity in `waitForInit()`. Fifteen per-domain `Persist*Instance` classes cover everything that can change: Signal, State, Session, Candle, Risk, Partial, Breakeven, Schedule, Recent, Notification, Log, Measure, Interval, Memory. Concrete scenarios that resolve cleanly:
|
|
474
183
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
*/
|
|
480
|
-
async function createLimitOrderAndWait(
|
|
481
|
-
exchange: ccxt.binance,
|
|
482
|
-
symbol: string,
|
|
483
|
-
side: "buy" | "sell",
|
|
484
|
-
qty: number,
|
|
485
|
-
price: number,
|
|
486
|
-
restore?: { tpPrice: number; slPrice: number }
|
|
487
|
-
): Promise<void> {
|
|
488
|
-
const order = await exchange.createOrder(symbol, "limit", side, qty, price);
|
|
184
|
+
- Process killed during order placement โ internal state unchanged, retried next tick.
|
|
185
|
+
- Network failure during an exchange call โ automatic retry on the next tick.
|
|
186
|
+
- Power loss during a save โ recovery from the last atomic write.
|
|
187
|
+
- OOM โ graceful shutdown with state preserved.
|
|
489
188
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
189
|
+
```typescript
|
|
190
|
+
listenSignalLive(async (event) => {
|
|
191
|
+
if (event.action === 'closed') {
|
|
192
|
+
await Live.dump(event.symbol, event.strategyName); // atomic snapshot to disk
|
|
193
|
+
await Partial.dump(event.symbol, event.strategyName);
|
|
496
194
|
}
|
|
195
|
+
if (event.action === 'scheduled' || event.action === 'cancelled') {
|
|
196
|
+
await Schedule.dump(event.symbol, event.strategyName);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
```
|
|
497
200
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// Wait for Binance to settle the cancellation before reading filled qty
|
|
501
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
201
|
+
</details>
|
|
502
202
|
|
|
503
|
-
|
|
504
|
-
const filledQty = final.filled ?? 0;
|
|
203
|
+
### 4. The state that can't be corrupted because it can't be expressed
|
|
505
204
|
|
|
506
|
-
|
|
507
|
-
// Sell partial fill via market to restore clean exchange state before backtest-kit retries
|
|
508
|
-
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
509
|
-
await exchange.createOrder(symbol, "market", rollbackSide, filledQty);
|
|
510
|
-
}
|
|
205
|
+
"Is this position closed?" is a question you should never have to ask at runtime. A signal here moves through a strict lifecycle โ **idle โ scheduled โ opened โ active โ closed** โ modeled with TypeScript discriminated unions. Reading a closed position's live PnL, or mutating an active trade as if it were idle, isn't a bug you catch in QA; it's a line that won't compile.
|
|
511
206
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const remainingQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
515
|
-
if (remainingQty > 0) {
|
|
516
|
-
await exchange.createOrder(symbol, "limit", "sell", remainingQty, restore.tpPrice);
|
|
517
|
-
await createStopLossOrder(exchange, symbol, remainingQty, restore.slPrice);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
207
|
+
<details>
|
|
208
|
+
<summary>The Code</summary>
|
|
520
209
|
|
|
521
|
-
|
|
522
|
-
}
|
|
210
|
+
Each state exposes only the data that is meaningful in that state, so the wrong access never type-checks:
|
|
523
211
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
533
|
-
|
|
534
|
-
// Spot does not support short selling โ reject immediately so backtest-kit skips the mutation
|
|
535
|
-
if (position === "short") {
|
|
536
|
-
throw new Error(`SpotBrokerAdapter: short position is not supported on spot (symbol=${symbol})`);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
const exchange = await getSpotExchange();
|
|
540
|
-
|
|
541
|
-
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
542
|
-
|
|
543
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
544
|
-
if (qty <= 0) {
|
|
545
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
549
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
550
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
551
|
-
|
|
552
|
-
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
553
|
-
await createLimitOrderAndWait(exchange, symbol, "buy", qty, openPrice);
|
|
554
|
-
|
|
555
|
-
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
556
|
-
try {
|
|
557
|
-
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
558
|
-
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
559
|
-
} catch (err) {
|
|
560
|
-
await exchange.createOrder(symbol, "market", "sell", qty);
|
|
561
|
-
throw err;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
566
|
-
const { symbol, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
567
|
-
const exchange = await getSpotExchange();
|
|
568
|
-
|
|
569
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
570
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
571
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
572
|
-
|
|
573
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
574
|
-
|
|
575
|
-
// Position already closed by SL/TP on exchange โ nothing to do, commit succeeds
|
|
576
|
-
if (qty === 0) {
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
581
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
582
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
583
|
-
|
|
584
|
-
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
585
|
-
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
589
|
-
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
590
|
-
const exchange = await getSpotExchange();
|
|
591
|
-
|
|
592
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
593
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
594
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
595
|
-
|
|
596
|
-
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
597
|
-
|
|
598
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
599
|
-
if (totalQty === 0) {
|
|
600
|
-
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
604
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
605
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
606
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
607
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
608
|
-
|
|
609
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
610
|
-
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
611
|
-
|
|
612
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
613
|
-
if (remainingQty > 0) {
|
|
614
|
-
try {
|
|
615
|
-
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
616
|
-
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
617
|
-
} catch (err) {
|
|
618
|
-
// Remaining position is unprotected โ close via market
|
|
619
|
-
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
620
|
-
throw err;
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
626
|
-
const { symbol, percentToClose, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
627
|
-
const exchange = await getSpotExchange();
|
|
628
|
-
|
|
629
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
630
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
631
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
632
|
-
|
|
633
|
-
const totalQty = await fetchFreeQty(exchange, symbol);
|
|
634
|
-
|
|
635
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
636
|
-
if (totalQty === 0) {
|
|
637
|
-
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
641
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
642
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
643
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
644
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
645
|
-
|
|
646
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
647
|
-
await createLimitOrderAndWait(exchange, symbol, "sell", qty, closePrice, { tpPrice, slPrice });
|
|
648
|
-
|
|
649
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
650
|
-
if (remainingQty > 0) {
|
|
651
|
-
try {
|
|
652
|
-
await exchange.createOrder(symbol, "limit", "sell", remainingQty, tpPrice);
|
|
653
|
-
await createStopLossOrder(exchange, symbol, remainingQty, slPrice);
|
|
654
|
-
} catch (err) {
|
|
655
|
-
// Remaining position is unprotected โ close via market
|
|
656
|
-
await exchange.createOrder(symbol, "market", "sell", remainingQty);
|
|
657
|
-
throw err;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
663
|
-
const { symbol, newStopLossPrice } = payload;
|
|
664
|
-
const exchange = await getSpotExchange();
|
|
665
|
-
|
|
666
|
-
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
667
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
668
|
-
const slOrder = orders.find((o) =>
|
|
669
|
-
o.side === "sell" &&
|
|
670
|
-
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
671
|
-
) ?? null;
|
|
672
|
-
if (slOrder) {
|
|
673
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
674
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
678
|
-
|
|
679
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
680
|
-
if (qty === 0) {
|
|
681
|
-
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
685
|
-
|
|
686
|
-
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
690
|
-
const { symbol, newTakeProfitPrice } = payload;
|
|
691
|
-
const exchange = await getSpotExchange();
|
|
692
|
-
|
|
693
|
-
// Cancel existing TP order only โ Spot has no reduceOnly, filter by side + type
|
|
694
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
695
|
-
const tpOrder = orders.find((o) =>
|
|
696
|
-
o.side === "sell" &&
|
|
697
|
-
["limit", "LIMIT"].includes(o.type ?? "")
|
|
698
|
-
) ?? null;
|
|
699
|
-
if (tpOrder) {
|
|
700
|
-
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
701
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
705
|
-
|
|
706
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
707
|
-
if (qty === 0) {
|
|
708
|
-
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
712
|
-
|
|
713
|
-
await exchange.createOrder(symbol, "limit", "sell", qty, tpPrice);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
717
|
-
const { symbol, newStopLossPrice } = payload;
|
|
718
|
-
const exchange = await getSpotExchange();
|
|
719
|
-
|
|
720
|
-
// Cancel existing SL order only โ Spot has no reduceOnly, filter by side + type
|
|
721
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
722
|
-
const slOrder = orders.find((o) =>
|
|
723
|
-
o.side === "sell" &&
|
|
724
|
-
["stop_loss_limit", "stop", "STOP_LOSS_LIMIT"].includes(o.type ?? "")
|
|
725
|
-
) ?? null;
|
|
726
|
-
if (slOrder) {
|
|
727
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
728
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
const qty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
732
|
-
|
|
733
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
734
|
-
if (qty === 0) {
|
|
735
|
-
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
739
|
-
|
|
740
|
-
await createStopLossOrder(exchange, symbol, qty, slPrice);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
744
|
-
const { symbol, currentPrice, cost, priceTakeProfit, priceStopLoss } = payload;
|
|
745
|
-
const exchange = await getSpotExchange();
|
|
746
|
-
|
|
747
|
-
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
748
|
-
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
749
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
750
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
751
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
752
|
-
|
|
753
|
-
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
754
|
-
const existing = await fetchFreeQty(exchange, symbol);
|
|
755
|
-
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
756
|
-
|
|
757
|
-
// Compare notional value rather than raw qty โ avoids float === 0 trap
|
|
758
|
-
// and correctly rejects dust balances left over from previous trades
|
|
759
|
-
if (existing * currentPrice < minNotional) {
|
|
760
|
-
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
764
|
-
|
|
765
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
766
|
-
if (qty <= 0) {
|
|
767
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
771
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
772
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
773
|
-
|
|
774
|
-
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
775
|
-
await createLimitOrderAndWait(exchange, symbol, "buy", qty, entryPrice, { tpPrice, slPrice });
|
|
776
|
-
|
|
777
|
-
// Refetch balance after fill โ existing snapshot is stale after cancel + fill
|
|
778
|
-
const totalQty = truncateQty(exchange, symbol, await fetchFreeQty(exchange, symbol));
|
|
779
|
-
|
|
780
|
-
// Recreate SL/TP on fresh total qty after successful fill
|
|
781
|
-
try {
|
|
782
|
-
await exchange.createOrder(symbol, "limit", "sell", totalQty, tpPrice);
|
|
783
|
-
await createStopLossOrder(exchange, symbol, totalQty, slPrice);
|
|
784
|
-
} catch (err) {
|
|
785
|
-
// Total position is unprotected โ close via market
|
|
786
|
-
await exchange.createOrder(symbol, "market", "sell", totalQty);
|
|
787
|
-
throw err;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
212
|
+
```typescript
|
|
213
|
+
listenSignal((event) => {
|
|
214
|
+
switch (event.action) {
|
|
215
|
+
case 'idle': /* no signal โ only monitoring fields exist */ break;
|
|
216
|
+
case 'scheduled': /* waiting for entry price โ has priceOpen, scheduledAt */ break;
|
|
217
|
+
case 'opened': /* just filled โ entry data, no closeReason yet */ break;
|
|
218
|
+
case 'active': /* live position โ pnl, peakProfit, maxDrawdown */ break;
|
|
219
|
+
case 'closed': /* exited โ closeReason, final pnl; live fields gone */ break;
|
|
790
220
|
}
|
|
791
|
-
);
|
|
792
|
-
|
|
793
|
-
Broker.enable();
|
|
221
|
+
});
|
|
794
222
|
```
|
|
795
223
|
|
|
796
|
-
|
|
224
|
+
Before any signal reaches the engine it passes a validation pipeline: TP/SL prices positive, relationship correct (`TP > entry > SL` long, inverse short), risk/reward โฅ your minimum, timestamps not in the future, interval-throttling respected. Invalid signals are rejected or logged โ never executed.
|
|
797
225
|
|
|
798
|
-
|
|
799
|
-
import ccxt from "ccxt";
|
|
800
|
-
import { singleshot, sleep } from "functools-kit";
|
|
801
|
-
import {
|
|
802
|
-
Broker,
|
|
803
|
-
IBroker,
|
|
804
|
-
BrokerSignalOpenPayload,
|
|
805
|
-
BrokerSignalClosePayload,
|
|
806
|
-
BrokerPartialProfitPayload,
|
|
807
|
-
BrokerPartialLossPayload,
|
|
808
|
-
BrokerTrailingStopPayload,
|
|
809
|
-
BrokerTrailingTakePayload,
|
|
810
|
-
BrokerBreakevenPayload,
|
|
811
|
-
BrokerAverageBuyPayload,
|
|
812
|
-
} from "backtest-kit";
|
|
226
|
+
</details>
|
|
813
227
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* Sleep between cancelOrder and fetchPositions to allow Binance to settle the
|
|
819
|
-
* cancellation โ reads immediately after cancel may return stale data.
|
|
820
|
-
*/
|
|
821
|
-
const CANCEL_SETTLE_MS = 2_000;
|
|
822
|
-
|
|
823
|
-
/**
|
|
824
|
-
* 3x leverage โ conservative choice for $1000 total fiat.
|
|
825
|
-
* Enough to matter, not enough to liquidate on normal volatility.
|
|
826
|
-
* Applied per-symbol on first open via setLeverage.
|
|
827
|
-
*/
|
|
828
|
-
const FUTURES_LEVERAGE = 3;
|
|
829
|
-
|
|
830
|
-
const getFuturesExchange = singleshot(async () => {
|
|
831
|
-
const exchange = new ccxt.binance({
|
|
832
|
-
apiKey: process.env.BINANCE_API_KEY,
|
|
833
|
-
secret: process.env.BINANCE_API_SECRET,
|
|
834
|
-
options: {
|
|
835
|
-
defaultType: "future",
|
|
836
|
-
adjustForTimeDifference: true,
|
|
837
|
-
recvWindow: 60000,
|
|
838
|
-
},
|
|
839
|
-
enableRateLimit: true,
|
|
840
|
-
});
|
|
841
|
-
await exchange.loadMarkets();
|
|
842
|
-
return exchange;
|
|
843
|
-
});
|
|
228
|
+
<details>
|
|
229
|
+
<summary>The Proof</summary>
|
|
844
230
|
|
|
845
|
-
|
|
846
|
-
* Truncate qty to exchange precision, always rounding down.
|
|
847
|
-
* Prevents over-selling due to floating point drift from fetchPositions.
|
|
848
|
-
*/
|
|
849
|
-
function truncateQty(exchange: ccxt.binance, symbol: string, qty: number): number {
|
|
850
|
-
return parseFloat(exchange.amountToPrecision(symbol, qty, exchange.TRUNCATE));
|
|
851
|
-
}
|
|
231
|
+
The discriminated-union result types (`IStrategyTickResultWaiting / โฆOpened / โฆClosed / โฆScheduled / โฆCancelled`) are enforced end-to-end: `ClientStrategy.tick()/backtest()`, `StrategyCoreService`, the persistence layer, and every notification contract (`SignalOpenedNotification`, `SignalClosedNotification`, `SignalCancelledNotification`, `SignalScheduledNotification`) carry the lifecycle state explicitly. `validation.test.mjs` exercises valid long/short, inverted TP/SL, negative prices, and future timestamps; `backtest.test.mjs` walks every close reason (`take_profit`, `stop_loss`, `time_expired`).
|
|
852
232
|
|
|
853
|
-
|
|
854
|
-
* Resolve position for symbol filtered by side โ safe in both one-way and hedge mode.
|
|
855
|
-
*/
|
|
856
|
-
function findPosition(positions: ccxt.Position[], symbol: string, side: "long" | "short") {
|
|
857
|
-
// Hedge mode: positions have explicit side field
|
|
858
|
-
const hedged = positions.find((p) => p.symbol === symbol && p.side === side);
|
|
859
|
-
if (hedged) {
|
|
860
|
-
return hedged;
|
|
861
|
-
}
|
|
862
|
-
// One-way mode: single position per symbol, side field may be undefined or mismatched
|
|
863
|
-
const pos = positions.find((p) => p.symbol === symbol) ?? null;
|
|
864
|
-
if (pos && pos.side && pos.side !== side) {
|
|
865
|
-
console.warn(`findPosition: expected side="${side}" but exchange returned side="${pos.side}" for ${symbol} โ possible one-way/hedge mode mismatch`);
|
|
866
|
-
}
|
|
867
|
-
return pos;
|
|
868
|
-
}
|
|
233
|
+
</details>
|
|
869
234
|
|
|
870
|
-
|
|
871
|
-
* Fetch current contracts qty for symbol/side.
|
|
872
|
-
*/
|
|
873
|
-
async function fetchContractsQty(
|
|
874
|
-
exchange: ccxt.binance,
|
|
875
|
-
symbol: string,
|
|
876
|
-
side: "long" | "short"
|
|
877
|
-
): Promise<number> {
|
|
878
|
-
const positions = await exchange.fetchPositions([symbol]);
|
|
879
|
-
const pos = findPosition(positions, symbol, side);
|
|
880
|
-
return Math.abs(parseFloat(String(pos?.contracts ?? 0)));
|
|
881
|
-
}
|
|
235
|
+
### 5. The order the exchange silently rejected
|
|
882
236
|
|
|
883
|
-
|
|
884
|
-
* Cancel all orders in parallel โ allSettled so a single failure (already filled,
|
|
885
|
-
* network blip) does not leave remaining orders uncancelled.
|
|
886
|
-
*/
|
|
887
|
-
async function cancelAllOrders(exchange: ccxt.binance, orders: ccxt.Order[], symbol: string): Promise<void> {
|
|
888
|
-
await Promise.allSettled(orders.map((o) => exchange.cancelOrder(o.id, symbol)));
|
|
889
|
-
}
|
|
237
|
+
Live trading's quiet killer: the exchange rejects, times out, or fills partially, and your bot's internal state no longer matches reality. The textbook "fix" is hand-written `try/catch` rollback around every order โ which is exactly the code that breaks on the edge case you didn't think of.
|
|
890
238
|
|
|
891
|
-
|
|
892
|
-
* Resolve Binance positionSide string from position direction.
|
|
893
|
-
* Required in hedge mode to correctly route orders; ignored in one-way mode.
|
|
894
|
-
*/
|
|
895
|
-
function toPositionSide(position: "long" | "short"): "LONG" | "SHORT" {
|
|
896
|
-
return position === "long" ? "LONG" : "SHORT";
|
|
897
|
-
}
|
|
239
|
+
Here, every state-mutating action fires through the broker adapter *before* the internal state changes. If the adapter throws โ rejection, timeout, network failure โ the mutation is skipped, the state stays exactly as it was, and the engine retries on the next tick. You never write rollback logic, and there is no half-applied state to reconcile. In backtest mode no adapter is called at all, so historical replays never touch exchange code.
|
|
898
240
|
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
async function createLimitOrderAndWait(
|
|
908
|
-
exchange: ccxt.binance,
|
|
909
|
-
symbol: string,
|
|
910
|
-
side: "buy" | "sell",
|
|
911
|
-
qty: number,
|
|
912
|
-
price: number,
|
|
913
|
-
params: Record<string, unknown> = {},
|
|
914
|
-
restore?: { exitSide: "buy" | "sell"; tpPrice: number; slPrice: number; positionSide: "long" | "short" }
|
|
915
|
-
): Promise<void> {
|
|
916
|
-
const order = await exchange.createOrder(symbol, "limit", side, qty, price, params);
|
|
241
|
+
<details>
|
|
242
|
+
<summary>The Code</summary>
|
|
243
|
+
|
|
244
|
+
The reusable core: place โ poll to fill โ on timeout cancel, market-out any partial fill, restore TP/SL so the position is never left naked, then throw so the engine retries.
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
async function createLimitOrderAndWait(exchange, symbol, side, qty, price, restore?) {
|
|
248
|
+
const order = await exchange.createOrder(symbol, 'limit', side, qty, price);
|
|
917
249
|
|
|
918
250
|
for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
|
|
919
251
|
await sleep(FILL_POLL_INTERVAL_MS);
|
|
920
|
-
|
|
921
|
-
if (status.status === "closed") {
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
252
|
+
if ((await exchange.fetchOrder(order.id, symbol)).status === 'closed') return; // filled
|
|
924
253
|
}
|
|
925
254
|
|
|
926
255
|
await exchange.cancelOrder(order.id, symbol);
|
|
256
|
+
await sleep(CANCEL_SETTLE_MS); // let the exchange settle before reading
|
|
927
257
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const final = await exchange.fetchOrder(order.id, symbol);
|
|
932
|
-
const filledQty = final.filled ?? 0;
|
|
933
|
-
|
|
934
|
-
if (filledQty > 0) {
|
|
935
|
-
// Close partial fill via market โ positionSide required in hedge mode (-4061 without it)
|
|
936
|
-
const rollbackSide = side === "buy" ? "sell" : "buy";
|
|
937
|
-
const rollbackPositionSide = params.positionSide ?? (restore ? toPositionSide(restore.positionSide) : undefined);
|
|
938
|
-
await exchange.createOrder(symbol, "market", rollbackSide, filledQty, undefined, {
|
|
939
|
-
reduceOnly: true,
|
|
940
|
-
...(rollbackPositionSide ? { positionSide: rollbackPositionSide } : {}),
|
|
941
|
-
});
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// Restore SL/TP on remaining position so it is not left unprotected during retry
|
|
945
|
-
if (restore) {
|
|
946
|
-
const remainingQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, restore.positionSide));
|
|
947
|
-
if (remainingQty > 0) {
|
|
948
|
-
await exchange.createOrder(symbol, "limit", restore.exitSide, remainingQty, restore.tpPrice, { reduceOnly: true });
|
|
949
|
-
await exchange.createOrder(symbol, "stop_market", restore.exitSide, remainingQty, undefined, { stopPrice: restore.slPrice, reduceOnly: true });
|
|
950
|
-
}
|
|
258
|
+
const filledQty = (await exchange.fetchOrder(order.id, symbol)).filled ?? 0;
|
|
259
|
+
if (filledQty > 0) { // roll the partial fill back to clean state
|
|
260
|
+
await exchange.createOrder(symbol, 'market', side === 'buy' ? 'sell' : 'buy', filledQty);
|
|
951
261
|
}
|
|
262
|
+
if (restore) { /* re-place TP + stop-loss on the remaining position so it is never unprotected */ }
|
|
952
263
|
|
|
953
|
-
throw new Error(
|
|
264
|
+
throw new Error('not filled in time โ partial fill rolled back, backtest-kit will retry');
|
|
954
265
|
}
|
|
266
|
+
```
|
|
955
267
|
|
|
956
|
-
Broker.
|
|
957
|
-
class implements IBroker {
|
|
958
|
-
|
|
959
|
-
async waitForInit(): Promise<void> {
|
|
960
|
-
await getFuturesExchange();
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
|
|
964
|
-
const { symbol, cost, priceOpen, priceTakeProfit, priceStopLoss, position } = payload;
|
|
965
|
-
const exchange = await getFuturesExchange();
|
|
966
|
-
|
|
967
|
-
// Set leverage before entry โ ensures consistent leverage regardless of previous session state
|
|
968
|
-
await exchange.setLeverage(FUTURES_LEVERAGE, symbol);
|
|
969
|
-
|
|
970
|
-
const qty = truncateQty(exchange, symbol, cost / priceOpen);
|
|
971
|
-
|
|
972
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
973
|
-
if (qty <= 0) {
|
|
974
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${priceOpen}`);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
const openPrice = parseFloat(exchange.priceToPrecision(symbol, priceOpen));
|
|
978
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
979
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
980
|
-
const entrySide = position === "long" ? "buy" : "sell";
|
|
981
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
982
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
983
|
-
const positionSide = toPositionSide(position);
|
|
984
|
-
|
|
985
|
-
// Entry: no restore needed โ position does not exist yet if entry times out
|
|
986
|
-
await createLimitOrderAndWait(exchange, symbol, entrySide, qty, openPrice, { positionSide });
|
|
987
|
-
|
|
988
|
-
// Post-fill: if TP/SL placement fails, position is open and unprotected โ close via market
|
|
989
|
-
try {
|
|
990
|
-
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
991
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
992
|
-
} catch (err) {
|
|
993
|
-
await exchange.createOrder(symbol, "market", exitSide, qty, undefined, { reduceOnly: true, positionSide });
|
|
994
|
-
throw err;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
async onSignalCloseCommit(payload: BrokerSignalClosePayload): Promise<void> {
|
|
999
|
-
const { symbol, position, currentPrice, priceTakeProfit, priceStopLoss } = payload;
|
|
1000
|
-
const exchange = await getFuturesExchange();
|
|
1001
|
-
|
|
1002
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1003
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1004
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1005
|
-
|
|
1006
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1007
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1008
|
-
|
|
1009
|
-
// Position already closed by SL/TP on exchange โ throw so backtest-kit can reconcile
|
|
1010
|
-
// the close price via its own mechanism rather than assuming a successful manual close
|
|
1011
|
-
if (qty === 0) {
|
|
1012
|
-
throw new Error(`SignalClose skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1016
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1017
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1018
|
-
|
|
1019
|
-
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1020
|
-
// Restore SL/TP if close times out so position is not left unprotected during retry
|
|
1021
|
-
await createLimitOrderAndWait(
|
|
1022
|
-
exchange, symbol, exitSide, qty, closePrice,
|
|
1023
|
-
{ reduceOnly: true },
|
|
1024
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
async onPartialProfitCommit(payload: BrokerPartialProfitPayload): Promise<void> {
|
|
1029
|
-
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1030
|
-
const exchange = await getFuturesExchange();
|
|
1031
|
-
|
|
1032
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1033
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1034
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1035
|
-
|
|
1036
|
-
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1037
|
-
|
|
1038
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1039
|
-
if (totalQty === 0) {
|
|
1040
|
-
throw new Error(`PartialProfit skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1044
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1045
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1046
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1047
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1048
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1049
|
-
const positionSide = toPositionSide(position);
|
|
1050
|
-
|
|
1051
|
-
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1052
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1053
|
-
await createLimitOrderAndWait(
|
|
1054
|
-
exchange, symbol, exitSide, qty, closePrice,
|
|
1055
|
-
{ reduceOnly: true },
|
|
1056
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1057
|
-
);
|
|
1058
|
-
|
|
1059
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
1060
|
-
if (remainingQty > 0) {
|
|
1061
|
-
try {
|
|
1062
|
-
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1063
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1064
|
-
} catch (err) {
|
|
1065
|
-
// Remaining position is unprotected โ close via market
|
|
1066
|
-
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1067
|
-
throw err;
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
async onPartialLossCommit(payload: BrokerPartialLossPayload): Promise<void> {
|
|
1073
|
-
const { symbol, percentToClose, currentPrice, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1074
|
-
const exchange = await getFuturesExchange();
|
|
1075
|
-
|
|
1076
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1077
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1078
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1079
|
-
|
|
1080
|
-
const totalQty = await fetchContractsQty(exchange, symbol, position);
|
|
1081
|
-
|
|
1082
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1083
|
-
if (totalQty === 0) {
|
|
1084
|
-
throw new Error(`PartialLoss skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
const qty = truncateQty(exchange, symbol, totalQty * (percentToClose / 100));
|
|
1088
|
-
const remainingQty = truncateQty(exchange, symbol, totalQty - qty);
|
|
1089
|
-
const closePrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1090
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1091
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1092
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1093
|
-
const positionSide = toPositionSide(position);
|
|
1094
|
-
|
|
1095
|
-
// reduceOnly: prevents accidental reversal if qty has drift vs real position
|
|
1096
|
-
// Restore SL/TP on remaining qty if partial close times out so position is not left unprotected
|
|
1097
|
-
await createLimitOrderAndWait(
|
|
1098
|
-
exchange, symbol, exitSide, qty, closePrice,
|
|
1099
|
-
{ reduceOnly: true },
|
|
1100
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1101
|
-
);
|
|
1102
|
-
|
|
1103
|
-
// Restore SL/TP on remaining qty after successful partial close
|
|
1104
|
-
if (remainingQty > 0) {
|
|
1105
|
-
try {
|
|
1106
|
-
await exchange.createOrder(symbol, "limit", exitSide, remainingQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1107
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, remainingQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1108
|
-
} catch (err) {
|
|
1109
|
-
// Remaining position is unprotected โ close via market
|
|
1110
|
-
await exchange.createOrder(symbol, "market", exitSide, remainingQty, undefined, { reduceOnly: true, positionSide });
|
|
1111
|
-
throw err;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
async onTrailingStopCommit(payload: BrokerTrailingStopPayload): Promise<void> {
|
|
1117
|
-
const { symbol, newStopLossPrice, position } = payload;
|
|
1118
|
-
const exchange = await getFuturesExchange();
|
|
1119
|
-
|
|
1120
|
-
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1121
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1122
|
-
const slOrder = orders.find((o) =>
|
|
1123
|
-
!!o.reduceOnly &&
|
|
1124
|
-
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1125
|
-
) ?? null;
|
|
1126
|
-
if (slOrder) {
|
|
1127
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1128
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1132
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1133
|
-
|
|
1134
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1135
|
-
if (qty === 0) {
|
|
1136
|
-
throw new Error(`TrailingStop skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1140
|
-
const positionSide = toPositionSide(position);
|
|
1141
|
-
|
|
1142
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1143
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
async onTrailingTakeCommit(payload: BrokerTrailingTakePayload): Promise<void> {
|
|
1147
|
-
const { symbol, newTakeProfitPrice, position } = payload;
|
|
1148
|
-
const exchange = await getFuturesExchange();
|
|
1149
|
-
|
|
1150
|
-
// Cancel existing TP order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1151
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1152
|
-
const tpOrder = orders.find((o) =>
|
|
1153
|
-
!!o.reduceOnly &&
|
|
1154
|
-
["limit", "LIMIT"].includes(o.type ?? "")
|
|
1155
|
-
) ?? null;
|
|
1156
|
-
if (tpOrder) {
|
|
1157
|
-
await exchange.cancelOrder(tpOrder.id, symbol);
|
|
1158
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1162
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1163
|
-
|
|
1164
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1165
|
-
if (qty === 0) {
|
|
1166
|
-
throw new Error(`TrailingTake skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, newTakeProfitPrice));
|
|
1170
|
-
const positionSide = toPositionSide(position);
|
|
1171
|
-
|
|
1172
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1173
|
-
await exchange.createOrder(symbol, "limit", exitSide, qty, tpPrice, { reduceOnly: true, positionSide });
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
async onBreakevenCommit(payload: BrokerBreakevenPayload): Promise<void> {
|
|
1177
|
-
const { symbol, newStopLossPrice, position } = payload;
|
|
1178
|
-
const exchange = await getFuturesExchange();
|
|
1179
|
-
|
|
1180
|
-
// Cancel existing SL order only โ filter by reduceOnly to avoid cancelling unrelated orders
|
|
1181
|
-
const orders = await exchange.fetchOpenOrders(symbol);
|
|
1182
|
-
const slOrder = orders.find((o) =>
|
|
1183
|
-
!!o.reduceOnly &&
|
|
1184
|
-
["stop_market", "stop", "STOP_MARKET"].includes(o.type ?? "")
|
|
1185
|
-
) ?? null;
|
|
1186
|
-
if (slOrder) {
|
|
1187
|
-
await exchange.cancelOrder(slOrder.id, symbol);
|
|
1188
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
const qty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1192
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1193
|
-
|
|
1194
|
-
// Position may have already been closed by SL/TP on exchange โ skip gracefully
|
|
1195
|
-
if (qty === 0) {
|
|
1196
|
-
throw new Error(`Breakeven skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, newStopLossPrice));
|
|
1200
|
-
const positionSide = toPositionSide(position);
|
|
1201
|
-
|
|
1202
|
-
// positionSide required in hedge mode (-4061 without it); ignored in one-way mode
|
|
1203
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, qty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
async onAverageBuyCommit(payload: BrokerAverageBuyPayload): Promise<void> {
|
|
1207
|
-
const { symbol, currentPrice, cost, position, priceTakeProfit, priceStopLoss } = payload;
|
|
1208
|
-
const exchange = await getFuturesExchange();
|
|
1209
|
-
|
|
1210
|
-
// Cancel existing SL/TP first โ existing check must happen after cancel+settle
|
|
1211
|
-
// to avoid race condition where SL/TP fills between the existence check and cancel
|
|
1212
|
-
const openOrders = await exchange.fetchOpenOrders(symbol);
|
|
1213
|
-
await cancelAllOrders(exchange, openOrders, symbol);
|
|
1214
|
-
await sleep(CANCEL_SETTLE_MS);
|
|
1215
|
-
|
|
1216
|
-
// Guard against DCA into a ghost position โ checked after cancel so the snapshot is fresh
|
|
1217
|
-
const existing = await fetchContractsQty(exchange, symbol, position);
|
|
1218
|
-
const minNotional = exchange.markets[symbol].limits?.cost?.min ?? 1;
|
|
1219
|
-
|
|
1220
|
-
// Compare notional value rather than raw contracts โ avoids float === 0 trap
|
|
1221
|
-
// and correctly rejects dust positions left over from previous trades
|
|
1222
|
-
if (existing * currentPrice < minNotional) {
|
|
1223
|
-
throw new Error(`AverageBuy skipped: no open position for ${symbol} on exchange โ SL/TP may have already been filled`);
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
const qty = truncateQty(exchange, symbol, cost / currentPrice);
|
|
1227
|
-
|
|
1228
|
-
// Guard: truncation may produce 0 if cost/price is below lot size
|
|
1229
|
-
if (qty <= 0) {
|
|
1230
|
-
throw new Error(`Computed qty is zero for ${symbol} โ cost=${cost}, price=${currentPrice}`);
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
const entryPrice = parseFloat(exchange.priceToPrecision(symbol, currentPrice));
|
|
1234
|
-
const tpPrice = parseFloat(exchange.priceToPrecision(symbol, priceTakeProfit));
|
|
1235
|
-
const slPrice = parseFloat(exchange.priceToPrecision(symbol, priceStopLoss));
|
|
1236
|
-
// positionSide required in hedge mode to add to correct side; ignored in one-way mode
|
|
1237
|
-
const positionSide = toPositionSide(position);
|
|
1238
|
-
const entrySide = position === "long" ? "buy" : "sell";
|
|
1239
|
-
const exitSide = position === "long" ? "sell" : "buy";
|
|
1240
|
-
|
|
1241
|
-
// DCA entry: restore SL/TP on existing qty if times out so position is not left unprotected
|
|
1242
|
-
await createLimitOrderAndWait(
|
|
1243
|
-
exchange, symbol, entrySide, qty, entryPrice,
|
|
1244
|
-
{ positionSide },
|
|
1245
|
-
{ exitSide, tpPrice, slPrice, positionSide: position }
|
|
1246
|
-
);
|
|
1247
|
-
|
|
1248
|
-
// Refetch contracts after fill โ existing snapshot is stale after cancel + fill
|
|
1249
|
-
const totalQty = truncateQty(exchange, symbol, await fetchContractsQty(exchange, symbol, position));
|
|
1250
|
-
|
|
1251
|
-
// Recreate SL/TP on fresh total qty after successful fill
|
|
1252
|
-
try {
|
|
1253
|
-
await exchange.createOrder(symbol, "limit", exitSide, totalQty, tpPrice, { reduceOnly: true, positionSide });
|
|
1254
|
-
await exchange.createOrder(symbol, "stop_market", exitSide, totalQty, undefined, { stopPrice: slPrice, reduceOnly: true, positionSide });
|
|
1255
|
-
} catch (err) {
|
|
1256
|
-
// Total position is unprotected โ close via market
|
|
1257
|
-
await exchange.createOrder(symbol, "market", exitSide, totalQty, undefined, { reduceOnly: true, positionSide });
|
|
1258
|
-
throw err;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
);
|
|
268
|
+
A hook wires it to position open. Signal open/close are routed automatically by an internal event bus the moment `Broker.enable()` is called โ no manual wiring. The other mutations are intercepted explicitly before their state change:
|
|
1263
269
|
|
|
270
|
+
```typescript
|
|
271
|
+
Broker.useBrokerAdapter(class implements IBroker {
|
|
272
|
+
async waitForInit() { await getExchange(); }
|
|
273
|
+
|
|
274
|
+
async onSignalOpenCommit({ symbol, cost, priceOpen, priceTakeProfit, priceStopLoss }) {
|
|
275
|
+
const ex = await getExchange();
|
|
276
|
+
const qty = truncateQty(ex, symbol, cost / priceOpen);
|
|
277
|
+
await createLimitOrderAndWait(ex, symbol, 'buy', qty, priceOpen); // entry
|
|
278
|
+
try { // protect immediately
|
|
279
|
+
await ex.createOrder(symbol, 'limit', 'sell', qty, priceTakeProfit);
|
|
280
|
+
await createStopLossOrder(ex, symbol, qty, priceStopLoss);
|
|
281
|
+
} catch (err) { await ex.createOrder(symbol, 'market', 'sell', qty); throw err; }
|
|
282
|
+
}
|
|
283
|
+
// onSignalCloseCommit ยท onPartialProfitCommit ยท onPartialLossCommit
|
|
284
|
+
// onTrailingStopCommit ยท onTrailingTakeCommit ยท onBreakevenCommit ยท onAverageBuyCommit
|
|
285
|
+
});
|
|
1264
286
|
Broker.enable();
|
|
1265
287
|
```
|
|
1266
288
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
#### Internals
|
|
289
|
+
Complete, production-grade **Spot** (`stop_loss_limit`, balance truncation, dust/notional guards) and **Futures** (`reduceOnly`, hedge-mode `positionSide`, `setLeverage`, ghost-position guards) adapters โ every hook, every edge case โ ship verbatim in the docs. The CLI can also dry-fire any single hook against your live adapter for verification before you wait hours for a real signal:
|
|
1270
290
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
291
|
+
```bash
|
|
292
|
+
npx @backtest-kit/cli --brokerdebug --commit signal-open --symbol BTCUSDT
|
|
293
|
+
```
|
|
1274
294
|
|
|
1275
|
-
|
|
295
|
+
</details>
|
|
1276
296
|
|
|
1277
|
-
|
|
1278
|
-
- **`Cron.register({ name, interval?, symbols?, handler })`** โ register a job. Returns a disposer. Re-registering the same `name` replaces the previous entry and bumps an internal generation counter (late writes from old handlers are ignored).
|
|
1279
|
-
- **`Cron.enable()`** โ subscribe `Cron` to the engine's lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`). Wrapped in `singleshot`; call once at startup.
|
|
1280
|
-
- **`Cron.disable()`** โ tear down the subscriptions installed by `enable()`. Safe to call multiple times and before `enable()`.
|
|
1281
|
-
- **`Cron.unregister(name)`** โ remove a registered job.
|
|
1282
|
-
- **`Cron.clear(symbol?)`** โ clear fire-once marks. `symbol` provided โ fan-out marks for that symbol only; no argument โ all marks. Does **not** touch in-flight handlers.
|
|
297
|
+
### 6. Averaging up is how a dip becomes a margin call
|
|
1283
298
|
|
|
1284
|
-
|
|
1285
|
-
- **Periodic** (`interval: "1h"`) โ handler fires once per boundary of that interval.
|
|
1286
|
-
- **Fire-once** (`interval` omitted) โ handler fires on the first matching tick and never again until `clear()` / `unregister` / re-`register`.
|
|
299
|
+
Dollar-cost averaging is where hand-rolled position math quietly bankrupts people. Average into a *rising* price by accident and you've raised your cost basis on a losing-direction trade โ the opposite of the intent. And once you add partial closes on top, the cost-basis bookkeeping becomes a second strategy you have to get right.
|
|
1287
300
|
|
|
1288
|
-
|
|
1289
|
-
- **Global** (`symbols` omitted) โ handler fires once per boundary across all parallel backtests. First symbol to reach the boundary opens the slot; others await the same promise.
|
|
1290
|
-
- **Fan-out** (`symbols: ["BTC", "ETH"]`) โ handler fires once per boundary **per whitelisted symbol**. Each symbol has its own slot.
|
|
301
|
+
`commitAverageBuy` is, by default, *only* accepted when price is below the running effective entry โ averaging up is silently rejected, structurally. The effective price is a cost-weighted harmonic mean (correct for fixed-dollar entries, where $100 buys different quantities at different prices), and every partial close snapshots its cost basis so PnL replays exactly without re-walking history. No math required from you โ the guardrail is in the engine.
|
|
1291
302
|
|
|
1292
303
|
<details>
|
|
1293
|
-
|
|
1294
|
-
The code
|
|
1295
|
-
</summary>
|
|
1296
|
-
|
|
1297
|
-
```typescript
|
|
1298
|
-
import { Cron, Backtest } from "backtest-kit";
|
|
1299
|
-
|
|
1300
|
-
// Global hourly job โ fires once per virtual hour across all parallel backtests.
|
|
1301
|
-
Cron.register({
|
|
1302
|
-
name: "tg-signal-parser",
|
|
1303
|
-
interval: "1h",
|
|
1304
|
-
handler: async ({ symbol, when, backtest }) => {
|
|
1305
|
-
await parseTelegramSignalsToMongo(when);
|
|
1306
|
-
},
|
|
1307
|
-
});
|
|
304
|
+
<summary>The Math</summary>
|
|
1308
305
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
interval: "1h",
|
|
1313
|
-
symbols: ["BTCUSDT", "ETHUSDT"],
|
|
1314
|
-
handler: async ({ symbol, when, backtest }) => {
|
|
1315
|
-
await fetchFundingRate(symbol, when);
|
|
1316
|
-
},
|
|
1317
|
-
});
|
|
306
|
+
```
|
|
307
|
+
effectivePrice = ฮฃcost / ฮฃ(cost / price) // cost-weighted harmonic mean
|
|
308
|
+
```
|
|
1318
309
|
|
|
1319
|
-
|
|
1320
|
-
Cron.register({
|
|
1321
|
-
name: "warm-cache",
|
|
1322
|
-
handler: async ({ symbol, when, backtest }) => {
|
|
1323
|
-
await warmupCache();
|
|
1324
|
-
},
|
|
1325
|
-
});
|
|
310
|
+
Each partial stores `costBasisAtClose` (the running dollar basis *before* it fired); a partial sell does not change the effective price of the coins still held. Final PnL is a dollar-weighted sum across every partial (each at its own effective price) plus the remainder, with slippage and per-leg fees:
|
|
1326
311
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
312
|
+
```
|
|
313
|
+
weight[i] = (percent[i]/100 ร costBasisAtClose[i]) / totalInvested
|
|
314
|
+
totalWeightedPnl = ฮฃ weight[i]ยทpnl[i] + remainingWeightยทpnlRemaining
|
|
315
|
+
pnlPercentage = totalWeightedPnl โ fees // open fee once + per-partial + final close
|
|
316
|
+
pnlCost = pnlPercentage / 100 ร totalInvested
|
|
317
|
+
```
|
|
1330
318
|
|
|
1331
|
-
|
|
1332
|
-
Backtest.background(symbol, { strategyName, exchangeName, frameName });
|
|
1333
|
-
}
|
|
319
|
+
Worked example โ LONG @1000, 4 accepted DCA + 1 rejected, 3 partials, close @1200 โ reconciles two independent ways to **+17.9%**:
|
|
1334
320
|
|
|
1335
|
-
|
|
1336
|
-
|
|
321
|
+
```
|
|
322
|
+
0.075ยท(+15.00) + 0.135ยท(โ7.98) + 0.316ยท(+12.91) + 0.474ยท(+29.04) โ +17.89%
|
|
323
|
+
coin cross-check: (34.50 + 49.69 + 142.72 + 244.67 โ 400) / 400 โ +17.90% โ
|
|
324
|
+
entry #5 @980 REJECTED โ 980 > effective entry โ929.92 (the guard firing)
|
|
1337
325
|
```
|
|
1338
326
|
|
|
1339
327
|
</details>
|
|
1340
328
|
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
`Cron.enable()` subscribes a single `singlerun`-wrapped handler to four lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`). `singlerun` merges all four streams into one serial queue, so concurrent ticks on the same `(symbol, virtual-minute)` cannot race to open the same slot. Each incoming tick is **base-aligned to the 1-minute boundary** before any further processing โ lifecycle pings may carry sub-second jitter, but Cron always reasons in whole minutes.
|
|
329
|
+
<details>
|
|
330
|
+
<summary>The Code</summary>
|
|
1344
331
|
|
|
1345
|
-
|
|
332
|
+
A complete DCA-ladder strategy โ open once, average on overlap-free dips up to 10 rungs, close at target โ is about thirty lines, and the dangerous math is all inside the engine:
|
|
1346
333
|
|
|
1347
|
-
|
|
334
|
+
```typescript
|
|
335
|
+
import { addStrategySchema, listenActivePing, Position,
|
|
336
|
+
commitAverageBuy, commitClosePending,
|
|
337
|
+
getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent } from 'backtest-kit';
|
|
1348
338
|
|
|
1349
|
-
|
|
1350
|
-
|
|
339
|
+
addStrategySchema({
|
|
340
|
+
strategyName: 'apr_2026_strategy',
|
|
341
|
+
getSignal: async (symbol, when, currentPrice) => ({
|
|
342
|
+
position: 'long',
|
|
343
|
+
...Position.moonbag({ position: 'long', currentPrice, percentStopLoss: 25 }),
|
|
344
|
+
minuteEstimatedTime: Infinity, cost: 100,
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
1351
347
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
For a candle with:
|
|
1358
|
-
- `timestamp` = candle open time (openTime)
|
|
1359
|
-
- `stepMs` = interval duration (e.g., 60000ms for "1m")
|
|
1360
|
-
- Candle close time = `timestamp + stepMs`
|
|
1361
|
-
|
|
1362
|
-
**Alignment:** All timestamps are aligned down to interval boundary.
|
|
1363
|
-
For example, for 15m interval: 00:17 โ 00:15, 00:44 โ 00:30
|
|
1364
|
-
|
|
1365
|
-
**Adapter contract:**
|
|
1366
|
-
- First candle.timestamp must equal aligned `since`
|
|
1367
|
-
- Adapter must return exactly `limit` candles
|
|
1368
|
-
- Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
|
|
1369
|
-
|
|
1370
|
-
**How `since` is calculated from `when`:**
|
|
1371
|
-
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1372
|
-
- `alignedWhen` = `Math.floor(when / stepMs) * stepMs` (aligned down to interval boundary)
|
|
1373
|
-
- `since` = `alignedWhen - limit * stepMs` (go back `limit` candles from aligned when)
|
|
1374
|
-
|
|
1375
|
-
**Boundary semantics (inclusive/exclusive):**
|
|
1376
|
-
- `since` is always **inclusive** โ first candle has `timestamp === since`
|
|
1377
|
-
- Exactly `limit` candles are returned
|
|
1378
|
-
- Last candle has `timestamp === since + (limit - 1) * stepMs` โ **inclusive**
|
|
1379
|
-
- For `getCandles`: `alignedWhen` is **exclusive** โ candle at that timestamp is NOT included (it's a pending/incomplete candle)
|
|
1380
|
-
- For `getRawCandles`: `eDate` is **exclusive** โ candle at that timestamp is NOT included (it's a pending/incomplete candle)
|
|
1381
|
-
- For `getNextCandles`: `alignedWhen` is **inclusive** โ first candle starts at `alignedWhen` (it's the current candle for backtest, already closed in historical data)
|
|
1382
|
-
|
|
1383
|
-
- `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
|
|
1384
|
-
- Aligns `when` down to interval boundary
|
|
1385
|
-
- Calculates `since = alignedWhen - limit * stepMs`
|
|
1386
|
-
- **since โ inclusive**, first candle.timestamp === since
|
|
1387
|
-
- **alignedWhen โ exclusive**, candle at alignedWhen is NOT returned
|
|
1388
|
-
- Range: `[since, alignedWhen)` โ half-open interval
|
|
1389
|
-
- Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before aligned when
|
|
1390
|
-
|
|
1391
|
-
- `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
|
|
1392
|
-
- Aligns `when` down to interval boundary
|
|
1393
|
-
- `since = alignedWhen` (starts from aligned when, going forward)
|
|
1394
|
-
- **since โ inclusive**, first candle.timestamp === since
|
|
1395
|
-
- Range: `[alignedWhen, alignedWhen + limit * stepMs)` โ half-open interval
|
|
1396
|
-
- Throws error in live mode to prevent look-ahead bias
|
|
1397
|
-
- Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles starting from aligned when
|
|
1398
|
-
|
|
1399
|
-
- `getRawCandles(symbol, interval, limit?, sDate?, eDate?)` - Flexible parameter combinations:
|
|
1400
|
-
- `(limit)` - since = alignedWhen - limit * stepMs, range `[since, alignedWhen)`
|
|
1401
|
-
- `(limit, sDate)` - since = align(sDate), returns `limit` candles forward, range `[since, since + limit * stepMs)`
|
|
1402
|
-
- `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs, **eDate โ exclusive**, range `[since, eDate)`
|
|
1403
|
-
- `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range, **sDate โ inclusive, eDate โ exclusive**, range `[sDate, eDate)`
|
|
1404
|
-
- `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles, **sDate โ inclusive**
|
|
1405
|
-
- All combinations respect look-ahead bias protection (eDate/endTime <= when)
|
|
1406
|
-
|
|
1407
|
-
**Persistent Cache:**
|
|
1408
|
-
- Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
|
|
1409
|
-
- Returns all candles if found, null if any missing (cache miss)
|
|
1410
|
-
- Cache and runtime use identical timestamp calculation logic
|
|
348
|
+
listenActivePing(async ({ symbol, currentPrice }) => { // the ladder
|
|
349
|
+
if ((await getPositionEntries(symbol)).length >= 10) return;
|
|
350
|
+
if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return;
|
|
351
|
+
await commitAverageBuy(symbol, 100); // rejected if it averages up
|
|
352
|
+
});
|
|
1411
353
|
|
|
1412
|
-
|
|
354
|
+
listenActivePing(async ({ symbol }) => { // exit on blended target
|
|
355
|
+
if (await getPositionPnlPercent(symbol) < 3) return;
|
|
356
|
+
await commitClosePending(symbol, { id: 'unknown', note: '# closed by target pnl' });
|
|
357
|
+
});
|
|
358
|
+
```
|
|
1413
359
|
|
|
1414
|
-
|
|
360
|
+
Every order primitive is here, each with per-entry PnL, peak-profit and max-drawdown tracking: market/limit entries, TP/SL/OCO exits, grid with auto-cancel, partial profit/loss levels, trailing take/stop (absorbed only when they tighten in your favour, computed from the *original* distance to avoid drift), breakeven (moves the stop to entry once profit clears fees+slippage), stop-limit entries, DCA, and time-attack / infinite-hold.
|
|
1415
361
|
|
|
1416
|
-
|
|
362
|
+
</details>
|
|
1417
363
|
|
|
1418
|
-
|
|
1419
|
-
- All timestamps are aligned down to interval boundary
|
|
1420
|
-
- First candle.timestamp must equal aligned `since`
|
|
1421
|
-
- Adapter must return exactly `limit` candles
|
|
1422
|
-
- Sequential timestamps: `since + i * stepMs`
|
|
364
|
+
### 7. Ten strategies, one account, 100% exposure
|
|
1423
365
|
|
|
366
|
+
Per-strategy risk checks miss the obvious portfolio truth: ten strategies each "risking 10%" is one account risking everything. Risk validation here runs across *all* strategies and symbols at once, with an atomic check-and-reserve that closes the race between "is this allowed?" and "the order went out."
|
|
1424
367
|
|
|
1425
|
-
|
|
368
|
+
<details>
|
|
369
|
+
<summary>The Code</summary>
|
|
1426
370
|
|
|
1427
|
-
|
|
371
|
+
```typescript
|
|
372
|
+
addRiskSchema({
|
|
373
|
+
riskName: 'demo',
|
|
374
|
+
validations: [
|
|
375
|
+
({ pendingSignal, currentPrice }) => { // TP โฅ 1%
|
|
376
|
+
const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
|
|
377
|
+
const tp = position === 'long'
|
|
378
|
+
? ((priceTakeProfit - priceOpen) / priceOpen) * 100
|
|
379
|
+
: ((priceOpen - priceTakeProfit) / priceOpen) * 100;
|
|
380
|
+
if (tp < 1) throw new Error(`TP too close: ${tp.toFixed(2)}%`);
|
|
381
|
+
},
|
|
382
|
+
({ pendingSignal, currentPrice }) => { // R/R โฅ 2:1
|
|
383
|
+
const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
|
|
384
|
+
const reward = position === 'long' ? priceTakeProfit - priceOpen : priceOpen - priceTakeProfit;
|
|
385
|
+
const risk = position === 'long' ? priceOpen - priceStopLoss : priceStopLoss - priceOpen;
|
|
386
|
+
if (reward / risk < 2) throw new Error('Poor R/R ratio');
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
});
|
|
1428
390
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
The Math
|
|
1432
|
-
</summary>
|
|
391
|
+
listenRisk(async (event) => { await Risk.dump(event.symbol, event.strategyName); }); // every rejection, logged
|
|
392
|
+
```
|
|
1433
393
|
|
|
1434
|
-
|
|
1435
|
-
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1436
|
-
- `offsetMinutes` = `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` (configurable)
|
|
1437
|
-
- `alignedTo` = `Math.floor(when / (offsetMinutes * 60000)) * (offsetMinutes * 60000)`
|
|
1438
|
-
- `to` = `alignedTo` (aligned down to offset boundary)
|
|
1439
|
-
- `from` = `alignedTo - offsetMinutes * 60000`
|
|
394
|
+
`ClientRisk` tracks every open position across the portfolio; multiple strategies can share one profile for holistic exposure. `checkSignalAndReserve` is the thread-safe variant โ after a successful reserve you **must** `addSignal` (finalize) or `removeSignal` (cancel) so reservations never go stale. A real LLM-gated portfolio improved from **+52.22% โ +68.90%** PNL, Sharpe **+0.309 โ +0.512**, win-rate **68% โ 82%** simply by letting a local model veto 6 signals โ 4 of them losers.
|
|
1440
395
|
|
|
1441
|
-
|
|
1442
|
-
- `getOrderBook(symbol, depth, from, to, backtest)` is called on the exchange schema
|
|
1443
|
-
- `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
|
|
1444
|
-
- The `from`/`to` range represents a time window of exactly `offsetMinutes` duration
|
|
1445
|
-
- Schema implementation may use the time range (backtest) or ignore it (live trading)
|
|
396
|
+
</details>
|
|
1446
397
|
|
|
1447
|
-
|
|
1448
|
-
```
|
|
1449
|
-
when = 1704067920000 // 2024-01-01 00:12:00 UTC
|
|
1450
|
-
offsetMinutes = 10
|
|
1451
|
-
offsetMs = 10 * 60000 // 600000ms
|
|
398
|
+
### 8. One process can trade the whole market
|
|
1452
399
|
|
|
1453
|
-
|
|
1454
|
-
= 1704067800000 // 2024-01-01 00:10:00 UTC
|
|
400
|
+
Spawning a process per symbol burns CPU on IPC and turns shared state โ global risk, candle cache โ into a distributed-systems problem you didn't sign up for. Dozens of symbols run concurrently here inside a **single Node process**, sharing one event loop, one Mongo pool, one Redis cache, with strict per-symbol state isolation.
|
|
1455
401
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
```
|
|
1459
|
-
</details>
|
|
402
|
+
<details>
|
|
403
|
+
<summary>The Proof</summary>
|
|
1460
404
|
|
|
1461
|
-
|
|
405
|
+
Measured on a commodity laptop (HP Victus, i5-13420H, 16 GB DDR4, NVMe SSD), 9 symbols in parallel, one Node process:
|
|
1462
406
|
|
|
1463
|
-
|
|
407
|
+
| Metric | Value |
|
|
408
|
+
|---|---|
|
|
409
|
+
| Wall-clock span (first โ last event) | **2,893 ms** |
|
|
410
|
+
| Events captured | **297** |
|
|
411
|
+
| Historical time advanced / symbol | **34 minutes** |
|
|
412
|
+
| Per-symbol replay speed | **โ703ร** real-time |
|
|
413
|
+
| Aggregate (9 symbols) | **โ6,326ร** real-time |
|
|
414
|
+
| Hot-loop throughput | **โ103 events/sec** |
|
|
1464
415
|
|
|
1465
|
-
|
|
1466
|
-
- Time range is aligned down to `CC_ORDER_BOOK_TIME_OFFSET_MINUTES` boundary
|
|
1467
|
-
- `to` = aligned timestamp, `from` = `to - offsetMinutes * 60000`
|
|
1468
|
-
- `depth` defaults to `CC_ORDER_BOOK_MAX_DEPTH_LEVELS`
|
|
1469
|
-
- Adapter receives `(symbol, depth, from, to, backtest)` โ may ignore `from`/`to` in live mode
|
|
416
|
+
Why it's fast: single-process concurrency (no IPC, no fork), an in-memory activity registry (`Lookup`) tracking every in-flight workload, a cooperative event-loop hand-off (`Candle.spinLock`) so parallel symbols advance round-robin instead of one hogging the CPU, Redis O(1) candle lookups, atomic `findOneAndUpdate` upserts (no read-modify-write), and `--cache` pre-warming so the inner loop never blocks on HTTP.
|
|
1470
417
|
|
|
1471
|
-
|
|
418
|
+
In live mode the bottleneck moves from CPU to the exchange โ and that is where the shared cache earns its keep. Every symbol pulls candles, order books, and trades through one **deduplicated** layer, so nine strategies asking for the same `BTCUSDT 1m` candle issue *one* request, not nine. Hand-written per-bot code with no cache hammers the REST endpoint until the exchange rate-limits it; here the dedup + Redis O(1) layer keeps request volume flat as you add symbols, so rate limits stay off your back instead of throttling the desk. The ร700 / ร6,300 figures are CPU-bound backtest replay; live throughput is paced by the exchange, but the request layer is built so that pacing is the exchange's published limit, not self-inflicted spam.
|
|
1472
419
|
|
|
1473
|
-
|
|
420
|
+
```typescript
|
|
421
|
+
import { Backtest, warmCandles } from 'backtest-kit';
|
|
1474
422
|
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
423
|
+
for (const symbol of ['BTCUSDT','ETHUSDT','SOLUSDT','BNBUSDT','XRPUSDT']) {
|
|
424
|
+
await warmCandles({ exchangeName: 'binance', interval: '1m', symbol,
|
|
425
|
+
from: new Date('2026-02-01T00:00:00Z'), to: new Date('2026-02-28T23:59:59Z') });
|
|
426
|
+
Backtest.background(symbol, { strategyName, exchangeName: 'binance', frameName: 'feb-2026' });
|
|
427
|
+
}
|
|
428
|
+
```
|
|
1480
429
|
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
</summary>
|
|
430
|
+
```bash
|
|
431
|
+
npx @backtest-kit/cli --backtest --entry ./content/multi-symbol.ts # CLI defers symbol selection to your file
|
|
432
|
+
```
|
|
1485
433
|
|
|
1486
|
-
|
|
1487
|
-
- `when` = current execution context time (from AsyncLocalStorage)
|
|
1488
|
-
- `alignedTo` = `Math.floor(when / 60000) * 60000` (aligned down to 1-minute boundary)
|
|
1489
|
-
- `windowMs` = `CC_AGGREGATED_TRADES_MAX_MINUTES * 60000 โ 60000`
|
|
1490
|
-
- `to` = `alignedTo`, `from` = `alignedTo โ windowMs`
|
|
434
|
+
</details>
|
|
1491
435
|
|
|
1492
|
-
|
|
436
|
+
### 9. When `./dump/` stops being enough
|
|
1493
437
|
|
|
1494
|
-
|
|
438
|
+
File storage is perfect on day one and a bottleneck the day you're doing thousands of context-keyed reads per second. Swap to MongoDB (durable, queryable, atomic) with a Redis O(1) cache via a single `setup()` โ all 15 persistence contracts reimplemented, and **not one line of strategy code changes.**
|
|
1495
439
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
when = 1704067920000 // 2024-01-01 00:12:00 UTC
|
|
1499
|
-
alignedTo = 1704067800000 // 2024-01-01 00:12:00 โ aligned to 00:12:00
|
|
1500
|
-
windowMs = 59 * 60000 // 3540000ms = 59 minutes
|
|
440
|
+
<details>
|
|
441
|
+
<summary>The Code</summary>
|
|
1501
442
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
443
|
+
```typescript
|
|
444
|
+
// config/setup.config.ts โ loaded once before any persistence call
|
|
445
|
+
import { setup } from '@backtest-kit/mongo';
|
|
446
|
+
setup(); // reads CC_MONGO_CONNECTION_STRING / CC_REDIS_* from env, or pass explicitly
|
|
447
|
+
```
|
|
1505
448
|
|
|
1506
|
-
|
|
1507
|
-
to = 23:13:00
|
|
1508
|
-
โ got 100 more โ total 220 trades
|
|
449
|
+
Fifteen adapters, each with a unique compound index (`Signal โ symbol+strategyName+exchangeName`, `Candle โ symbol+interval+timestamp`, `Memory โ signalId+bucketName+memoryId`, โฆ). Candle records are immutable (`$setOnInsert`, first write wins); Measure/Interval/Memory use soft delete (`removed` flag) for an audit trail. Reads go Redis-first for the Mongo `_id`, then `findById` โ two O(1) ops; a miss falls back to an indexed `findOne` and backfills. Writes are one `findOneAndUpdate({ upsert:true, new:true })` round-trip, so the unique index rejects concurrent duplicates at the storage engine and a write-then-read always sees fresh data. Signal-affecting adapters store the simulation `when`, so look-ahead protection is enforceable even inside the database.
|
|
1509
450
|
|
|
1510
|
-
|
|
1511
|
-
|
|
451
|
+
```
|
|
452
|
+
read signal (BTCUSDT, my_strategy, binance)
|
|
453
|
+
โโ Redis GET โ hit โ Mongo findById(_id) โ O(1) + O(1)
|
|
454
|
+
โโ Redis GET โ miss โ Mongo findOne(filter) โ Redis SET โ return
|
|
455
|
+
```
|
|
1512
456
|
|
|
1513
|
-
|
|
1514
|
-
- `getAggregatedTrades(symbol, from, to, backtest)` is called on the exchange schema
|
|
1515
|
-
- `from`/`to` are `Date` objects
|
|
1516
|
-
- Schema implementation may use the time range (backtest) or ignore it (live trading)
|
|
457
|
+
The default file adapter is already crash-safe (atomic temp+rename, repair on restart) โ you get durability before you ever add a database.
|
|
1517
458
|
|
|
1518
459
|
</details>
|
|
1519
460
|
|
|
1520
|
-
|
|
461
|
+
### 10. A Sharpe of 10,000,000 is a bug, not an edge
|
|
1521
462
|
|
|
1522
|
-
|
|
463
|
+
Metrics that a tiny sample can't support are worse than no metrics โ they're false confidence you bet money on. The analytics engine was rebuilt against canonical definitions and an independent 84-file reference testbed, and it prints **`N/A`** rather than a number it can't stand behind.
|
|
1523
464
|
|
|
1524
|
-
|
|
465
|
+
<details>
|
|
466
|
+
<summary>The Math</summary>
|
|
1525
467
|
|
|
1526
|
-
**
|
|
468
|
+
- **Pooled Sharpe** (v10.2.0+): per-trade returns are pooled across all symbols into one sample, then Sharpe is computed on that distribution โ replacing the trade-count-weighted *average of ratios*, which inflates when one symbol is great and another negative. The header reads `Pooled Sharpe`, not `Portfolio Sharpe`, with a Markowitz disclaimer so it's never mistaken for covariance-based optimization.
|
|
469
|
+
- **Bessel's correction (Nโ1)** for unbiased variance โ no risk underestimation on small samples.
|
|
470
|
+
- **Compounded equity curve** for Max Drawdown / Calmar / Recovery Factor โ no double-counting of percentage returns.
|
|
471
|
+
- **Geometric annualization** for expected yearly returns โ accounts for volatility drag (a 50% loss needs a 100% gain to recover).
|
|
472
|
+
- **Canonical Sortino (1991)** with downside deviation over `N_total`.
|
|
473
|
+
- **Float-artifact guard:** identical-return series produce stddev โ1e-17; an `STDDEV_EPSILON` guard returns `N/A` instead of a fake Sharpe of 10,000,000. Gates of โฅ10 signals and โฅ14 calendar days gate publication.
|
|
1527
474
|
|
|
1528
|
-
|
|
475
|
+
Dashboard revenue is dollar-true: `pnlCost = pnlPercentage/100 ร pnlEntries`, summed across closed signals per window (Today / Yesterday / 7d / 31d), anchored to the run end in backtest and `Date.now()` live.
|
|
1529
476
|
|
|
1530
|
-
|
|
1531
|
-
// 15-minute interval example:
|
|
1532
|
-
when = 1704067920000 // 00:12:00
|
|
1533
|
-
step = 15 // 15 minutes
|
|
1534
|
-
stepMs = 15 * 60000 // 900000ms
|
|
1535
|
-
|
|
1536
|
-
// Alignment: round down to nearest interval boundary
|
|
1537
|
-
alignedWhen = Math.floor(when / stepMs) * stepMs
|
|
1538
|
-
// = Math.floor(1704067920000 / 900000) * 900000
|
|
1539
|
-
// = 1704067200000 (00:00:00)
|
|
1540
|
-
|
|
1541
|
-
// Calculate since for 4 candles backwards:
|
|
1542
|
-
since = alignedWhen - 4 * stepMs
|
|
1543
|
-
// = 1704067200000 - 4 * 900000
|
|
1544
|
-
// = 1704063600000 (23:00:00 previous day)
|
|
1545
|
-
|
|
1546
|
-
// Expected candles:
|
|
1547
|
-
// [0] timestamp = 1704063600000 (23:00)
|
|
1548
|
-
// [1] timestamp = 1704064500000 (23:15)
|
|
1549
|
-
// [2] timestamp = 1704065400000 (23:30)
|
|
1550
|
-
// [3] timestamp = 1704066300000 (23:45)
|
|
1551
|
-
```
|
|
1552
|
-
|
|
1553
|
-
**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.
|
|
477
|
+
</details>
|
|
1554
478
|
|
|
1555
|
-
|
|
1556
|
-
- โ
`getCandles()` - validates first timestamp and count
|
|
1557
|
-
- โ
`getNextCandles()` - validates first timestamp and count
|
|
1558
|
-
- โ
`getRawCandles()` - validates first timestamp and count
|
|
1559
|
-
- โ
Cache read - calculates exact expected timestamps
|
|
1560
|
-
- โ
Cache write - stores validated candles
|
|
479
|
+
### 11. The jobs that fire on virtual time
|
|
1561
480
|
|
|
1562
|
-
|
|
481
|
+
Most schedulers run on wall-clock โ useless in a backtest that replays a month in three seconds. `Cron` runs on the *same* time stream your strategies see, firing on candle boundaries, coordinated across parallel backtests so one boundary never double-fires. The identical API drives live re-polling and one-shot backtest prep.
|
|
1563
482
|
|
|
1564
|
-
|
|
483
|
+
<details>
|
|
484
|
+
<summary>The Code</summary>
|
|
1565
485
|
|
|
1566
|
-
|
|
486
|
+
```typescript
|
|
487
|
+
import { Cron, Backtest } from 'backtest-kit';
|
|
1567
488
|
|
|
1568
|
-
|
|
489
|
+
Cron.register({ name: 'tg-parser', interval: '1h', // global, hourly
|
|
490
|
+
handler: async ({ when }) => { await parseTelegramSignals(when); } });
|
|
1569
491
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
since: Sat Sep 20 2025 17:00:00 GMT+0500 โ looks uneven (17:00)
|
|
1573
|
-
since: Sat Sep 20 2025 21:00:00 GMT+0500 โ looks uneven (21:00)
|
|
1574
|
-
since: Sun Sep 21 2025 05:00:00 GMT+0500 โ looks uneven (05:00)
|
|
1575
|
-
```
|
|
492
|
+
Cron.register({ name: 'funding', interval: '1h', symbols: ['BTCUSDT','ETHUSDT'], // per-symbol fan-out
|
|
493
|
+
handler: async ({ symbol, when }) => { await fetchFundingRate(symbol, when); } });
|
|
1576
494
|
|
|
1577
|
-
|
|
495
|
+
Cron.register({ name: 'warm-cache', // fire-once, global
|
|
496
|
+
handler: async () => { await warmupCache(); } });
|
|
1578
497
|
|
|
1579
|
-
|
|
1580
|
-
since: Sat, 20 Sep 2025 08:00:00 GMT โ 08:00 UTC โ
|
|
1581
|
-
since: Sat, 20 Sep 2025 12:00:00 GMT โ 12:00 UTC โ
|
|
1582
|
-
since: Sat, 20 Sep 2025 16:00:00 GMT โ 16:00 UTC โ
|
|
1583
|
-
since: Sun, 21 Sep 2025 00:00:00 GMT โ 00:00 UTC โ
|
|
498
|
+
Cron.enable(); // wire to engine lifecycle once; every tick is forwarded automatically
|
|
1584
499
|
```
|
|
1585
500
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
### ๐ญ What this means:
|
|
1589
|
-
- `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`
|
|
1590
|
-
- Multi-timeframe data is automatically synchronized
|
|
1591
|
-
- **Impossible to introduce look-ahead bias** - all time boundaries are enforced
|
|
1592
|
-
- Same code works in both backtest and live modes
|
|
1593
|
-
- Boundary semantics prevent edge cases in signal generation
|
|
501
|
+
`enable()` merges four lifecycle subjects (`beforeStart`, `idlePing`, `activePing`, `schedulePing`) into one serial queue via `singlerun`; each tick is base-aligned to the minute. Coordination keys `${name}:${alignedMs}:${symbol?}:g${generation}` give mutex semantics โ parallel backtests on the same boundary share one in-flight promise (first opens the slot, others await). Fire-once marks record only on success, so a failed handler retries; the generation suffix isolates re-registrations from late writes.
|
|
1594
502
|
|
|
503
|
+
</details>
|
|
1595
504
|
|
|
1596
|
-
|
|
505
|
+
### 12. You shouldn't have to abandon TradingView or Python to use TypeScript
|
|
1597
506
|
|
|
1598
|
-
|
|
507
|
+
The honest objection to a TS trading engine is "but my indicators live in Pine Script and TA-Lib." So they don't have to move. Run native Pine Script, run Python via WASM, use 50+ built-in indicators, or drop in zero-dependency quant ports โ all under the same temporal guarantees.
|
|
1599
508
|
|
|
1600
|
-
|
|
509
|
+
<details>
|
|
510
|
+
<summary>The Code</summary>
|
|
1601
511
|
|
|
1602
|
-
|
|
512
|
+
**Pine Script** โ v5/v6, 60+ indicators, 1:1 syntax, look-ahead-safe ([`@backtest-kit/pinets`](https://www.npmjs.com/package/@backtest-kit/pinets)):
|
|
1603
513
|
|
|
1604
514
|
```typescript
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
listenDoneBacktest(event => { /* finalize / dump report */ });
|
|
515
|
+
import { File, getSignal } from '@backtest-kit/pinets';
|
|
516
|
+
const signal = await getSignal(File.fromPath('strategy.pine'),
|
|
517
|
+
{ symbol: 'BTCUSDT', timeframe: '5m', limit: 100 }); // plots: Signal/Close/StopLoss/TakeProfit/EstimatedTime
|
|
1609
518
|
```
|
|
1610
519
|
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
Suitable for research, scripting, testing, and LLM agents.
|
|
520
|
+
**50+ indicators across 1m/15m/30m/1h + order book, as LLM-ready Markdown, in one call** ([`@backtest-kit/signals`](https://www.npmjs.com/package/@backtest-kit/signals)):
|
|
1614
521
|
|
|
1615
522
|
```typescript
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
}
|
|
523
|
+
import { commitHistorySetup } from '@backtest-kit/signals';
|
|
524
|
+
await commitHistorySetup('BTCUSDT', messages); // order book + candles + indicators, cached per TTL
|
|
1619
525
|
```
|
|
1620
526
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
**Open-source QuantConnect/MetaTrader without the vendor lock-in**
|
|
527
|
+
**Typed DAG** of computations, resolved in topological order with `Promise.all` parallelism, serializable to a DB ([`@backtest-kit/graph`](https://www.npmjs.com/package/@backtest-kit/graph)):
|
|
1624
528
|
|
|
1625
|
-
|
|
529
|
+
```typescript
|
|
530
|
+
import { sourceNode, outputNode, resolve } from '@backtest-kit/graph';
|
|
531
|
+
const higher = sourceNode(async (symbol) => extract(await run(File.fromPath('timeframe_4h.pine'), { symbol, timeframe: '4h', limit: 100 }), { allowLong: 'AllowLong', allowShort: 'AllowShort', noTrades: 'NoTrades' }));
|
|
532
|
+
const lower = sourceNode(async (symbol) => extract(await run(File.fromPath('timeframe_15m.pine'), { symbol, timeframe: '15m', limit: 100 }), { position: 'Signal', priceOpen: 'Close', priceTakeProfit: 'TakeProfit', priceStopLoss: 'StopLoss' }));
|
|
533
|
+
const mtf = outputNode(([h, l]) => { // combine; null when timeframes disagree
|
|
534
|
+
if (h.noTrades || l.position === 0) return null;
|
|
535
|
+
if (h.allowShort && l.position === 1) return null;
|
|
536
|
+
if (h.allowLong && l.position === -1) return null;
|
|
537
|
+
return toSignalDto(randomString(), l, null);
|
|
538
|
+
}, higher, lower);
|
|
539
|
+
addStrategySchema({ strategyName: 'mtf', interval: '5m', getSignal: () => resolve(mtf) });
|
|
540
|
+
```
|
|
1626
541
|
|
|
1627
|
-
-
|
|
1628
|
-
- Self-hosted - your code, your data, your infrastructure
|
|
1629
|
-
- No platform fees or hidden costs
|
|
1630
|
-
- Full control over execution and data sources
|
|
1631
|
-
- [GUI](https://npmjs.com/package/@backtest-kit/ui) for visualization and monitoring
|
|
542
|
+
**Python via WASM (WASI)** runs `ta-lib`/`pandas`/`scikit-learn` indicators in the Node event loop with no IPC. And zero-dependency TS ports of the math behind vectorbt โ see [See also](#-see-also).
|
|
1632
543
|
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
The `backtest-kit` ecosystem extends beyond the core library, offering complementary packages and tools to enhance your trading system development experience:
|
|
544
|
+
</details>
|
|
1636
545
|
|
|
546
|
+
### 13. AI strategies without ten provider SDKs
|
|
1637
547
|
|
|
1638
|
-
|
|
548
|
+
LLM-driven signals normally mean per-provider boilerplate and JSON you can't trust. One HOF API spans 10+ providers; structured output is schema-enforced; trading context is injected automatically.
|
|
1639
549
|
|
|
1640
|
-
>
|
|
550
|
+
<details>
|
|
551
|
+
<summary>The Code</summary>
|
|
1641
552
|
|
|
1642
|
-
|
|
553
|
+
```typescript
|
|
554
|
+
import { deepseek } from '@backtest-kit/ollama';
|
|
555
|
+
addStrategy({
|
|
556
|
+
strategyName: 'llm-signal', interval: '5m',
|
|
557
|
+
// swap deepseek() โ claude() / gpt5() / ollama() with no other change
|
|
558
|
+
getSignal: deepseek(getSignal, 'deepseek-chat', process.env.DEEPSEEK_API_KEY),
|
|
559
|
+
});
|
|
560
|
+
```
|
|
1643
561
|
|
|
1644
|
-
|
|
1645
|
-
- ๐ **Zero Config**: Run a backtest with one command โ no setup code needed
|
|
1646
|
-
- ๐ **Three Modes**: `--backtest`, `--paper`, `--live` with graceful SIGINT shutdown
|
|
1647
|
-
- ๐พ **Auto Cache**: Warms OHLCV candle cache for all intervals before the backtest starts
|
|
1648
|
-
- ๐ **Web Dashboard**: Launch `@backtest-kit/ui` with a single `--ui` flag
|
|
1649
|
-
- ๐ฌ **Telegram Alerts**: Formatted trade notifications with price charts via `--telegram`
|
|
1650
|
-
- ๐๏ธ **Monorepo Ready**: Each strategy's `dump/`, `modules/`, and `template/` are automatically isolated by entry point directory
|
|
562
|
+
Providers: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama (local), GLM-4. Structured output is enforced with Zod / JSON schema via `addOutline` (auto-retry on malformed output, custom rules like "SL must be below entry for LONG"); token rotation accepts a key array; prompts live in `config/prompt/*.cjs` and are memoized to kill redundant backtest API calls. The full LLM strategy โ fetch multi-timeframe candles, ask the model, dump the reasoning, return a validated signal:
|
|
1651
563
|
|
|
1652
|
-
|
|
1653
|
-
|
|
564
|
+
```typescript
|
|
565
|
+
import { v4 as uuid } from 'uuid';
|
|
566
|
+
import { addStrategySchema, getCandles, dumpAgentAnswer, dumpRecord } from 'backtest-kit';
|
|
567
|
+
import { json } from './utils/json.mjs';
|
|
568
|
+
import { getMessages } from './utils/messages.mjs';
|
|
1654
569
|
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
570
|
+
addStrategySchema({
|
|
571
|
+
strategyName: 'llm-strategy', interval: '5m', riskName: 'demo',
|
|
572
|
+
getSignal: async (symbol) => {
|
|
573
|
+
const messages = await getMessages(symbol, {
|
|
574
|
+
candles1h: await getCandles(symbol, '1h', 24),
|
|
575
|
+
candles15m: await getCandles(symbol, '15m', 48),
|
|
576
|
+
candles5m: await getCandles(symbol, '5m', 60),
|
|
577
|
+
candles1m: await getCandles(symbol, '1m', 60),
|
|
578
|
+
});
|
|
579
|
+
const resultId = uuid();
|
|
580
|
+
const signal = await json(messages); // LLM โ structured signal
|
|
581
|
+
await dumpAgentAnswer({ dumpId: 'position-context', bucketName: 'mtf', messages, description: 'agent reasoning' });
|
|
582
|
+
await dumpRecord({ dumpId: 'position-entry', bucketName: 'mtf', record: signal, description: 'signal params' });
|
|
583
|
+
return { ...signal, id: resultId };
|
|
584
|
+
},
|
|
585
|
+
});
|
|
1658
586
|
```
|
|
1659
587
|
|
|
588
|
+
Memory adapters persist LLM reasoning per signal (BM25 search, soft delete); `dumpAgentAnswer` archives the full conversation โ roles, reasoning, tool calls โ attached to the signal, so an opaque model decision becomes a debuggable record.
|
|
1660
589
|
|
|
1661
|
-
|
|
590
|
+
</details>
|
|
1662
591
|
|
|
1663
|
-
|
|
592
|
+
---
|
|
1664
593
|
|
|
1665
|
-
The
|
|
594
|
+
## The API assumes you will make every mistake
|
|
1666
595
|
|
|
1667
|
-
|
|
1668
|
-
- ๐ **Pine Script v5/v6**: Native TradingView syntax with 1:1 compatibility
|
|
1669
|
-
- ๐ฏ **60+ Indicators**: SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Stochastic built-in
|
|
1670
|
-
- ๐ **File or Code**: Load `.pine` files or pass code strings directly
|
|
1671
|
-
- ๐บ๏ธ **Plot Extraction**: Flexible mapping from Pine `plot()` outputs to structured signals
|
|
1672
|
-
- โก **Cached Execution**: Memoized file reads for repeated strategy runs
|
|
596
|
+
Read back through the rakes and a pattern shows: none of them are solved by *telling you to be careful*. Look-ahead bias isn't prevented by a lint rule โ there's simply no timestamp to pass. Averaging up isn't discouraged in the docs โ the call is rejected. A closed position's live PnL isn't a runtime guard โ it doesn't compile. The whole surface is built on the assumption that you, or the model writing your strategy, will eventually do the wrong thing at 3 a.m. โ so the wrong thing is made unreachable. This is the "pit of success": the easy path and the correct path are the same path.
|
|
1673
597
|
|
|
1674
|
-
|
|
1675
|
-
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.
|
|
598
|
+
And the shape of that surface is **reactive โ React for traders.** You never write the time loop. You don't iterate candles, advance a clock, or poll for fills. You *declare reactions* to lifecycle events, and the engine owns the loop in both backtest and live. `getSignal` is your pure render function โ given the current state of the world, return a signal or `null`. The `listen*` family is your effects layer โ small handlers that fire when the position's state changes, exactly like subscribing to state in a component. Composition is additive: stack independent listeners and each one minds its own concern, the same way you'd split hooks.
|
|
1676
599
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
npm install @backtest-kit/pinets pinets backtest-kit
|
|
1680
|
-
```
|
|
600
|
+
<details>
|
|
601
|
+
<summary>The Code</summary>
|
|
1681
602
|
|
|
603
|
+
`getSignal` declares *what* to open; the listeners declare *how the position behaves once alive* โ a DCA ladder, a profit target, and an error sink, three independent reactions to the same event stream, no shared loop, no manual bookkeeping:
|
|
1682
604
|
|
|
1683
|
-
|
|
605
|
+
```typescript
|
|
606
|
+
import {
|
|
607
|
+
addStrategySchema, listenActivePing, listenError, Log, Position,
|
|
608
|
+
commitAverageBuy, commitClosePending,
|
|
609
|
+
getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent,
|
|
610
|
+
} from "backtest-kit";
|
|
611
|
+
import { errorData, getErrorMessage, str } from "functools-kit";
|
|
1684
612
|
|
|
1685
|
-
|
|
613
|
+
const HARD_STOP = 25, TARGET_PROFIT = 3, STEP = 100, MAX_STEPS = 10;
|
|
1686
614
|
|
|
1687
|
-
|
|
615
|
+
// render: given "now", declare the position to open (or null to stay flat)
|
|
616
|
+
addStrategySchema({
|
|
617
|
+
strategyName: "apr_2026_strategy",
|
|
618
|
+
getSignal: async (symbol, when, currentPrice) => ({
|
|
619
|
+
position: "long",
|
|
620
|
+
...Position.moonbag({ position: "long", currentPrice, percentStopLoss: HARD_STOP }),
|
|
621
|
+
minuteEstimatedTime: Infinity, cost: STEP,
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
1688
624
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
625
|
+
// effect: average into dips, up to 10 overlap-free rungs (averaging up is rejected for you)
|
|
626
|
+
listenActivePing(async ({ symbol, currentPrice }) => {
|
|
627
|
+
if ((await getPositionEntries(symbol)).length >= MAX_STEPS) return;
|
|
628
|
+
if (await getPositionEntryOverlap(symbol, currentPrice, { upperPercent: 5, lowerPercent: 1 })) return;
|
|
629
|
+
await commitAverageBuy(symbol, STEP);
|
|
630
|
+
});
|
|
1695
631
|
|
|
1696
|
-
|
|
1697
|
-
|
|
632
|
+
// effect: close the whole position once blended PnL clears the target
|
|
633
|
+
listenActivePing(async ({ symbol, data }) => {
|
|
634
|
+
if (await getPositionPnlPercent(symbol) < TARGET_PROFIT) return;
|
|
635
|
+
Log.info("position closed due to the target pnl reached", { symbol, data });
|
|
636
|
+
await commitClosePending(symbol, { id: "unknown", note: str.newline("# Closed by target pnl") });
|
|
637
|
+
});
|
|
1698
638
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
npm install @backtest-kit/graph backtest-kit
|
|
639
|
+
// effect: a single place for anything that goes wrong
|
|
640
|
+
listenError((error) => Log.debug("error", { error: errorData(error), message: getErrorMessage(error) }));
|
|
1702
641
|
```
|
|
1703
642
|
|
|
643
|
+
The full reactive surface โ subscribe to any point in a position's life and the engine fires it in order, queued, never overlapping: `listenSignal` / `listenSignalBacktest` / `listenSignalLive` (lifecycle), `listenActivePing` (per-minute while a position is live), `listenSchedulePing` / `listenIdlePing`, `listenPartialProfit` / `listenPartialLoss`, `listenBreakevenAvailable`, `listenHighestProfit`, `listenMaxDrawdown`, `listenRisk` (rejections), `listenError` / `listenExit`, `listenDone*`, plus `*Once` filtered variants for one-shot reactions. You compose behavior by adding handlers, not by editing a loop.
|
|
1704
644
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ui)** ๐
|
|
1708
|
-
|
|
1709
|
-
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.
|
|
645
|
+
</details>
|
|
1710
646
|
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
- ๐ฏ **Signal Tracking**: View opened, closed, scheduled, and cancelled signals with full details
|
|
1714
|
-
- ๐ **Risk Analysis**: Monitor risk rejections and position management
|
|
1715
|
-
- ๐ **Notifications**: Real-time notification system for all trading events
|
|
1716
|
-
- ๐น **Trailing & Breakeven**: Visualize trailing stop/take and breakeven events
|
|
1717
|
-
- ๐จ **Material Design**: Beautiful UI with MUI 5 and Mantine components
|
|
647
|
+
<details>
|
|
648
|
+
<summary>The Proof</summary>
|
|
1718
649
|
|
|
1719
|
-
|
|
1720
|
-
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.
|
|
650
|
+
The five guarantees that make the surface fool-proof, each enforced by the engine rather than by convention:
|
|
1721
651
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
652
|
+
1. **Ambient temporal context** โ no `currentDate`/`timestamp` parameter exists to forget; the engine resolves "now" from `AsyncLocalStorage` and blocks future data at the adapter level.
|
|
653
|
+
2. **Type-safe state machine** โ `idle โ scheduled โ pending โ opened โ active โ closed` as discriminated unions; calling a close on an already-closed signal, or editing an active trade's entry, is a compile error.
|
|
654
|
+
3. **Guarded DCA** โ `commitAverageBuy` rejects any call that would worsen the harmonic-mean effective entry; you cannot accidentally average up.
|
|
655
|
+
4. **Transactional broker commits (the "no-try-catch" rule)** โ the adapter intercepts every mutation before internal state changes; an exchange throw rolls back and retries on the next tick, so you never hand-write rollback.
|
|
656
|
+
5. **Automatic signal validation** โ TP/SL soundness, R/R minimum, and interval throttling are checked before a signal reaches execution; invalid signals are logged or rejected, never run.
|
|
1726
657
|
|
|
658
|
+
Because the loop belongs to the engine, the *same* declarations run identically in backtest and live โ the reactive model is the reason "same code, both modes" is structurally true, not just aspirational.
|
|
1727
659
|
|
|
1728
|
-
|
|
660
|
+
</details>
|
|
1729
661
|
|
|
1730
|
-
|
|
662
|
+
---
|
|
1731
663
|
|
|
1732
|
-
|
|
664
|
+
## Receipts
|
|
1733
665
|
|
|
1734
|
-
|
|
1735
|
-
- ๐๏ธ **MongoDB Backend**: All 15 persistence adapters implemented with Mongoose and unique compound indexes
|
|
1736
|
-
- โก **O(1) Reads via Redis**: Every context-key lookup goes through ioredis โ one `GET` + one `findById`, no B-tree scans
|
|
1737
|
-
- ๐ **Atomic Writes**: `findOneAndUpdate` with `upsert: true` guarantees read-after-write correctness with no race conditions
|
|
1738
|
-
- ๐ก๏ธ **Look-Ahead Bias Protection**: Adapters that affect signal logic store the simulation timestamp so backtest-kit can enforce temporal correctness
|
|
1739
|
-
- ๐ชฆ **Soft Delete**: Measure, Interval, and Memory records carry a `removed` flag instead of being physically deleted
|
|
1740
|
-
- ๐ **Zero Strategy Changes**: Drop `setup()` into your entry point, everything else stays the same
|
|
666
|
+
Toy READMEs prove a moving-average crossover on daily candles. These are eight production-quality strategies, each a *different* signal source, each backtested on real history with the numbers written down. They live in [`/example`](https://github.com/tripolskypetr/backtest-kit/tree/master/example) โ clone it, run it, get the same prints.
|
|
1741
667
|
|
|
1742
|
-
|
|
1743
|
-
|
|
668
|
+
| Strategy | Ticker ยท Period | Signal source | Net PNL | Sharpe |
|
|
669
|
+
|---|---|---|---:|---:|
|
|
670
|
+
| [Neural Network](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy) | BTC ยท Oct 2021 | TensorFlow NN (8โ6โ4โ1) predicting next-candle close | **+18.26%** | 0.31 |
|
|
671
|
+
| [Python EMA Crossover](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy) | DOT ยท Feb 2021 | EMA(9)/EMA(21) via WebAssembly (WASI) | **+5.52%** | 0.09 |
|
|
672
|
+
| [Polymarket ฮprob](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2024.strategy) | BTC ยท Apr 2024 | Prediction-market probability shifts | **+0.63%** | 0.065 |
|
|
673
|
+
| [Pine Script Range Breakout](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy) | BTC ยท Dec 2025 | Bollinger + range + volume spike (Pine) | **+2.40%** | 0.06 |
|
|
674
|
+
| [Liquidity Harvesting](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy) | TRX ยท Jan 2026 | Telegram channel signals, **inverted** | **+8.58%** | **1.14** |
|
|
675
|
+
| [AI News Sentiment](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy) | BTC ยท Feb 2026 | LLM on live news (Tavily + Ollama) | **+16.99%** | 0.25 |
|
|
676
|
+
| [SHORT DCA Ladder](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy) | BTC ยท Mar 2026 | Fixed SHORT + ladder up (โค10 rungs) | **+37.83%** | 0.35 |
|
|
677
|
+
| [LONG DCA Ladder](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy) | BTC ยท Apr 2026 | Fixed LONG + ladder down (โค10 rungs) | **+67.85%** | 0.12 |
|
|
1744
678
|
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
npm install @backtest-kit/mongo backtest-kit mongoose ioredis
|
|
1748
|
-
```
|
|
679
|
+
<details>
|
|
680
|
+
<summary>The Proof</summary>
|
|
1749
681
|
|
|
682
|
+
- **Liquidity Harvesting (Sharpe 1.14)** โ a Telegram channel published SHORT signals with ~0.375:1 R/R and 106% deposit at risk at 25ร leverage, mathematically guaranteed to lose; a volume spike appeared 15 min before every post and the TP step multipliers were identical across signals โ an algorithm. Inverting it turned **โ5.05% โ +8.58%**, profit factor **0.56 โ 7.31**. The edge was the bot crowd, not the indicators.
|
|
683
|
+
- **AI News Sentiment** held SHORT through nearly all of a โ16.4% month, flipped to LONG on the recovery bounce, and flipped back on geopolitical news โ **+16.99%** where buy-and-hold lost 16%.
|
|
684
|
+
- **DCA Ladders** show the trade-off honestly: high % return on deployed capital, but absolute fiat risk grows with rungs (Mar: โ$104.93 on a 10-rung position; theoretical max โ$2,500 if a non-reverting trend hits the 25% hard stop with all rungs filled). The README states the downside, not just the upside.
|
|
1750
685
|
|
|
1751
|
-
|
|
686
|
+
Every example documents price context, trade log, equity curve, and risk analysis โ and several ship a `--noDCA` / single-entry variant so you can see exactly what the position management bought you.
|
|
1752
687
|
|
|
1753
|
-
>
|
|
688
|
+
</details>
|
|
1754
689
|
|
|
1755
|
-
|
|
690
|
+
---
|
|
1756
691
|
|
|
1757
|
-
|
|
1758
|
-
- ๐ **10+ LLM Providers**: OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, Ollama
|
|
1759
|
-
- ๐ **Token Rotation**: Automatic API key rotation for Ollama (others throw clear errors)
|
|
1760
|
-
- ๐ฏ **Structured Output**: Enforced JSON schema for trading signals (position, price levels, risk notes)
|
|
1761
|
-
- ๐ **Flexible Auth**: Context-based API keys or environment variables
|
|
1762
|
-
- โก **Unified API**: Single interface across all providers
|
|
1763
|
-
- ๐ **Trading-First**: Built for backtest-kit with position sizing and risk management
|
|
692
|
+
## How it sits next to the alternatives
|
|
1764
693
|
|
|
1765
|
-
|
|
1766
|
-
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.
|
|
1767
|
-
|
|
1768
|
-
#### Get Started
|
|
1769
|
-
```bash
|
|
1770
|
-
npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
|
|
1771
|
-
```
|
|
694
|
+
The honest version: for a quick research prototype or a single MA crossover, VectorBT or Backtrader are hard to beat on raw speed. The moment you need to *deploy* โ complex position sizing, AI agents, a network outage that mustn't desync your bot โ is where the guardrails below start to matter.
|
|
1772
695
|
|
|
696
|
+
| | Backtest Kit | Backtrader | VectorBT | MetaTrader/MQL5 | QuantConnect | Freqtrade |
|
|
697
|
+
|---|---|---|---|---|---|---|
|
|
698
|
+
| Language | TypeScript | Python | Python | MQL5 | C#/Python | Python |
|
|
699
|
+
| Live trading | โ
built-in | โ ๏ธ manual | โ research | โ
| โ
| โ
|
|
|
700
|
+
| Look-ahead prevention | โ
engine-enforced | โ ๏ธ discipline | โ ๏ธ discipline | โ ๏ธ discipline | โ ๏ธ partial | โ ๏ธ partial |
|
|
701
|
+
| Crash-safe persistence | โ
atomic + Mongo | โ | โ | โ | โ ๏ธ cloud | โ ๏ธ basic |
|
|
702
|
+
| Transactional broker | โ
auto rollback | โ | โ | โ | โ ๏ธ partial | โ ๏ธ basic |
|
|
703
|
+
| Type-safe state machine | โ
compile-time | โ | โ | โ | โ | โ |
|
|
704
|
+
| DCA / partial closes | โ
first-class | โ ๏ธ manual | โ ๏ธ manual | โ ๏ธ manual | โ ๏ธ manual | โ ๏ธ limited |
|
|
705
|
+
| AI / LLM integration | โ
built-in | โ | โ | โ | โ ๏ธ custom | โ |
|
|
706
|
+
| Pine Script | โ
native | โ | โ | โ
| โ | โ |
|
|
707
|
+
| Self-hosted | โ
100% | โ
| โ
| โ ๏ธ desktop | โ cloud | โ
|
|
|
1773
708
|
|
|
1774
|
-
|
|
709
|
+
Open-source QuantConnect/MetaTrader without the lock-in: pure TypeScript, your code, your data, your machines, no platform fees, no proprietary GUI. Drop any library into `getSignal` โ Ollama, [`neural-trader`](https://www.npmjs.com/package/neural-trader), your own.
|
|
1775
710
|
|
|
1776
|
-
|
|
711
|
+
---
|
|
1777
712
|
|
|
1778
|
-
|
|
713
|
+
## ๐ Ecosystem
|
|
1779
714
|
|
|
1780
|
-
|
|
1781
|
-
- ๐ **Multi-Timeframe Analysis**: 1m, 15m, 30m, 1h with synchronized indicator computation
|
|
1782
|
-
- ๐ฏ **50+ Technical Indicators**: RSI, MACD, Bollinger Bands, Stochastic, ADX, ATR, CCI, Fibonacci, Support/Resistance
|
|
1783
|
-
- ๐ **Order Book Analysis**: Bid/ask depth, spread, liquidity imbalance, top 20 levels
|
|
1784
|
-
- ๐ค **AI-Ready Output**: Markdown reports formatted for LLM context injection
|
|
1785
|
-
- โก **Performance Optimized**: Intelligent caching with configurable TTL per timeframe
|
|
715
|
+
**The core is a library; the CLI is the framework on top โ and the framework is optional.** Think React vs Next.js. `backtest-kit` (the reactive engine โ `getSignal` + the `listen*`/`commit*` API) is the library you build against directly. `@backtest-kit/cli` is the Next.js: it wires the runner, candle cache, dashboard, Telegram, and graceful shutdown so you don't have to โ but you can ignore it entirely and call `Backtest.run()` / `Live.background()` yourself. `@backtest-kit/sidekick` is the explicit middle ground โ it scaffolds a project where every wire (exchange adapter, frames, risk rules, strategy, runner) lives as plain, editable source in **your** userspace, with no CLI in the loop and nothing hidden. You pick how much magic you want.
|
|
1786
716
|
|
|
1787
|
-
|
|
1788
|
-
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.
|
|
717
|
+
On the "dependency zoo": every package below is authored by one team and shipped by the commercial vendor [TheOneTrade](https://theonetrade.github.io) โ versioned together, released together. Treat it like the .NET base class library: a single coherent contract where the userspace surface (`getSignal`, `commit*`, `listen*`, `get*`) does not churn under you between releases. You install only what a given strategy needs, and the heavy or platform-specific pieces (Python-via-WASM, TensorFlow builds) sit behind their own optional packages so the core install stays clean and conflict-free.
|
|
1789
718
|
|
|
1790
|
-
|
|
719
|
+
### `@backtest-kit/cli` โ [npm](https://www.npmjs.com/package/@backtest-kit/cli)
|
|
720
|
+
Zero-boilerplate runner. Modes: `--backtest / --paper / --live / --walker / --main / --pine / --editor / --dump / --pnldebug / --brokerdebug / --flush / --init / --docker`. Auto candle caching, monorepo cwd-resolution with per-strategy `.env` override, folder-based import aliases, broker module hooks, `setup.config` / `loader.config` / `alias.config`, graceful SIGINT.
|
|
1791
721
|
```bash
|
|
1792
|
-
|
|
722
|
+
npx -y @backtest-kit/cli --init
|
|
1793
723
|
```
|
|
1794
724
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
> **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/sidekick)** ๐
|
|
1799
|
-
|
|
1800
|
-
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.
|
|
1801
|
-
|
|
1802
|
-
#### Key Features
|
|
1803
|
-
- ๐ **Zero Config**: Get started with one command - no setup required
|
|
1804
|
-
- ๐ฆ **Complete Template**: Includes backtest strategy, risk management, and LLM integration
|
|
1805
|
-
- ๐ค **AI-Powered**: Pre-configured with DeepSeek, Claude, and GPT-5 fallback chain
|
|
1806
|
-
- ๐ **Technical Analysis**: Built-in 50+ indicators via @backtest-kit/signals
|
|
1807
|
-
- ๐ **Environment Setup**: Auto-generated .env with all API key placeholders
|
|
1808
|
-
- ๐ **Best Practices**: Production-ready code structure with examples
|
|
1809
|
-
|
|
1810
|
-
#### Use Case
|
|
1811
|
-
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.
|
|
1812
|
-
|
|
1813
|
-
#### Get Started
|
|
725
|
+
### `@backtest-kit/pinets` โ [npm](https://www.npmjs.com/package/@backtest-kit/pinets)
|
|
726
|
+
Run TradingView Pine Script v5/v6 in Node, 60+ indicators, 1:1 syntax, `getSignal` / `run` / `extract` / `extractRows`.
|
|
1814
727
|
```bash
|
|
1815
|
-
|
|
1816
|
-
cd my-trading-bot
|
|
1817
|
-
npm start
|
|
728
|
+
npm install @backtest-kit/pinets pinets backtest-kit
|
|
1818
729
|
```
|
|
1819
730
|
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
### backtest-monorepo-parallel
|
|
1824
|
-
|
|
1825
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/backtest-monorepo-parallel)** ๐๏ธ
|
|
1826
|
-
|
|
1827
|
-
The **backtest-monorepo-parallel** repository is a TypeScript monorepo template that runs **9 symbols in parallel** in a single Node process on top of shared Mongo + Redis infrastructure, with a self-enforcement runtime that exposes the workspace DI container to `./content/` strategy files. No wiring, no bundler hooks, no strategy-author changes.
|
|
1828
|
-
|
|
1829
|
-
#### Key Features
|
|
1830
|
-
- โก **~6 300ร Real-Time Aggregate**: 9 symbols ร ~703ร per-symbol replay speed, ~103 events/sec in the hot `listenActivePing โ commitAverageBuy` loop on a commodity i5-13420H laptop
|
|
1831
|
-
- ๐งต **Single-Process Concurrency**: All 9 `Backtest.background(...)` contexts share one event loop, one Mongo pool, one Redis pool โ no IPC, no fork overhead
|
|
1832
|
-
- ๐ **DI Surface**: Workspace services typed via rolled-up `types.d.ts` and reachable from strategy files at evaluation time
|
|
1833
|
-
- ๐๏ธ **Mode A / Mode B**: `--entry` flag toggles between parallel runner (`CC_SYMBOL_LIST` fan-out) and single-strategy CLI mode
|
|
1834
|
-
- ๐งฉ **Linear Scaling Recipe**: Adding a service = +1 file, +1 symbol, +1 provider, +1 ioc entry โ no churn under `./content/`
|
|
1835
|
-
|
|
1836
|
-
#### Use Case
|
|
1837
|
-
Use when you need to backtest many symbols concurrently against the same strategy without spawning subprocesses, and want a scaffold where new services, collections, and Redis caches drop in alongside existing ones without restructuring. Ideal as the starting point for a production parallel-symbol backtesting setup.
|
|
1838
|
-
|
|
1839
|
-
#### Get Started
|
|
731
|
+
### `@backtest-kit/graph` โ [npm](https://www.npmjs.com/package/@backtest-kit/graph)
|
|
732
|
+
Compose computations as a typed DAG; resolved in topological order with `Promise.all`, serializable to a DB for storage.
|
|
1840
733
|
```bash
|
|
1841
|
-
|
|
734
|
+
npm install @backtest-kit/graph backtest-kit
|
|
1842
735
|
```
|
|
1843
736
|
|
|
737
|
+
### `@backtest-kit/ui` โ [npm](https://www.npmjs.com/package/@backtest-kit/ui)
|
|
738
|
+
React/MUI dashboard with Lightweight Charts: live signal-lifecycle state-machine view, per-signal inspection, risk/partial/trailing/breakeven views, manual control, Pine editor.
|
|
739
|
+
```typescript
|
|
740
|
+
import { serve } from '@backtest-kit/ui';
|
|
741
|
+
serve('0.0.0.0', 60050); // http://localhost:60050
|
|
742
|
+
```
|
|
1844
743
|
|
|
1845
|
-
### backtest-
|
|
1846
|
-
|
|
1847
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/backtest-ollama-crontab)** ๐
|
|
1848
|
-
|
|
1849
|
-
The **backtest-ollama-crontab** repository is a TypeScript monorepo template that wires a cloud/local **Ollama** into a trading-signal pipeline as a risk filter, with a **15-minute crontab** ingesting signals from any public Telegram channel. The **same code runs in both live and backtest modes** โ the crontab re-polls live and pulls the entire frame at startup in backtest.
|
|
1850
|
-
|
|
1851
|
-
#### Key Features
|
|
1852
|
-
- ๐ค **Local/Cloud LLM Risk Filter**: Per-signal verdict from local Ollama (`gpt-oss` quantized) returning `riskAction: "skip" | "follow"`, with empirical rules embedded in the system prompt and tunable without recompiling packages
|
|
1853
|
-
- โฐ **Crontab-Driven Ingestion**: `Cron.register(..., interval: "15m")` for live re-polling of the Telegram channel, plus a fire-once `Cron.register(...)` (no `interval`) for backtest-time bulk prepare โ same code path in both modes
|
|
1854
|
-
- ๐ก **Telegram MTProto Crawler**: QR-code session auth, `iterMessages` pull from any public channel into a `parser-items` Mongo collection, regex extraction of `direction / entry / targets / stoploss` into `screen-items`
|
|
1855
|
-
- ๐ง **Outline-Based Risk Logic**: Risk outline ingests 1m/15m candles + a pre-computed metrics packet (`avgRangePct`, `momentum24hPct`) and produces a zod-validated verdict consumed by the strategy
|
|
1856
|
-
- ๐ **Reproducible Backtest Comparison**: same parsed-signal set, two backtests side-by-side โ **+52.22% โ +68.90%** total PNL, Sharpe **+0.309 โ +0.512**, winrate **68% โ 82%**, profit factor **2.73 โ 6.37** with the LLM gate enabled
|
|
1857
|
-
|
|
1858
|
-
#### Use Case
|
|
1859
|
-
Reference for integrating any local LLM into a backtest-kit pipeline as a signal filter, and for combining periodic crontab pulls (live) with one-shot bulk prepare (backtest) via the same `Cron.register` API.
|
|
1860
|
-
|
|
1861
|
-
#### Get Started
|
|
744
|
+
### `@backtest-kit/mongo` โ [npm](https://www.npmjs.com/package/@backtest-kit/mongo)
|
|
745
|
+
MongoDB source-of-truth + Redis O(1) cache. All 15 persistence contracts, atomic upserts, soft delete, look-ahead-safe `when`. Zero strategy changes.
|
|
1862
746
|
```bash
|
|
1863
|
-
|
|
747
|
+
npm install @backtest-kit/mongo backtest-kit mongoose ioredis
|
|
1864
748
|
```
|
|
1865
749
|
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-redis-mongo-docker)** ๐ณ
|
|
1870
|
-
|
|
1871
|
-
The **backtest-kit-redis-mongo-docker** repository is a production-grade integration that replaces the default file-based `./dump/` persistence with **MongoDB** as the source of truth and **Redis** as an O(1) lookup cache, packaged with `docker-compose` for one-command deploys.
|
|
1872
|
-
|
|
1873
|
-
#### Key Features
|
|
1874
|
-
- ๐๏ธ **15 Persist Adapters**: Full implementation of every `IPersist*Instance` contract (Candle, Signal, Schedule, Risk, Partial, Breakeven, Storage, Notification, Log, Measure, Interval, Memory, Recent, State, Session) on top of MongoDB + Redis
|
|
1875
|
-
- โ๏ธ **Atomic Read-After-Write**: Single-round-trip `findOneAndUpdate` with unique compound indexes โ no E11000 leaks under concurrent writes
|
|
1876
|
-
- โก **Redis O(1) Cache**: Per-domain `*CacheService` over `ioredis` for context-key โ id lookups; cache miss falls back to Mongo and backfills automatically
|
|
1877
|
-
- ๐ก๏ธ **Look-Ahead Bias Protection**: Indexed `when: Number` column on every signal-affecting schema, fed by backtest-kit 9.0+'s `when: Date` adapter argument
|
|
1878
|
-
- ๐ณ **Docker Compose Stack**: Separate compose files for Mongo and Redis plus a main container with networks; configurable via `CC_MONGO_CONNECTION_STRING` / `CC_REDIS_*` env vars
|
|
1879
|
-
|
|
1880
|
-
#### Use Case
|
|
1881
|
-
Drop-in persistence upgrade for any backtest-kit project that outgrows the default file-based `./dump/` layout โ strategy code, runners, and the CLI entry point stay unchanged. Use it when you need durable storage, concurrent-safe writes, fast restart recovery, or a containerized deployment for live and paper trading.
|
|
1882
|
-
|
|
1883
|
-
#### Get Started
|
|
750
|
+
### `@backtest-kit/ollama` โ [npm](https://www.npmjs.com/package/@backtest-kit/ollama)
|
|
751
|
+
Universal LLM adapter: 10+ providers, structured output, token rotation, fallback chains, trading-context injection.
|
|
1884
752
|
```bash
|
|
1885
|
-
|
|
753
|
+
npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
|
|
1886
754
|
```
|
|
1887
755
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/backtest-kit-skills)** ๐ค
|
|
1892
|
-
|
|
1893
|
-
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.
|
|
1894
|
-
|
|
1895
|
-
#### Key Features
|
|
1896
|
-
- ๐ค **Claude Code Skill**: Installed under `~/.claude/skills/backtest-kit/` โ strategy generation, debugging, and API reference
|
|
1897
|
-
- ๐ **Mintlify Docs**: Full documentation site runnable locally
|
|
1898
|
-
- ๐ฏ **Strategy Generation**: Complete TypeScript files with all schema registrations and runner setup
|
|
1899
|
-
- ๐ **Debugging Help**: Catches common mistakes (missing `await`, wrong TP/SL direction, top-level commit calls)
|
|
1900
|
-
- ๐ **API Reference**: All schemas, commit functions, event listeners, LLM integration, graph pipelines, and persistence adapters
|
|
1901
|
-
|
|
1902
|
-
#### Use Case
|
|
1903
|
-
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.
|
|
1904
|
-
|
|
1905
|
-
#### Get Started
|
|
756
|
+
### `@backtest-kit/signals` โ [npm](https://www.npmjs.com/package/@backtest-kit/signals)
|
|
757
|
+
50+ indicators across 4 timeframes + order book, multi-timeframe synchronized, LLM-ready Markdown reports.
|
|
1906
758
|
```bash
|
|
1907
|
-
|
|
759
|
+
npm install @backtest-kit/signals backtest-kit
|
|
1908
760
|
```
|
|
1909
761
|
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
> **[Explore on GitHub](https://github.com/backtest-kit/uzse-backtest-app)** ๐
|
|
1914
|
-
|
|
1915
|
-
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.
|
|
1916
|
-
|
|
1917
|
-
#### Key Features
|
|
1918
|
-
- ๐ **Off-TradingView Markets**: Works with any exchange that exposes trade history โ no TradingView dependency
|
|
1919
|
-
- ๐ฏ๏ธ **Candle Builder**: Aggregates raw trades into 1m candles, fills intraday and non-trading day gaps, builds higher timeframes up to `1d`
|
|
1920
|
-
- ๐๏ธ **MongoDB Backend**: Idempotent import with unique index โ re-runs never create duplicates
|
|
1921
|
-
- ๐ **Custom Exchange Adapter**: Connects MongoDB candles to backtest-kit via `addExchangeSchema`
|
|
1922
|
-
- ๐ **Pine Script Support**: Full `@backtest-kit/pinets` integration โ run any Pine Script v5/v6 indicator on local market data
|
|
1923
|
-
|
|
1924
|
-
#### Use Case
|
|
1925
|
-
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.
|
|
1926
|
-
|
|
1927
|
-
#### Get Started
|
|
762
|
+
### `@backtest-kit/sidekick` โ [npm](https://www.npmjs.com/package/@backtest-kit/sidekick)
|
|
763
|
+
The "eject" of `--init`: scaffolds a project where exchange adapter, frames, risk rules, strategy, and runner are all editable source. 4H-trend + 15m-signal Pine template, partial profit taking, breakeven trailing.
|
|
1928
764
|
```bash
|
|
1929
|
-
|
|
765
|
+
npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
|
|
1930
766
|
```
|
|
1931
767
|
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
#### ๐ง Neural Network Strategy (Oct 2021)
|
|
1935
|
-
|
|
1936
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/oct_2021.strategy)
|
|
768
|
+
---
|
|
1937
769
|
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
#### ๐ฒ Pine Script Range Breakout (Dec 2025)
|
|
1941
|
-
|
|
1942
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/dec_2025.strategy)
|
|
1943
|
-
|
|
1944
|
-
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.
|
|
1945
|
-
|
|
1946
|
-
#### ๐ช Signal Inversion Strategy (Jan 2026)
|
|
1947
|
-
|
|
1948
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/jan_2026.strategy)
|
|
1949
|
-
|
|
1950
|
-
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.
|
|
1951
|
-
|
|
1952
|
-
#### ๐ฐ AI News Sentiment (Feb 2026)
|
|
1953
|
-
|
|
1954
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2026.strategy)
|
|
1955
|
-
|
|
1956
|
-
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.
|
|
1957
|
-
|
|
1958
|
-
#### ๐ช SHORT DCA Ladder (Mar 2026)
|
|
1959
|
-
|
|
1960
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/mar_2026.strategy)
|
|
1961
|
-
|
|
1962
|
-
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.
|
|
1963
|
-
|
|
1964
|
-
#### ๐ง LONG DCA Ladder (Apr 2026)
|
|
1965
|
-
|
|
1966
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/apr_2026.strategy)
|
|
1967
|
-
|
|
1968
|
-
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).
|
|
1969
|
-
|
|
1970
|
-
#### ๐ Python EMA Crossover (Feb 2021)
|
|
1971
|
-
|
|
1972
|
-
> Link to [the source code](https://github.com/tripolskypetr/backtest-kit/tree/master/example/content/feb_2021.strategy)
|
|
1973
|
-
|
|
1974
|
-
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.
|
|
770
|
+
## ๐ช Community
|
|
1975
771
|
|
|
1976
|
-
|
|
772
|
+
Real, runnable templates โ not slideware. And worth naming the concern directly: yes, this is one author's ecosystem, which is exactly what makes it *coherent* โ but coherent is not captive. Everything is **MIT and open-source**, the core engine has **zero hard dependency** on any `@backtest-kit/*` add-on (you can run `getSignal` + `listen*` against a bare `addExchangeSchema` and nothing else), and each repo below is an independent reference you're meant to **fork and own**. The lock-in you'd normally fear โ a closed runtime, a proprietary data format, a cloud you can't leave โ none of it applies; the persistence is plain files or your own Mongo, the signals are your code, and the exit cost is a `git clone`.
|
|
1977
773
|
|
|
1978
|
-
|
|
774
|
+
- **[backtest-monorepo-parallel](https://github.com/backtest-kit/backtest-monorepo-parallel)** โ 9 symbols in parallel in one Node process on shared Mongo+Redis, ~6,300ร real-time, self-enforcement runtime exposing the workspace DI container to `./content/` strategy files. The scaling recipe: +1 service = +1 file, +1 provider, +1 ioc entry.
|
|
775
|
+
- **[backtest-ollama-crontab](https://github.com/backtest-kit/backtest-ollama-crontab)** โ a local Ollama (`gpt-oss` quantized) as a per-signal risk gate plus a 15-minute crontab ingesting any public Telegram channel; the *same code* re-polls live and bulk-prepares in backtest. Documented result: **+52.22% โ +68.90%** with the LLM gate on.
|
|
776
|
+
- **[backtest-kit-redis-mongo-docker](https://github.com/backtest-kit/backtest-kit-redis-mongo-docker)** โ production persistence: all 15 adapters on Mongo+Redis, atomic read-after-write, `docker-compose` one-command deploy.
|
|
777
|
+
- **[backtest-kit-skills](https://github.com/backtest-kit/backtest-kit-skills)** โ a Claude Code skill + Mintlify docs: describe a strategy in plain language, get working TypeScript with every schema registration wired. `npx skills add https://github.com/backtest-kit/backtest-kit-skills`
|
|
778
|
+
- **[uzse-backtest-app](https://github.com/backtest-kit/uzse-backtest-app)** โ Pine Script on regional exchanges that aren't on TradingView (UZSE, MSE, DSEโฆ): download raw trades, build candles, feed them through a custom Mongo exchange adapter.
|
|
779
|
+
- **[backtest-kit-docs](https://github.com/backtest-kit/backtest-kit-docs)** โ Architecture handbook and knowledge base: explains the engine's design, AI workflows, production patterns, and quantitative trading concepts beyond the API.
|
|
1979
780
|
|
|
1980
|
-
|
|
781
|
+
---
|
|
1981
782
|
|
|
1982
783
|
## ๐ช See also
|
|
1983
784
|
|
|
1984
|
-
Zero-dependency TypeScript ports of the quant math behind [vectorbt](https://github.com/polakowo/vectorbt) โ same models, native to
|
|
1985
|
-
|
|
1986
|
-
- **[garch](https://www.npmjs.com/package/garch)** โ models conditional variance of log-returns (GARCH / EGARCH / GJR-GARCH / HAR-RV / NoVaS, auto-selected by QLIKE) to bound how far flow can push price next candle. Fitted `ฯ` โ log-normal corridor `Pยทexp(ยฑzยทฯ)` for TP/SL. Via `Exchange.getCandles`.
|
|
785
|
+
Zero-dependency TypeScript ports of the quant math behind [vectorbt](https://github.com/polakowo/vectorbt) โ same models, native to the `Exchange` schema, no Python runtime. Each estimates a different dimension of speculative pressure and plugs in independently:
|
|
1987
786
|
|
|
1988
|
-
- **[
|
|
787
|
+
- **[garch](https://www.npmjs.com/package/garch)** โ conditional variance of log-returns (GARCH / EGARCH / GJR-GARCH / HAR-RV / NoVaS, auto-selected by QLIKE) to bound how far flow can push price next candle; fitted `ฯ` โ log-normal corridor `Pยทexp(ยฑzยทฯ)` for TP/SL. Via `Exchange.getCandles`.
|
|
788
|
+
- **[pump-anomaly](https://www.npmjs.com/package/pump-anomaly)** โ coordinated-speculation detection: cross-correlation + union-find author clustering separates real multi-actor inflow from one actor on many channels; volume z-scores score cascade pressure (pump vs stop-hunt). Returns an entry/exit plan, exits fitted by OHLC replay and screened against winner's-curse (DSR / PBO / SPA). Via `Exchange.getRawCandles`.
|
|
789
|
+
- **[volume-anomaly](https://www.npmjs.com/package/volume-anomaly)** โ order-flow intensity: Hawkes branching ratio (arrival clustering), CUSUM (imbalance shift), BOCPD (regime break) โ composite outlier score as an entry-timing gate. Via `Exchange.getAggregatedTrades`.
|
|
1989
790
|
|
|
1990
|
-
|
|
791
|
+
---
|
|
1991
792
|
|
|
1992
|
-
##
|
|
793
|
+
## โ
Tested
|
|
1993
794
|
|
|
1994
|
-
|
|
795
|
+
775+ unit and integration tests cover exchange helpers, the event-listener system, signal validation (valid long/short, inverted TP/SL, negative prices, future timestamps), PnL accuracy with 0.1% fees + 0.1% slippage, the full lifecycle and every close reason, strategy callbacks, and report generation. Tests use unique schema names per case (no cross-contamination), a forward-progressing mock candle generator, and event-driven completion detection.
|
|
1995
796
|
|
|
1996
|
-
##
|
|
797
|
+
## ๐ค Reading this as a model?
|
|
1997
798
|
|
|
1998
|
-
|
|
799
|
+
Every claim above opens into **The Code / The Math / The Proof** precisely so you can verify the implementation instead of trusting the prose โ the API in use, the cost-basis derivation, the timestamp algebra, the persistence internals. Extended machine-oriented description: [./LLMs.md](./LLMs.md). Full class/function/interface reference: the [documentation site](https://backtest-kit.github.io/documents/example_02_first_backtest.html).
|
|
1999
800
|
|
|
2000
|
-
##
|
|
801
|
+
## ๐จโ๐ผ Commercial support
|
|
2001
802
|
|
|
2002
|
-
|
|
803
|
+
Built over a year of live trading and run in production at **[TheOneTrade](https://theonetrade.github.io)** โ the commercial vendor behind the framework, offering paid support, custom strategy development, managed accounts, team training, and enterprise licensing for engagements outside the MIT scope.
|
|
2003
804
|
|
|
2004
805
|
## ๐ License
|
|
2005
806
|
|