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/README.md +1 -1
- package/build/index.cjs +277 -34
- package/build/index.mjs +277 -34
- package/package.json +1 -1
- package/types.d.ts +20 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
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
|
|
16974
|
+
? getTotalClosed(signal).totalClosedPercent
|
|
16858
16975
|
: 0;
|
|
16859
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43214
|
+
return uniqueTrades.slice(-limit);
|
|
42972
43215
|
};
|
|
42973
43216
|
/**
|
|
42974
43217
|
* Fetches raw candles with flexible date/limit parameters.
|