backtest-kit 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.cjs CHANGED
@@ -5064,7 +5064,7 @@ const CALL_SCHEDULE_PING_CALLBACKS_FN = functoolsKit.trycatch(beginTime(async (s
5064
5064
  await ExecutionContextService.runInContext(async () => {
5065
5065
  const publicSignal = TO_PUBLIC_SIGNAL(scheduled, currentPrice);
5066
5066
  // Call system onSchedulePing callback first (emits to pingSubject)
5067
- await self.params.onSchedulePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, self.params.execution.context.backtest, timestamp);
5067
+ await self.params.onSchedulePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, currentPrice, self.params.execution.context.backtest, timestamp);
5068
5068
  // Call user onSchedulePing callback only if signal is still active (not cancelled, not activated)
5069
5069
  if (self.params.callbacks?.onSchedulePing) {
5070
5070
  await self.params.callbacks.onSchedulePing(self.params.execution.context.symbol, publicSignal, new Date(timestamp), self.params.execution.context.backtest);
@@ -5090,7 +5090,7 @@ const CALL_ACTIVE_PING_CALLBACKS_FN = functoolsKit.trycatch(beginTime(async (sel
5090
5090
  await ExecutionContextService.runInContext(async () => {
5091
5091
  const publicSignal = TO_PUBLIC_SIGNAL(pending, currentPrice);
5092
5092
  // Call system onActivePing callback first (emits to activePingSubject)
5093
- await self.params.onActivePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, self.params.execution.context.backtest, timestamp);
5093
+ await self.params.onActivePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, currentPrice, self.params.execution.context.backtest, timestamp);
5094
5094
  // Call user onActivePing callback only if signal is still active (not closed)
5095
5095
  if (self.params.callbacks?.onActivePing) {
5096
5096
  await self.params.callbacks.onActivePing(self.params.execution.context.symbol, publicSignal, new Date(timestamp), self.params.execution.context.backtest);
@@ -5888,6 +5888,57 @@ const CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, signal, averagePrice, c
5888
5888
  await CALL_TICK_CALLBACKS_FN(self, self.params.execution.context.symbol, result, closeTimestamp, self.params.execution.context.backtest);
5889
5889
  return result;
5890
5890
  };
5891
+ const CLOSE_USER_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, closedSignal, averagePrice, closeTimestamp) => {
5892
+ const syncCloseAllowed = await CALL_SIGNAL_SYNC_CLOSE_FN(closeTimestamp, averagePrice, "closed", closedSignal, self);
5893
+ if (!syncCloseAllowed) {
5894
+ self.params.logger.info("ClientStrategy backtest: user-closed signal rejected by sync, will retry", {
5895
+ symbol: self.params.execution.context.symbol,
5896
+ signalId: closedSignal.id,
5897
+ });
5898
+ self._closedSignal = null;
5899
+ self._pendingSignal = closedSignal;
5900
+ throw new Error(`ClientStrategy backtest: signal close rejected by sync (signalId=${closedSignal.id}). ` +
5901
+ `Retry backtest() with new candle data.`);
5902
+ }
5903
+ self._closedSignal = null;
5904
+ await CALL_COMMIT_FN(self, {
5905
+ action: "close-pending",
5906
+ symbol: self.params.execution.context.symbol,
5907
+ strategyName: self.params.strategyName,
5908
+ exchangeName: self.params.exchangeName,
5909
+ frameName: self.params.frameName,
5910
+ signalId: closedSignal.id,
5911
+ backtest: true,
5912
+ closeId: closedSignal.closeId,
5913
+ timestamp: closeTimestamp,
5914
+ totalEntries: closedSignal._entry?.length ?? 1,
5915
+ totalPartials: closedSignal._partial?.length ?? 0,
5916
+ originalPriceOpen: closedSignal.priceOpen,
5917
+ pnl: toProfitLossDto(closedSignal, averagePrice),
5918
+ });
5919
+ await CALL_CLOSE_CALLBACKS_FN(self, self.params.execution.context.symbol, closedSignal, averagePrice, closeTimestamp, self.params.execution.context.backtest);
5920
+ await CALL_PARTIAL_CLEAR_FN(self, self.params.execution.context.symbol, closedSignal, averagePrice, closeTimestamp, self.params.execution.context.backtest);
5921
+ await CALL_BREAKEVEN_CLEAR_FN(self, self.params.execution.context.symbol, closedSignal, averagePrice, closeTimestamp, self.params.execution.context.backtest);
5922
+ await CALL_RISK_REMOVE_SIGNAL_FN(self, self.params.execution.context.symbol, closeTimestamp, self.params.execution.context.backtest);
5923
+ const pnl = toProfitLossDto(closedSignal, averagePrice);
5924
+ const result = {
5925
+ action: "closed",
5926
+ signal: TO_PUBLIC_SIGNAL(closedSignal, averagePrice),
5927
+ currentPrice: averagePrice,
5928
+ closeReason: "closed",
5929
+ closeTimestamp,
5930
+ pnl,
5931
+ strategyName: self.params.method.context.strategyName,
5932
+ exchangeName: self.params.method.context.exchangeName,
5933
+ frameName: self.params.method.context.frameName,
5934
+ symbol: self.params.execution.context.symbol,
5935
+ backtest: self.params.execution.context.backtest,
5936
+ closeId: closedSignal.closeId,
5937
+ createdAt: closeTimestamp,
5938
+ };
5939
+ await CALL_TICK_CALLBACKS_FN(self, self.params.execution.context.symbol, result, closeTimestamp, self.params.execution.context.backtest);
5940
+ return result;
5941
+ };
5891
5942
  const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
5892
5943
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
5893
5944
  const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
@@ -5905,7 +5956,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5905
5956
  if (self._cancelledSignal) {
5906
5957
  // Сигнал был отменен через cancel() в onSchedulePing
5907
5958
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "user");
5908
- return { activated: false, cancelled: true, activationIndex: i, result };
5959
+ return { outcome: "cancelled", result };
5909
5960
  }
5910
5961
  // КРИТИЧНО: Проверяем был ли сигнал активирован пользователем через activateScheduled()
5911
5962
  // Обрабатываем inline (как в tick()) с риск-проверкой по averagePrice
@@ -5919,7 +5970,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5919
5970
  signalId: activatedSignal.id,
5920
5971
  });
5921
5972
  await self.setScheduledSignal(null);
5922
- return { activated: false, cancelled: false, activationIndex: i, result: null };
5973
+ return { outcome: "pending" };
5923
5974
  }
5924
5975
  // Риск-проверка по averagePrice (симметрия с LIVE tick())
5925
5976
  if (await functoolsKit.not(CALL_RISK_CHECK_SIGNAL_FN(self, self.params.execution.context.symbol, activatedSignal, averagePrice, candle.timestamp, self.params.execution.context.backtest))) {
@@ -5928,7 +5979,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5928
5979
  signalId: activatedSignal.id,
5929
5980
  });
5930
5981
  await self.setScheduledSignal(null);
5931
- return { activated: false, cancelled: false, activationIndex: i, result: null };
5982
+ return { outcome: "pending" };
5932
5983
  }
