backtest-kit 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Build sophisticated trading systems with confidence. Backtest Kit empowers you t
12
12
 
13
13
  ## ✨ Why Choose Backtest Kit?
14
14
 
15
- - 🚀 **Production-Ready Architecture**: Seamlessly switch between backtest and live modes with robust error recovery and graceful shutdown mechanisms. Your strategy code remains identical across environments.
15
+ - 🚀 **Production-Ready Architecture**: Seamlessly switch between backtest and live modes with robust error recovery and graceful shutdown mechanisms. Your strategy code remains identical across environments.
16
16
 
17
17
  - 💾 **Crash-Safe Persistence**: Atomic file writes with automatic state recovery ensure no duplicate signals or lost data—even after crashes. Resume execution exactly where you left off. 🔄
18
18
 
@@ -46,7 +46,7 @@ Build sophisticated trading systems with confidence. Backtest Kit empowers you t
46
46
 
47
47
  - 🔒 **Safe Math & Robustness**: All metrics protected against NaN/Infinity with unsafe numeric checks. Returns N/A for invalid calculations. ✨
48
48
 
49
- - 🧪 **Comprehensive Test Coverage**: 123 unit and integration tests covering validation, PNL, callbacks, reports, performance tracking, walker, heatmap, position sizing, risk management, scheduled signals, and event system.
49
+ - 🧪 **Comprehensive Test Coverage**: 123 unit and integration tests covering validation, PNL, callbacks, reports, performance tracking, walker, heatmap, position sizing, risk management, scheduled signals, and event system.
50
50
 
51
51
  ---
52
52
 
