backtest-kit 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/tripolskypetr/backtest-kit)
10
10
  [![npm](https://img.shields.io/npm/v/backtest-kit.svg?style=flat-square)](https://npmjs.org/package/backtest-kit)
11
11
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue)]()
12
+ [![Build](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml/badge.svg)](https://github.com/tripolskypetr/backtest-kit/actions/workflows/webpack.yml)
12
13
 
13
14
  Build reliable trading systems: backtest on historical data, deploy live bots with recovery, and optimize strategies using LLMs like Ollama.
14
15
 
@@ -212,83 +213,113 @@ temporal time context to your strategies.
212
213
  <summary>
213
214
  The Math
214
215
  </summary>
215
-
216
+
216
217
  For a candle with:
217
- - `timestamp` = candle open time
218
+ - `timestamp` = candle open time (openTime)
218
219
  - `stepMs` = interval duration (e.g., 60000ms for "1m")
219
220
  - Candle close time = `timestamp + stepMs`
220
221
 
221
- The candle is included if: `timestamp + stepMs < upperBoundary`
222
-
223
- - `getCandles(symbol, interval, limit)` - Returns data in range `(when - limit*interval, when)`
224
- - Fetches historical candles backwards from execution context time
225
- - Only fully closed candles are included (candle must close before `when`)
226
- - Lower bound: `candle.timestamp > sinceTimestamp` (exclusive)
227
- - Upper bound: `candle.timestamp + stepMs < when` (exclusive)
228
- - Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before current time
229
-
230
- - `getNextCandles(symbol, interval, limit)` - Returns data in range `(when, when + limit*interval)`
231
- - Fetches future candles forwards from execution context time (backtest only)
232
- - Only fully closed candles are included
233
- - Lower bound: `candle.timestamp > when` (exclusive)
234
- - Upper bound: `candle.timestamp + stepMs < endTime` (exclusive)
222
+ **Alignment:** All timestamps are aligned down to interval boundary.
223
+ For example, for 15m interval: 00:17 → 00:15, 00:44 → 00:30
224
+
225
+ **Adapter contract:**
226
+ - First candle.timestamp must equal aligned `since`
227
+ - Adapter must return exactly `limit` candles
228
+ - Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
229
+
230
+ **How `since` is calculated from `when`:**
231
+ - `when` = current execution context time (from AsyncLocalStorage)
232
+ - `alignedWhen` = `Math.floor(when / stepMs) * stepMs` (aligned down to interval boundary)
233
+ - `since` = `alignedWhen - limit * stepMs` (go back `limit` candles from aligned when)
234
+
235
+ **Boundary semantics (inclusive/exclusive):**
236
+ - `since` is always **inclusive** — first candle has `timestamp === since`
237
+ - Exactly `limit` candles are returned
238
+ - Last candle has `timestamp === since + (limit - 1) * stepMs` — **inclusive**
239
+ - For `getCandles`: `alignedWhen` is **exclusive** — candle at that timestamp is NOT included (it's a pending/incomplete candle)
240
+ - For `getRawCandles`: `eDate` is **exclusive** — candle at that timestamp is NOT included (it's a pending/incomplete candle)
241
+ - For `getNextCandles`: `alignedWhen` is **inclusive** — first candle starts at `alignedWhen` (it's the current candle for backtest, already closed in historical data)
242
+
243
+ - `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
244
+ - Aligns `when` down to interval boundary
245
+ - Calculates `since = alignedWhen - limit * stepMs`
246
+ - **since — inclusive**, first candle.timestamp === since
247
+ - **alignedWhen — exclusive**, candle at alignedWhen is NOT returned
248
+ - Range: `[since, alignedWhen)` — half-open interval
249
+ - Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending before aligned when
250
+
251
+ - `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
252
+ - Aligns `when` down to interval boundary
253
+ - `since = alignedWhen` (starts from aligned when, going forward)
254
+ - **since — inclusive**, first candle.timestamp === since
255
+ - Range: `[alignedWhen, alignedWhen + limit * stepMs)` — half-open interval
235
256
  - Throws error in live mode to prevent look-ahead bias
236
- - Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles after current time
257
+ - Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles starting from aligned when
237
258
 
238
259
  - `getRawCandles(symbol, interval, limit?, sDate?, eDate?)` - Flexible parameter combinations:
239
- - `(limit)` - Returns data in range `(now - limit*interval, now)`
240
- - `(limit, sDate)` - Returns data in range `(sDate, sDate + limit*interval)`
241
- - `(limit, undefined, eDate)` - Returns data in range `(eDate - limit*interval, eDate)`
242
- - `(undefined, sDate, eDate)` - Returns data in range `(sDate, eDate)`, limit calculated from range
243
- - `(limit, sDate, eDate)` - Returns data in range `(sDate, eDate)`, limit used only for fetch size
244
- - All combinations use: `candle.timestamp > sDate && candle.timestamp + stepMs < eDate`
245
- - All combinations respect exclusive boundaries and look-ahead bias protection
260
+ - `(limit)` - since = alignedWhen - limit * stepMs, range `[since, alignedWhen)`
261
+ - `(limit, sDate)` - since = align(sDate), returns `limit` candles forward, range `[since, since + limit * stepMs)`
262
+ - `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs, **eDate — exclusive**, range `[since, eDate)`
263
+ - `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range, **sDate — inclusive, eDate exclusive**, range `[sDate, eDate)`
264
+ - `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles, **sDate inclusive**
265
+ - All combinations respect look-ahead bias protection (eDate/endTime <= when)
246
266
 
247
267
  **Persistent Cache:**
248
- - Candle cache uses identical boundary semantics: `timestamp > sinceTimestamp && timestamp + stepMs < untilTimestamp`
249
- - Cache and runtime filters are synchronized to prevent inconsistencies
250
- - Cache returns only candles that match the requested time range exactly
268
+ - Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
269
+ - Returns all candles if found, null if any missing (cache miss)
270
+ - Cache and runtime use identical timestamp calculation logic
251
271
 
252
272
  </details>
253
273
 
254
- #### Boundary Semantics:
255
-
256
- All methods use **strict exclusive boundaries** - candles at exact boundary times are excluded. This prevents accidental inclusion of boundary conditions in backtest logic and ensures consistent behavior across cache and runtime.
274
+ #### Candle Timestamp Convention:
257
275
 
258
276
  According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
259
277
 
260
- **Key principle:** A candle is included only if it **fully closed** before the upper boundary.
278
+ **Key principles:**
279
+ - All timestamps are aligned down to interval boundary
280
+ - First candle.timestamp must equal aligned `since`
281
+ - Adapter must return exactly `limit` candles
282
+ - Sequential timestamps: `since + i * stepMs`
261
283
 
262
- ### 🔬 Technical Details: The `+ stepMs` Check
284
+ ### 🔬 Technical Details: Timestamp Alignment
263
285
 
264
- **Why check `candle.timestamp + stepMs < upperBoundary` instead of just `candle.timestamp < upperBoundary`?**
286
+ **Why align timestamps to interval boundaries?**
265
287
 
266
- Because a candle's **timestamp is when it opens**, not when it closes:
288
+ Because candle APIs return data starting from exact interval boundaries:
267
289
 
268
290
  ```typescript
269
- // 1-minute candle example:
270
- timestamp = 1000 // Candle opens at 1000ms
271
- stepMs = 60000 // Duration: 60 seconds
272
- // Candle closes at: 1000 + 60000 = 61000ms
273
-
274
- // Without + stepMs (WRONG):
275
- candle.timestamp < 61000
276
- 1000 < 61000 // TRUE - includes candle that hasn't finished yet!
277
-
278
- // With + stepMs (CORRECT):
279
- candle.timestamp + stepMs < 61000
280
- 1000 + 60000 < 61000
281
- 61000 < 61000 // FALSE - correctly excludes unclosed candle
291
+ // 15-minute interval example:
292
+ when = 1704067920000 // 00:12:00
293
+ step = 15 // 15 minutes
294
+ stepMs = 15 * 60000 // 900000ms
295
+
296
+ // Alignment: round down to nearest interval boundary
297
+ alignedWhen = Math.floor(when / stepMs) * stepMs
298
+ // = Math.floor(1704067920000 / 900000) * 900000
299
+ // = 1704067200000 (00:00:00)
300
+
301
+ // Calculate since for 4 candles backwards:
302
+ since = alignedWhen - 4 * stepMs
303
+ // = 1704067200000 - 4 * 900000
304
+ // = 1704063600000 (23:00:00 previous day)
305
+
306
+ // Expected candles:
307
+ // [0] timestamp = 1704063600000 (23:00)
308
+ // [1] timestamp = 1704064500000 (23:15)
309
+ // [2] timestamp = 1704065400000 (23:30)
310
+ // [3] timestamp = 1704066300000 (23:45)
282
311
  ```
283
312
 
284
- **This check is applied consistently across:**
285
- - ✅ `getCandles()` filtering
286
- - `getNextCandles()` filtering
287
- - ✅ `getRawCandles()` filtering (all parameter combinations)
288
- - ✅ Cache read operations
289
- - ✅ Cache write operations
313
+ **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.
314
+
315
+ **Validation is applied consistently across:**
316
+ - ✅ `getCandles()` - validates first timestamp and count
317
+ - ✅ `getNextCandles()` - validates first timestamp and count
318
+ - ✅ `getRawCandles()` - validates first timestamp and count
319
+ - ✅ Cache read - calculates exact expected timestamps
320
+ - ✅ Cache write - stores validated candles
290
321
 
291
- **Result:** Zero chance of including incomplete or "forming" candles in your strategy logic.
322
+ **Result:** Deterministic candle retrieval with exact timestamp matching.
292
323
 
293
324
  ### 💭 What this means:
294
325
  - `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`