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/README.md +1 -1
- package/build/index.cjs +277 -34
- package/build/index.mjs +277 -34
- package/package.json +2 -2
- package/types.d.ts +20 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
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
|
|
16994
|
+
? getTotalClosed(signal).totalClosedPercent
|
|
16878
16995
|
: 0;
|
|
16879
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43234
|
+
return uniqueTrades.slice(-limit);
|
|
42992
43235
|
};
|
|
42993
43236
|
/**
|
|
42994
43237
|
* Fetches raw candles with flexible date/limit parameters.
|