backtest-kit 15.0.0 → 15.1.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.mjs CHANGED
@@ -5276,6 +5276,14 @@ const validateCandles = (candles) => {
5276
5276
  candle.volume < 0) {
5277
5277
  throw new Error(`validateCandles: candle[${i}] has zero or negative values`);
5278
5278
  }
5279
+ // OHLC coherence: high < low is definitionally corrupt — such a candle
5280
+ // would silently skew VWAP ((h+l+c)/3) and scheduled activation (low/high
5281
+ // breach checks). Deliberately NOT extended to open/close vs high/low:
5282
+ // cross-feed aggregation occasionally puts open/close a rounding step
5283
+ // outside [low, high] on real exchanges.
5284
+ if (candle.high < candle.low) {
5285
+ throw new Error(`validateCandles: candle[${i}] has high (${candle.high}) < low (${candle.low})`);
5286
+ }
5279
5287
  // Check for anomalously low prices (incomplete candle indicator)
5280
5288
  if (candle.open < minValidPrice ||
5281
5289
  candle.high < minValidPrice ||
@@ -5434,7 +5442,7 @@ const GET_CANDLES_FN$1 = async (dto, since, self) => {
5434
5442
  return result;
5435
5443
  }
5436
5444
  catch (err) {
5437
- const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}}`;
5445
+ const message = `ClientExchange GET_CANDLES_FN: attempt ${i + 1} failed for symbol=${dto.symbol}, interval=${dto.interval}, since=${since.toISOString()}, limit=${dto.limit}`;
5438
5446
  const payload = {
5439
5447
  error: errorData(err),
5440
5448
  message: getErrorMessage(err),
@@ -5442,7 +5450,9 @@ const GET_CANDLES_FN$1 = async (dto, since, self) => {
5442
5450
  self.params.logger.warn(message, payload);
5443
5451
  console.warn(message, payload);
5444
5452
  lastError = err;
5445
- await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
5453
+ if (i < GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT - 1) {
5454
+ await sleep(GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_DELAY_MS);
5455
+ }
5446
5456
  }
5447
5457
  }
5448
5458
  throw lastError ?? new Error(`ClientExchange GET_CANDLES_FN: no fetch attempts made (CC_GET_CANDLES_RETRY_COUNT=${GLOBAL_CONFIG.CC_GET_CANDLES_RETRY_COUNT})`);
@@ -5598,7 +5608,9 @@ class ClientExchange {
5598
5608
  * @param symbol - Trading pair symbol
5599
5609
  * @param interval - Candle interval
5600
5610
  * @param limit - Number of candles to fetch
5601
- * @returns Promise resolving to array of candles
5611
+ * @returns Promise resolving to array of candles; an EMPTY array when the
5612
+ * requested range extends beyond Date.now() (those candles have not
5613
+ * closed yet in the real world — the caller decides how to proceed)
5602
5614
  * @throws Error if trying to fetch future candles in live mode
5603
5615
  */
5604
5616
  async getNextCandles(symbol, interval, limit) {
@@ -5818,6 +5830,20 @@ class ClientExchange {
5818
5830
  }
5819
5831
  // Align sDate down to interval boundary
5820
5832
  sinceTimestamp = ALIGN_TO_INTERVAL_FN$2(sDate, step);
5833
+ // The fetch is driven by sDate+limit, not by eDate — validate the ACTUAL
5834
+ // end of the range like Case 4 does, otherwise a limit larger than the
5835
+ // sDate..eDate span silently reads candles past `when` (and past eDate).
5836
+ const endTimestamp = sinceTimestamp + limit * stepMs;
5837
+ if (endTimestamp > whenTimestamp) {
5838
+ throw new Error(`ClientExchange getRawCandles: calculated endTimestamp (${endTimestamp}) exceeds execution context when (${whenTimestamp}). Look-ahead bias protection.`);
5839
+ }
5840
+ // eDate is a hard bound, not advisory: the declared range must contain the
5841
+ // whole fetch, otherwise the eDate<=when validation above is illusory.
5842
+ const alignedEDate = ALIGN_TO_INTERVAL_FN$2(eDate, step);
5843
+ if (endTimestamp > alignedEDate) {
5844
+ throw new Error(`ClientExchange getRawCandles: limit (${limit}) extends the fetch end (${endTimestamp}) past aligned eDate (${alignedEDate}). ` +
5845
+ `With sDate+eDate+limit the whole range [alignedSDate, alignedSDate + limit*step] must fit within eDate.`);
5846
+ }
5821
5847
  calculatedLimit = limit;
5822
5848
  }
5823
5849
  // Case 2: sDate + eDate (no limit) - calculate limit from date range
@@ -5999,8 +6025,13 @@ class ClientExchange {
5999
6025
  // Move window backwards
6000
6026
  windowEnd = windowStart;
6001
6027
  }
6028
+ // Deduplicate by trade id across window seams: adjacent windows share the
6029
+ // boundary timestamp, and the adapter [from, to] inclusivity is not pinned
6030
+ // by the contract — an inclusive adapter returns the boundary trade in
6031
+ // BOTH windows (systematically so for minute-bucketed sources).
6032
+ const uniqueTrades = Array.from(new Map(result.map((trade) => [trade.id, trade])).values());
6002
6033
  // Slice to requested limit (most recent trades)
6003
- return result.slice(-limit);
6034
+ return uniqueTrades.slice(-limit);
6004
6035
  }
6005
6036
  }
6006
6037
 
@@ -6336,6 +6367,18 @@ const weightedHarmonicMean = (entries) => {
6336
6367
  return totalCost / totalCoins;
6337
6368
  };
6338
6369
 
6370
+ /**
6371
+ * Относительная составляющая допуска для guard'а «партиалы превысили вложения».
6372
+ *
6373
+ * Кап партиалов (PARTIAL_CAP_TOLERANCE_FACTOR в ClientStrategy) пропускает
6374
+ * floating-point дрейф до totalInvested × 1e-9 НА ШАГ, а этот guard заново
6375
+ * суммирует весь реплей partial-истории — дрейф накапливается по шагам, поэтому
6376
+ * запас на порядок шире (1e-8). Чисто абсолютный порог ($0.001) отвергал
6377
+ * легитимное 100%-закрытие позиции с крупным кастомным cost (>$1M): центы
6378
+ * ULP-шума double — это не превышение вложений. Реальный перебор (проценты,
6379
+ * а не 1e-8 относительных) по-прежнему отсекается.
6380
+ */
6381
+ const PARTIAL_OVERCLOSE_RELATIVE_TOLERANCE = 1e-8;
6339
6382
  /**
6340
6383
  * Calculates profit/loss for a closed signal with slippage and fees.
6341
6384
  *
@@ -6396,7 +6439,8 @@ const toProfitLossDto = (signal, priceClose) => {
6396
6439
  weight *
6397
6440
  (priceCloseWithSlippage / priceOpenWithSlippage);
6398
6441
  }
6399
- if (closedDollarValue > totalInvested + 0.001) {
6442
+ // Допуск absolute-OR-relative: см. PARTIAL_OVERCLOSE_RELATIVE_TOLERANCE
6443
+ if (closedDollarValue > totalInvested + Math.max(0.001, totalInvested * PARTIAL_OVERCLOSE_RELATIVE_TOLERANCE)) {
6400
6444
  throw new Error(`Partial closes dollar value (${closedDollarValue.toFixed(4)}) exceeds total invested (${totalInvested}) — signal id: ${signal.id}`);
6401
6445
  }
6402
6446
  // Remaining position
@@ -7731,8 +7775,14 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
7731
7775
  const intervalMinutes = INTERVAL_MINUTES$8[self.params.interval];
7732
7776
  const intervalMs = intervalMinutes * 60 * 1000;
7733
7777
  const alignedTime = Math.floor(currentTime / intervalMs) * intervalMs;
7734
- // Проверяем что наступил новый интервал (по aligned timestamp)
7735
- if (self._lastSignalTimestamp !== null &&
7778
+ // Проверяем что наступил новый интервал (по aligned timestamp).
7779
+ // User-queued DTO (createSignal) минует троттл: это явная команда, а не
7780
+ // периодическая генерация — ожидание границы интервала задерживало её
7781
+ // до целого интервала (час для "1h"). Потребление DTO при этом занимает
7782
+ // слот текущего интервала (ниже), так что собственная генерация
7783
+ // стратегии не учащается.
7784
+ if (!self._userSignal &&
7785
+ self._lastSignalTimestamp !== null &&
7736
7786
  alignedTime === self._lastSignalTimestamp) {
7737
7787
  return null;
7738
7788
  }
@@ -7748,10 +7798,23 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
7748
7798
  {
7749
7799
  if (!self._userSignal) {
7750
7800
  const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
7751
- signal = await Promise.race([
7752
- self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7753
- sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
7754
- ]);
7801
+ // Cancelable timeout instead of a plain sleep: Promise.race does not
7802
+ // cancel the loser, so the sleep timer stayed referenced for the full
7803
+ // CC_MAX_SIGNAL_GENERATION_SECONDS after every getSignal call — keeping
7804
+ // the node process alive up to 3 minutes after a backtest finishes and
7805
+ // piling up one live timer per interval on long runs.
7806
+ let timeoutId;
7807
+ try {
7808
+ signal = await Promise.race([
7809
+ self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
7810
+ new Promise((res) => {
7811
+ timeoutId = setTimeout(() => res(TIMEOUT_SYMBOL$1), timeoutMs);
7812
+ }),
7813
+ ]);
7814
+ }
7815
+ finally {
7816
+ timeoutId !== undefined && clearTimeout(timeoutId);
7817
+ }
7755
7818
  }
7756
7819
  if (self._userSignal) {
7757
7820
  const userDto = self._userSignal;
@@ -8010,6 +8073,17 @@ const WAIT_FOR_INIT_FN$4 = async (self) => {
8010
8073
  // _closedSignal), so they stand on their own and are restored unconditionally too.
8011
8074
  self._takeProfitSignal = strategyData.takeProfitSignal;
8012
8075
  self._stopLossSignal = strategyData.stopLossSignal;
8076
+ // Restore the commit queue when its attribution target is the deferred USER
8077
+ // close snapshot (closePending / full-partial auto-close): that snapshot has
8078
+ // pendingSignalId already null, so the pending-id match below cannot apply,
8079
+ // yet PROCESS_COMMIT_QUEUE_FN attributes drained ops to _closedSignal — the
8080
+ // non-crash flow delivers them, so dropping the queue here would lose
8081
+ // broker-confirmed operations only because a restart happened in between.
8082
+ // Broker-confirmed TP/SL fill snapshots intentionally do NOT restore the
8083
+ // queue: those ops are void (see the orphaned-queue recovery test).
8084
+ if (strategyData.closedSignal) {
8085
+ self._commitQueue = strategyData.commitQueue ?? [];
8086
+ }
8013
8087
  }
8014
8088
  // Restore pending signal. A context mismatch skips ONLY this block (not an
8015
8089
  // early return): the scheduled restore below and the onInit call must still run.
@@ -11849,11 +11923,15 @@ class ClientStrategy {
11849
11923
  // NOTE: No _isStopped check here - cancellation must work for graceful shutdown
11850
11924
  if (this._cancelledSignal) {
11851
11925
  const cancelledSignal = this._cancelledSignal;
11926
+ // Release the slot reserved at scheduled-signal creation BEFORE persisting
11927
+ // the drained flag: a crash in between replays the (idempotent) removal on
11928
+ // restart instead of orphaning the slot — an orphan blocks the shared
11929
+ // concurrency limit for the whole lifetime, forever for Infinity-hold
11930
+ // (expiry pruning never removes those, and no owner is left to removeSignal).
11931
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11852
11932
  this._cancelledSignal = null; // Clear after emitting
11853
11933
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11854
11934
  await PERSIST_STRATEGY_FN(this);
11855
- // Release the slot reserved at scheduled-signal creation
11856
- await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11857
11935
  this.params.logger.info("ClientStrategy tick: scheduled signal was cancelled", {
11858
11936
  symbol: this.params.execution.context.symbol,
11859
11937
  signalId: cancelledSignal.id,
@@ -11912,6 +11990,10 @@ class ClientStrategy {
11912
11990
  // Do NOT clear _closedSignal — retry on next tick
11913
11991
  return await RETURN_IDLE_FN(this, currentPrice);
11914
11992
  }
11993
+ // Release the risk slot BEFORE persisting the drained flag: a crash in
11994
+ // between replays the (idempotent) removal on restart instead of orphaning
11995
+ // the slot until lifetime expiry (forever for Infinity-hold positions).
11996
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11915
11997
  this._closedSignal = null; // Clear only after sync confirmed
11916
11998
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11917
11999
  await PERSIST_STRATEGY_FN(this);
@@ -11946,8 +12028,8 @@ class ClientStrategy {
11946
12028
  // КРИТИЧНО: Очищаем состояние ClientPartial при закрытии позиции
11947
12029
  await CALL_PARTIAL_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
11948
12030
  // КРИТИЧНО: Очищаем состояние ClientBreakeven при закрытии позиции
12031
+ // (риск-слот уже освобождён ДО персиста дренированного флага — см. выше)
11949
12032
  await CALL_BREAKEVEN_CLEAR_FN(this, this.params.execution.context.symbol, closedSignal, currentPrice, currentTime, this.params.execution.context.backtest);
11950
- await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11951
12033
  const result = {
11952
12034
  action: "closed",
11953
12035
  signal: publicSignal,
@@ -11970,6 +12052,10 @@ class ClientStrategy {
11970
12052
  // The exchange filled the TP order (e.g. by high/low); close bypassing the VWAP TP check.
11971
12053
  if (this._takeProfitSignal) {
11972
12054
  const filledSignal = this._takeProfitSignal;
12055
+ // Release the risk slot BEFORE persisting the drained flag (crash-safe:
12056
+ // removal is idempotent and re-runs on replay; the late removal inside
12057
+ // CLOSE_PENDING_SIGNAL_AS_FILL_FN stays for the backtest paths)
12058
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11973
12059
  this._takeProfitSignal = null; // Clear after draining
11974
12060
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11975
12061
  await PERSIST_STRATEGY_FN(this);
@@ -11983,6 +12069,10 @@ class ClientStrategy {
11983
12069
  // The exchange filled the SL order (e.g. by high/low); close bypassing the VWAP SL check.
11984
12070
  if (this._stopLossSignal) {
11985
12071
  const filledSignal = this._stopLossSignal;
12072
+ // Release the risk slot BEFORE persisting the drained flag (crash-safe:
12073
+ // removal is idempotent and re-runs on replay; the late removal inside
12074
+ // CLOSE_PENDING_SIGNAL_AS_FILL_FN stays for the backtest paths)
12075
+ await CALL_RISK_REMOVE_SIGNAL_FN(this, this.params.execution.context.symbol, currentTime, this.params.execution.context.backtest);
11986
12076
  this._stopLossSignal = null; // Clear after draining
11987
12077
  // Persist the cleared deferred state so the drained flag is not replayed on restart
11988
12078
  await PERSIST_STRATEGY_FN(this);
@@ -12592,6 +12682,13 @@ class ClientStrategy {
12592
12682
  hasStopLossSignal: this._stopLossSignal !== null,
12593
12683
  });
12594
12684
  this._isStopped = true;
12685
+ // A queued createSignal DTO is an explicit intent to open a NEW position —
12686
+ // stop voids it, like it cancels a scheduled signal. Nothing was placed on
12687
+ // the exchange for a queued DTO, so the drop is safe. Without this,
12688
+ // _isStopped (deliberately NOT persisted: restart = operator intent to run)
12689
+ // left the DTO on disk and the next restart silently opened a stale position.
12690
+ const droppedUserSignal = this._userSignal !== null;
12691
+ this._userSignal = null;
12595
12692
  // NOTE: _isStopped blocks NEW position opening, but deferred user commands
12596
12693
  // and broker-confirmed fills must still drain on subsequent ticks:
12597
12694
  // - _cancelledSignal / _closedSignal are KEPT — their drain emits the
@@ -12615,6 +12712,11 @@ class ClientStrategy {
12615
12712
  this._activatedSignal = null;
12616
12713
  this._scheduledSignal = null;
12617
12714
  if (!signalToCancel) {
12715
+ // Persist the dropped createSignal DTO even when there is nothing to
12716
+ // cancel — otherwise a crash after stop restores and opens it.
12717
+ if (droppedUserSignal && !backtest) {
12718
+ await PERSIST_STRATEGY_FN(this);
12719
+ }
12618
12720
  return;
12619
12721
  }
12620
12722
  if (!this._cancelledSignal) {
@@ -12819,6 +12921,12 @@ class ClientStrategy {
12819
12921
  */
12820
12922
  async createSignal(symbol, currentPrice, dto) {
12821
12923
  this.params.logger.debug("ClientStrategy createSignal", { symbol, currentPrice });
12924
+ // Queueing a DTO is opening a NEW position — blocked while stopped,
12925
+ // mirroring activateScheduled (stopStrategy likewise voids an already
12926
+ // queued DTO).
12927
+ if (this._isStopped) {
12928
+ throw new Error(`ClientStrategy createSignal: strategy is stopped for symbol=${symbol}`);
12929
+ }
12822
12930
  // Validate BEFORE mutating state — a bad DTO or a busy strategy must store nothing.
12823
12931
  // Reuse validateSignal (the canonical getSignal-output validator, branching
12824
12932
  // pending-vs-scheduled by the same rule as GET_SIGNAL_FN). It forwards to
@@ -16588,6 +16696,9 @@ const calculateKellyCriterion = (params, schema) => {
16588
16696
  if (winLossRatio <= 0) {
16589
16697
  throw new Error("winLossRatio must be positive");
16590
16698
  }
16699
+ if (priceOpen <= 0) {
16700
+ throw new Error("priceOpen must be positive");
16701
+ }
16591
16702
  // Kelly formula: (W * R - L) / R
16592
16703
  // W = win rate, L = loss rate (1 - W), R = win/loss ratio
16593
16704
  const kellyPercentage = (winRate * winLossRatio - (1 - winRate)) / winLossRatio;
@@ -16613,6 +16724,9 @@ const calculateATRBased = (params, schema) => {
16613
16724
  }
16614
16725
  const riskAmount = accountBalance * (riskPercentage / 100);
16615
16726
  const stopDistance = atr * atrMultiplier;
16727
+ if (stopDistance === 0) {
16728
+ throw new Error("ATR stop distance cannot be zero (check atrMultiplier)");
16729
+ }
16616
16730
  return riskAmount / stopDistance;
16617
16731
  };
16618
16732
  /**
@@ -16684,6 +16798,9 @@ const CALCULATE_FN = async (params, self) => {
16684
16798
  }
16685
16799
  // Apply max position percentage constraint (risk cap)
16686
16800
  if (schema.maxPositionPercentage !== undefined) {
16801
+ if (params.priceOpen <= 0) {
16802
+ throw new Error("ClientSizing calculate: priceOpen must be positive to apply maxPositionPercentage");
16803
+ }
16687
16804
  const maxByPercentage = (params.accountBalance * schema.maxPositionPercentage) /
16688
16805
  100 /
16689
16806
  params.priceOpen;
@@ -16854,15 +16971,21 @@ const TO_RISK_SIGNAL = (signal, currentPrice, timestamp) => {
16854
16971
  const hasTrailingSL = "_trailingPriceStopLoss" in signal && signal._trailingPriceStopLoss !== undefined;
16855
16972
  const hasTrailingTP = "_trailingPriceTakeProfit" in signal && signal._trailingPriceTakeProfit !== undefined;
16856
16973
  const partialExecuted = ("_partial" in signal && Array.isArray(signal._partial))
16857
- ? signal._partial.reduce((sum, partial) => sum + partial.percent, 0)
16974
+ ? getTotalClosed(signal).totalClosedPercent
16858
16975
  : 0;
16859
- const pnl = signal._isScheduled ? ZERO_PNL : toProfitLossDto(signal, currentPrice);
16976
+ // A market-open candidate (ISignalDto without priceOpen) opens at currentPrice
16977
+ // apply the same fallback BEFORE computing pnl, otherwise pnl.pnlPercentage is
16978
+ // NaN and every numeric comparison in user risk validations silently passes.
16979
+ const pnlSignal = signal.priceOpen === undefined
16980
+ ? { ...signal, priceOpen: currentPrice }
16981
+ : signal;
16982
+ const pnl = signal._isScheduled ? ZERO_PNL : toProfitLossDto(pnlSignal, currentPrice);
16860
16983
  const maxDrawdown = signal._isScheduled ? ZERO_PNL : pnl;
16861
16984
  const peakProfit = signal._isScheduled ? ZERO_PNL : pnl;
16862
16985
  return {
16863
16986
  ...structuredClone(signal),
16864
16987
  cost: signal.cost || GLOBAL_CONFIG.CC_POSITION_ENTRY_COST,
16865
- timestamp: signal.timestamp || timestamp,
16988
+ timestamp: signal.timestamp ?? timestamp,
16866
16989
  totalEntries: 1,
16867
16990
  totalPartials: 0,
16868
16991
  priceOpen: signal.priceOpen ?? currentPrice,
@@ -16986,10 +17109,20 @@ class ClientRisk {
16986
17109
  this.params = params;
16987
17110
  /**
16988
17111
  * Map of active positions tracked across all strategies.
16989
- * Key: `${strategyName}:${exchangeName}:${symbol}`
17112
+ * Key: `${strategyName}_${exchangeName}_${symbol}` (see CREATE_NAME_FN)
16990
17113
  * Starts as POSITION_NEED_FETCH symbol, gets initialized on first use.
16991
17114
  */
16992
17115
  this._activePositions = POSITION_NEED_FETCH;
17116
+ /**
17117
+ * Keys of transient reservation placeholders (checkSignalAndReserve) still
17118
+ * awaiting their finalizing addSignal / releasing removeSignal. Excluded from
17119
+ * persisted snapshots in _updatePositions: a reservation is process-transient,
17120
+ * but a CONCURRENT strategy's addSignal persists the whole shared map — flushed
17121
+ * to disk that placeholder would survive a crash as a phantom position and
17122
+ * block the shared concurrency limit for the whole signal lifetime (forever
17123
+ * for minuteEstimatedTime: Infinity, which the expiry pruning never removes).
17124
+ */
17125
+ this._reservedKeys = new Set();
16993
17126
  /**
16994
17127
  * Initializes active positions by loading from persistence.
16995
17128
  * Uses singleshot pattern to ensure initialization happens exactly once.
@@ -17060,8 +17193,11 @@ class ClientRisk {
17060
17193
  }
17061
17194
  }
17062
17195
  if (rejectionResult) {
17063
- // Call params.onRejected for riskSubject emission
17064
- await this.params.onRejected(params.symbol, params, riskMap.size, rejectionResult, params.timestamp, this.params.backtest);
17196
+ // Call params.onRejected for riskSubject emission.
17197
+ // Use the time-service timestamp — the same clock the rest of this
17198
+ // critical section runs on (TO_RISK_SIGNAL, waitForInit, reservation),
17199
+ // not the caller-supplied params.timestamp.
17200
+ await this.params.onRejected(params.symbol, params, riskMap.size, rejectionResult, timestamp, this.params.backtest);
17065
17201
  // Call schema callbacks.onRejected if defined
17066
17202
  await CALL_REJECTED_CALLBACKS_FN(this, params.symbol, params);
17067
17203
  return false;
@@ -17083,9 +17219,16 @@ class ClientRisk {
17083
17219
  priceOpen: signal.priceOpen ?? params.currentPrice,
17084
17220
  priceStopLoss: signal.priceStopLoss,
17085
17221
  priceTakeProfit: signal.priceTakeProfit,
17086
- minuteEstimatedTime: signal.minuteEstimatedTime,
17222
+ // The DTO is risk-checked BEFORE GET_SIGNAL_FN applies row defaults, so
17223
+ // apply the same lifetime default here — an undefined placeholder value
17224
+ // poisons expiry math (openTimestamp + undefined * 60_000 = NaN) in
17225
+ // concurrent validations reading activePositions.
17226
+ minuteEstimatedTime: signal.minuteEstimatedTime ?? GLOBAL_CONFIG.CC_MAX_SIGNAL_LIFETIME_MINUTES,
17087
17227
  openTimestamp: timestamp,
17088
17228
  });
17229
+ // Transient placeholder: visible to concurrent checks, but kept out of
17230
+ // persisted snapshots until addSignal finalizes it (see _reservedKeys)
17231
+ this._reservedKeys.add(reserveKey);
17089
17232
  }
17090
17233
  // All checks passed
17091
17234
  await CALL_ALLOWED_CALLBACKS_FN(this, params.symbol, params);
@@ -17135,7 +17278,8 @@ class ClientRisk {
17135
17278
  if (this._activePositions === POSITION_NEED_FETCH) {
17136
17279
  await this.waitForInit(when);
17137
17280
  }
17138
- await PersistRiskAdapter.writePositionData(Array.from(this._activePositions), this.params.riskName, this.params.exchangeName, when);
17281
+ // Reservation placeholders stay in-memory only (see _reservedKeys)
17282
+ await PersistRiskAdapter.writePositionData(Array.from(this._activePositions).filter(([key]) => !this._reservedKeys.has(key)), this.params.riskName, this.params.exchangeName, when);
17139
17283
  }
17140
17284
  /**
17141
17285
  * Registers a new opened signal.
@@ -17168,6 +17312,8 @@ class ClientRisk {
17168
17312
  minuteEstimatedTime: positionData.minuteEstimatedTime,
17169
17313
  openTimestamp: positionData.openTimestamp,
17170
17314
  });
17315
+ // The placeholder (if any) is finalized into a real position — persist it
17316
+ this._reservedKeys.delete(key);
17171
17317
  await this._updatePositions(new Date(timestamp));
17172
17318
  }
17173
17319
  finally {
@@ -17193,6 +17339,7 @@ class ClientRisk {
17193
17339
  const key = CREATE_NAME_FN(context.strategyName, context.exchangeName, symbol);
17194
17340
  const riskMap = this._activePositions;
17195
17341
  riskMap.delete(key);
17342
+ this._reservedKeys.delete(key);
17196
17343
  await this._updatePositions(new Date(timestamp));
17197
17344
  }
17198
17345
  finally {
@@ -18427,6 +18574,12 @@ class ClientAction {
18427
18574
  * Starts as null, gets initialized on first use.
18428
18575
  */
18429
18576
  this._handlerInstance = null;
18577
+ /**
18578
+ * Terminal flag set by dispose(). Once true, all event methods become no-ops:
18579
+ * the handler will not be recreated (waitForInit is singleshot), so late
18580
+ * events must not reach the schema callbacks either.
18581
+ */
18582
+ this._isDisposed = false;
18430
18583
  /**
18431
18584
  * Initializes handler instance using singleshot pattern.
18432
18585
  * Ensures initialization happens exactly once.
@@ -18437,6 +18590,7 @@ class ClientAction {
18437
18590
  * Uses singleshot pattern to ensure cleanup happens exactly once.
18438
18591
  */
18439
18592
  this.dispose = singleshot(async () => {
18593
+ this._isDisposed = true;
18440
18594
  await WAIT_FOR_DISPOSE_FN(this);
18441
18595
  });
18442
18596
  }
@@ -18450,6 +18604,12 @@ class ClientAction {
18450
18604
  frameName: this.params.frameName,
18451
18605
  action: event.action,
18452
18606
  });
18607
+ // Dropped after dispose(): the handler is gone and will not be recreated
18608
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18609
+ // would desync them from the handler.
18610
+ if (this._isDisposed) {
18611
+ return;
18612
+ }
18453
18613
  if (!this._handlerInstance) {
18454
18614
  await this.waitForInit();
18455
18615
  }
@@ -18468,6 +18628,12 @@ class ClientAction {
18468
18628
  frameName: this.params.frameName,
18469
18629
  action: event.action,
18470
18630
  });
18631
+ // Dropped after dispose(): the handler is gone and will not be recreated
18632
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18633
+ // would desync them from the handler.
18634
+ if (this._isDisposed) {
18635
+ return;
18636
+ }
18471
18637
  if (!this._handlerInstance) {
18472
18638
  await this.waitForInit();
18473
18639
  }
@@ -18487,6 +18653,12 @@ class ClientAction {
18487
18653
  frameName: this.params.frameName,
18488
18654
  action: event.action,
18489
18655
  });
18656
+ // Dropped after dispose(): the handler is gone and will not be recreated
18657
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18658
+ // would desync them from the handler.
18659
+ if (this._isDisposed) {
18660
+ return;
18661
+ }
18490
18662
  if (!this._handlerInstance) {
18491
18663
  await this.waitForInit();
18492
18664
  }
@@ -18505,6 +18677,12 @@ class ClientAction {
18505
18677
  strategyName: this.params.strategyName,
18506
18678
  frameName: this.params.frameName,
18507
18679
  });
18680
+ // Dropped after dispose(): the handler is gone and will not be recreated
18681
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18682
+ // would desync them from the handler.
18683
+ if (this._isDisposed) {
18684
+ return;
18685
+ }
18508
18686
  if (!this._handlerInstance) {
18509
18687
  await this.waitForInit();
18510
18688
  }
@@ -18523,6 +18701,12 @@ class ClientAction {
18523
18701
  strategyName: this.params.strategyName,
18524
18702
  frameName: this.params.frameName,
18525
18703
  });
18704
+ // Dropped after dispose(): the handler is gone and will not be recreated
18705
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18706
+ // would desync them from the handler.
18707
+ if (this._isDisposed) {
18708
+ return;
18709
+ }
18526
18710
  if (!this._handlerInstance) {
18527
18711
  await this.waitForInit();
18528
18712
  }
@@ -18541,6 +18725,12 @@ class ClientAction {
18541
18725
  strategyName: this.params.strategyName,
18542
18726
  frameName: this.params.frameName,
18543
18727
  });
18728
+ // Dropped after dispose(): the handler is gone and will not be recreated
18729
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18730
+ // would desync them from the handler.
18731
+ if (this._isDisposed) {
18732
+ return;
18733
+ }
18544
18734
  if (!this._handlerInstance) {
18545
18735
  await this.waitForInit();
18546
18736
  }
@@ -18559,6 +18749,12 @@ class ClientAction {
18559
18749
  strategyName: this.params.strategyName,
18560
18750
  frameName: this.params.frameName,
18561
18751
  });
18752
+ // Dropped after dispose(): the handler is gone and will not be recreated
18753
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18754
+ // would desync them from the handler.
18755
+ if (this._isDisposed) {
18756
+ return;
18757
+ }
18562
18758
  if (!this._handlerInstance) {
18563
18759
  await this.waitForInit();
18564
18760
  }
@@ -18582,6 +18778,12 @@ class ClientAction {
18582
18778
  frameName: this.params.frameName,
18583
18779
  action: event.action,
18584
18780
  });
18781
+ // Dropped after dispose(): the handler is gone and will not be recreated
18782
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18783
+ // would desync them from the handler.
18784
+ if (this._isDisposed) {
18785
+ return;
18786
+ }
18585
18787
  if (!this._handlerInstance) {
18586
18788
  await this.waitForInit();
18587
18789
  }
@@ -18605,6 +18807,12 @@ class ClientAction {
18605
18807
  frameName: this.params.frameName,
18606
18808
  action: event.action,
18607
18809
  });
18810
+ // Dropped after dispose(): the handler is gone and will not be recreated
18811
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18812
+ // would desync them from the handler.
18813
+ if (this._isDisposed) {
18814
+ return;
18815
+ }
18608
18816
  if (!this._handlerInstance) {
18609
18817
  await this.waitForInit();
18610
18818
  }
@@ -18623,6 +18831,12 @@ class ClientAction {
18623
18831
  strategyName: this.params.strategyName,
18624
18832
  frameName: this.params.frameName,
18625
18833
  });
18834
+ // Dropped after dispose(): the handler is gone and will not be recreated
18835
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18836
+ // would desync them from the handler.
18837
+ if (this._isDisposed) {
18838
+ return;
18839
+ }
18626
18840
  if (!this._handlerInstance) {
18627
18841
  await this.waitForInit();
18628
18842
  }
@@ -18641,6 +18855,12 @@ class ClientAction {
18641
18855
  strategyName: this.params.strategyName,
18642
18856
  frameName: this.params.frameName,
18643
18857
  });
18858
+ // Dropped after dispose(): the handler is gone and will not be recreated
18859
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18860
+ // would desync them from the handler.
18861
+ if (this._isDisposed) {
18862
+ return;
18863
+ }
18644
18864
  if (!this._handlerInstance) {
18645
18865
  await this.waitForInit();
18646
18866
  }
@@ -18659,6 +18879,12 @@ class ClientAction {
18659
18879
  strategyName: this.params.strategyName,
18660
18880
  frameName: this.params.frameName,
18661
18881
  });
18882
+ // Dropped after dispose(): the handler is gone and will not be recreated
18883
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18884
+ // would desync them from the handler.
18885
+ if (this._isDisposed) {
18886
+ return;
18887
+ }
18662
18888
  if (!this._handlerInstance) {
18663
18889
  await this.waitForInit();
18664
18890
  }
@@ -18678,6 +18904,12 @@ class ClientAction {
18678
18904
  strategyName: this.params.strategyName,
18679
18905
  frameName: this.params.frameName,
18680
18906
  });
18907
+ // Dropped after dispose(): the handler is gone and will not be recreated
18908
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18909
+ // would desync them from the handler.
18910
+ if (this._isDisposed) {
18911
+ return;
18912
+ }
18681
18913
  if (!this._handlerInstance) {
18682
18914
  await this.waitForInit();
18683
18915
  }
@@ -18697,6 +18929,12 @@ class ClientAction {
18697
18929
  strategyName: this.params.strategyName,
18698
18930
  frameName: this.params.frameName,
18699
18931
  });
18932
+ // Dropped after dispose(): the handler is gone and will not be recreated
18933
+ // (waitForInit is singleshot), so firing only the schema callbacks here
18934
+ // would desync them from the handler.
18935
+ if (this._isDisposed) {
18936
+ return;
18937
+ }
18700
18938
  if (!this._handlerInstance) {
18701
18939
  await this.waitForInit();
18702
18940
  }
@@ -34328,10 +34566,10 @@ const HANDLE_LOSS_FN = async (symbol, data, currentPrice, lossPercent, backtest,
34328
34566
  * @param backtest - True if backtest mode, false if live mode
34329
34567
  * @param self - ClientPartial instance reference
34330
34568
  */
34331
- const WAIT_FOR_INIT_FN$1 = async (symbol, strategyName, exchangeName, frameName, backtest, self) => {
34569
+ const WAIT_FOR_INIT_FN$1 = async (symbol, strategyName, exchangeName, frameName, self) => {
34332
34570
  self.params.logger.debug("ClientPartial waitForInit", {
34333
34571
  symbol,
34334
- backtest,
34572
+ backtest: self.params.backtest,
34335
34573
  strategyName,
34336
34574
  exchangeName,
34337
34575
  });
@@ -34457,7 +34695,7 @@ class ClientPartial {
34457
34695
  * // Now profit()/loss() can be called
34458
34696
  * ```