5933
5984
  const pendingSignal = {
5934
5985
  ...activatedSignal,
@@ -5957,7 +6008,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5957
6008
  originalPriceOpen: activatedSignal.priceOpen,
5958
6009
  pnl: toProfitLossDto(activatedSignal, averagePrice),
5959
6010
  });
5960
- return { activated: false, cancelled: true, activationIndex: i, result: null };
6011
+ return { outcome: "pending" };
5961
6012
  }
5962
6013
  await self.setScheduledSignal(null);
5963
6014
  await self.setPendingSignal(pendingSignal);
@@ -5990,18 +6041,13 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5990
6041
  });
5991
6042
  await CALL_OPEN_CALLBACKS_FN(self, self.params.execution.context.symbol, pendingSignal, pendingSignal.priceOpen, candle.timestamp, self.params.execution.context.backtest);
5992
6043
  await CALL_BACKTEST_SCHEDULE_OPEN_FN(self, self.params.execution.context.symbol, pendingSignal, candle.timestamp, self.params.execution.context.backtest);
5993
- return {
5994
- activated: true,
5995
- cancelled: false,
5996
- activationIndex: i,
5997
- result: null,
5998
- };
6044
+ return { outcome: "activated", activationIndex: i };
5999
6045
  }
6000
6046
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
6001
6047
  const elapsedTime = candle.timestamp - scheduled.scheduledAt;
6002
6048
  if (elapsedTime >= maxTimeToWait) {
6003
6049
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "timeout");
6004
- return { activated: false, cancelled: true, activationIndex: i, result };
6050
+ return { outcome: "cancelled", result };
6005
6051
  }
6006
6052
  let shouldActivate = false;
6007
6053
  let shouldCancel = false;
@@ -6039,27 +6085,17 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
6039
6085
  }
6040
6086
  if (shouldCancel) {
6041
6087
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "price_reject");
6042
- return { activated: false, cancelled: true, activationIndex: i, result };
6088
+ return { outcome: "cancelled", result };
6043
6089
  }
6044
6090
  if (shouldActivate) {
6045
6091
  await ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, candle.timestamp);
6046
- return {
6047
- activated: true,
6048
- cancelled: false,
6049
- activationIndex: i,
6050
- result: null,
6051
- };
6092
+ return { outcome: "activated", activationIndex: i };
6052
6093
  }
6053
6094
  await CALL_SCHEDULE_PING_CALLBACKS_FN(self, self.params.execution.context.symbol, scheduled, candle.timestamp, true, averagePrice);
6054
6095
  // Process queued commit events with candle timestamp
6055
6096
  await PROCESS_COMMIT_QUEUE_FN(self, averagePrice, candle.timestamp);
6056
6097
  }
6057
- return {
6058
- activated: false,
6059
- cancelled: false,
6060
- activationIndex: -1,
6061
- result: null,
6062
- };
6098
+ return { outcome: "pending" };
6063
6099
  };
6064
6100
  const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
6065
6101
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
@@ -6078,6 +6114,10 @@ const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
6078
6114
  const startIndex = Math.max(0, i - (candlesCount - 1));
