backtest-kit 2.3.1 → 2.3.2

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
@@ -212,83 +212,95 @@ temporal time context to your strategies.
212
212
  <summary>
213
213
  The Math
214
214
  </summary>
215
-
215
+
216
216
  For a candle with:
217
- - `timestamp` = candle open time
217
+ - `timestamp` = candle open time (openTime)
218
218
  - `stepMs` = interval duration (e.g., 60000ms for "1m")
219
219
  - Candle close time = `timestamp + stepMs`
220
220
 
221
- The candle is included if: `timestamp + stepMs < upperBoundary`
221
+ **Alignment:** All timestamps are aligned down to interval boundary.
222
+ For example, for 15m interval: 00:17 → 00:15, 00:44 → 00:30
223
+
224
+ **Adapter contract:**
225
+ - First candle.timestamp must equal aligned `since`
226
+ - Adapter must return exactly `limit` candles
227
+ - Sequential timestamps: `since + i * stepMs` for i = 0..limit-1
222
228
 
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
+ - `getCandles(symbol, interval, limit)` - Returns exactly `limit` candles
230
+ - Aligns `when` down to interval boundary
231
+ - Calculates `since = alignedWhen - limit * stepMs`
232
+ - First candle.timestamp === since
233
+ - Example: `getCandles("BTCUSDT", "1m", 100)` returns 100 candles ending at aligned when
229
234
 
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)
235
+ - `getNextCandles(symbol, interval, limit)` - Returns exactly `limit` candles (backtest only)
236
+ - Aligns `when` down to interval boundary
237
+ - `since = alignedWhen` (starts from aligned when, going forward)
238
+ - First candle.timestamp === since
235
239
  - Throws error in live mode to prevent look-ahead bias
236
- - Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles after current time
240
+ - Example: `getNextCandles("BTCUSDT", "1m", 10)` returns next 10 candles after aligned when
237
241
 
238
242
  - `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
243
+ - `(limit)` - since = alignedWhen - limit * stepMs
244
+ - `(limit, sDate)` - since = align(sDate), returns `limit` candles forward
245
+ - `(limit, undefined, eDate)` - since = align(eDate) - limit * stepMs
246
+ - `(undefined, sDate, eDate)` - since = align(sDate), limit calculated from range
247
+ - `(limit, sDate, eDate)` - since = align(sDate), returns `limit` candles
248
+ - All combinations respect look-ahead bias protection (eDate/endTime <= when)
246
249
 
247
250
  **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
251
+ - Cache lookup calculates expected timestamps: `since + i * stepMs` for i = 0..limit-1
252
+ - Returns all candles if found, null if any missing (cache miss)
253
+ - Cache and runtime use identical timestamp calculation logic
251
254
 
252
255
  </details>
253
256
 
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.
257
+ #### Candle Timestamp Convention:
257
258
 
258
259
  According to this `timestamp` of a candle in backtest-kit is exactly the `openTime`, not ~~`closeTime`~~
259
260
 
260
- **Key principle:** A candle is included only if it **fully closed** before the upper boundary.
261
+ **Key principles:**
262
+ - All timestamps are aligned down to interval boundary
263
+ - First candle.timestamp must equal aligned `since`
264
+ - Adapter must return exactly `limit` candles
265
+ - Sequential timestamps: `since + i * stepMs`
261
266
 
262
- ### 🔬 Technical Details: The `+ stepMs` Check
267
+ ### 🔬 Technical Details: Timestamp Alignment
263
268
 
264
- **Why check `candle.timestamp + stepMs < upperBoundary` instead of just `candle.timestamp < upperBoundary`?**
269
+ **Why align timestamps to interval boundaries?**
265
270
 
266
- Because a candle's **timestamp is when it opens**, not when it closes:
271
+ Because candle APIs return data starting from exact interval boundaries:
267
272
 
268
273
  ```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
274
+ // 15-minute interval example:
275
+ when = 1704067920000 // 00:12:00
276
+ step = 15 // 15 minutes
277
+ stepMs = 15 * 60000 // 900000ms
278
+
279
+ // Alignment: round down to nearest interval boundary
280
+ alignedWhen = Math.floor(when / stepMs) * stepMs
281
+ // = Math.floor(1704067920000 / 900000) * 900000
282
+ // = 1704067200000 (00:00:00)
283
+
284
+ // Calculate since for 4 candles backwards:
285
+ since = alignedWhen - 4 * stepMs
286
+ // = 1704067200000 - 4 * 900000
287
+ // = 1704063600000 (23:00:00 previous day)
288
+
289
+ // Expected candles:
290
+ // [0] timestamp = 1704063600000 (23:00)
291
+ // [1] timestamp = 1704064500000 (23:15)
292
+ // [2] timestamp = 1704065400000 (23:30)
293
+ // [3] timestamp = 1704066300000 (23:45)
282
294
  ```
283
295
 
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
296
+ **Validation is applied consistently across:**
297
+ - ✅ `getCandles()` - validates first timestamp and count
298
+ - ✅ `getNextCandles()` - validates first timestamp and count
299
+ - ✅ `getRawCandles()` - validates first timestamp and count
300
+ - ✅ Cache read - calculates exact expected timestamps
301
+ - ✅ Cache write - stores validated candles
290
302
 
291
- **Result:** Zero chance of including incomplete or "forming" candles in your strategy logic.
303
+ **Result:** Deterministic candle retrieval with exact timestamp matching.
292
304
 
293
305
  ### 💭 What this means:
294
306
  - `getCandles()` always returns data UP TO the current backtest timestamp using `async_hooks`