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.
Files changed (2) hide show
  1. package/README.md +524 -1723
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # ๐Ÿงฟ Backtest Kit
4
4
 
5
- > A TypeScript framework for backtesting and live trading strategies on multi-asset, crypto, forex or [DEX (peer-to-peer marketplace)](https://en.wikipedia.org/wiki/Decentralized_finance#Decentralized_exchanges), spot, futures with crash-safe persistence, signal validation, and AI optimization.
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
  ![screenshot](https://raw.githubusercontent.com/tripolskypetr/backtest-kit/HEAD/assets/screenshots/screenshot16.png)
8
8
 
@@ -11,1995 +11,796 @@
11
11
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
12
12
  [![Build](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml/badge.svg)](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
13
13
 
14
- Build reliable trading systems: backtest on historical data, deploy live bots with recovery, and optimize strategies using LLMs like Ollama.
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
- ๐Ÿ“š **[API Reference](https://backtest-kit.github.io/documents/example_02_first_backtest.html)** | ๐ŸŒŸ **[Quick Start](https://github.com/tripolskypetr/backtest-kit/tree/master/example)** | **๐Ÿ“ฐ [Article](https://backtest-kit.github.io/documents/article_07_ai_news_trading_signals.html)**
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
- ## ๐Ÿš€ Quick Start
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
- > **New to backtest-kit?** The fastest way to get a real, production-ready setup is to clone the [reference implementation](https://github.com/tripolskypetr/backtest-kit/tree/master/example) โ€” a fully working news-sentiment AI trading system with LLM forecasting, multi-timeframe data, and a documented February 2026 backtest. Start there instead of from scratch.
20
+ ---
21
21
 
22
- ### ๐ŸŽฏ The Casual Way: CLI Init
22
+ ## Start here
23
23
 
24
- > **Minimal scaffold โ€” all boilerplate stays inside `@backtest-kit/cli`:**
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
- ```bash
27
- npx @backtest-kit/cli --init --output backtest-kit-project
28
- cd backtest-kit-project
29
- npm install
30
- npm start
31
- ```
32
-
33
- The generated project contains only your strategy files. There is no bootstrap, exchange registration, or runner code to maintain โ€” all of that lives inside `@backtest-kit/cli` and is invoked via `npm start`. Library documentation is fetched automatically into `docs/lib/` on init.
34
-
35
- ### ๐Ÿ—๏ธ Alternative: Sidekick CLI
36
-
37
- > **Full-control scaffold โ€” all wiring is in your project files:**
26
+ <details>
27
+ <summary>The Code</summary>
38
28
 
39
29
  ```bash
40
- npx -y @backtest-kit/sidekick my-trading-bot
41
- cd my-trading-bot
42
- npm start
43
- ```
44
-
45
- Sidekick generates a project where the exchange adapter, frame definitions, risk rules, strategy logic, and runner script all live as editable source files inside the project. Use it when you need full visibility and control over every part of the setup.
46
-
47
- ### ๐Ÿณ Running in Docker
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
- > **Automatic restarts โ€” Zero-downtime trading:**
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
- ```bash
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
- Install the core library and peer dependencies manually. Use this approach when integrating backtest-kit into an existing project or when you need full control over your package setup.
69
-
70
- ## โœจ Why Choose Backtest Kit?
71
-
72
- - ๐Ÿš€ **Production-Ready**: Seamless switch between backtest/live modes; identical code across environments.
73
- - ๐Ÿ’พ **Crash-Safe**: Atomic persistence recovers states after crashes, preventing duplicates or losses.
74
- - โœ… **Validation**: Checks signals for TP/SL logic, risk/reward ratios, whipsaw protection and portfolio limits.
75
- - ๐Ÿ”„ **Efficient Execution**: Streaming architecture for large datasets; VWAP pricing for realism.
76
- - ๐Ÿšช **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, addRiskSchema } from 'backtest-kit';
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 exchange = new ccxt.binance();
133
- const ohlcv = await exchange.fetchOHLCV(symbol, interval, since.getTime(), limit);
134
- return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({ 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: (symbol, price) => price.toFixed(2),
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
- // Risk profile
141
- addRiskSchema({
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
- // Time frame
161
- addFrameSchema({
162
- frameName: '1d-test',
163
- interval: '1m',
164
- startDate: new Date('2025-12-01'),
165
- endDate: new Date('2025-12-02'),
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
- ### ๐Ÿ’ก Example Strategy (with LLM)
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
- addStrategySchema({
177
- strategyName: 'llm-strategy',
178
- interval: '5m',
179
- riskName: 'demo',
180
- getSignal: async (symbol) => {
79
+ ---
181
80
 
182
- const candles1h = await getCandles(symbol, "1h", 24);
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
- const messages = await getMessages(symbol, {
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
- const resultId = uuid();
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
- await dumpAgentAnswer({
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
- await dumpRecord({
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
- return { ...signal, id: resultId };
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
- ### ๐Ÿงช Run Backtest
217
- ```typescript
218
- import { Backtest, listenSignalBacktest, listenDoneBacktest } from 'backtest-kit';
93
+ <details>
94
+ <summary>The Math</summary>
219
95
 
220
- Backtest.background('BTCUSDT', {
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
- listenSignalBacktest((event) => console.log(event));
227
- listenDoneBacktest(async (event) => {
228
- await Backtest.dump(event.symbol, event.strategyName); // Generate report
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
- ### ๐Ÿ“ˆ Run Live Trading
233
- ```typescript
234
- import { Live, listenSignalLive } from 'backtest-kit';
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
- Live.background('BTCUSDT', {
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
- listenSignalLive((event) => console.log(event));
242
- ```
111
+ </details>
243
112
 
244
- ### ๐Ÿ“ก Monitoring & Events
113
+ <details>
114
+ <summary>The Code</summary>
245
115
 
246
- - Use `listenRisk`, `listenError`, `listenPartialProfit/Loss` for alerts.
247
- - Dump reports: `Backtest.dump()`, `Live.dump()`.
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
- ## ๐ŸŒ Global Configuration
129
+ The bias you can't introduce by hand is the bias you'll never debug in production.
250
130
 
251
- Customize via `setConfig()`:
131
+ </details>
252
132
 
253
- - `CC_SCHEDULE_AWAIT_MINUTES`: Pending timeout (default: 120).
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
- ## ๐Ÿ’ป Developer Note
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
- Backtest Kit is **not a data-processing library** - it is a **time execution engine**. Think of the engine as an **async stream of time**, where your strategy is evaluated step by step.
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
- ### ๐Ÿ” How PNL Works
139
+ <details>
140
+ <summary>The Code</summary>
261
141
 
262
- These three functions work together to dynamically manage the position. To reduce position linearity, by default, each DCA entry is formatted as a fixed **unit of $100**. This can be changed. No mathematical knowledge is required.
142
+ ```typescript
143
+ // Backtest โ€” a historical frame drives the clock
144
+ Backtest.background('BTCUSDT', { strategyName, exchangeName, frameName });
263
145
 
264
- **Public API:**
265
- - **`commitAverageBuy`** โ€” adds a new DCA entry. By default, **only accepted when current price is below a new low**. Silently rejected otherwise. This prevents averaging up. Can be overridden using `setConfig`
266
- - **`commitPartialProfit`** โ€” closes X% of the position at a profit. Locks in gains while keeping exposure.
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
- <details>
270
- <summary>
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
- #### Internals
153
+ And one engine, two ways to consume it โ€” pick by use case, not by capability:
368
154
 
369
- **`priceOpen`** is the harmonic mean of all accepted DCA entries. After each partial close (`commitPartialProfit` or `commitPartialLoss`), the remaining cost basis is carried forward into the harmonic mean calculation for subsequent entries โ€” so `priceOpen` shifts after every partial, which in turn changes whether the next `commitAverageBuy` call will be accepted.
155
+ ```typescript
156
+ // Event-driven (production bots, monitoring)
157
+ Backtest.background('BTCUSDT', config);
158
+ listenSignalBacktest(e => {/* โ€ฆ */});
370
159
 
371
- ### ๐Ÿ” How Broker Transactional Integrity Works
160
+ // Async iterator (research, scripts, LLM agents)
161
+ for await (const event of Backtest.run('BTCUSDT', config)) { /* signal | progress | done */ }
162
+ ```
372
163
 
373
- `Broker.useBrokerAdapter` connects a live exchange (ccxt, Binance, etc.) to the framework with transaction safety. Every commit method fires **before** the internal position state mutates. If the exchange rejects the order, the fill times out, or the network fails, the adapter throws, the mutation is skipped, and backtest-kit retries automatically on the next tick.
164
+ </details>
374
165
 
375
166
  <details>
376
- <summary>
377
- The code
378
- </summary>
379
-
380
- **Spot**
167
+ <summary>The Proof</summary>
381
168
 
382
- ```typescript
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
- const FILL_POLL_INTERVAL_MS = 10_000;
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
- * Cancel all orders in parallel โ€” allSettled so a single failure (already filled,
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
- * Place a limit order and poll until filled (status === "closed").
477
- * On timeout: cancel the order, settle, check partial fill and sell it via market,
478
- * restore SL/TP on remaining position so it is never left unprotected, then throw.
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
- for (let i = 0; i < FILL_POLL_ATTEMPTS; i++) {
491
- await sleep(FILL_POLL_INTERVAL_MS);
492
- const status = await exchange.fetchOrder(order.id, symbol);
493
- if (status.status === "closed") {
494
- return;
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
- await exchange.cancelOrder(order.id, symbol);
499
-
500
- // Wait for Binance to settle the cancellation before reading filled qty
501
- await sleep(CANCEL_SETTLE_MS);
201
+ </details>
502
202
 
503
- const final = await exchange.fetchOrder(order.id, symbol);
504
- const filledQty = final.filled ?? 0;
203
+ ### 4. The state that can't be corrupted because it can't be expressed
505
204
 
506
- if (filledQty > 0) {
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
- // Restore SL/TP on remaining position so it is not left unprotected during retry
513
- if (restore) {
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
- throw new Error(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ€” partial fill rolled back, backtest-kit will retry`);
522
- }
210
+ Each state exposes only the data that is meaningful in that state, so the wrong access never type-checks:
523
211
 
524
- Broker.useBrokerAdapter(
525
- class implements IBroker {
526
-
527
- async waitForInit(): Promise<void> {
528
- await getSpotExchange();
529
- }
530
-
531
- async onSignalOpenCommit(payload: BrokerSignalOpenPayload): Promise<void> {
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
- **Futures**
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
- ```typescript
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
- const FILL_POLL_INTERVAL_MS = 10_000;
815
- const FILL_POLL_ATTEMPTS = 10;
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
- * Place a limit order and poll until filled (status === "closed").
901
- * On timeout: cancel the order, settle, check partial fill and close it via market,
902
- * restore SL/TP on remaining position so it is never left unprotected, then throw.
903
- *
904
- * positionSide is forwarded into rollback market order so hedge mode accounts
905
- * correctly route the close without -4061 error.
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
- const status = await exchange.fetchOrder(order.id, symbol);
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
- // Wait for Binance to settle the cancellation before reading filled qty
929
- await sleep(CANCEL_SETTLE_MS);
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(`Limit order ${order.id} [${side} ${qty} ${symbol} @ ${price}] not filled in time โ€” partial fill rolled back, backtest-kit will retry`);
264
+ throw new Error('not filled in time โ€” partial fill rolled back, backtest-kit will retry');
954
265
  }
266
+ ```
955
267
 
956
- Broker.useBrokerAdapter(
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
- </details>
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
- Signal open/close events are routed automatically via an internal event bus once `Broker.enable()` is called. **No manual wiring needed.** All other operations (`partialProfit`, `trailingStop`, `breakeven`, `averageBuy`) are intercepted explicitly before the corresponding state mutation.
1272
-
1273
- ### ๐Ÿ” How Cron Works
291
+ ```bash
292
+ npx @backtest-kit/cli --brokerdebug --commit signal-open --symbol BTCUSDT
293
+ ```
1274
294
 
1275
- `Cron` is a periodic / fire-once scheduler that runs in **virtual time** โ€” the same time stream your strategies see in backtest mode. Handlers fire on candle-interval boundaries (`1m`, `5m`, `1h`, `1d`, โ€ฆ) and are coordinated across parallel `Backtest.background(symbol, ...)` runs so the same boundary never produces two concurrent invocations.
295
+ </details>
1276
296
 
1277
- **Public API:**
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
- **Two modes per `interval`:**
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
- **Two scopes per `symbols`:**
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
- <summary>
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
- // Per-symbol fan-out โ€” fires once per hour per whitelisted symbol.
1310
- Cron.register({
1311
- name: "fetch-funding",
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
- // Fire-once warm-up โ€” runs once globally on the very first tick.
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
- // Wire Cron to the engine once at startup. After this every strategy tick is
1328
- // forwarded into Cron automatically โ€” no manual listener wiring needed.
1329
- Cron.enable();
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
- for (const symbol of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "TRXUSDT"]) {
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
- // On shutdown:
1336
- // Cron.disable();
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
- #### Internals
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
- Coordination keys are built as `${name}:${alignedMs}:${symbol?}:g${generation}`. Parallel backtests that hit the same key share a single in-flight promise (mutex semantics): the first opens the slot and runs the handler, others `await` the same promise and release together. After `.finally()` the slot is removed and the next boundary creates a fresh promise. Fire-once entries additionally record a `_firedOnce` mark on success so subsequent ticks skip them โ€” a failed handler is **not** marked, so it retries on the next tick. The generation suffix isolates re-registrations: a late write from a still-in-flight handler of a previous `register()` carries the old generation and never collides with the new entry.
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
- ### ๐Ÿ” How getCandles Works
334
+ ```typescript
335
+ import { addStrategySchema, listenActivePing, Position,
336
+ commitAverageBuy, commitClosePending,
337
+ getPositionEntries, getPositionEntryOverlap, getPositionPnlPercent } from 'backtest-kit';
1348
338
 
1349
- backtest-kit uses Node.js `AsyncLocalStorage` to automatically provide
1350
- temporal time context to your strategies.
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
- <details>
1353
- <summary>
1354
- The Math
1355
- </summary>
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
- </details>
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
- #### Candle Timestamp Convention:
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
- According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
362
+ </details>
1417
363
 
1418
- **Key principles:**
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
- ### ๐Ÿ” How getOrderBook Works
368
+ <details>
369
+ <summary>The Code</summary>
1426
370
 
1427
- Order book fetching uses the same temporal alignment as candles, but with a configurable time offset window instead of candle intervals.
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
- <details>
1430
- <summary>
1431
- The Math
1432
- </summary>
391
+ listenRisk(async (event) => { await Risk.dump(event.symbol, event.strategyName); }); // every rejection, logged
392
+ ```
1433
393
 
1434
- **Time range calculation:**
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
- **Adapter contract:**
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
- **Example with CC_ORDER_BOOK_TIME_OFFSET_MINUTES = 10:**
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
- alignedTo = Math.floor(1704067920000 / 600000) * 600000
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
- to = 1704067800000 // 00:10:00 UTC
1457
- from = 1704067200000 // 00:00:00 UTC
1458
- ```
1459
- </details>
402
+ <details>
403
+ <summary>The Proof</summary>
1460
404
 
1461
- #### Order Book Timestamp Convention:
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
- Unlike candles, most exchanges (e.g. Binance `GET /api/v3/depth`) only expose the **current** order book with no historical query support โ€” for backtest you must provide your own snapshot storage.
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
- **Key principles:**
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
- ### ๐Ÿ” How getAggregatedTrades Works
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
- Aggregated trades fetching uses the same look-ahead bias protection as candles - `to` is always aligned down to the nearest minute boundary so future trades are never visible to the strategy.
420
+ ```typescript
421
+ import { Backtest, warmCandles } from 'backtest-kit';
1474
422
 
1475
- **Key principles:**
1476
- - `to` is always aligned down to the 1-minute boundary โ€” prevents look-ahead bias
1477
- - Without `limit`: returns one full window (`CC_AGGREGATED_TRADES_MAX_MINUTES`)
1478
- - With `limit`: paginates backwards until collected, then slices to most recent `limit`
1479
- - Adapter receives `(symbol, from, to, backtest)` โ€” may ignore `from`/`to` in live mode
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
- <details>
1482
- <summary>
1483
- The Math
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
- **Time range calculation:**
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
- **Without `limit`:** fetches a single window and returns it as-is.
436
+ ### 9. When `./dump/` stops being enough
1493
437
 
1494
- **With `limit`:** paginates backwards in `CC_AGGREGATED_TRADES_MAX_MINUTES` chunks until at least `limit` trades are collected, then slices to the most recent `limit` trades.
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
- **Example with CC_AGGREGATED_TRADES_MAX_MINUTES = 60, limit = 200:**
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
- Window 1: from = 00:12:00 โˆ’ 59m = 23:13:00
1503
- to = 00:12:00
1504
- โ†’ got 120 trades โ€” not enough
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
- Window 2: from = 23:13:00 โˆ’ 59m = 22:14:00
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
- result = last 200 of 220 (most recent)
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
- **Adapter contract:**
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
- #### Aggregated Trades Timestamp Convention:
461
+ ### 10. A Sharpe of 10,000,000 is a bug, not an edge
1521
462
 
1522
- **Compatible with:** [garch](https://www.npmjs.com/package/garch) for volatility modelling and [volume-anomaly](https://www.npmjs.com/package/volume-anomaly) for detecting abnormal trade volume โ€” both accept the same `from`/`to` time range format that `getAggregatedTrades` produces.
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
- ### ๐Ÿ”ฌ Technical Details: Timestamp Alignment
465
+ <details>
466
+ <summary>The Math</summary>
1525
467
 
1526
- **Why align timestamps to interval boundaries?**
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
- Because candle APIs return data starting from exact interval boundaries:
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
- ```typescript
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
- **Validation is applied consistently across:**
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
- **Result:** Deterministic candle retrieval with exact timestamp matching.
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
- ### ๐Ÿ• Timezone Warning: Candle Boundaries Are UTC-Based
483
+ <details>
484
+ <summary>The Code</summary>
1565
485
 
1566
- All candle timestamp alignment uses UTC (Unix epoch). For intervals like `4h`, boundaries are `00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC`. If your local timezone offset is not a multiple of the interval, the `since` timestamps will look "uneven" in local time.
486
+ ```typescript
487
+ import { Cron, Backtest } from 'backtest-kit';
1567
488
 
1568
- For example, in UTC+5 the same 4h candle request logs as:
489
+ Cron.register({ name: 'tg-parser', interval: '1h', // global, hourly
490
+ handler: async ({ when }) => { await parseTelegramSignals(when); } });
1569
491
 
1570
- ```
1571
- since: Sat Sep 20 2025 13:00:00 GMT+0500 โ† looks uneven (13:00)
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
- But in UTC these are perfectly aligned 4h boundaries:
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
- Use `toUTCString()` or `toISOString()` in callbacks to see the actual aligned UTC times.
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
- ## ๐Ÿง  Two Ways to Run the Engine
505
+ ### 12. You shouldn't have to abandon TradingView or Python to use TypeScript
1597
506
 
1598
- Backtest Kit exposes the same runtime in two equivalent forms. Both approaches use **the same engine and guarantees** - only the consumption model differs.
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
- ### 1๏ธโƒฃ Event-driven (background execution)
509
+ <details>
510
+ <summary>The Code</summary>
1601
511
 
1602
- Suitable for production bots, monitoring, and long-running processes.
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
- Backtest.background('BTCUSDT', config);
1606
-
1607
- listenSignalBacktest(event => { /* handle signals */ });
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
- ### 2๏ธโƒฃ Async Iterator (pull-based execution)
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
- for await (const event of Backtest.run('BTCUSDT', config)) {
1617
- // signal | trade | progress | done
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
- ## โš”๏ธ Think of it as...
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
- Unlike cloud-based platforms, backtest-kit runs entirely in your environment. You own the entire stack from data ingestion to live execution. In addition to Ollama, you can use [neural-trader](https://www.npmjs.com/package/neural-trader) in `getSignal` function or any other third party library
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
- - No C#/C++ required - pure TypeScript/JavaScript
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
- ## ๐ŸŒ Ecosystem
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
- ### @backtest-kit/cli
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
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/cli)** ๐Ÿ“Ÿ
550
+ <details>
551
+ <summary>The Code</summary>
1641
552
 
1642
- The **@backtest-kit/cli** package is a zero-boilerplate CLI runner for backtest-kit strategies. Point it at your strategy file and run backtests, paper trading, or live bots โ€” no infrastructure code required.
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
- #### Key Features
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
- #### Use Case
1653
- The fastest way to run any backtest-kit strategy from the command line. Instead of writing boilerplate for storage, notifications, candle caching, and signal logging, add one dependency and wire up your `package.json` scripts. Works equally well for a single-strategy project or a monorepo with dozens of strategies in separate subdirectories.
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
- #### Get Started
1656
- ```bash
1657
- npx -y @backtest-kit/cli --init
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
- ### @backtest-kit/pinets
590
+ </details>
1662
591
 
1663
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/pinets)** ๐Ÿ“œ
592
+ ---
1664
593
 
1665
- The **@backtest-kit/pinets** package lets you run TradingView Pine Script strategies directly in Node.js. Port your existing Pine Script indicators to backtest-kit with zero rewrite using the [PineTS](https://github.com/QuantForgeOrg/PineTS) runtime.
594
+ ## The API assumes you will make every mistake
1666
595
 
1667
- #### Key Features
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
- #### Use Case
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
- #### Get Started
1678
- ```bash
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
- ### @backtest-kit/graph
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
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/graph)** ๐Ÿ”—
613
+ const HARD_STOP = 25, TARGET_PROFIT = 3, STEP = 100, MAX_STEPS = 10;
1686
614
 
1687
- The **@backtest-kit/graph** package lets you compose backtest-kit computations as a typed directed acyclic graph (DAG). Define source nodes that fetch market data and output nodes that compute derived values โ€” then resolve the whole graph in topological order with automatic parallelism.
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
- #### Key Features
1690
- - ๐Ÿ”Œ **DAG Execution**: Nodes are resolved bottom-up in topological order with `Promise.all` parallelism
1691
- - ๐Ÿ”’ **Type-Safe Values**: TypeScript infers the return type of every node through the graph via generics
1692
- - ๐Ÿงฑ **Two APIs**: Low-level `INode` for runtime/storage, high-level `sourceNode` + `outputNode` builders for authoring
1693
- - ๐Ÿ’พ **DB-Ready Serialization**: `serialize` / `deserialize` convert the graph to a flat `IFlatNode[]` list with `id` / `nodeIds`
1694
- - ๐ŸŒ **Context-Aware Fetch**: `sourceNode` receives `(symbol, when, exchangeName)` from the execution context automatically
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
- #### Use Case
1697
- Perfect for multi-timeframe strategies where multiple Pine Script or indicator computations must be combined. Instead of manually chaining async calls, define each computation as a node and let the graph resolve dependencies in parallel. Adding a new filter or timeframe requires no changes to the existing wiring.
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
- #### Get Started
1700
- ```bash
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
- ### @backtest-kit/ui
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
- #### Key Features
1712
- - ๐Ÿ“ˆ **Interactive Charts**: Candlestick visualization with Lightweight Charts (1m, 15m, 1h timeframes)
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
- #### Use Case
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
- #### Get Started
1723
- ```bash
1724
- npm install @backtest-kit/ui backtest-kit ccxt
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
- ### @backtest-kit/mongo
660
+ </details>
1729
661
 
1730
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/mongo)** ๐Ÿ’พ
662
+ ---
1731
663
 
1732
- The **@backtest-kit/mongo** package replaces the default file-based `./dump/` storage with MongoDB as the source of truth and Redis as an O(1) lookup cache. All 15 `IPersist*Instance` contracts from backtest-kit are implemented โ€” strategy code stays unchanged.
664
+ ## Receipts
1733
665
 
1734
- #### Key Features
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
- #### Use Case
1743
- Perfect for production deployments where the default file-based storage is a bottleneck or a reliability concern. During backtests, backtest-kit performs thousands of context-keyed reads per second โ€” Redis eliminates the per-request B-tree traversal and makes repeated reads effectively free. MongoDB provides durability, atomic upserts, and a queryable signal history that survives process restarts.
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
- #### Get Started
1746
- ```bash
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
- ### @backtest-kit/ollama
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
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/ollama)** ๐Ÿค–
688
+ </details>
1754
689
 
1755
- The **@backtest-kit/ollama** package is a multi-provider LLM inference library that supports 10+ providers including OpenAI, Claude, DeepSeek, Grok, Mistral, Perplexity, Cohere, Alibaba, Hugging Face, and Ollama with unified API and automatic token rotation.
690
+ ---
1756
691
 
1757
- #### Key Features
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
- #### Use Case
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
- ### @backtest-kit/signals
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
- > **[Explore on NPM](https://www.npmjs.com/package/@backtest-kit/signals)** ๐Ÿ“Š
711
+ ---
1777
712
 
1778
- The **@backtest-kit/signals** package is a technical analysis and trading signal generation library designed for AI-powered trading systems. It computes 50+ indicators across 4 timeframes and generates markdown reports optimized for LLM consumption.
713
+ ## ๐ŸŒ Ecosystem
1779
714
 
1780
- #### Key Features
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
- #### Use Case
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
- #### Get Started
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
- npm install @backtest-kit/signals backtest-kit
722
+ npx -y @backtest-kit/cli --init
1793
723
  ```
1794
724
 
1795
-
1796
- ### @backtest-kit/sidekick
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
- npx -y @backtest-kit/sidekick my-trading-bot
1816
- cd my-trading-bot
1817
- npm start
728
+ npm install @backtest-kit/pinets pinets backtest-kit
1818
729
  ```
1819
730
 
1820
-
1821
- ## ๐Ÿ‘ช Community
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
- git clone https://github.com/backtest-kit/backtest-monorepo-parallel.git
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-ollama-crontab
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
- git clone https://github.com/backtest-kit/backtest-ollama-crontab.git
747
+ npm install @backtest-kit/mongo backtest-kit mongoose ioredis
1864
748
  ```
1865
749
 
1866
-
1867
- ### backtest-kit-redis-mongo-docker
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
- git clone https://github.com/backtest-kit/backtest-kit-redis-mongo-docker.git
753
+ npm install @backtest-kit/ollama agent-swarm-kit backtest-kit
1886
754
  ```
1887
755
 
1888
-
1889
- ### backtest-kit-skills
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
- npx skills add https://github.com/backtest-kit/backtest-kit-skills
759
+ npm install @backtest-kit/signals backtest-kit
1908
760
  ```
1909
761
 
1910
-
1911
- ### uzse-backtest-app
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
- git clone https://github.com/backtest-kit/uzse-backtest-app.git
765
+ npx -y @backtest-kit/sidekick my-trading-bot && cd my-trading-bot && npm start
1930
766
  ```
1931
767
 
1932
- ## ๐Ÿงฉ Strategy Examples
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
- Trains a feed-forward `TensorFlow` neural network (8โ†’6โ†’4โ†’1 architecture) every 8 hours to predict where the next candle will close within its high-low range. When current price is below predicted price, opens a LONG with 1% trailing take-profit.
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
- ## ๐Ÿ‘จโ€๐Ÿ’ผ Commercial Support
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
- > **[TheOneTrade](https://theonetrade.github.io)** ๐Ÿ’ผ
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
- **TheOneTrade** is the commercial vendor behind backtest-kit, providing paid support, custom strategy development, managed accounts, team training, and enterprise licensing. Contact for SLAs, private features, or quant team engagements that fall outside the MIT-licensed open-source scope.
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 backtest-kit's `Exchange` schema, no Python runtime. Each estimates a different dimension of speculative pressure and plugs in independently:
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
- - **[pump-anomaly](https://www.npmjs.com/package/pump-anomaly)** โ€” detects coordinated speculation: 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` (pretrain model in past).
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
- - **[volume-anomaly](https://www.npmjs.com/package/volume-anomaly)** โ€” quantifies 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`.
791
+ ---
1991
792
 
1992
- ## ๐Ÿค– Are you a robot?
793
+ ## โœ… Tested
1993
794
 
1994
- **For language models**: Read extended description in [./LLMs.md](./LLMs.md)
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
- ## โœ… Tested & Reliable
797
+ ## ๐Ÿค– Reading this as a model?
1997
798
 
1998
- 775+ tests cover validation, recovery, reports, and events.
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
- ## ๐Ÿค Contribute
801
+ ## ๐Ÿ‘จโ€๐Ÿ’ผ Commercial support
2001
802
 
2002
- Fork/PR on [GitHub](https://github.com/tripolskypetr/backtest-kit).
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