6079
6115
  const recentCandles = candles.slice(startIndex, i + 1);
6080
6116
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
6117
+ // КРИТИЧНО: Проверяем был ли сигнал закрыт пользователем через closePending()
6118
+ if (self._closedSignal) {
6119
+ return await CLOSE_USER_PENDING_SIGNAL_IN_BACKTEST_FN(self, self._closedSignal, averagePrice, currentCandleTimestamp);
6120
+ }
6081
6121
  await CALL_ACTIVE_PING_CALLBACKS_FN(self, self.params.execution.context.symbol, signal, currentCandleTimestamp, true, averagePrice);
6082
6122
  let shouldClose = false;
6083
6123
  let closeReason;
@@ -6182,7 +6222,32 @@ const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
6182
6222
  // Process queued commit events with candle timestamp
6183
6223
  await PROCESS_COMMIT_QUEUE_FN(self, averagePrice, currentCandleTimestamp);
6184
6224
  }
6185
- return null;
6225
+ // Loop exhausted without closing — check if we have enough data
6226
+ const lastCandles = candles.slice(-GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
6227
+ const lastPrice = GET_AVG_PRICE_FN(lastCandles);
6228
+ const closeTimestamp = lastCandles[lastCandles.length - 1].timestamp;
6229
+ const signalTime = signal.pendingAt;
6230
+ const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
6231
+ const elapsedTime = closeTimestamp - signalTime;
6232
+ if (elapsedTime < maxTimeToWait) {
6233
+ const bufferCandlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
6234
+ const requiredCandlesCount = signal.minuteEstimatedTime + bufferCandlesCount + 1;
6235
+ throw new Error(functoolsKit.str.newline(`ClientStrategy backtest: Insufficient candle data for pending signal. ` +
6236
+ `Signal opened at ${new Date(signal.pendingAt).toISOString()}, ` +
6237
+ `last candle at ${new Date(closeTimestamp).toISOString()}. ` +
6238
+ `Elapsed: ${Math.floor(elapsedTime / 60000)}min of ${signal.minuteEstimatedTime}min required. ` +
6239
+ `Provided ${candles.length} candles, but need at least ${requiredCandlesCount} candles. ` +
6240
+ `\nBreakdown: ${signal.minuteEstimatedTime} candles for signal lifetime + ${bufferCandlesCount} buffer candles. ` +
6241
+ `\nBuffer explanation: VWAP calculation requires ${GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT} candles, ` +
6242
+ `so first ${bufferCandlesCount} candles are skipped to ensure accurate price averaging. ` +
6243
+ `Provide complete candle range: [pendingAt - ${bufferCandlesCount}min, pendingAt + ${signal.minuteEstimatedTime}min].`));
6244
+ }
6245
+ const timeExpiredResult = await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(self, signal, lastPrice, "time_expired", closeTimestamp);
6246
+ if (!timeExpiredResult) {
6247
+ throw new Error(`ClientStrategy backtest: time_expired close rejected by sync (signalId=${signal.id}). ` +
6248
+ `Retry backtest() with new candle data.`);
6249
+ }
6250
+ return timeExpiredResult;
6186
6251
  };
