backtest-kit 15.0.0 → 15.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
@@ -5296,6 +5296,14 @@ const validateCandles = (candles) => {
5296
5296
  candle.volume < 0) {
5297
5297
  throw new Error(`validateCandles: candle[${i}] has zero or negative values`);
5298
5298
  }
5299
+ // OHLC coherence: high < low is definitionally corrupt — such a candle
5300
+ // would silently skew VWAP ((h+l+c)/3) and scheduled activation (low/high
5301
+ // breach checks). Deliberately NOT extended to open/close vs high/low:
5302
+ // cross-feed aggregation occasionally puts open/close a rounding step
5303
+ // outside [low, high] on real exchanges.
5304
+ if (candle.high < candle.low) {
5305
+ throw new Error(`validateCandles: candle[${i}] has high (${candle.high}) < low (${candle.low})`);
5306
+ }
5299
5307
  // Check for anomalously low prices (incomplete candle indicator)
5300
5308
  if (candle.open < minValidPrice ||
5301
5309
  candle.high < minValidPrice ||
@@ -5454,7 +5462,7 @@ const GET_CANDLES_FN$1 = async (dto, since, self) => {
5454
5462
  return result;
5455
5463
  }
5456
5464
  catch (err) {
5457
- const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
5465
+ const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}`;
5458
5466
  const payload = {
5459
5467
  error: functoolsKit.errorData(err),
5460
5468
  message: functoolsKit.getErrorMessage(err),
@@ -5462,7 +5470,9 @@ const GET_CANDLES_FN$1 = async (dto, since, self) => {
5462
5470
  self.params.logger.warn(message, payload);
5463
5471
  console.warn(message, payload);
5464
5472
  lastError = err;
5465
- await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
5473
+ if (i < GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT - 1) {
5474
+ await functoolsKit.sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
5475
+ }
5466
5476
  }
5467
5477
  }
5468
5478
  throw lastError ?? new Error(`ClientExchange GET_CANDLES_FN: no fetch attempts made (CC_GET_CANDLES_RETRY_COUNT=${GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT})`);
@@ -5618,7 +5628,9 @@ class ClientExchange {
5618
5628
  * @param symbol - Trading pair symbol
5619
5629
  * @param interval - Candle interval
5620
5630
  * @param limit - Number of candles to fetch
5621
- * @returns Promise resolving to array of candles
5631
+ * @returns Promise resolving to array of candles; an EMPTY array when the
5632
+ * requested range extends beyond Date.now() (those candles have not
5633
+ * closed yet in the real world — the caller decides how to proceed)
5622
5634
  * @throws Error if trying to fetch future candles in live mode
5623
5635
  */
5624
5636
  async getNextCandles(symbol, interval, limit) {
@@ -5838,6 +5850,20 @@ class ClientExchange {
5838
5850
  }
5839
5851
  // Align sDate down to interval boundary
5840
5852
  sinceTimestamp = ALIGN_TO_INTERVAL_FN$2(sDate, step);
5853
+ // The fetch is driven by sDate+limit, not by eDate — validate the ACTUAL
5854
+ // end of the range like Case 4 does, otherwise a limit larger than the
5855
+ // sDate..eDate span silently reads candles past `when` (and past eDate).
5856
+ const endTimestamp = sinceTimestamp + limit * stepMs;
5857
+ if (endTimestamp > whenTimestamp) {
5858
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
5859
+ }
5860
+ // eDate is a hard bound, not advisory: the declared range must contain the
5861
+ // whole fetch, otherwise the eDate<=when validation above is illusory.
5862
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$2(eDate, step);
5863
+ if (endTimestamp > alignedEDate) {
5864
+ throw new Error(`ClientExchange getRawCandles: limit (${limit}) extends the fetch end (${endTimestamp}) past aligned eDate (${alignedEDate}). ` +
5865
+ `With sDate+eDate+limit the whole range [alignedSDate, alignedSDate + limit*step] must fit within eDate.`);
5866
+ }
5841
5867
  calculatedLimit = limit;
5842
5868
  }
5843
5869
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -6019,8 +6045,13 @@ class ClientExchange {
6019
6045
  // Move window backwards
6020
6046
  windowEnd = windowStart;
6021
6047
  }
6048
+ // Deduplicate by trade id across window seams: adjacent windows share the
6049
+ // boundary timestamp, and the adapter [from, to] inclusivity is not pinned
6050
+ // by the contract — an inclusive adapter returns the boundary trade in
6051
+ // BOTH windows (systematically so for minute-bucketed sources).
6052
+ const uniqueTrades = Array.from(new Map(result.map((trade) => [trade.id, trade])).values());
6022
6053
  // Slice to requested limit (most recent trades)
6023
- return result.slice(-limit);
6054
+ return uniqueTrades.slice(-limit);
6024
6055
  }
6025
6056
  }
6026
6057
 
@@ -6356,6 +6387,18 @@ const weightedHarmonicMean = (entries) => {
6356
6387
  return totalCost / totalCoins;
6357
6388
  };
6358
6389
 
6390
+ /**
6391
+ * Относительная составляющая допуска для guard'а «партиалы превысили вложения».
6392
+ *
6393
+ * Кап партиалов (PARTIAL_CAP_TOLERANCE_FACTOR в ClientStrategy) пропускает
6394
+ * floating-point дрейф до totalInvested × 1e-9 НА ШАГ, а этот guard заново
6395
+ * суммирует весь реплей partial-истории — дрейф накапливается по шагам, поэтому
6396
+ * запас на порядок шире (1e-8). Чисто абсолютный порог ($0.001) отвергал
6397
+ * легитимное 100%-закрытие позиции с крупным кастомным cost (>$1M): центы
6398
+ * ULP-шума double — это не превышение вложений. Реальный перебор (проценты,
6399
+ * а не 1e-8 относительных) по-прежнему отсекается.
6400
+ */
6401
+ const PARTIAL_OVERCLOSE_RELATIVE_TOLERANCE = 1e-8;
6359
6402
  /**
6360
6403
  * Calculates profit/loss for a closed signal with slippage and fees.
6361
6404
  *
@@ -6416,7 +6459,8 @@ const toProfitLossDto = (signal, priceClose) => {
6416
6459
  weight *
6417
6460
  (priceCloseWithSlippage / priceOpenWithSlippage);
6418
6461
  }
6419
- if (closedDollarValue > totalInvested + 0.001) {
6462
+ // Допуск absolute-OR-relative: см. PARTIAL_OVERCLOSE_RELATIVE_TOLERANCE
6463
+ if (closedDollarValue > totalInvested + Math.max(0.001, totalInvested * PARTIAL_OVERCLOSE_RELATIVE_TOLERANCE)) {
6420
6464
  throw new Error(`Partial closes dollar value (${closedDollarValue.toFixed(4)}) exceeds total invested (${totalInvested}) — signal id: ${signal.id}`);
6421
6465
  }
6422
6466
  // Remaining position
@@ -7751,8 +7795,14 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
7751
7795
  const intervalMinutes = INTERVAL_MINUTES$8[self.params.interval];
7752
7796
  const intervalMs = intervalMinutes * 60 * 1000;
7753
7797
  const alignedTime = Math.floor(currentTime / intervalMs) * intervalMs;
7754
- // Проверяем что наступил новый интервал (по aligned timestamp)
7755
- if (self._lastSignalTimestamp !== null &&
7798
+ // Проверяем что наступил новый интервал (по aligned timestamp).
7799
+ // User-queued DTO (createSignal) минует троттл: это явная команда, а не
7800
+ // периодическая генерация — ожидание границы интервала задерживало её
7801
+ // до целого интервала (час для "1h"). Потребление DTO при этом занимает
7802
+ // слот текущего интервала (ниже), так что собственная генерация
7803
+ // стратегии не учащается.
7804
+ if (!self._userSignal &&
7805
+ self._lastSignalTimestamp !== null &&
7756
7806
  alignedTime === self._lastSignalTimestamp) {
7757
7807
  return null;
7758
7808
  }
@@ -7768,10 +7818,23 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
7768
7818
  {
7769
7819
  if (!self._userSignal) {
7770
7820
  const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
7771
- signal = await Promise.race([
7772
- self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7773
- functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
7774
- ]);
7821
+ // Cancelable timeout instead of a plain sleep: Promise.race does not
7822
+ // cancel the loser, so the sleep timer stayed referenced for the full
7823
+ // CC_MAX_SIGNAL_GENERATION_SECONDS after every getSignal call — keeping
7824
+ // the node process alive up to 3 minutes after a backtest finishes and
7825
+ // piling up one live timer per interval on long runs.
7826
+ let timeoutId;
7827
+ try {
7828
+ signal = await Promise.race([
7829
+ self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7830
+ new Promise((res) => {
7831
+ timeoutId = setTimeout(() => res(TIMEOUT_SYMBOL$1), timeoutMs);
7832
+ }),
7833
+ ]);
7834
+ }
7835
+ finally {
7836
+ timeoutId !== undefined && clearTimeout(timeoutId);
7837
+ }
7775
7838
  }
7776
7839
  if (self._userSignal) {
7777
7840
  const userDto = self._userSignal;
@@ -8030,6 +8093,17 @@ const WAIT_FOR_INIT_FN$4 = async (self) => {
8030
8093
  // _closedSignal), so they stand on their own and are restored unconditionally too.
8031
8094
  self._takeProfitSignal = strategyData.takeProfitSignal;
8032
8095
  self._stopLossSignal = strategyData.stopLossSignal;
8096
+ // Restore the commit queue when its attribution target is the deferred USER
8097
+ // close snapshot (closePending / full-partial auto-close): that snapshot has
8098
+ // pendingSignalId already null, so the pending-id match below cannot apply,
8099
+ // yet PROCESS_COMMIT_QUEUE_FN attributes drained ops to _closedSignal — the
8100
+ // non-crash flow delivers them, so dropping the queue here would lose
8101
+ // broker-confirmed operations only because a restart happened in between.
8102
+ // Broker-confirmed TP/SL fill snapshots intentionally do NOT restore the
8103
+ // queue: those ops are void (see the orphaned-queue recovery test).
8104
+ if (strategyData.closedSignal) {
8105
+ self._commitQueue = strategyData.commitQueue ?? [];
8106
+ }
8033
8107
  }
8034
8108
  // Restore pending signal. A context mismatch skips ONLY this block (not an
8035
8109
  // early return): the scheduled restore below and the onInit call must still run.
@@ -11869,11 +11943,15 @@ class ClientStrategy {
11869
11943
  // NOTE: No _isStopped check here - cancellation must work for graceful shutdown
11870
11944
  if (this._cancelledSignal) {
11871
11945
  const cancelledSignal = this._cancelledSignal;
11946
+ // Release the slot reserved at scheduled-signal creation BEFORE persisting
11947
+ // the drained flag: a crash in between replays the (idempotent) removal on
11948
+ // restart instead of orphaning the slot — an orphan blocks the shared
11949
+ // concurrency limit for the whole lifetime, forever for Infinity-hold
11950
+ // (expiry pruning never removes those, and no owner is left to removeSignal).
11951
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11872
11952
  this._cancelledSignal = null; // Clear after emitting
11873
11953
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11874
11954
  await PERSIST_STRATEGY_FN(this);
11875
- // Release the slot reserved at scheduled-signal creation
11876
- await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11877
11955
  this.params.logger.info("ClientStrategy tick: scheduled signal was cancelled", {
11878
11956
  symbol: this.params.execution.context.symbol,
11879
11957
  signalId: cancelledSignal.id,
@@ -11932,6 +12010,10 @@ class ClientStrategy {
11932
12010
  // Do NOT clear _closedSignal — retry on next tick
11933
12011
  return await RETURN_IDLE_FN(this, currentPrice);
11934
12012
  }
12013
+ // Release the risk slot BEFORE persisting the drained flag: a crash in
12014
+ // between replays the (idempotent) removal on restart instead of orphaning
12015
+ // the slot until lifetime expiry (forever for Infinity-hold positions).
12016
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11935
12017
  this._closedSignal = null; // Clear only after sync confirmed
11936
12018
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11937
12019
  await PERSIST_STRATEGY_FN(this);
@@ -11966,8 +12048,8 @@ class ClientStrategy {
11966
12048
  // КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
11967
12049
  await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
11968
12050
  // КРИТИЧНО: Очищаем состояние ClientBreakeven при закрытии позиции
12051
+ // (риск-слот уже освобождён ДО персиста дренированного флага — см. выше)
11969
12052
  await CALL_BREAKEVEN_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
11970
- await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11971
12053
  const result = {
11972
12054
  action: "closed",
11973
12055
  signal: publicSignal,
@@ -11990,6 +12072,10 @@ class ClientStrategy {
11990
12072
  // The exchange filled the TP order (e.g. by high/low); close bypassing the VWAP TP check.
11991
12073
  if (this._takeProfitSignal) {
11992
12074
  const filledSignal = this._takeProfitSignal;
12075
+ // Release the risk slot BEFORE persisting the drained flag (crash-safe:
12076
+ // removal is idempotent and re-runs on replay; the late removal inside
12077
+ // CLOSE_PENDING_SIGNAL_AS_FILL_FN stays for the backtest paths)
12078
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11993
12079
  this._takeProfitSignal = null; // Clear after draining
11994
12080
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11995
12081
  await PERSIST_STRATEGY_FN(this);
@@ -12003,6 +12089,10 @@ class ClientStrategy {
12003
12089
  // The exchange filled the SL order (e.g. by high/low); close bypassing the VWAP SL check.
12004
12090
  if (this._stopLossSignal) {
12005
12091
  const filledSignal = this._stopLossSignal;
12092
+ // Release the risk slot BEFORE persisting the drained flag (crash-safe:
12093
+ // removal is idempotent and re-runs on replay; the late removal inside
12094
+ // CLOSE_PENDING_SIGNAL_AS_FILL_FN stays for the backtest paths)
12095
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
12006
12096
  this._stopLossSignal = null; // Clear after draining
12007
12097
  // Persist the cleared deferred state so the drained flag is not replayed on restart
12008
12098
  await PERSIST_STRATEGY_FN(this);
@@ -12612,6 +12702,13 @@ class ClientStrategy {
12612
12702
  hasStopLossSignal: this._stopLossSignal !== null,
12613
12703
  });
12614
12704
  this._isStopped = true;
12705
+ // A queued createSignal DTO is an explicit intent to open a NEW position —
12706
+ // stop voids it, like it cancels a scheduled signal. Nothing was placed on
12707
+ // the exchange for a queued DTO, so the drop is safe. Without this,
12708
+ // _isStopped (deliberately NOT persisted: restart = operator intent to run)
12709
+ // left the DTO on disk and the next restart silently opened a stale position.
12710
+ const droppedUserSignal = this._userSignal !== null;
12711
+ this._userSignal = null;
12615
12712
  // NOTE: _isStopped blocks NEW position opening, but deferred user commands
12616
12713
  // and broker-confirmed fills must still drain on subsequent ticks:
12617
12714
  // - _cancelledSignal / _closedSignal are KEPT — their drain emits the
@@ -12635,6 +12732,11 @@ class ClientStrategy {
12635
12732
  this._activatedSignal = null;
12636
12733
  this._scheduledSignal = null;
12637
12734
  if (!signalToCancel) {
12735
+ // Persist the dropped createSignal DTO even when there is nothing to
12736
+ // cancel — otherwise a crash after stop restores and opens it.
12737
+ if (droppedUserSignal && !backtest) {
12738
+ await PERSIST_STRATEGY_FN(this);
12739
+ }
12638
12740
  return;
12639
12741
  }
12640
12742
  if (!this._cancelledSignal) {
@@ -12839,6 +12941,12 @@ class ClientStrategy {
12839
12941
  */
12840
12942
  async createSignal(symbol, currentPrice, dto) {
12841
12943
  this.params.logger.debug("ClientStrategy createSignal", { symbol, currentPrice });
12944
+ // Queueing a DTO is opening a NEW position — blocked while stopped,
12945
+ // mirroring activateScheduled (stopStrategy likewise voids an already
12946
+ // queued DTO).
12947
+ if (this._isStopped) {
12948
+ throw new Error(`ClientStrategy createSignal: strategy is stopped for symbol=${symbol}`);
12949
+ }
12842
12950
  // Validate BEFORE mutating state — a bad DTO or a busy strategy must store nothing.
12843
12951
  // Reuse validateSignal (the canonical getSignal-output validator, branching
12844
12952
  // pending-vs-scheduled by the same rule as GET_SIGNAL_FN). It forwards to
@@ -16608,6 +16716,9 @@ const calculateKellyCriterion = (params, schema) => {
16608
16716
  if (winLossRatio <= 0) {
16609
16717
  throw new Error("winLossRatio must be positive");
16610
16718
  }
16719
+ if (priceOpen <= 0) {
16720
+ throw new Error("priceOpen must be positive");
16721
+ }
16611
16722
  // Kelly formula: (W * R - L) / R
16612
16723
  // W = win rate, L = loss rate (1 - W), R = win/loss ratio
16613
16724
  const kellyPercentage = (winRate * winLossRatio - (1 - winRate)) / winLossRatio;
@@ -16633,6 +16744,9 @@ const calculateATRBased = (params, schema) => {
16633
16744
  }
16634
16745
  const riskAmount = accountBalance * (riskPercentage / 100);
16635
16746
  const stopDistance = atr * atrMultiplier;
16747
+ if (stopDistance === 0) {
16748
+ throw new Error("ATR stop distance cannot be zero (check atrMultiplier)");
16749
+ }
16636
16750
  return riskAmount / stopDistance;
16637
16751
  };
16638
16752
  /**
@@ -16704,6 +16818,9 @@ const CALCULATE_FN = async (params, self) => {
16704
16818
  }
16705
16819
  // Apply max position percentage constraint (risk cap)
16706
16820
  if (schema.maxPositionPercentage !== undefined) {
16821
+ if (params.priceOpen <= 0) {
16822
+ throw new Error("ClientSizing calculate: priceOpen must be positive to apply maxPositionPercentage");
16823
+ }
16707
16824
  const maxByPercentage = (params.accountBalance * schema.maxPositionPercentage) /
16708
16825
  100 /
16709
16826
  params.priceOpen;
@@ -16874,15 +16991,21 @@ const TO_RISK_SIGNAL = (signal, currentPrice, timestamp) => {
16874
16991
  const hasTrailingSL = "_trailingPriceStopLoss" in signal && signal._trailingPriceStopLoss !== undefined;
16875
16992
  const hasTrailingTP = "_trailingPriceTakeProfit" in signal && signal._trailingPriceTakeProfit !== undefined;
16876
16993
  const partialExecuted = ("_partial" in signal && Array.isArray(signal._partial))
16877
- ? signal._partial.reduce((sum, partial) => sum + partial.percent, 0)
16994
+ ? getTotalClosed(signal).totalClosedPercent
16878
16995
  : 0;
16879
- const pnl = signal._isScheduled ? ZERO_PNL : toProfitLossDto(signal, currentPrice);
16996
+ // A market-open candidate (ISignalDto without priceOpen) opens at currentPrice
16997
+ // apply the same fallback BEFORE computing pnl, otherwise pnl.pnlPercentage is
16998
+ // NaN and every numeric comparison in user risk validations silently passes.
16999
+ const pnlSignal = signal.priceOpen === undefined
17000
+ ? { ...signal, priceOpen: currentPrice }
17001
+ : signal;
17002
+ const pnl = signal._isScheduled ? ZERO_PNL : toProfitLossDto(pnlSignal, currentPrice);
16880
17003
  const maxDrawdown = signal._isScheduled ? ZERO_PNL : pnl;
16881
17004
  const peakProfit = signal._isScheduled ? ZERO_PNL : pnl;
16882
17005
  return {
16883
17006
  ...structuredClone(signal),
16884
17007
  cost: signal.cost || GLOBAL_CONFIG.CC_POSITION_ENTRY_COST,
16885
- timestamp: signal.timestamp || timestamp,
17008
+ timestamp: signal.timestamp ?? timestamp,
16886
17009
  totalEntries: 1,
16887
17010
  totalPartials: 0,
16888
17011
  priceOpen: signal.priceOpen ?? currentPrice,
@@ -17006,10 +17129,20 @@ class ClientRisk {
17006
17129
  this.params = params;
17007
17130
  /**
17008
17131
  * Map of active positions tracked across all strategies.
17009
- * Key: `${strategyName}:${exchangeName}:${symbol}`
17132
+ * Key: `${strategyName}_${exchangeName}_${symbol}` (see CREATE_NAME_FN)
17010
17133
  * Starts as POSITION_NEED_FETCH symbol, gets initialized on first use.
17011
17134
  */
17012
17135
  this._activePositions = POSITION_NEED_FETCH;
17136
+ /**
17137
+ * Keys of transient reservation placeholders (checkSignalAndReserve) still
17138
+ * awaiting their finalizing addSignal / releasing removeSignal. Excluded from
17139
+ * persisted snapshots in _updatePositions: a reservation is process-transient,
17140
+ * but a CONCURRENT strategy's addSignal persists the whole shared map — flushed
17141
+ * to disk that placeholder would survive a crash as a phantom position and
17142
+ * block the shared concurrency limit for the whole signal lifetime (forever
17143
+ * for minuteEstimatedTime: Infinity, which the expiry pruning never removes).
17144
+ */
17145
+ this._reservedKeys = new Set();
17013
17146
  /**
17014
17147
  * Initializes active positions by loading from persistence.
17015
17148
  * Uses singleshot pattern to ensure initialization happens exactly once.
@@ -17080,8 +17213,11 @@ class ClientRisk {
17080
17213
  }
17081
17214
  }
17082
17215
  if (rejectionResult) {
17083
- // Call params.onRejected for riskSubject emission
17084
- await this.params.onRejected(params.symbol, params, riskMap.size, rejectionResult, params.timestamp, this.params.backtest);
17216
+ // Call params.onRejected for riskSubject emission.
17217
+ // Use the time-service timestamp — the same clock the rest of this
17218
+ // critical section runs on (TO_RISK_SIGNAL, waitForInit, reservation),
17219
+ // not the caller-supplied params.timestamp.
17220
+ await this.params.onRejected(params.symbol, params, riskMap.size, rejectionResult, timestamp, this.params.backtest);
17085
17221
  // Call schema callbacks.onRejected if defined
17086
17222
  await CALL_REJECTED_CALLBACKS_FN(this, params.symbol, params);
17087
17223
  return false;
@@ -17103,9 +17239,16 @@ class ClientRisk {
17103
17239
  priceOpen: signal.priceOpen ?? params.currentPrice,
17104
17240
  priceStopLoss: signal.priceStopLoss,
17105
17241
  priceTakeProfit: signal.priceTakeProfit,
17106
- minuteEstimatedTime: signal.minuteEstimatedTime,
17242
+ // The DTO is risk-checked BEFORE GET_SIGNAL_FN applies row defaults, so
17243
+ // apply the same lifetime default here — an undefined placeholder value
17244
+ // poisons expiry math (openTimestamp + undefined * 60_000 = NaN) in
17245
+ // concurrent validations reading activePositions.
17246
+ minuteEstimatedTime: signal.minuteEstimatedTime ?? GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES,
17107
17247
  openTimestamp: timestamp,
17108
17248
  });
17249
+ // Transient placeholder: visible to concurrent checks, but kept out of
17250
+ // persisted snapshots until addSignal finalizes it (see _reservedKeys)
17251
+ this._reservedKeys.add(reserveKey);
17109
17252
  }
17110
17253
  // All checks passed
17111
17254
  await CALL_ALLOWED_CALLBACKS_FN(this, params.symbol, params);
@@ -17155,7 +17298,8 @@ class ClientRisk {
17155
17298
  if (this._activePositions === POSITION_NEED_FETCH) {
17156
17299
  await this.waitForInit(when);
17157
17300
  }
17158
- await PersistRiskAdapter.writePositionData(Array.from(this._activePositions), this.params.riskName, this.params.exchangeName, when);
17301
+ // Reservation placeholders stay in-memory only (see _reservedKeys)
17302
+ await PersistRiskAdapter.writePositionData(Array.from(this._activePositions).filter(([key]) => !this._reservedKeys.has(key)), this.params.riskName, this.params.exchangeName, when);
17159
17303
  }
17160
17304
  /**
17161
17305
  * Registers a new opened signal.
@@ -17188,6 +17332,8 @@ class ClientRisk {
17188
17332
  minuteEstimatedTime: positionData.minuteEstimatedTime,
17189
17333
  openTimestamp: positionData.openTimestamp,
17190
17334
  });
17335
+ // The placeholder (if any) is finalized into a real position — persist it
17336
+ this._reservedKeys.delete(key);
17191
17337
  await this._updatePositions(new Date(timestamp));
17192
17338
  }
17193
17339
  finally {
@@ -17213,6 +17359,7 @@ class ClientRisk {
17213
17359
  const key = CREATE_NAME_FN(context.strategyName, context.exchangeName, symbol);
17214
17360
  const riskMap = this._activePositions;
17215
17361
  riskMap.delete(key);
17362
+ this._reservedKeys.delete(key);
17216
17363
  await this._updatePositions(new Date(timestamp));
17217
17364
  }
17218
17365
  finally {
@@ -18447,6 +18594,12 @@ class ClientAction {
18447
18594
  * Starts as null, gets initialized on first use.
18448
18595
  */
18449
18596
  this._handlerInstance = null;
18597
+ /**
18598
+ * Terminal flag set by dispose(). Once true, all event methods become no-ops:
18599
+ * the handler will not be recreated (waitForInit is singleshot), so late
18600
+ * events must not reach the schema callbacks either.
18601
+ */
18602
+ this._isDisposed = false;
18450
18603
  /**
18451
18604
  * Initializes handler instance using singleshot pattern.
18452
18605
  * Ensures initialization happens exactly once.
@@ -18457,6 +18610,7 @@ class ClientAction {
18457
18610
  * Uses singleshot pattern to ensure cleanup happens exactly once.
18458
18611
  */
18459
18612
  this.dispose = functoolsKit.singleshot(async () => {
18613
+ this._isDisposed = true;
18460
18614
  await WAIT_FOR_DISPOSE_FN(this);
18461
18615
  });
18462
18616
  }
@@ -18470,6 +18624,12 @@ class ClientAction {
18470
18624
  frameName: this.params.frameName,
18471
18625
  action: event.action,
18472
18626
  });
18627
+ // Dropped after dispose(): the handler is gone and will not be recreated
18628
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18629
+ // would desync them from the handler.
18630
+ if (this._isDisposed) {
18631
+ return;
18632
+ }
18473
18633
  if (!this._handlerInstance) {
18474
18634
  await this.waitForInit();
18475
18635
  }
@@ -18488,6 +18648,12 @@ class ClientAction {
18488
18648
  frameName: this.params.frameName,
18489
18649
  action: event.action,
18490
18650
  });
18651
+ // Dropped after dispose(): the handler is gone and will not be recreated
18652
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18653
+ // would desync them from the handler.
18654
+ if (this._isDisposed) {
18655
+ return;
18656
+ }
18491
18657
  if (!this._handlerInstance) {
18492
18658
  await this.waitForInit();
18493
18659
  }
@@ -18507,6 +18673,12 @@ class ClientAction {
18507
18673
  frameName: this.params.frameName,
18508
18674
  action: event.action,
18509
18675
  });
18676
+ // Dropped after dispose(): the handler is gone and will not be recreated
18677
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18678
+ // would desync them from the handler.
18679
+ if (this._isDisposed) {
18680
+ return;
18681
+ }
18510
18682
  if (!this._handlerInstance) {
18511
18683
  await this.waitForInit();
18512
18684
  }
@@ -18525,6 +18697,12 @@ class ClientAction {
18525
18697
  strategyName: this.params.strategyName,
18526
18698
  frameName: this.params.frameName,
18527
18699
  });
18700
+ // Dropped after dispose(): the handler is gone and will not be recreated
18701
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18702
+ // would desync them from the handler.
18703
+ if (this._isDisposed) {
18704
+ return;
18705
+ }
18528
18706
  if (!this._handlerInstance) {
18529
18707
  await this.waitForInit();
18530
18708
  }
@@ -18543,6 +18721,12 @@ class ClientAction {
18543
18721
  strategyName: this.params.strategyName,
18544
18722
  frameName: this.params.frameName,
18545
18723
  });
18724
+ // Dropped after dispose(): the handler is gone and will not be recreated
18725
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18726
+ // would desync them from the handler.
18727
+ if (this._isDisposed) {
18728
+ return;
18729
+ }
18546
18730
  if (!this._handlerInstance) {
18547
18731
  await this.waitForInit();
18548
18732
  }
@@ -18561,6 +18745,12 @@ class ClientAction {
18561
18745
  strategyName: this.params.strategyName,
18562
18746
  frameName: this.params.frameName,
18563
18747
  });
18748
+ // Dropped after dispose(): the handler is gone and will not be recreated
18749
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18750
+ // would desync them from the handler.
18751
+ if (this._isDisposed) {
18752
+ return;
18753
+ }
18564
18754
  if (!this._handlerInstance) {
18565
18755
  await this.waitForInit();
18566
18756
  }
@@ -18579,6 +18769,12 @@ class ClientAction {
18579
18769
  strategyName: this.params.strategyName,
18580
18770
  frameName: this.params.frameName,
18581
18771
  });
18772
+ // Dropped after dispose(): the handler is gone and will not be recreated
18773
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18774
+ // would desync them from the handler.
18775
+ if (this._isDisposed) {
18776
+ return;
18777
+ }
18582
18778
  if (!this._handlerInstance) {
18583
18779
  await this.waitForInit();
18584
18780
  }
@@ -18602,6 +18798,12 @@ class ClientAction {
18602
18798
  frameName: this.params.frameName,
18603
18799
  action: event.action,
18604
18800
  });
18801
+ // Dropped after dispose(): the handler is gone and will not be recreated
18802
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18803
+ // would desync them from the handler.
18804
+ if (this._isDisposed) {
18805
+ return;
18806
+ }
18605
18807
  if (!this._handlerInstance) {
18606
18808
  await this.waitForInit();
18607
18809
  }
@@ -18625,6 +18827,12 @@ class ClientAction {
18625
18827
  frameName: this.params.frameName,
18626
18828
  action: event.action,
18627
18829
  });
18830
+ // Dropped after dispose(): the handler is gone and will not be recreated
18831
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18832
+ // would desync them from the handler.
18833
+ if (this._isDisposed) {
18834
+ return;
18835
+ }
18628
18836
  if (!this._handlerInstance) {
18629
18837
  await this.waitForInit();
18630
18838
  }
@@ -18643,6 +18851,12 @@ class ClientAction {
18643
18851
  strategyName: this.params.strategyName,
18644
18852
  frameName: this.params.frameName,
18645
18853
  });
18854
+ // Dropped after dispose(): the handler is gone and will not be recreated
18855
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18856
+ // would desync them from the handler.
18857
+ if (this._isDisposed) {
18858
+ return;
18859
+ }
18646
18860
  if (!this._handlerInstance) {
18647
18861
  await this.waitForInit();
18648
18862
  }
@@ -18661,6 +18875,12 @@ class ClientAction {
18661
18875
  strategyName: this.params.strategyName,
18662
18876
  frameName: this.params.frameName,
18663
18877
  });
18878
+ // Dropped after dispose(): the handler is gone and will not be recreated
18879
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18880
+ // would desync them from the handler.
18881
+ if (this._isDisposed) {
18882
+ return;
18883
+ }
18664
18884
  if (!this._handlerInstance) {
18665
18885
  await this.waitForInit();
18666
18886
  }
@@ -18679,6 +18899,12 @@ class ClientAction {
18679
18899
  strategyName: this.params.strategyName,
18680
18900
  frameName: this.params.frameName,
18681
18901
  });
18902
+ // Dropped after dispose(): the handler is gone and will not be recreated
18903
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18904
+ // would desync them from the handler.
18905
+ if (this._isDisposed) {
18906
+ return;
18907
+ }
18682
18908
  if (!this._handlerInstance) {
18683
18909
  await this.waitForInit();
18684
18910
  }
@@ -18698,6 +18924,12 @@ class ClientAction {
18698
18924
  strategyName: this.params.strategyName,
18699
18925
  frameName: this.params.frameName,
18700
18926
  });
18927
+ // Dropped after dispose(): the handler is gone and will not be recreated
18928
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18929
+ // would desync them from the handler.
18930
+ if (this._isDisposed) {
18931
+ return;
18932
+ }
18701
18933
  if (!this._handlerInstance) {
18702
18934
  await this.waitForInit();
18703
18935
  }
@@ -18717,6 +18949,12 @@ class ClientAction {
18717
18949
  strategyName: this.params.strategyName,
18718
18950
  frameName: this.params.frameName,
18719
18951
  });
18952
+ // Dropped after dispose(): the handler is gone and will not be recreated
18953
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18954
+ // would desync them from the handler.
18955
+ if (this._isDisposed) {
18956
+ return;
18957
+ }
18720
18958
  if (!this._handlerInstance) {
18721
18959
  await this.waitForInit();
18722
18960
  }
@@ -34348,10 +34586,10 @@ const HANDLE_LOSS_FN = async (symbol, data, currentPrice, lossPercent, backtest,
34348
34586
  * @param backtest - True if backtest mode, false if live mode
34349
34587
  * @param self - ClientPartial instance reference
34350
34588
  */
34351
- const WAIT_FOR_INIT_FN$1 = async (symbol, strategyName, exchangeName, frameName, backtest, self) => {
34589
+ const WAIT_FOR_INIT_FN$1 = async (symbol, strategyName, exchangeName, frameName, self) => {
34352
34590
  self.params.logger.debug("ClientPartial waitForInit", {
34353
34591
  symbol,
34354
- backtest,
34592
+ backtest: self.params.backtest,
34355
34593
  strategyName,
34356
34594
  exchangeName,
34357
34595
  });
@@ -34477,7 +34715,7 @@ class ClientPartial {
34477
34715
  * // Now profit()/loss() can be called
34478
34716
  * ```
34479
34717
  */
34480
- this.waitForInit = functoolsKit.singleshot(async (symbol, strategyName, exchangeName, frameName, backtest) => await WAIT_FOR_INIT_FN$1(symbol, strategyName, exchangeName, frameName, backtest, this));
34718
+ this.waitForInit = functoolsKit.singleshot(async (symbol, strategyName, exchangeName, frameName) => await WAIT_FOR_INIT_FN$1(symbol, strategyName, exchangeName, frameName, this));
34481
34719
  }