@@ -227,6 +227,7 @@ Backtest.background("BTCUSDT", {
227
227
  - 🛡️ **Signal Lifecycle**: Type-safe state machine prevents invalid state transitions. 🚑
228
228
  - 📦 **Dependency Inversion**: Lazy-load components at runtime for modular, scalable designs. 🧩
229
229
  - 🔍 **Schema Reflection**: Runtime introspection with `listExchanges()`, `listStrategies()`, `listFrames()`. 📊
230
+ - 🔬 **Data Validation**: Automatic detection and rejection of incomplete candles from Binance API with anomaly checks.
230
231
 
231
232
  ---
232
233
 
@@ -261,7 +262,7 @@ Check out the sections below for detailed examples! 📚
261
262
 
262
263
  ### 1. Register Exchange Data Source
263
264
 
264
- You can plug any data sourceCCXT for live data or a database for faster backtesting:
265
+ You can plug any data source: CCXT for live data or a database for faster backtesting:
265
266
 
266
267
  ```typescript
267
268
  import { addExchange } from "backtest-kit";
package/build/index.cjs CHANGED
@@ -37,6 +37,44 @@ const GLOBAL_CONFIG = {
37
37
  * Default: 1440 minutes (1 day)
38
38
  */
39
39
  CC_MAX_SIGNAL_LIFETIME_MINUTES: 1440,
40
+ /**
41
+ * Number of retries for getCandles function
42
+ * Default: 3 retries
43
+ */
44
+ CC_GET_CANDLES_RETRY_COUNT: 3,
45
+ /**
46
+ * Delay between retries for getCandles function (in milliseconds)
47
+ * Default: 5000 ms (5 seconds)
48
+ */
49
+ CC_GET_CANDLES_RETRY_DELAY_MS: 5000,
50
+ /**
51
+ * Maximum allowed deviation factor for price anomaly detection.
52
+ * Price should not be more than this factor lower than reference price.
53
+ *
54
+ * Reasoning:
55
+ * - Incomplete candles from Binance API typically have prices near 0 (e.g., $0.01-1)
56
+ * - Normal BTC price ranges: $20,000-100,000
57
+ * - Factor 1000 catches prices below $20-100 when median is $20,000-100,000
58
+ * - Factor 100 would be too permissive (allows $200 when median is $20,000)
59
+ * - Factor 10000 might be too strict for low-cap altcoins
60
+ *
61
+ * Example: BTC at $50,000 median → threshold $50 (catches $0.01-1 anomalies)
62
+ */
63
+ CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR: 1000,
64
+ /**
65
+ * Minimum number of candles required for reliable median calculation.
66
+ * Below this threshold, use simple average instead of median.
67
+ *
68
+ * Reasoning:
69
+ * - Each candle provides 4 price points (OHLC)
70
+ * - 5 candles = 20 price points, sufficient for robust median calculation
71
+ * - Below 5 candles, single anomaly can heavily skew median
72
+ * - Statistical rule of thumb: minimum 7-10 data points for median stability
73
+ * - Average is more stable than median for small datasets (n < 20)
74
+ *
75
+ * Example: 3 candles = 12 points (use average), 5 candles = 20 points (use median)
76
+ */
77
+ CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN: 5,
40
78
  };
41
79
 
42
80
  const { init, inject, provide } = diKit.createActivator("backtest");
@@ -271,6 +309,92 @@ const INTERVAL_MINUTES$2 = {
271
309
  "6h": 360,
272
310
  "8h": 480,
273
311
  };
312
+ /**
313
+ * Validates that all candles have valid OHLCV data without anomalies.
314
+ * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
315
+ * Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
316
+ *
317
+ * @param candles - Array of candle data to validate
318
+ * @throws Error if any candles have anomalous OHLCV values
319
+ */
320
+ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
321
+ if (candles.length === 0) {
322
+ return;
323
+ }
324
+ // Calculate reference price (median or average depending on candle count)
325
+ const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
326
+ const validPrices = allPrices.filter(p => p > 0);
327
+ let referencePrice;
328
+ if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
329
+ // Use median for reliable statistics with enough data
330
+ const sortedPrices = [...validPrices].sort((a, b) => a - b);
331
+ referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
332
+ }
333
+ else {
334
+ // Use average for small datasets (more stable than median)
335
+ const sum = validPrices.reduce((acc, p) => acc + p, 0);
336
+ referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
337
+ }
338
+ if (referencePrice === 0) {
339
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
340
+ }
341
+ const minValidPrice = referencePrice / GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
342
+ for (let i = 0; i < candles.length; i++) {
343
+ const candle = candles[i];
344
+ // Check for invalid numeric values
345
+ if (!Number.isFinite(candle.open) ||
346
+ !Number.isFinite(candle.high) ||
347
+ !Number.isFinite(candle.low) ||
348
+ !Number.isFinite(candle.close) ||
349
+ !Number.isFinite(candle.volume) ||
350
+ !Number.isFinite(candle.timestamp)) {
351
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
352
+ }
353
+ // Check for negative values
354
+ if (candle.open <= 0 ||
355
+ candle.high <= 0 ||
356
+ candle.low <= 0 ||
357
+ candle.close <= 0 ||
358
+ candle.volume < 0) {
359
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
360
+ }
361
+ // Check for anomalously low prices (incomplete candle indicator)
362
+ if (candle.open < minValidPrice ||
363
+ candle.high < minValidPrice ||
364
+ candle.low < minValidPrice ||
365
+ candle.close < minValidPrice) {
366
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
367
+ `OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
368
+ `reference: ${referencePrice}, threshold: ${minValidPrice}`);
369
+ }
370
+ }
371
+ };
372
+ /**
373
+ * Retries the getCandles function with specified retry count and delay.
374
+ * @param dto - Data transfer object containing symbol, interval, and limit
375
+ * @param since - Date object representing the start time for fetching candles
376
+ * @param self - Instance of ClientExchange
377
+ * @returns Promise resolving to array of candle data
378
+ */
379
+ const GET_CANDLES_FN = async (dto, since, self) => {
380
+ let lastError;
381
+ for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
382
+ try {
383
+ const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit);
384
+ VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
385
+ return result;
386
+ }
387
+ catch (err) {
388
+ self.params.logger.warn(`ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`, {
389
+ error: functoolsKit.errorData(err),
390
+ message: functoolsKit.getErrorMessage(err),
391
+ });
392
+ lastError = err;
393
+ await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
394
+ }
395
+ }
396
+ throw lastError;
397
+ };
274
398
  /**
275
399
  * Client implementation for exchange data access.
276
400
  *
@@ -321,7 +445,7 @@ class ClientExchange {
321
445
  throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
322
446
  }
323
447
  const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
324
- const data = await this.params.getCandles(symbol, interval, since, limit);
448
+ const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
325
449
  // Filter candles to strictly match the requested range
326
450
  const whenTimestamp = this.params.execution.context.when.getTime();
327
451
  const sinceTimestamp = since.getTime();
@@ -359,7 +483,7 @@ class ClientExchange {
359
483
  if (endTime > now) {
360
484
  return [];
361
485
  }
362
- const data = await this.params.getCandles(symbol, interval, since, limit);
486
+ const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
363
487
  // Filter candles to strictly match the requested range
364
488
  const sinceTimestamp = since.getTime();
365
489
  const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= endTime);
@@ -1126,7 +1250,7 @@ class PersistSignalUtils {
1126
1250
  * async readValue(id) { return JSON.parse(await redis.get(id)); }
1127
1251
  * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1128
1252
  * }
1129
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
1253
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1130
1254
  * ```
1131
1255
  */