6187
6252
  /**
6188
6253
  * Client implementation for trading strategy lifecycle management.
@@ -7129,11 +7194,12 @@ class ClientStrategy {
7129
7194
  priceOpen: scheduled.priceOpen,
7130
7195
  position: scheduled.position,
7131
7196
  });
7132
- const { activated, cancelled, activationIndex, result } = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles);
7133
- if (cancelled && result) {
7134
- return result;
7197
+ const scheduledResult = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles);
7198
+ if (scheduledResult.outcome === "cancelled") {
7199
+ return scheduledResult.result;
7135
7200
  }
7136
- if (activated) {
7201
+ if (scheduledResult.outcome === "activated") {
7202
+ const { activationIndex } = scheduledResult;
7137
7203
  // КРИТИЧНО: activationIndex - индекс свечи активации в массиве candles
7138
7204
  // BacktestLogicPrivateService включил буфер в начало массива, поэтому перед activationIndex достаточно свечей
7139
7205
  // PROCESS_PENDING_SIGNAL_CANDLES_FN пропустит первые bufferCandlesCount свечей для VWAP
@@ -7205,40 +7271,7 @@ class ClientStrategy {
7205
7271
  if (candles.length < candlesCount) {
7206
7272
  this.params.logger.warn(`ClientStrategy backtest: Expected at least ${candlesCount} candles for VWAP, got ${candles.length}`);
7207
7273
  }
7208
- const closedResult = await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles);
7209
- if (closedResult) {
7210
- return closedResult;
7211
- }
7212
- // Signal didn't close during candle processing - check if we have enough data
7213
- const lastCandles = candles.slice(-GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
7214
- const lastPrice = GET_AVG_PRICE_FN(lastCandles);
7215
- const closeTimestamp = lastCandles[lastCandles.length - 1].timestamp;
7216
- const signalTime = signal.pendingAt;
7217
- const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
7218
- const elapsedTime = closeTimestamp - signalTime;
7219
- // Check if we actually reached time expiration or just ran out of candles
7220
- if (elapsedTime < maxTimeToWait) {
7221
- // EDGE CASE: backtest() called with insufficient candle data
7222
- const bufferCandlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
7223
- const requiredCandlesCount = signal.minuteEstimatedTime + bufferCandlesCount + 1;
7224
- throw new Error(functoolsKit.str.newline(`ClientStrategy backtest: Insufficient candle data for pending signal. ` +
7225
- `Signal opened at ${new Date(signal.pendingAt).toISOString()}, ` +
7226
- `last candle at ${new Date(closeTimestamp).toISOString()}. ` +
7227
- `Elapsed: ${Math.floor(elapsedTime / 60000)}min of ${signal.minuteEstimatedTime}min required. ` +
7228
- `Provided ${candles.length} candles, but need at least ${requiredCandlesCount} candles. ` +
7229
- `\nBreakdown: ${signal.minuteEstimatedTime} candles for signal lifetime + ${bufferCandlesCount} buffer candles. ` +
7230
- `\nBuffer explanation: VWAP calculation requires ${GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT} candles, ` +
7231
- `so first ${bufferCandlesCount} candles are skipped to ensure accurate price averaging. ` +
7232
- `Provide complete candle range: [pendingAt - ${bufferCandlesCount}min, pendingAt + ${signal.minuteEstimatedTime}min].`));
7233
- }
7234
- // Time actually expired - close with time_expired
7235
- const timeExpiredResult = await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(this, signal, lastPrice, "time_expired", closeTimestamp);
7236
- if (!timeExpiredResult) {
7237
- // Sync rejected the close — signal remains in _pendingSignal, caller must retry
7238
- throw new Error(`ClientStrategy backtest: time_expired close rejected by sync (signalId=${signal.id}). ` +
7239
- `Retry backtest() with new candle data.`);
7240
- }
7241
- return timeExpiredResult;
7274
+ return await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles);
7242
7275
  }
7243
7276
  /**
7244
7277
  * Stops the strategy from generating new signals.
@@ -9009,11 +9042,12 @@ const CREATE_KEY_FN$o = (symbol, strategyName, exchangeName, frameName, backtest
9009
9042
  * @param self - Reference to StrategyConnectionService instance
9010
9043
  * @returns Callback function for schedule ping events
9011
9044
  */