34459
34697
  */
34460
- this.waitForInit = singleshot(async (symbol, strategyName, exchangeName, frameName, backtest) => await WAIT_FOR_INIT_FN$1(symbol, strategyName, exchangeName, frameName, backtest, this));
34698
+ this.waitForInit = singleshot(async (symbol, strategyName, exchangeName, frameName) => await WAIT_FOR_INIT_FN$1(symbol, strategyName, exchangeName, frameName, this));
34461
34699
  }
34462
34700
  /**
34463
34701
  * Persists current partial state to disk.
@@ -34789,7 +35027,7 @@ class PartialConnectionService {
34789
35027
  when,
34790
35028
  });
34791
35029
  const partial = this.getPartial(data.id, backtest);
34792
- await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
35030
+ await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
34793
35031
  return await partial.profit(symbol, data, currentPrice, revenuePercent, backtest, when);
34794
35032
  };
34795
35033
  /**
@@ -34816,7 +35054,7 @@ class PartialConnectionService {
34816
35054
  when,
34817
35055
  });
34818
35056
  const partial = this.getPartial(data.id, backtest);
34819
- await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
35057
+ await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
34820
35058
  return await partial.loss(symbol, data, currentPrice, lossPercent, backtest, when);
34821
35059
  };
34822
35060
  /**
@@ -34844,7 +35082,7 @@ class PartialConnectionService {
34844
35082
  backtest,
34845
35083
  });
34846
35084
  const partial = this.getPartial(data.id, backtest);
34847
- await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
35085
+ await partial.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
34848
35086
  await partial.clear(symbol, data, priceClose, backtest);
34849
35087
  const key = CREATE_KEY_FN$l(data.id, backtest);
34850
35088
  this.getPartial.clear(key);
@@ -35590,12 +35828,12 @@ const HANDLE_BREAKEVEN_FN = async (symbol, data, currentPrice, backtest, when, s
35590
35828
  * @param exchangeName - Exchange identifier
35591
35829
  * @param self - ClientBreakeven instance reference
35592
35830
  */