34482
34720
  /**
34483
34721
  * Persists current partial state to disk.
@@ -34809,7 +35047,7 @@ class PartialConnectionService {
34809
35047
  when,
34810
35048
  });
34811
35049
  const partial = this.getPartial(data.id, backtest);
34812
- await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
35050
+ await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
34813
35051
  return await partial.profit(symbol, data, currentPrice, revenuePercent, backtest, when);
34814
35052
  };
34815
35053
  /**
@@ -34836,7 +35074,7 @@ class PartialConnectionService {
34836
35074
  when,
34837
35075
  });
34838
35076
  const partial = this.getPartial(data.id, backtest);
34839
- await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
35077
+ await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
34840
35078
  return await partial.loss(symbol, data, currentPrice, lossPercent, backtest, when);
34841
35079
  };
34842
35080
  /**
@@ -34864,7 +35102,7 @@ class PartialConnectionService {
34864
35102
  backtest,
34865
35103
  });
34866
35104
  const partial = this.getPartial(data.id, backtest);
34867
- await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
35105
+ await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
34868
35106
  await partial.clear(symbol, data, priceClose, backtest);
34869
35107
  const key = CREATE_KEY_FN$l(data.id, backtest);
34870
35108
  this.getPartial.clear(key);
@@ -35610,12 +35848,12 @@ const HANDLE_BREAKEVEN_FN = async (symbol, data, currentPrice, backtest, when, s
35610
35848
  * @param exchangeName - Exchange identifier
35611
35849
  * @param self - ClientBreakeven instance reference
35612
35850
  */