1132
1256
  usePersistSignalAdapter(Ctor) {
@@ -1141,16 +1265,16 @@ class PersistSignalUtils {
1141
1265
  * @example
1142
1266
  * ```typescript
1143
1267
  * // Custom adapter
1144
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
1268
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1145
1269
  *
1146
1270
  * // Read signal
1147
- * const signal = await PersistSignalAdaper.readSignalData("my-strategy", "BTCUSDT");
1271
+ * const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
1148
1272
  *
1149
1273
  * // Write signal
1150
- * await PersistSignalAdaper.writeSignalData(signal, "my-strategy", "BTCUSDT");
1274
+ * await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
1151
1275
  * ```
1152
1276
  */
1153
- const PersistSignalAdaper = new PersistSignalUtils();
1277
+ const PersistSignalAdapter = new PersistSignalUtils();
1154
1278
  const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
1155
1279
  const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
1156
1280
  const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
@@ -1523,7 +1647,7 @@ const WAIT_FOR_INIT_FN$1 = async (self) => {
1523
1647
  if (self.params.execution.context.backtest) {
1524
1648
  return;
1525
1649
  }
1526
- const pendingSignal = await PersistSignalAdaper.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
1650
+ const pendingSignal = await PersistSignalAdapter.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
1527
1651
  if (!pendingSignal) {
1528
1652
  return;
1529
1653
  }
@@ -1985,25 +2109,33 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
1985
2109
  let shouldActivate = false;
1986
2110
  let shouldCancel = false;
1987
2111
  if (scheduled.position === "long") {
1988
- // КРИТИЧНО: Сначала проверяем StopLoss (отмена приоритетнее активации)
1989
- // Отмена если цена упала СЛИШКОМ низко (ниже SL)
2112
+ // КРИТИЧНО для LONG:
2113
+ // - priceOpen > priceStopLoss (по валидации)
2114
+ // - Активация: low <= priceOpen (цена упала до входа)
2115
+ // - Отмена: low <= priceStopLoss (цена пробила SL)
2116
+ //
2117
+ // EDGE CASE: если low <= priceStopLoss И low <= priceOpen на ОДНОЙ свече:
2118
+ // => Отмена имеет ПРИОРИТЕТ! (SL пробит ДО или ВМЕСТЕ с активацией)
2119
+ // Сигнал НЕ открывается, сразу отменяется
1990
2120
  if (candle.low <= scheduled.priceStopLoss) {
1991
2121
  shouldCancel = true;
1992
2122
  }
1993
- // Long = покупаем дешевле, ждем падения цены ДО priceOpen
1994
- // Активируем только если НЕ пробит StopLoss
1995
2123
  else if (candle.low <= scheduled.priceOpen) {
1996
2124
  shouldActivate = true;
1997
2125
  }
1998
2126
  }
1999
2127
  if (scheduled.position === "short") {
2000
- // КРИТИЧНО: Сначала проверяем StopLoss (отмена приоритетнее активации)
2001
- // Отмена если цена выросла СЛИШКОМ высоко (выше SL)
2128
+ // КРИТИЧНО для SHORT:
2129
+ // - priceOpen < priceStopLoss (по валидации)
2130
+ // - Активация: high >= priceOpen (цена выросла до входа)
2131
+ // - Отмена: high >= priceStopLoss (цена пробила SL)
2132
+ //
2133
+ // EDGE CASE: если high >= priceStopLoss И high >= priceOpen на ОДНОЙ свече:
2134
+ // => Отмена имеет ПРИОРИТЕТ! (SL пробит ДО или ВМЕСТЕ с активацией)
2135
+ // Сигнал НЕ открывается, сразу отменяется
2002
2136
  if (candle.high >= scheduled.priceStopLoss) {
2003
2137
  shouldCancel = true;
2004
2138
  }
2005
- // Short = продаем дороже, ждем роста цены ДО priceOpen
2006
- // Активируем только если НЕ пробит StopLoss
2007
2139
  else if (candle.high >= scheduled.priceOpen) {
2008
2140
  shouldActivate = true;
2009
2141
  }
@@ -2143,10 +2275,15 @@ class ClientStrategy {
2143
2275
  pendingSignal,
2144
2276
  });
2145
2277
  this._pendingSignal = pendingSignal;
2278
+ // КРИТИЧНО: Всегда вызываем коллбек onWrite для тестирования persist storage
2279
+ // даже в backtest режиме, чтобы тесты могли перехватывать вызовы через mock adapter
2280
+ if (this.params.callbacks?.onWrite) {
2281
+ this.params.callbacks.onWrite(this.params.execution.context.symbol, this._pendingSignal, this.params.execution.context.backtest);
2282
+ }
2146
2283
  if (this.params.execution.context.backtest) {
2147
2284
  return;
2148
2285
  }
2149
- await PersistSignalAdaper.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
2286
+ await PersistSignalAdapter.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
2150
2287
  }
2151
2288
  /**
2152
2289
  * Performs a single tick of strategy execution.
@@ -10100,7 +10237,7 @@ exports.MethodContextService = MethodContextService;
10100
10237
  exports.Performance = Performance;
10101
10238
  exports.PersistBase = PersistBase;
10102
10239
  exports.PersistRiskAdapter = PersistRiskAdapter;
10103
- exports.PersistSignalAdaper = PersistSignalAdaper;
10240
+ exports.PersistSignalAdapter = PersistSignalAdapter;
10104
10241
  exports.PositionSize = PositionSize;
10105
10242
  exports.Schedule = Schedule;
10106
10243
  exports.Walker = Walker;
package/build/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createActivator } from 'di-kit';
2
2
  import { scoped } from 'di-scoped';
3
- import { memoize, makeExtendable, singleshot, getErrorMessage, not, trycatch, retry, Subject, randomString, errorData, ToolRegistry, isObject, sleep, resolveDocuments, str, queued } from 'functools-kit';
3
+ import { errorData, getErrorMessage, sleep, memoize, makeExtendable, singleshot, not, trycatch, retry, Subject, randomString, ToolRegistry, isObject, resolveDocuments, str, queued } from 'functools-kit';
4
4
  import fs, { mkdir, writeFile } from 'fs/promises';
5
5
  import path, { join } from 'path';
6
6
  import crypto from 'crypto';
@@ -35,6 +35,44 @@ const GLOBAL_CONFIG = {
35
35
  * Default: 1440 minutes (1 day)
36
36
  */
37
37
  CC_MAX_SIGNAL_LIFETIME_MINUTES: 1440,
38
+ /**
39
+ * Number of retries for getCandles function
40
+ * Default: 3 retries
41
+ */
42
+ CC_GET_CANDLES_RETRY_COUNT: 3,
43
+ /**
44
+ * Delay between retries for getCandles function (in milliseconds)
45
+ * Default: 5000 ms (5 seconds)
46
+ */
47
+ CC_GET_CANDLES_RETRY_DELAY_MS: 5000,
48
+ /**
49
+ * Maximum allowed deviation factor for price anomaly detection.
50
+ * Price should not be more than this factor lower than reference price.
51
+ *
52
+ * Reasoning:
53
+ * - Incomplete candles from Binance API typically have prices near 0 (e.g., $0.01-1)
54
+ * - Normal BTC price ranges: $20,000-100,000
55
+ * - Factor 1000 catches prices below $20-100 when median is $20,000-100,000
56
+ * - Factor 100 would be too permissive (allows $200 when median is $20,000)
57
+ * - Factor 10000 might be too strict for low-cap altcoins
58
+ *
59
+ * Example: BTC at $50,000 median → threshold $50 (catches $0.01-1 anomalies)
60
+ */
61
+ CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR: 1000,
62
+ /**
63
+ * Minimum number of candles required for reliable median calculation.
64
+ * Below this threshold, use simple average instead of median.
65
+ *
66
+ * Reasoning:
67
+ * - Each candle provides 4 price points (OHLC)
68
+ * - 5 candles = 20 price points, sufficient for robust median calculation
69
+ * - Below 5 candles, single anomaly can heavily skew median
70
+ * - Statistical rule of thumb: minimum 7-10 data points for median stability
71
+ * - Average is more stable than median for small datasets (n < 20)
72
+ *
73
+ * Example: 3 candles = 12 points (use average), 5 candles = 20 points (use median)
74
+ */
75
+ CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN: 5,
38
76
  };
39
77
 
40
78
  const { init, inject, provide } = createActivator("backtest");
@@ -269,6 +307,92 @@ const INTERVAL_MINUTES$2 = {
269
307
  "6h": 360,
270
308
  "8h": 480,
271
309
  };
310
+ /**
311
+ * Validates that all candles have valid OHLCV data without anomalies.
312
+ * Detects incomplete candles from Binance API by checking for abnormally low prices or volumes.
313
+ * Incomplete candles often have prices like 0.1 instead of normal 100,000 or zero volume.
314
+ *
315
+ * @param candles - Array of candle data to validate
316
+ * @throws Error if any candles have anomalous OHLCV values
317
+ */
318
+ const VALIDATE_NO_INCOMPLETE_CANDLES_FN = (candles) => {
319
+ if (candles.length === 0) {
320
+ return;
321
+ }
322
+ // Calculate reference price (median or average depending on candle count)
323
+ const allPrices = candles.flatMap((c) => [c.open, c.high, c.low, c.close]);
324
+ const validPrices = allPrices.filter(p => p > 0);
325
+ let referencePrice;
326
+ if (candles.length >= GLOBAL_CONFIG.CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN) {
327
+ // Use median for reliable statistics with enough data
328
+ const sortedPrices = [...validPrices].sort((a, b) => a - b);
329
+ referencePrice = sortedPrices[Math.floor(sortedPrices.length / 2)] || 0;
330
+ }
331
+ else {
332
+ // Use average for small datasets (more stable than median)
333
+ const sum = validPrices.reduce((acc, p) => acc + p, 0);
334
+ referencePrice = validPrices.length > 0 ? sum / validPrices.length : 0;
335
+ }
336
+ if (referencePrice === 0) {
337
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: cannot calculate reference price (all prices are zero)`);
338
+ }
339
+ const minValidPrice = referencePrice / GLOBAL_CONFIG.CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR;
340
+ for (let i = 0; i < candles.length; i++) {
341
+ const candle = candles[i];
342
+ // Check for invalid numeric values
343
+ if (!Number.isFinite(candle.open) ||
344
+ !Number.isFinite(candle.high) ||
345
+ !Number.isFinite(candle.low) ||
346
+ !Number.isFinite(candle.close) ||
347
+ !Number.isFinite(candle.volume) ||
348
+ !Number.isFinite(candle.timestamp)) {
349
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has invalid numeric values (NaN or Infinity)`);
350
+ }
351
+ // Check for negative values
352
+ if (candle.open <= 0 ||
353
+ candle.high <= 0 ||
354
+ candle.low <= 0 ||
355
+ candle.close <= 0 ||
356
+ candle.volume < 0) {
357
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has zero or negative values`);
358
+ }
359
+ // Check for anomalously low prices (incomplete candle indicator)
360
+ if (candle.open < minValidPrice ||
361
+ candle.high < minValidPrice ||
362
+ candle.low < minValidPrice ||
363
+ candle.close < minValidPrice) {
364
+ throw new Error(`VALIDATE_NO_INCOMPLETE_CANDLES_FN: candle[${i}] has anomalously low price. ` +
365
+ `OHLC: [${candle.open}, ${candle.high}, ${candle.low}, ${candle.close}], ` +
366
+ `reference: ${referencePrice}, threshold: ${minValidPrice}`);
367
+ }
368
+ }
369
+ };
370
+ /**
371
+ * Retries the getCandles function with specified retry count and delay.
372
+ * @param dto - Data transfer object containing symbol, interval, and limit
373
+ * @param since - Date object representing the start time for fetching candles
374
+ * @param self - Instance of ClientExchange
375
+ * @returns Promise resolving to array of candle data
376
+ */
377
+ const GET_CANDLES_FN = async (dto, since, self) => {
378
+ let lastError;
379
+ for (let i = 0; i !== GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT; i++) {
380
+ try {
381
+ const result = await self.params.getCandles(dto.symbol, dto.interval, since, dto.limit);
382
+ VALIDATE_NO_INCOMPLETE_CANDLES_FN(result);
383
+ return result;
384
+ }
385
+ catch (err) {
386
+ self.params.logger.warn(`ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`, {
387
+ error: errorData(err),
388
+ message: getErrorMessage(err),
389
+ });
390
+ lastError = err;
391
+ await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
392
+ }
393
+ }
394
+ throw lastError;
395
+ };
272
396
  /**
273
397
  * Client implementation for exchange data access.
274
398
  *
@@ -319,7 +443,7 @@ class ClientExchange {
319
443
  throw new Error(`ClientExchange unknown time adjust for interval=${interval}`);
320
444
  }
321
445
  const since = new Date(this.params.execution.context.when.getTime() - adjust * 60 * 1000);
322
- const data = await this.params.getCandles(symbol, interval, since, limit);
446
+ const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
323
447
  // Filter candles to strictly match the requested range
324
448
  const whenTimestamp = this.params.execution.context.when.getTime();
325
449
  const sinceTimestamp = since.getTime();
@@ -357,7 +481,7 @@ class ClientExchange {
357
481
  if (endTime > now) {
358
482
  return [];
359
483
  }
360
- const data = await this.params.getCandles(symbol, interval, since, limit);
484
+ const data = await GET_CANDLES_FN({ symbol, interval, limit }, since, this);
361
485
  // Filter candles to strictly match the requested range
362
486
  const sinceTimestamp = since.getTime();
363
487
  const filteredData = data.filter((candle) => candle.timestamp >= sinceTimestamp && candle.timestamp <= endTime);
@@ -1124,7 +1248,7 @@ class PersistSignalUtils {
1124
1248
  * async readValue(id) { return JSON.parse(await redis.get(id)); }
1125
1249
  * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
1126
1250
  * }
1127
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
1251
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1128
1252
  * ```
1129
1253
  */
1130
1254
  usePersistSignalAdapter(Ctor) {
@@ -1139,16 +1263,16 @@ class PersistSignalUtils {
1139
1263
  * @example
1140
1264
  * ```typescript
1141
1265
  * // Custom adapter
1142
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
1266
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
1143
1267
  *
1144
1268
  * // Read signal
1145
- * const signal = await PersistSignalAdaper.readSignalData("my-strategy", "BTCUSDT");
1269
+ * const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
1146
1270
  *
1147
1271
  * // Write signal
1148
- * await PersistSignalAdaper.writeSignalData(signal, "my-strategy", "BTCUSDT");
1272
+ * await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
1149
1273
  * ```
1150
1274
  */
1151
- const PersistSignalAdaper = new PersistSignalUtils();
1275
+ const PersistSignalAdapter = new PersistSignalUtils();
1152
1276
  const PERSIST_RISK_UTILS_METHOD_NAME_USE_PERSIST_RISK_ADAPTER = "PersistRiskUtils.usePersistRiskAdapter";
1153
1277
  const PERSIST_RISK_UTILS_METHOD_NAME_READ_DATA = "PersistRiskUtils.readPositionData";
1154
1278
  const PERSIST_RISK_UTILS_METHOD_NAME_WRITE_DATA = "PersistRiskUtils.writePositionData";
@@ -1521,7 +1645,7 @@ const WAIT_FOR_INIT_FN$1 = async (self) => {
1521
1645
  if (self.params.execution.context.backtest) {
1522
1646
  return;
1523
1647
  }
1524
- const pendingSignal = await PersistSignalAdaper.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
1648
+ const pendingSignal = await PersistSignalAdapter.readSignalData(self.params.strategyName, self.params.execution.context.symbol);
1525
1649
  if (!pendingSignal) {
1526
1650
  return;
1527
1651
  }
@@ -1983,25 +2107,33 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
1983
2107
  let shouldActivate = false;
1984
2108
  let shouldCancel = false;
1985
2109
  if (scheduled.position === "long") {
1986
- // КРИТИЧНО: Сначала проверяем StopLoss (отмена приоритетнее активации)
1987
- // Отмена если цена упала СЛИШКОМ низко (ниже SL)
2110
+ // КРИТИЧНО для LONG:
2111
+ // - priceOpen > priceStopLoss (по валидации)
2112
+ // - Активация: low <= priceOpen (цена упала до входа)
2113
+ // - Отмена: low <= priceStopLoss (цена пробила SL)
2114
+ //
2115
+ // EDGE CASE: если low <= priceStopLoss И low <= priceOpen на ОДНОЙ свече:
2116
+ // => Отмена имеет ПРИОРИТЕТ! (SL пробит ДО или ВМЕСТЕ с активацией)
2117
+ // Сигнал НЕ открывается, сразу отменяется
1988
2118
  if (candle.low <= scheduled.priceStopLoss) {
1989
2119
  shouldCancel = true;
1990
2120
  }
1991
- // Long = покупаем дешевле, ждем падения цены ДО priceOpen
1992
- // Активируем только если НЕ пробит StopLoss
1993
2121
  else if (candle.low <= scheduled.priceOpen) {
1994
2122
  shouldActivate = true;
1995
2123
  }
1996
2124
  }
1997
2125
  if (scheduled.position === "short") {
1998
- // КРИТИЧНО: Сначала проверяем StopLoss (отмена приоритетнее активации)
1999
- // Отмена если цена выросла СЛИШКОМ высоко (выше SL)
2126
+ // КРИТИЧНО для SHORT:
2127
+ // - priceOpen < priceStopLoss (по валидации)
2128
+ // - Активация: high >= priceOpen (цена выросла до входа)
2129
+ // - Отмена: high >= priceStopLoss (цена пробила SL)
2130
+ //
2131
+ // EDGE CASE: если high >= priceStopLoss И high >= priceOpen на ОДНОЙ свече:
2132
+ // => Отмена имеет ПРИОРИТЕТ! (SL пробит ДО или ВМЕСТЕ с активацией)
2133
+ // Сигнал НЕ открывается, сразу отменяется
2000
2134
  if (candle.high >= scheduled.priceStopLoss) {
2001
2135
  shouldCancel = true;
2002
2136
  }
2003
- // Short = продаем дороже, ждем роста цены ДО priceOpen
2004
- // Активируем только если НЕ пробит StopLoss
2005
2137
  else if (candle.high >= scheduled.priceOpen) {
2006
2138
  shouldActivate = true;
2007
2139
  }
@@ -2141,10 +2273,15 @@ class ClientStrategy {
2141
2273
  pendingSignal,
2142
2274
  });
2143
2275
  this._pendingSignal = pendingSignal;
2276
+ // КРИТИЧНО: Всегда вызываем коллбек onWrite для тестирования persist storage
2277
+ // даже в backtest режиме, чтобы тесты могли перехватывать вызовы через mock adapter
2278
+ if (this.params.callbacks?.onWrite) {
2279
+ this.params.callbacks.onWrite(this.params.execution.context.symbol, this._pendingSignal, this.params.execution.context.backtest);
2280
+ }
2144
2281
  if (this.params.execution.context.backtest) {
2145
2282
  return;
2146
2283
  }
2147
- await PersistSignalAdaper.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
2284
+ await PersistSignalAdapter.writeSignalData(this._pendingSignal, this.params.strategyName, this.params.execution.context.symbol);
2148
2285
  }
2149
2286
  /**
2150
2287
  * Performs a single tick of strategy execution.
@@ -10090,4 +10227,4 @@ PositionSizeUtils.atrBased = async (symbol, accountBalance, priceOpen, atr, cont
10090
10227
  };
10091
10228
  const PositionSize = PositionSizeUtils;
10092
10229
 
10093
- export { Backtest, ExecutionContextService, Heat, Live, MethodContextService, Performance, PersistBase, PersistRiskAdapter, PersistSignalAdaper, PositionSize, Schedule, Walker, addExchange, addFrame, addRisk, addSizing, addStrategy, addWalker, emitters, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listExchanges, listFrames, listRisks, listSizings, listStrategies, listWalkers, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenPerformance, listenProgress, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, setConfig, setLogger };
10230
+ export { Backtest, ExecutionContextService, Heat, Live, MethodContextService, Performance, PersistBase, PersistRiskAdapter, PersistSignalAdapter, PositionSize, Schedule, Walker, addExchange, addFrame, addRisk, addSizing, addStrategy, addWalker, emitters, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listExchanges, listFrames, listRisks, listSizings, listStrategies, listWalkers, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenPerformance, listenProgress, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, setConfig, setLogger };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",
package/types.d.ts CHANGED
@@ -31,6 +31,44 @@ declare const GLOBAL_CONFIG: {
31
31
  * Default: 1440 minutes (1 day)
32
32
  */
33
33
  CC_MAX_SIGNAL_LIFETIME_MINUTES: number;
34
+ /**
35
+ * Number of retries for getCandles function
36
+ * Default: 3 retries
37
+ */
38
+ CC_GET_CANDLES_RETRY_COUNT: number;
39
+ /**
40
+ * Delay between retries for getCandles function (in milliseconds)
41
+ * Default: 5000 ms (5 seconds)
42
+ */
43
+ CC_GET_CANDLES_RETRY_DELAY_MS: number;
44
+ /**
45
+ * Maximum allowed deviation factor for price anomaly detection.
46
+ * Price should not be more than this factor lower than reference price.
47
+ *
48
+ * Reasoning:
49
+ * - Incomplete candles from Binance API typically have prices near 0 (e.g., $0.01-1)
50
+ * - Normal BTC price ranges: $20,000-100,000
51
+ * - Factor 1000 catches prices below $20-100 when median is $20,000-100,000
52
+ * - Factor 100 would be too permissive (allows $200 when median is $20,000)
53
+ * - Factor 10000 might be too strict for low-cap altcoins
54
+ *
55
+ * Example: BTC at $50,000 median → threshold $50 (catches $0.01-1 anomalies)
56
+ */
57
+ CC_GET_CANDLES_PRICE_ANOMALY_THRESHOLD_FACTOR: number;
58
+ /**
59
+ * Minimum number of candles required for reliable median calculation.
60
+ * Below this threshold, use simple average instead of median.
61
+ *
62
+ * Reasoning:
63
+ * - Each candle provides 4 price points (OHLC)
64
+ * - 5 candles = 20 price points, sufficient for robust median calculation
65
+ * - Below 5 candles, single anomaly can heavily skew median
66
+ * - Statistical rule of thumb: minimum 7-10 data points for median stability
67
+ * - Average is more stable than median for small datasets (n < 20)
68
+ *
69
+ * Example: 3 candles = 12 points (use average), 5 candles = 20 points (use median)
70
+ */
71
+ CC_GET_CANDLES_MIN_CANDLES_FOR_MEDIAN: number;
34
72
  };
35
73
  /**
36
74
  * Type for global configuration object.
@@ -608,6 +646,8 @@ interface IStrategyCallbacks {
608
646
  onSchedule: (symbol: string, data: IScheduledSignalRow, currentPrice: number, backtest: boolean) => void;
609
647
  /** Called when scheduled signal is cancelled without opening position */
610
648
  onCancel: (symbol: string, data: IScheduledSignalRow, currentPrice: number, backtest: boolean) => void;
649
+ /** Called when signal is written to persist storage (for testing) */
650
+ onWrite: (symbol: string, data: ISignalRow | null, backtest: boolean) => void;
611
651
  }
612
652
  /**
613
653
  * Strategy schema registered via addStrategy().
@@ -3433,7 +3473,7 @@ declare class PersistSignalUtils {
3433
3473
  * async readValue(id) { return JSON.parse(await redis.get(id)); }
3434
3474
  * async writeValue(id, entity) { await redis.set(id, JSON.stringify(entity)); }
3435
3475
  * }
3436
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
3476
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
3437
3477
  * ```
3438
3478
  */
3439
3479
  usePersistSignalAdapter(Ctor: TPersistBaseCtor<StrategyName, SignalData>): void;
@@ -3468,16 +3508,16 @@ declare class PersistSignalUtils {
3468
3508
  * @example
3469
3509
  * ```typescript
3470
3510
  * // Custom adapter
3471
- * PersistSignalAdaper.usePersistSignalAdapter(RedisPersist);
3511
+ * PersistSignalAdapter.usePersistSignalAdapter(RedisPersist);
3472
3512
  *
3473
3513
  * // Read signal
3474
- * const signal = await PersistSignalAdaper.readSignalData("my-strategy", "BTCUSDT");
3514
+ * const signal = await PersistSignalAdapter.readSignalData("my-strategy", "BTCUSDT");
3475
3515
  *
3476
3516
  * // Write signal
3477
- * await PersistSignalAdaper.writeSignalData(signal, "my-strategy", "BTCUSDT");
3517
+ * await PersistSignalAdapter.writeSignalData(signal, "my-strategy", "BTCUSDT");
3478
3518
  * ```
3479
3519
  */
3480
- declare const PersistSignalAdaper: PersistSignalUtils;
3520
+ declare const PersistSignalAdapter: PersistSignalUtils;
3481
3521
  /**
3482
3522
  * Type for persisted risk positions data.
3483
3523
  * Stores Map entries as array of [key, value] tuples for JSON serialization.
@@ -6326,4 +6366,4 @@ declare const backtest: {
6326
6366
  loggerService: LoggerService;
6327
6367
  };
6328
6368
 
6329
- export { Backtest, type BacktestStatistics, type CandleInterval, type DoneContract, type EntityId, ExecutionContextService, type FrameInterval, type GlobalConfig, Heat, type ICandleData, type IExchangeSchema, type IFrameSchema, type IHeatmapRow, type IHeatmapStatistics, type IPersistBase, type IPositionSizeATRParams, type IPositionSizeFixedPercentageParams, type IPositionSizeKellyParams, type IRiskActivePosition, type IRiskCheckArgs, type IRiskSchema, type IRiskValidation, type IRiskValidationFn, type IRiskValidationPayload, type IScheduledSignalRow, type ISignalDto, type ISignalRow, type ISizingCalculateParams, type ISizingCalculateParamsATR, type ISizingCalculateParamsFixedPercentage, type ISizingCalculateParamsKelly, type ISizingSchema, type ISizingSchemaATR, type ISizingSchemaFixedPercentage, type ISizingSchemaKelly, type IStrategyPnL, type IStrategySchema, type IStrategyTickResult, type IStrategyTickResultActive, type IStrategyTickResultCancelled, type IStrategyTickResultClosed, type IStrategyTickResultIdle, type IStrategyTickResultOpened, type IStrategyTickResultScheduled, type IWalkerResults, type IWalkerSchema, type IWalkerStrategyResult, Live, type LiveStatistics, MethodContextService, Performance, type PerformanceContract, type PerformanceMetricType, type PerformanceStatistics, PersistBase, PersistRiskAdapter, PersistSignalAdaper, PositionSize, type ProgressContract, type RiskData, Schedule, type ScheduleStatistics, type SignalData, type SignalInterval, type TPersistBase, type TPersistBaseCtor, Walker, type WalkerMetric, type WalkerStatistics, addExchange, addFrame, addRisk, addSizing, addStrategy, addWalker, emitters, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listExchanges, listFrames, listRisks, listSizings, listStrategies, listWalkers, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenPerformance, listenProgress, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, setConfig, setLogger };
6369
+ export { Backtest, type BacktestStatistics, type CandleInterval, type DoneContract, type EntityId, ExecutionContextService, type FrameInterval, type GlobalConfig, Heat, type ICandleData, type IExchangeSchema, type IFrameSchema, type IHeatmapRow, type IHeatmapStatistics, type IPersistBase, type IPositionSizeATRParams, type IPositionSizeFixedPercentageParams, type IPositionSizeKellyParams, type IRiskActivePosition, type IRiskCheckArgs, type IRiskSchema, type IRiskValidation, type IRiskValidationFn, type IRiskValidationPayload, type IScheduledSignalRow, type ISignalDto, type ISignalRow, type ISizingCalculateParams, type ISizingCalculateParamsATR, type ISizingCalculateParamsFixedPercentage, type ISizingCalculateParamsKelly, type ISizingSchema, type ISizingSchemaATR, type ISizingSchemaFixedPercentage, type ISizingSchemaKelly, type IStrategyPnL, type IStrategySchema, type IStrategyTickResult, type IStrategyTickResultActive, type IStrategyTickResultCancelled, type IStrategyTickResultClosed, type IStrategyTickResultIdle, type IStrategyTickResultOpened, type IStrategyTickResultScheduled, type IWalkerResults, type IWalkerSchema, type IWalkerStrategyResult, Live, type LiveStatistics, MethodContextService, Performance, type PerformanceContract, type PerformanceMetricType, type PerformanceStatistics, PersistBase, PersistRiskAdapter, PersistSignalAdapter, PositionSize, type ProgressContract, type RiskData, Schedule, type ScheduleStatistics, type SignalData, type SignalInterval, type TPersistBase, type TPersistBaseCtor, Walker, type WalkerMetric, type WalkerStatistics, addExchange, addFrame, addRisk, addSizing, addStrategy, addWalker, emitters, formatPrice, formatQuantity, getAveragePrice, getCandles, getDate, getMode, backtest as lib, listExchanges, listFrames, listRisks, listSizings, listStrategies, listWalkers, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenPerformance, listenProgress, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, setConfig, setLogger };