35593
- const WAIT_FOR_INIT_FN = async (symbol, strategyName, exchangeName, frameName, backtest, self) => {
35831
+ const WAIT_FOR_INIT_FN = async (symbol, strategyName, exchangeName, frameName, self) => {
35594
35832
  self.params.logger.debug("ClientBreakeven waitForInit", {
35595
35833
  symbol,
35596
35834
  strategyName,
35597
35835
  exchangeName,
35598
- backtest,
35836
+ backtest: self.params.backtest,
35599
35837
  });
35600
35838
  if (self._states !== NEED_FETCH) {
35601
35839
  throw new Error("ClientBreakeven WAIT_FOR_INIT_FN should be called once!");
@@ -35716,7 +35954,7 @@ class ClientBreakeven {
35716
35954
  * // Now check() can be called
35717
35955
  * ```
35718
35956
  */
35719
- this.waitForInit = singleshot(async (symbol, strategyName, exchangeName, frameName, backtest) => await WAIT_FOR_INIT_FN(symbol, strategyName, exchangeName, frameName, backtest, this));
35957
+ this.waitForInit = singleshot(async (symbol, strategyName, exchangeName, frameName) => await WAIT_FOR_INIT_FN(symbol, strategyName, exchangeName, frameName, this));
35720
35958
  }
35721
35959
  /**
35722
35960
  * Persists current breakeven state to disk.
@@ -35973,7 +36211,7 @@ class BreakevenConnectionService {
35973
36211
  when,
35974
36212
  });
35975
36213
  const breakeven = this.getBreakeven(data.id, backtest);
35976
- await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
36214
+ await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
35977
36215
  return await breakeven.check(symbol, data, currentPrice, backtest, when);
35978
36216
  };
35979
36217
  /**
@@ -36002,7 +36240,7 @@ class BreakevenConnectionService {
36002
36240
  backtest,
36003
36241
  });
36004
36242
  const breakeven = this.getBreakeven(data.id, backtest);
36005
- await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName, backtest);
36243
+ await breakeven.waitForInit(symbol, data.strategyName, data.exchangeName, data.frameName);
36006
36244
  await breakeven.clear(symbol, data, priceClose, backtest);
36007
36245
  const key = CREATE_KEY_FN$i(data.id, backtest);
36008
36246
  this.getBreakeven.clear(key);
@@ -42967,8 +43205,13 @@ class ExchangeInstance {
42967
43205
  // Move window backwards
42968
43206
  windowEnd = windowStart;
42969
43207
  }
43208
+ // Deduplicate by trade id across window seams: adjacent windows share the
43209
+ // boundary timestamp, and the adapter [from, to] inclusivity is not pinned
43210
+ // by the contract — an inclusive adapter returns the boundary trade in
43211
+ // BOTH windows (systematically so for minute-bucketed sources).
43212
+ const uniqueTrades = Array.from(new Map(result.map((trade) => [trade.id, trade])).values());
42970
43213
  // Slice to requested limit (most recent trades)
42971
- return result.slice(-limit);
43214
+ return uniqueTrades.slice(-limit);
42972
43215
  };
42973
43216
  /**
42974
43217
  * Fetches raw candles with flexible date/limit parameters.