35613
- const WAIT_FOR_INIT_FN = async (symbol, strategyName, exchangeName, frameName, backtest, self) => {
35851
+ const WAIT_FOR_INIT_FN = async (symbol, strategyName, exchangeName, frameName, self) => {
35614
35852
  self.params.logger.debug("ClientBreakeven waitForInit", {
35615
35853
  symbol,
35616
35854
  strategyName,
35617
35855
  exchangeName,
35618
- backtest,
35856
+ backtest: self.params.backtest,
35619
35857
  });
35620
35858
  if (self._states !== NEED_FETCH) {
35621
35859
  throw new Error("ClientBreakeven WAIT_FOR_INIT_FN should be called once!");
@@ -35736,7 +35974,7 @@ class ClientBreakeven {
35736
35974
  * // Now check() can be called
35737
35975
  * ```
35738
35976
  */
35739
- this.waitForInit = functoolsKit.singleshot(async (symbol, strategyName, exchangeName, frameName, backtest) => await WAIT_FOR_INIT_FN(symbol, strategyName, exchangeName, frameName, backtest, this));
35977
+ this.waitForInit = functoolsKit.singleshot(async (symbol, strategyName, exchangeName, frameName) => await WAIT_FOR_INIT_FN(symbol, strategyName, exchangeName, frameName, this));
35740
35978
  }
35741
35979
  /**
35742
35980
  * Persists current breakeven state to disk.
@@ -35993,7 +36231,7 @@ class BreakevenConnectionService {
35993
36231
  when,
35994
36232
  });
35995
36233
  const breakeven = this.getBreakeven(data.id, backtest);
35996
- await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
36234
+ await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
35997
36235
  return await breakeven.check(symbol, data, currentPrice, backtest, when);
35998
36236
  };
35999
36237
  /**
@@ -36022,7 +36260,7 @@ class BreakevenConnectionService {
36022
36260
  backtest,
36023
36261
  });
36024
36262
  const breakeven = this.getBreakeven(data.id, backtest);
36025
- await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
36263
+ await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
36026
36264
  await breakeven.clear(symbol, data, priceClose, backtest);
36027
36265
  const key = CREATE_KEY_FN$i(data.id, backtest);
36028
36266
  this.getBreakeven.clear(key);
@@ -42987,8 +43225,13 @@ class ExchangeInstance {
42987
43225
  // Move window backwards
42988
43226
  windowEnd = windowStart;
42989
43227
  }
43228
+ // Deduplicate by trade id across window seams: adjacent windows share the
43229
+ // boundary timestamp, and the adapter [from, to] inclusivity is not pinned
43230
+ // by the contract — an inclusive adapter returns the boundary trade in
43231
+ // BOTH windows (systematically so for minute-bucketed sources).
43232
+ const uniqueTrades = Array.from(new Map(result.map((trade) => [trade.id, trade])).values());
42990
43233
  // Slice to requested limit (most recent trades)
42991
- return result.slice(-limit);
43234
+ return uniqueTrades.slice(-limit);
42992
43235
  };
42993
43236
  /**
42994
43237
  * Fetches raw candles with flexible date/limit parameters.