9012
- const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => functoolsKit.trycatch(async (symbol, strategyName, exchangeName, data, backtest, timestamp) => {
9045
+ const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => functoolsKit.trycatch(async (symbol, strategyName, exchangeName, data, currentPrice, backtest, timestamp) => {
9013
9046
  const event = {
9014
9047
  symbol,
9015
9048
  strategyName,
9016
9049
  exchangeName,
9050
+ currentPrice,
9017
9051
  data,
9018
9052
  backtest,
9019
9053
  timestamp,
@@ -9042,11 +9076,12 @@ const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => functoolsKit.trycatch(async (sy
9042
9076
  * @param self - Reference to StrategyConnectionService instance
9043
9077
  * @returns Callback function for active ping events
9044
9078
  */
9045
- const CREATE_COMMIT_ACTIVE_PING_FN = (self) => functoolsKit.trycatch(async (symbol, strategyName, exchangeName, data, backtest, timestamp) => {
9079
+ const CREATE_COMMIT_ACTIVE_PING_FN = (self) => functoolsKit.trycatch(async (symbol, strategyName, exchangeName, data, currentPrice, backtest, timestamp) => {
9046
9080
  const event = {
9047
9081
  symbol,
9048
9082
  strategyName,
9049
9083
  exchangeName,
9084
+ currentPrice,
9050
9085
  data,
9051
9086
  backtest,
9052
9087
  timestamp,
package/build/index.mjs CHANGED
@@ -5044,7 +5044,7 @@ const CALL_SCHEDULE_PING_CALLBACKS_FN = trycatch(beginTime(async (self, symbol,
5044
5044
  await ExecutionContextService.runInContext(async () => {
5045
5045
  const publicSignal = TO_PUBLIC_SIGNAL(scheduled, currentPrice);
5046
5046
  // Call system onSchedulePing callback first (emits to pingSubject)
5047
- await self.params.onSchedulePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, self.params.execution.context.backtest, timestamp);
5047
+ await self.params.onSchedulePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, currentPrice, self.params.execution.context.backtest, timestamp);
5048
5048
  // Call user onSchedulePing callback only if signal is still active (not cancelled, not activated)
5049
5049
  if (self.params.callbacks?.onSchedulePing) {
5050
5050
  await self.params.callbacks.onSchedulePing(self.params.execution.context.symbol, publicSignal, new Date(timestamp), self.params.execution.context.backtest);
@@ -5070,7 +5070,7 @@ const CALL_ACTIVE_PING_CALLBACKS_FN = trycatch(beginTime(async (self, symbol, pe
5070
5070
  await ExecutionContextService.runInContext(async () => {
5071
5071
  const publicSignal = TO_PUBLIC_SIGNAL(pending, currentPrice);
5072
5072
  // Call system onActivePing callback first (emits to activePingSubject)
5073
- await self.params.onActivePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, self.params.execution.context.backtest, timestamp);
5073
+ await self.params.onActivePing(self.params.execution.context.symbol, self.params.method.context.strategyName, self.params.method.context.exchangeName, publicSignal, currentPrice, self.params.execution.context.backtest, timestamp);
5074
5074
  // Call user onActivePing callback only if signal is still active (not closed)
5075
5075
  if (self.params.callbacks?.onActivePing) {
5076
5076
  await self.params.callbacks.onActivePing(self.params.execution.context.symbol, publicSignal, new Date(timestamp), self.params.execution.context.backtest);
@@ -5868,6 +5868,57 @@ const CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, signal, averagePrice, c
5868
5868
  await CALL_TICK_CALLBACKS_FN(self, self.params.execution.context.symbol, result, closeTimestamp, self.params.execution.context.backtest);
5869
5869
  return result;
5870
5870
  };
5871
+ const CLOSE_USER_PENDING_SIGNAL_IN_BACKTEST_FN = async (self, closedSignal, averagePrice, closeTimestamp) => {
5872
+ const syncCloseAllowed = await CALL_SIGNAL_SYNC_CLOSE_FN(closeTimestamp, averagePrice, "closed", closedSignal, self);
5873
+ if (!syncCloseAllowed) {
5874
+ self.params.logger.info("ClientStrategy backtest: user-closed signal rejected by sync, will retry", {
5875
+ symbol: self.params.execution.context.symbol,
5876
+ signalId: closedSignal.id,
5877
+ });
5878
+ self._closedSignal = null;
5879
+ self._pendingSignal = closedSignal;
5880
+ throw new Error(`ClientStrategy backtest: signal close rejected by sync (signalId=${closedSignal.id}). ` +
5881
+ `Retry backtest() with new candle data.`);
5882
+ }
5883
+ self._closedSignal = null;
5884
+ await CALL_COMMIT_FN(self, {
5885
+ action: "close-pending",
5886
+ symbol: self.params.execution.context.symbol,
5887
+ strategyName: self.params.strategyName,
5888
+ exchangeName: self.params.exchangeName,
5889
+ frameName: self.params.frameName,
5890
+ signalId: closedSignal.id,
5891
+ backtest: true,
5892
+ closeId: closedSignal.closeId,
5893
+ timestamp: closeTimestamp,
5894
+ totalEntries: closedSignal._entry?.length ?? 1,
5895
+ totalPartials: closedSignal._partial?.length ?? 0,
5896
+ originalPriceOpen: closedSignal.priceOpen,
5897
+ pnl: toProfitLossDto(closedSignal, averagePrice),
5898
+ });
5899
+ await CALL_CLOSE_CALLBACKS_FN(self, self.params.execution.context.symbol, closedSignal, averagePrice, closeTimestamp, self.params.execution.context.backtest);
5900
+ await CALL_PARTIAL_CLEAR_FN(self, self.params.execution.context.symbol, closedSignal, averagePrice, closeTimestamp, self.params.execution.context.backtest);
5901
+ await CALL_BREAKEVEN_CLEAR_FN(self, self.params.execution.context.symbol, closedSignal, averagePrice, closeTimestamp, self.params.execution.context.backtest);
5902
+ await CALL_RISK_REMOVE_SIGNAL_FN(self, self.params.execution.context.symbol, closeTimestamp, self.params.execution.context.backtest);
5903
+ const pnl = toProfitLossDto(closedSignal, averagePrice);
5904
+ const result = {
5905
+ action: "closed",
5906
+ signal: TO_PUBLIC_SIGNAL(closedSignal, averagePrice),
5907
+ currentPrice: averagePrice,
5908
+ closeReason: "closed",
5909
+ closeTimestamp,
5910
+ pnl,
5911
+ strategyName: self.params.method.context.strategyName,
5912
+ exchangeName: self.params.method.context.exchangeName,
5913
+ frameName: self.params.method.context.frameName,
5914
+ symbol: self.params.execution.context.symbol,
5915
+ backtest: self.params.execution.context.backtest,
5916
+ closeId: closedSignal.closeId,
5917
+ createdAt: closeTimestamp,
5918
+ };
5919
+ await CALL_TICK_CALLBACKS_FN(self, self.params.execution.context.symbol, result, closeTimestamp, self.params.execution.context.backtest);
5920
+ return result;
5921
+ };
5871
5922
  const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) => {
5872
5923
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
5873
5924
  const maxTimeToWait = GLOBAL_CONFIG.CC_SCHEDULE_AWAIT_MINUTES * 60 * 1000;
@@ -5885,7 +5936,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5885
5936
  if (self._cancelledSignal) {
5886
5937
  // Сигнал был отменен через cancel() в onSchedulePing
5887
5938
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "user");
5888
- return { activated: false, cancelled: true, activationIndex: i, result };
5939
+ return { outcome: "cancelled", result };
5889
5940
  }
5890
5941
  // КРИТИЧНО: Проверяем был ли сигнал активирован пользователем через activateScheduled()
5891
5942
  // Обрабатываем inline (как в tick()) с риск-проверкой по averagePrice
@@ -5899,7 +5950,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5899
5950
  signalId: activatedSignal.id,
5900
5951
  });
5901
5952
  await self.setScheduledSignal(null);
5902
- return { activated: false, cancelled: false, activationIndex: i, result: null };
5953
+ return { outcome: "pending" };
5903
5954
  }
5904
5955
  // Риск-проверка по averagePrice (симметрия с LIVE tick())
5905
5956
  if (await not(CALL_RISK_CHECK_SIGNAL_FN(self, self.params.execution.context.symbol, activatedSignal, averagePrice, candle.timestamp, self.params.execution.context.backtest))) {
@@ -5908,7 +5959,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5908
5959
  signalId: activatedSignal.id,
5909
5960
  });
5910
5961
  await self.setScheduledSignal(null);
5911
- return { activated: false, cancelled: false, activationIndex: i, result: null };
5962
+ return { outcome: "pending" };
5912
5963
  }
5913
5964
  const pendingSignal = {
5914
5965
  ...activatedSignal,
@@ -5937,7 +5988,7 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5937
5988
  originalPriceOpen: activatedSignal.priceOpen,
5938
5989
  pnl: toProfitLossDto(activatedSignal, averagePrice),
5939
5990
  });
5940
- return { activated: false, cancelled: true, activationIndex: i, result: null };
5991
+ return { outcome: "pending" };
5941
5992
  }
5942
5993
  await self.setScheduledSignal(null);
5943
5994
  await self.setPendingSignal(pendingSignal);
@@ -5970,18 +6021,13 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
5970
6021
  });
5971
6022
  await CALL_OPEN_CALLBACKS_FN(self, self.params.execution.context.symbol, pendingSignal, pendingSignal.priceOpen, candle.timestamp, self.params.execution.context.backtest);
5972
6023
  await CALL_BACKTEST_SCHEDULE_OPEN_FN(self, self.params.execution.context.symbol, pendingSignal, candle.timestamp, self.params.execution.context.backtest);
5973
- return {
5974
- activated: true,
5975
- cancelled: false,
5976
- activationIndex: i,
5977
- result: null,
5978
- };
6024
+ return { outcome: "activated", activationIndex: i };
5979
6025
  }
5980
6026
  // КРИТИЧНО: Проверяем timeout ПЕРЕД проверкой цены
5981
6027
  const elapsedTime = candle.timestamp - scheduled.scheduledAt;
5982
6028
  if (elapsedTime >= maxTimeToWait) {
5983
6029
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "timeout");
5984
- return { activated: false, cancelled: true, activationIndex: i, result };
6030
+ return { outcome: "cancelled", result };
5985
6031
  }
5986
6032
  let shouldActivate = false;
5987
6033
  let shouldCancel = false;
@@ -6019,27 +6065,17 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles) =>
6019
6065
  }
6020
6066
  if (shouldCancel) {
6021
6067
  const result = await CANCEL_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, averagePrice, candle.timestamp, "price_reject");
6022
- return { activated: false, cancelled: true, activationIndex: i, result };
6068
+ return { outcome: "cancelled", result };
6023
6069
  }
6024
6070
  if (shouldActivate) {
6025
6071
  await ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN(self, scheduled, candle.timestamp);
6026
- return {
6027
- activated: true,
6028
- cancelled: false,
6029
- activationIndex: i,
6030
- result: null,
6031
- };
6072
+ return { outcome: "activated", activationIndex: i };
6032
6073
  }
6033
6074
  await CALL_SCHEDULE_PING_CALLBACKS_FN(self, self.params.execution.context.symbol, scheduled, candle.timestamp, true, averagePrice);
6034
6075
  // Process queued commit events with candle timestamp
6035
6076
  await PROCESS_COMMIT_QUEUE_FN(self, averagePrice, candle.timestamp);
6036
6077
  }
6037
- return {
6038
- activated: false,
6039
- cancelled: false,
6040
- activationIndex: -1,
6041
- result: null,
6042
- };
6078
+ return { outcome: "pending" };
6043
6079
  };
6044
6080
  const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
6045
6081
  const candlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT;
@@ -6058,6 +6094,10 @@ const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
6058
6094
  const startIndex = Math.max(0, i - (candlesCount - 1));
6059
6095
  const recentCandles = candles.slice(startIndex, i + 1);
6060
6096
  const averagePrice = GET_AVG_PRICE_FN(recentCandles);
6097
+ // КРИТИЧНО: Проверяем был ли сигнал закрыт пользователем через closePending()
6098
+ if (self._closedSignal) {
6099
+ return await CLOSE_USER_PENDING_SIGNAL_IN_BACKTEST_FN(self, self._closedSignal, averagePrice, currentCandleTimestamp);
6100
+ }
6061
6101
  await CALL_ACTIVE_PING_CALLBACKS_FN(self, self.params.execution.context.symbol, signal, currentCandleTimestamp, true, averagePrice);
6062
6102
  let shouldClose = false;
6063
6103
  let closeReason;
@@ -6162,7 +6202,32 @@ const PROCESS_PENDING_SIGNAL_CANDLES_FN = async (self, signal, candles) => {
6162
6202
  // Process queued commit events with candle timestamp
6163
6203
  await PROCESS_COMMIT_QUEUE_FN(self, averagePrice, currentCandleTimestamp);
6164
6204
  }
6165
- return null;
6205
+ // Loop exhausted without closing — check if we have enough data
6206
+ const lastCandles = candles.slice(-GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
6207
+ const lastPrice = GET_AVG_PRICE_FN(lastCandles);
6208
+ const closeTimestamp = lastCandles[lastCandles.length - 1].timestamp;
6209
+ const signalTime = signal.pendingAt;
6210
+ const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
6211
+ const elapsedTime = closeTimestamp - signalTime;
6212
+ if (elapsedTime < maxTimeToWait) {
6213
+ const bufferCandlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
6214
+ const requiredCandlesCount = signal.minuteEstimatedTime + bufferCandlesCount + 1;
6215
+ throw new Error(str.newline(`ClientStrategy backtest: Insufficient candle data for pending signal. ` +
6216
+ `Signal opened at ${new Date(signal.pendingAt).toISOString()}, ` +
6217
+ `last candle at ${new Date(closeTimestamp).toISOString()}. ` +
6218
+ `Elapsed: ${Math.floor(elapsedTime / 60000)}min of ${signal.minuteEstimatedTime}min required. ` +
6219
+ `Provided ${candles.length} candles, but need at least ${requiredCandlesCount} candles. ` +
6220
+ `\nBreakdown: ${signal.minuteEstimatedTime} candles for signal lifetime + ${bufferCandlesCount} buffer candles. ` +
6221
+ `\nBuffer explanation: VWAP calculation requires ${GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT} candles, ` +
6222
+ `so first ${bufferCandlesCount} candles are skipped to ensure accurate price averaging. ` +
6223
+ `Provide complete candle range: [pendingAt - ${bufferCandlesCount}min, pendingAt + ${signal.minuteEstimatedTime}min].`));
6224
+ }
6225
+ const timeExpiredResult = await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(self, signal, lastPrice, "time_expired", closeTimestamp);
6226
+ if (!timeExpiredResult) {
6227
+ throw new Error(`ClientStrategy backtest: time_expired close rejected by sync (signalId=${signal.id}). ` +
6228
+ `Retry backtest() with new candle data.`);
6229
+ }
6230
+ return timeExpiredResult;
6166
6231
  };
6167
6232
  /**
6168
6233
  * Client implementation for trading strategy lifecycle management.
@@ -7109,11 +7174,12 @@ class ClientStrategy {
7109
7174
  priceOpen: scheduled.priceOpen,
7110
7175
  position: scheduled.position,
7111
7176
  });
7112
- const { activated, cancelled, activationIndex, result } = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles);
7113
- if (cancelled && result) {
7114
- return result;
7177
+ const scheduledResult = await PROCESS_SCHEDULED_SIGNAL_CANDLES_FN(this, scheduled, candles);
7178
+ if (scheduledResult.outcome === "cancelled") {
7179
+ return scheduledResult.result;
7115
7180
  }
7116
- if (activated) {
7181
+ if (scheduledResult.outcome === "activated") {
7182
+ const { activationIndex } = scheduledResult;
7117
7183
  // КРИТИЧНО: activationIndex - индекс свечи активации в массиве candles
7118
7184
  // BacktestLogicPrivateService включил буфер в начало массива, поэтому перед activationIndex достаточно свечей
7119
7185
  // PROCESS_PENDING_SIGNAL_CANDLES_FN пропустит первые bufferCandlesCount свечей для VWAP
@@ -7185,40 +7251,7 @@ class ClientStrategy {
7185
7251
  if (candles.length < candlesCount) {
7186
7252
  this.params.logger.warn(`ClientStrategy backtest: Expected at least ${candlesCount} candles for VWAP, got ${candles.length}`);
7187
7253
  }
7188
- const closedResult = await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles);
7189
- if (closedResult) {
7190
- return closedResult;
7191
- }
7192
- // Signal didn't close during candle processing - check if we have enough data
7193
- const lastCandles = candles.slice(-GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT);
7194
- const lastPrice = GET_AVG_PRICE_FN(lastCandles);
7195
- const closeTimestamp = lastCandles[lastCandles.length - 1].timestamp;
7196
- const signalTime = signal.pendingAt;
7197
- const maxTimeToWait = signal.minuteEstimatedTime * 60 * 1000;
7198
- const elapsedTime = closeTimestamp - signalTime;
7199
- // Check if we actually reached time expiration or just ran out of candles
7200
- if (elapsedTime < maxTimeToWait) {
7201
- // EDGE CASE: backtest() called with insufficient candle data
7202
- const bufferCandlesCount = GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT - 1;
7203
- const requiredCandlesCount = signal.minuteEstimatedTime + bufferCandlesCount + 1;
7204
- throw new Error(str.newline(`ClientStrategy backtest: Insufficient candle data for pending signal. ` +
7205
- `Signal opened at ${new Date(signal.pendingAt).toISOString()}, ` +
7206
- `last candle at ${new Date(closeTimestamp).toISOString()}. ` +
7207
- `Elapsed: ${Math.floor(elapsedTime / 60000)}min of ${signal.minuteEstimatedTime}min required. ` +
7208
- `Provided ${candles.length} candles, but need at least ${requiredCandlesCount} candles. ` +
7209
- `\nBreakdown: ${signal.minuteEstimatedTime} candles for signal lifetime + ${bufferCandlesCount} buffer candles. ` +
7210
- `\nBuffer explanation: VWAP calculation requires ${GLOBAL_CONFIG.CC_AVG_PRICE_CANDLES_COUNT} candles, ` +
7211
- `so first ${bufferCandlesCount} candles are skipped to ensure accurate price averaging. ` +
7212
- `Provide complete candle range: [pendingAt - ${bufferCandlesCount}min, pendingAt + ${signal.minuteEstimatedTime}min].`));
7213
- }
7214
- // Time actually expired - close with time_expired
7215
- const timeExpiredResult = await CLOSE_PENDING_SIGNAL_IN_BACKTEST_FN(this, signal, lastPrice, "time_expired", closeTimestamp);
7216
- if (!timeExpiredResult) {
7217
- // Sync rejected the close — signal remains in _pendingSignal, caller must retry
7218
- throw new Error(`ClientStrategy backtest: time_expired close rejected by sync (signalId=${signal.id}). ` +
7219
- `Retry backtest() with new candle data.`);
7220
- }
7221
- return timeExpiredResult;
7254
+ return await PROCESS_PENDING_SIGNAL_CANDLES_FN(this, signal, candles);
7222
7255
  }
7223
7256
  /**
7224
7257
  * Stops the strategy from generating new signals.
@@ -8989,11 +9022,12 @@ const CREATE_KEY_FN$o = (symbol, strategyName, exchangeName, frameName, backtest
8989
9022
  * @param self - Reference to StrategyConnectionService instance
8990
9023
  * @returns Callback function for schedule ping events
8991
9024
  */
8992
- const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => trycatch(async (symbol, strategyName, exchangeName, data, backtest, timestamp) => {
9025
+ const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => trycatch(async (symbol, strategyName, exchangeName, data, currentPrice, backtest, timestamp) => {
8993
9026
  const event = {
8994
9027
  symbol,
8995
9028
  strategyName,
8996
9029
  exchangeName,
9030
+ currentPrice,
8997
9031
  data,
8998
9032
  backtest,
8999
9033
  timestamp,
@@ -9022,11 +9056,12 @@ const CREATE_COMMIT_SCHEDULE_PING_FN = (self) => trycatch(async (symbol, strateg
9022
9056
  * @param self - Reference to StrategyConnectionService instance
9023
9057
  * @returns Callback function for active ping events
9024
9058
  */
9025
- const CREATE_COMMIT_ACTIVE_PING_FN = (self) => trycatch(async (symbol, strategyName, exchangeName, data, backtest, timestamp) => {
9059
+ const CREATE_COMMIT_ACTIVE_PING_FN = (self) => trycatch(async (symbol, strategyName, exchangeName, data, currentPrice, backtest, timestamp) => {
9026
9060
  const event = {
9027
9061
  symbol,
9028
9062
  strategyName,
9029
9063
  exchangeName,
9064
+ currentPrice,
9030
9065
  data,
9031
9066
  backtest,
9032
9067
  timestamp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backtest-kit",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "A TypeScript library for trading system backtest",
5
5
  "author": {
6
6
  "name": "Petr Tripolsky",
package/types.d.ts CHANGED
@@ -941,6 +941,13 @@ interface SchedulePingContract {
941
941
  * Contains all signal information: id, position, priceOpen, priceTakeProfit, priceStopLoss, etc.
942
942
  */
943
943
  data: IScheduledSignalRow;
944
+ /**
945
+ * Current market price of the symbol at the time of the ping.
946
+ * Useful for users to implement custom monitoring logic based on price conditions.
947
+ * For example, users can choose to cancel the scheduled signal if the price moves too far from priceOpen.
948
+ * Note: This is the current price at the time of the ping, not necessarily the priceOpen of the signal.
949
+ */
950
+ currentPrice: number;
944
951
  /**
945
952
  * Execution mode flag.
946
953
  * - true: Event from backtest execution (historical candle data)
@@ -1015,6 +1022,13 @@ interface ActivePingContract {
1015
1022
  * Contains all signal information: id, position, priceOpen, priceTakeProfit, priceStopLoss, etc.
1016
1023
  */
1017
1024
  data: ISignalRow;
1025
+ /**
1026
+ * Current market price of the symbol at the time of the ping.
1027
+ * Useful for users to implement custom management logic based on price conditions.
1028
+ * For example, users can choose to close the pending signal if the price moves too far from priceOpen.
1029
+ * Note: This is the current price at the time of the ping, not necessarily the priceOpen of the signal.
1030
+ */
1031
+ currentPrice: number;
1018
1032
  /**
1019
1033
  * Execution mode flag.
1020
1034
  * - true: Event from backtest execution (historical candle data)