backtest-kit 3.7.0 → 3.7.1

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
@@ -443,6 +443,14 @@ const GLOBAL_CONFIG = {
443
443
  * Default: true (mutex locking enabled for candle fetching)
444
444
  */
445
445
  CC_ENABLE_CANDLE_FETCH_MUTEX: true,
446
+ /**
447
+ * Enables DCA (Dollar-Cost Averaging) logic even if antirecord is not broken.
448
+ * Allows to commitAverageBuy if currentPrice is not the lowest price since entry, but still lower than priceOpen.
449
+ * This can help improve average entry price in cases where price has rebounded after entry but is still below priceOpen, without waiting for a new lower price.
450
+ *
451
+ * Default: true (DCA logic enabled everywhere, not just when antirecord is broken)
452
+ */
453
+ CC_ENABLE_DCA_EVERYWHERE: false,
446
454
  };
447
455
  const DEFAULT_CONFIG = Object.freeze({ ...GLOBAL_CONFIG });
448
456
 
@@ -3174,75 +3182,90 @@ class ExchangeConnectionService {
3174
3182
  }
3175
3183
  }
3176
3184
 
3185
+ const COST_BASIS_PER_ENTRY$3 = 100;
3177
3186
  /**
3178
3187
  * Returns the effective entry price for price calculations.
3179
3188
  *
3180
- * When the _entry array exists and has at least one element, returns
3181
- * the simple arithmetic mean of all entry prices (DCA average).
3182
- * Otherwise returns the original signal.priceOpen.
3189
+ * Uses harmonic mean (correct for fixed-dollar DCA: $100 per entry).
3183
3190
  *
3184
- * This mirrors the _trailingPriceStopLoss pattern: original price is preserved
3185
- * in signal.priceOpen (for identity/tracking), while calculations use the
3186
- * effective averaged price returned by this function.
3191
+ * When partial closes exist, replays the partial sequence to reconstruct
3192
+ * the running cost basis at each partial — no extra stored fields needed.
3187
3193
  *
3188
- * @param signal - Signal row (ISignalRow or IScheduledSignalRow)
3189
- * @returns Effective entry price for distance and PNL calculations
3194
+ * Cost basis replay:
3195
+ * costBasis starts at 0
3196
+ * for each partial[i]:
3197
+ * newEntries = entryCountAtClose[i] - entryCountAtClose[i-1] (or entryCountAtClose[0] for i=0)
3198
+ * costBasis += newEntries × $100 ← add DCA entries up to this partial
3199
+ * positionCostBasisAtClose[i] = costBasis ← snapshot BEFORE close
3200
+ * costBasis × = (1 - percent[i] / 100) ← reduce after close
3201
+ *
3202
+ * @param signal - Signal row
3203
+ * @returns Effective entry price for PNL calculations
3190
3204
  */
3191
3205
  const getEffectivePriceOpen = (signal) => {
3192
- if (signal._entry && signal._entry.length > 0) {
3193
- return signal._entry.reduce((sum, e) => sum + e.price, 0) / signal._entry.length;
3194
- }
3195
- return signal.priceOpen;
3206
+ if (!signal._entry || signal._entry.length === 0)
3207
+ return signal.priceOpen;
3208
+ const entries = signal._entry;
3209
+ const partials = signal._partial ?? [];
3210
+ // No partial exits — pure harmonic mean of all entries
3211
+ if (partials.length === 0) {
3212
+ return harmonicMean(entries.map((e) => e.price));
3213
+ }
3214
+ // Replay cost basis through all partials to get snapshot at the last one
3215
+ let costBasis = 0;
3216
+ for (let i = 0; i < partials.length; i++) {
3217
+ const prevCount = i === 0 ? 0 : partials[i - 1].entryCountAtClose;
3218
+ const newEntryCount = partials[i].entryCountAtClose - prevCount;
3219
+ costBasis += newEntryCount * COST_BASIS_PER_ENTRY$3;
3220
+ // costBasis is now positionCostBasisAtClose for partials[i]
3221
+ if (i < partials.length - 1) {
3222
+ costBasis *= 1 - partials[i].percent / 100;
3223
+ }
3224
+ }
3225
+ const lastPartial = partials[partials.length - 1];
3226
+ // Dollar cost basis remaining after the last partial close
3227
+ const remainingCostBasis = costBasis * (1 - lastPartial.percent / 100);
3228
+ // Coins remaining from the old position
3229
+ const oldCoins = remainingCostBasis / lastPartial.effectivePrice;
3230
+ // New DCA entries added AFTER the last partial close
3231
+ const newEntries = entries.slice(lastPartial.entryCountAtClose);
3232
+ // Coins from new DCA entries (each costs $100)
3233
+ const newCoins = newEntries.reduce((sum, e) => sum + 100 / e.price, 0);
3234
+ const totalCoins = oldCoins + newCoins;
3235
+ if (totalCoins === 0)
3236
+ return lastPartial.effectivePrice;
3237
+ const totalCost = remainingCostBasis + newEntries.length * 100;
3238
+ return totalCost / totalCoins;
3239
+ };
3240
+ const harmonicMean = (prices) => {
3241
+ if (prices.length === 0)
3242
+ return 0;
3243
+ return prices.length / prices.reduce((sum, p) => sum + 1 / p, 0);
3196
3244
  };
3197
3245
 
3246
+ const COST_BASIS_PER_ENTRY$2 = 100;
3198
3247
  /**
3199
3248
  * Calculates profit/loss for a closed signal with slippage and fees.
3200
3249
  *
3201
3250
  * For signals with partial closes:
3202
- * - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
3203
- * - Each partial close has its own slippage
3204
- * - Open fee is charged once; close fees are proportional to each partial's size
3205
- * - Total fees = CC_PERCENT_FEE (open) + Σ CC_PERCENT_FEE × (partial% / 100) × (closeWithSlip / openWithSlip)
3206
- *
3207
- * Formula breakdown:
3208
- * 1. Apply slippage to open/close prices (worse execution)
3209
- * - LONG: buy higher (+slippage), sell lower (-slippage)
3210
- * - SHORT: sell lower (-slippage), buy higher (+slippage)
3211
- * 2. Calculate raw PNL percentage
3212
- * - LONG: ((closePrice - openPrice) / openPrice) * 100
3213
- * - SHORT: ((openPrice - closePrice) / openPrice) * 100
3214
- * 3. Subtract total fees: open fee + close fee adjusted for slippage-affected execution price
3251
+ * - Weights are calculated by ACTUAL DOLLAR VALUE of each partial relative to total invested.
3252
+ * This correctly handles DCA entries that occur before or after partial closes.
3253
+ *
3254
+ * Cost basis is reconstructed by replaying the partial sequence via entryCountAtClose + percent:
3255
+ * costBasis = 0
3256
+ * for each partial[i]:
3257
+ * costBasis += (entryCountAtClose[i] - entryCountAtClose[i-1]) × $100
3258
+ * partialDollarValue[i] = (percent[i] / 100) × costBasis
3259
+ * weight[i] = partialDollarValue[i] / totalInvested
3260
+ * costBasis *= (1 - percent[i] / 100)
3261
+ *
3262
+ * Fee structure:
3263
+ * - Open fee: CC_PERCENT_FEE (charged once)
3264
+ * - Close fee: CC_PERCENT_FEE × weight × (closeWithSlip / openWithSlip) per partial/remaining
3215
3265
  *
3216
3266
  * @param signal - Closed signal with position details and optional partial history
3217
3267
  * @param priceClose - Actual close price at final exit
3218
3268
  * @returns PNL data with percentage and prices
3219
- *
3220
- * @example
3221
- * ```typescript
3222
- * // Signal without partial closes
3223
- * const pnl = toProfitLossDto(
3224
- * {
3225
- * position: "long",
3226
- * priceOpen: 100,
3227
- * },
3228
- * 110 // close at +10%
3229
- * );
3230
- * console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
3231
- *
3232
- * // Signal with partial closes
3233
- * const pnlPartial = toProfitLossDto(
3234
- * {
3235
- * position: "long",
3236
- * priceOpen: 100,
3237
- * _partial: [
3238
- * { type: "profit", percent: 30, price: 120 }, // +20% on 30%
3239
- * { type: "profit", percent: 40, price: 115 }, // +15% on 40%
3240
- * ],
3241
- * },
3242
- * 105 // final close at +5% for remaining 30%
3243
- * );
3244
- * // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
3245
- * ```
3246
3269
  */
3247
3270
  const toProfitLossDto = (signal, priceClose) => {
3248
3271
  const priceOpen = getEffectivePriceOpen(signal);
@@ -3251,47 +3274,65 @@ const toProfitLossDto = (signal, priceClose) => {
3251
3274
  let totalWeightedPnl = 0;
3252
3275
  // Open fee is paid once for the whole position
3253
3276
  let totalFees = GLOBAL_CONFIG.CC_PERCENT_FEE;
3254
- // priceOpenWithSlippage is the same for all partials compute once
3255
- const priceOpenWithSlippage = signal.position === "long"
3256
- ? priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3257
- : priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3277
+ // Total invested capital = number of DCA entries × $100 per entry
3278
+ const totalInvested = signal._entry ? signal._entry.length * 100 : 100;
3279
+ let closedDollarValue = 0;
3280
+ // Running cost basis replayed from entryCountAtClose + percent
3281
+ let costBasis = 0;
3258
3282
  // Calculate PNL for each partial close
3259
- for (const partial of signal._partial) {
3260
- const partialPercent = partial.percent;
3283
+ for (let i = 0; i < signal._partial.length; i++) {
3284
+ const partial = signal._partial[i];
3285
+ // Add DCA entries that existed at this partial but not at the previous one
3286
+ const prevCount = i === 0 ? 0 : signal._partial[i - 1].entryCountAtClose;
3287
+ const newEntryCount = partial.entryCountAtClose - prevCount;
3288
+ costBasis += newEntryCount * COST_BASIS_PER_ENTRY$2;
3289
+ // Real dollar value sold in this partial
3290
+ const partialDollarValue = (partial.percent / 100) * costBasis;
3291
+ // Weight relative to total invested capital
3292
+ const weight = partialDollarValue / totalInvested;
3293
+ closedDollarValue += partialDollarValue;
3294
+ // Reduce cost basis after close
3295
+ costBasis *= 1 - partial.percent / 100;
3296
+ // Use the effective entry price snapshot captured at the time of this partial close
3297
+ const priceOpenWithSlippage = signal.position === "long"
3298
+ ? partial.effectivePrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3299
+ : partial.effectivePrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3261
3300
  const priceCloseWithSlippage = signal.position === "long"
3262
3301
  ? partial.price * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3263
3302
  : partial.price * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3264
- // Calculate PNL for this partial
3265
3303
  const partialPnl = signal.position === "long"
3266
3304
  ? ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100
3267
3305
  : ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
3268
- // Weight by percentage of position closed
3269
- totalWeightedPnl += (partialPercent / 100) * partialPnl;
3270
- // Close fee is proportional to the size of this partial and adjusted for slippage
3271
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (partialPercent / 100) * (priceCloseWithSlippage / priceOpenWithSlippage);
3272
- }
3273
- // Calculate PNL for remaining position (if any)
3274
- // Compute totalClosed from _partial array
3275
- const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
3276
- if (totalClosed > 100) {
3277
- throw new Error(`Partial closes exceed 100%: ${totalClosed}% (signal id: ${signal.id})`);
3278
- }
3279
- const remainingPercent = 100 - totalClosed;
3280
- if (remainingPercent > 0) {
3306
+ totalWeightedPnl += weight * partialPnl;
3307
+ // Close fee proportional to real dollar weight
3308
+ totalFees +=
3309
+ GLOBAL_CONFIG.CC_PERCENT_FEE *
3310
+ weight *
3311
+ (priceCloseWithSlippage / priceOpenWithSlippage);
3312
+ }
3313
+ if (closedDollarValue > totalInvested + 0.001) {
3314
+ throw new Error(`Partial closes dollar value (${closedDollarValue.toFixed(4)}) exceeds total invested (${totalInvested}) — signal id: ${signal.id}`);
3315
+ }
3316
+ // Remaining position
3317
+ const remainingDollarValue = totalInvested - closedDollarValue;
3318
+ const remainingWeight = remainingDollarValue / totalInvested;
3319
+ if (remainingWeight > 0) {
3320
+ // Use current effective price — reflects all DCA including post-partial entries
3321
+ const remainingOpenWithSlippage = signal.position === "long"
3322
+ ? priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3323
+ : priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3281
3324
  const priceCloseWithSlippage = signal.position === "long"
3282
3325
  ? priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3283
3326
  : priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3284
- // Calculate PNL for remaining
3285
3327
  const remainingPnl = signal.position === "long"
3286
- ? ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100
3287
- : ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
3288
- // Weight by remaining percentage
3289
- totalWeightedPnl += (remainingPercent / 100) * remainingPnl;
3290
- // Close fee is proportional to the remaining size and adjusted for slippage
3291
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (remainingPercent / 100) * (priceCloseWithSlippage / priceOpenWithSlippage);
3328
+ ? ((priceCloseWithSlippage - remainingOpenWithSlippage) / remainingOpenWithSlippage) * 100
3329
+ : ((remainingOpenWithSlippage - priceCloseWithSlippage) / remainingOpenWithSlippage) * 100;
3330
+ totalWeightedPnl += remainingWeight * remainingPnl;
3331
+ totalFees +=
3332
+ GLOBAL_CONFIG.CC_PERCENT_FEE *
3333
+ remainingWeight *
3334
+ (priceCloseWithSlippage / remainingOpenWithSlippage);
3292
3335
  }
3293
- // Subtract total fees from weighted PNL
3294
- // totalFees = CC_PERCENT_FEE (open) + Σ CC_PERCENT_FEE × (partialPercent/100) × (closeWithSlip/openWithSlip)
3295
3336
  const pnlPercentage = totalWeightedPnl - totalFees;
3296
3337
  return {
3297
3338
  pnlPercentage,
@@ -3303,33 +3344,24 @@ const toProfitLossDto = (signal, priceClose) => {
3303
3344
  let priceOpenWithSlippage;
3304
3345
  let priceCloseWithSlippage;
3305
3346
  if (signal.position === "long") {
3306
- // LONG: покупаем дороже, продаем дешевле
3307
3347
  priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3308
3348
  priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3309
3349
  }
3310
3350
  else {
3311
- // SHORT: продаем дешевле, покупаем дороже
3312
3351
  priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3313
3352
  priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3314
3353
  }
3315
- // Открытие: комиссия от цены входа; закрытие: комиссия от фактической цены выхода (с учётом slippage)
3316
- const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE * (1 + priceCloseWithSlippage / priceOpenWithSlippage);
3354
+ const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE *
3355
+ (1 + priceCloseWithSlippage / priceOpenWithSlippage);
3317
3356
  let pnlPercentage;
3318
3357
  if (signal.position === "long") {
3319
- // LONG: прибыль при росте цены
3320
3358
  pnlPercentage =
3321
- ((priceCloseWithSlippage - priceOpenWithSlippage) /
3322
- priceOpenWithSlippage) *
3323
- 100;
3359
+ ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
3324
3360
  }
3325
3361
  else {
3326
- // SHORT: прибыль при падении цены
3327
3362
  pnlPercentage =
3328
- ((priceOpenWithSlippage - priceCloseWithSlippage) /
3329
- priceOpenWithSlippage) *
3330
- 100;
3363
+ ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
3331
3364
  }
3332
- // Вычитаем комиссии
3333
3365
  pnlPercentage -= totalFee;
3334
3366
  return {
3335
3367
  pnlPercentage,
@@ -3385,6 +3417,54 @@ const toPlainString = (content) => {
3385
3417
  return text.trim();
3386
3418
  };
3387
3419
 
3420
+ const COST_BASIS_PER_ENTRY$1 = 100;
3421
+ /**
3422
+ * Returns the total closed state of a position using cost-basis replay.
3423
+ *
3424
+ * Correctly accounts for DCA entries added between partial closes via averageBuy().
3425
+ * Simple percent summation (sum of _partial[i].percent) is INCORRECT when averageBuy()
3426
+ * is called between partials — this function uses the same cost-basis replay as
3427
+ * toProfitLossDto to compute the true dollar-weighted closed fraction.
3428
+ *
3429
+ * Cost-basis replay:
3430
+ * costBasis = 0
3431
+ * for each partial[i]:
3432
+ * costBasis += (entryCountAtClose[i] - entryCountAtClose[i-1]) × $100
3433
+ * closedDollar += (percent[i] / 100) × costBasis
3434
+ * costBasis ×= (1 - percent[i] / 100)
3435
+ * // then add entries added AFTER the last partial
3436
+ * costBasis += (currentEntryCount - lastPartialEntryCount) × $100
3437
+ *
3438
+ * @param signal - Signal row with _partial and _entry arrays
3439
+ * @returns Object with totalClosedPercent (0–100+) and remainingCostBasis (dollar value still open)
3440
+ */
3441
+ const getTotalClosed = (signal) => {
3442
+ const partials = signal._partial ?? [];
3443
+ const currentEntryCount = signal._entry?.length ?? 1;
3444
+ const totalInvested = currentEntryCount * COST_BASIS_PER_ENTRY$1;
3445
+ if (partials.length === 0) {
3446
+ return {
3447
+ totalClosedPercent: 0,
3448
+ remainingCostBasis: totalInvested,
3449
+ };
3450
+ }
3451
+ let costBasis = 0;
3452
+ let closedDollarValue = 0;
3453
+ for (let i = 0; i < partials.length; i++) {
3454
+ const prevCount = i === 0 ? 0 : partials[i - 1].entryCountAtClose;
3455
+ costBasis += (partials[i].entryCountAtClose - prevCount) * COST_BASIS_PER_ENTRY$1;
3456
+ closedDollarValue += (partials[i].percent / 100) * costBasis;
3457
+ costBasis *= 1 - partials[i].percent / 100;
3458
+ }
3459
+ // Add entries added AFTER the last partial (not yet accounted for in the loop)
3460
+ const lastEntryCount = partials[partials.length - 1].entryCountAtClose;
3461
+ costBasis += (currentEntryCount - lastEntryCount) * COST_BASIS_PER_ENTRY$1;
3462
+ return {
3463
+ totalClosedPercent: totalInvested > 0 ? (closedDollarValue / totalInvested) * 100 : 0,
3464
+ remainingCostBasis: costBasis,
3465
+ };
3466
+ };
3467
+
3388
3468
  /**
3389
3469
  * Wraps a function to execute it outside of the current execution context if one exists.
3390
3470
  *
@@ -3427,6 +3507,19 @@ const beginTime = (run) => (...args) => {
3427
3507
  return fn();
3428
3508
  };
3429
3509
 
3510
+ /**
3511
+ * Retrieves the current timestamp for debugging purposes.
3512
+ * If an execution context is active (e.g., during a backtest), it returns the timestamp from the context to ensure consistency with the simulated time.
3513
+ * Can be empty (undefined) if not called from strategy async context, as it's intended for debugging and not critical for logic.
3514
+ * @return {number | undefined} The current timestamp in milliseconds from the execution context, or undefined if not available.
3515
+ */
3516
+ const getDebugTimestamp = () => {
3517
+ if (ExecutionContextService.hasContext()) {
3518
+ return bt.executionContextService.context.when.getTime();
3519
+ }
3520
+ return undefined;
3521
+ };
3522
+
3430
3523
  const INTERVAL_MINUTES$6 = {
3431
3524
  "1m": 1,
3432
3525
  "3m": 3,
@@ -3435,6 +3528,7 @@ const INTERVAL_MINUTES$6 = {
3435
3528
  "30m": 30,
3436
3529
  "1h": 60,
3437
3530
  };
3531
+ const COST_BASIS_PER_ENTRY = 100;
3438
3532
  /**
3439
3533
  * Mock value for scheduled signal pendingAt timestamp.
3440
3534
  * Used to indicate that the actual pendingAt will be set upon activation.
@@ -4036,7 +4130,7 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
4036
4130
  scheduledAt: currentTime,
4037
4131
  pendingAt: currentTime, // Для immediate signal оба времени одинаковые
4038
4132
  _isScheduled: false,
4039
- _entry: [{ price: signal.priceOpen }],
4133
+ _entry: [{ price: signal.priceOpen, debugTimestamp: currentTime }],
4040
4134
  };
4041
4135
  // Валидируем сигнал перед возвратом
4042
4136
  VALIDATE_SIGNAL_FN(signalRow, currentPrice, false);
@@ -4058,7 +4152,7 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
4058
4152
  scheduledAt: currentTime,
4059
4153
  pendingAt: SCHEDULED_SIGNAL_PENDING_MOCK, // Временно, обновится при активации
4060
4154
  _isScheduled: true,
4061
- _entry: [{ price: signal.priceOpen }],
4155
+ _entry: [{ price: signal.priceOpen, debugTimestamp: currentTime }],
4062
4156
  };
4063
4157
  // Валидируем сигнал перед возвратом
4064
4158
  VALIDATE_SIGNAL_FN(scheduledSignalRow, currentPrice, true);
@@ -4076,7 +4170,7 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
4076
4170
  scheduledAt: currentTime,
4077
4171
  pendingAt: currentTime, // Для immediate signal оба времени одинаковые
4078
4172
  _isScheduled: false,
4079
- _entry: [{ price: currentPrice }],
4173
+ _entry: [{ price: currentPrice, debugTimestamp: currentTime }],
4080
4174
  };
4081
4175
  // Валидируем сигнал перед возвратом
4082
4176
  VALIDATE_SIGNAL_FN(signalRow, currentPrice, false);
@@ -4150,37 +4244,39 @@ const PARTIAL_PROFIT_FN = (self, signal, percentToClose, currentPrice) => {
4150
4244
  // Initialize partial array if not present
4151
4245
  if (!signal._partial)
4152
4246
  signal._partial = [];
4153
- // Calculate current totals (computed values)
4154
- const tpClosed = signal._partial
4155
- .filter((p) => p.type === "profit")
4156
- .reduce((sum, p) => sum + p.percent, 0);
4157
- const slClosed = signal._partial
4158
- .filter((p) => p.type === "loss")
4159
- .reduce((sum, p) => sum + p.percent, 0);
4160
- const totalClosed = tpClosed + slClosed;
4161
- // Check if would exceed 100% total closed
4162
- const newTotalClosed = totalClosed + percentToClose;
4163
- if (newTotalClosed > 100) {
4164
- self.params.logger.warn("PARTIAL_PROFIT_FN: would exceed 100% closed, skipping", {
4247
+ // Check if would exceed 100% total closed (dollar-basis, DCA-aware)
4248
+ const { totalClosedPercent, remainingCostBasis } = getTotalClosed(signal);
4249
+ const totalInvested = (signal._entry?.length ?? 1) * COST_BASIS_PER_ENTRY;
4250
+ const newPartialDollar = (percentToClose / 100) * remainingCostBasis;
4251
+ const newTotalClosedDollar = (totalClosedPercent / 100) * totalInvested + newPartialDollar;
4252
+ if (newTotalClosedDollar > totalInvested) {
4253
+ self.params.logger.warn("PARTIAL_PROFIT_FN: would exceed 100% closed (dollar basis), skipping", {
4165
4254
  signalId: signal.id,
4166
- currentTotalClosed: totalClosed,
4255
+ totalClosedPercent,
4256
+ remainingCostBasis,
4167
4257
  percentToClose,
4168
- newTotalClosed,
4258
+ newPartialDollar,
4259
+ totalInvested,
4169
4260
  });
4170
4261
  return false;
4171
4262
  }
4263
+ // Capture effective entry price at the moment of partial close (for DCA-aware PNL)
4264
+ const effectivePrice = getEffectivePriceOpen(signal);
4265
+ const entryCountAtClose = signal._entry ? signal._entry.length : 1;
4172
4266
  // Add new partial close entry
4173
4267
  signal._partial.push({
4174
4268
  type: "profit",
4175
4269
  percent: percentToClose,
4270
+ entryCountAtClose,
4176
4271
  price: currentPrice,
4272
+ debugTimestamp: getDebugTimestamp(),
4273
+ effectivePrice,
4177
4274
  });
4178
4275
  self.params.logger.info("PARTIAL_PROFIT_FN executed", {
4179
4276
  signalId: signal.id,
4180
4277
  percentClosed: percentToClose,
4181
- totalClosed: newTotalClosed,
4278
+ totalClosedPercent: totalClosedPercent + (newPartialDollar / totalInvested) * 100,
4182
4279
  currentPrice,
4183
- tpClosed: tpClosed + percentToClose,
4184
4280
  });
4185
4281
  return true;
4186
4282
  };
@@ -4188,37 +4284,39 @@ const PARTIAL_LOSS_FN = (self, signal, percentToClose, currentPrice) => {
4188
4284
  // Initialize partial array if not present
4189
4285
  if (!signal._partial)
4190
4286
  signal._partial = [];
4191
- // Calculate current totals (computed values)
4192
- const tpClosed = signal._partial
4193
- .filter((p) => p.type === "profit")
4194
- .reduce((sum, p) => sum + p.percent, 0);
4195
- const slClosed = signal._partial
4196
- .filter((p) => p.type === "loss")
4197
- .reduce((sum, p) => sum + p.percent, 0);
4198
- const totalClosed = tpClosed + slClosed;
4199
- // Check if would exceed 100% total closed
4200
- const newTotalClosed = totalClosed + percentToClose;
4201
- if (newTotalClosed > 100) {
4202
- self.params.logger.warn("PARTIAL_LOSS_FN: would exceed 100% closed, skipping", {
4287
+ // Check if would exceed 100% total closed (dollar-basis, DCA-aware)
4288
+ const { totalClosedPercent, remainingCostBasis } = getTotalClosed(signal);
4289
+ const totalInvested = (signal._entry?.length ?? 1) * COST_BASIS_PER_ENTRY;
4290
+ const newPartialDollar = (percentToClose / 100) * remainingCostBasis;
4291
+ const newTotalClosedDollar = (totalClosedPercent / 100) * totalInvested + newPartialDollar;
4292
+ if (newTotalClosedDollar > totalInvested) {
4293
+ self.params.logger.warn("PARTIAL_LOSS_FN: would exceed 100% closed (dollar basis), skipping", {
4203
4294
  signalId: signal.id,
4204
- currentTotalClosed: totalClosed,
4295
+ totalClosedPercent,
4296
+ remainingCostBasis,
4205
4297
  percentToClose,
4206
- newTotalClosed,
4298
+ newPartialDollar,
4299
+ totalInvested,
4207
4300
  });
4208
4301
  return false;
4209
4302
  }
4303
+ // Capture effective entry price at the moment of partial close (for DCA-aware PNL)
4304
+ const effectivePrice = getEffectivePriceOpen(signal);
4305
+ const entryCountAtClose = signal._entry ? signal._entry.length : 1;
4210
4306
  // Add new partial close entry
4211
4307
  signal._partial.push({
4212
4308
  type: "loss",
4213
4309
  percent: percentToClose,
4214
4310
  price: currentPrice,
4311
+ entryCountAtClose,
4312
+ effectivePrice,
4313
+ debugTimestamp: getDebugTimestamp(),
4215
4314
  });
4216
4315
  self.params.logger.warn("PARTIAL_LOSS_FN executed", {
4217
4316
  signalId: signal.id,
4218
4317
  percentClosed: percentToClose,
4219
- totalClosed: newTotalClosed,
4318
+ totalClosedPercent: totalClosedPercent + (newPartialDollar / totalInvested) * 100,
4220
4319
  currentPrice,
4221
- slClosed: slClosed + percentToClose,
4222
4320
  });
4223
4321
  return true;
4224
4322
  };
@@ -4612,12 +4710,12 @@ const BREAKEVEN_FN = (self, signal, currentPrice) => {
4612
4710
  const AVERAGE_BUY_FN = (self, signal, currentPrice) => {
4613
4711
  // Ensure _entry is initialized (handles signals loaded from disk without _entry)
4614
4712
  if (!signal._entry || signal._entry.length === 0) {
4615
- signal._entry = [{ price: signal.priceOpen }];
4713
+ signal._entry = [{ price: signal.priceOpen, debugTimestamp: getDebugTimestamp() }];
4616
4714
  }
4617
4715
  const lastEntry = signal._entry[signal._entry.length - 1];
4618
4716
  if (signal.position === "long") {
4619
4717
  // LONG: averaging down = currentPrice must be strictly lower than last entry
4620
- if (currentPrice >= lastEntry.price) {
4718
+ if (!GLOBAL_CONFIG.CC_ENABLE_DCA_EVERYWHERE && currentPrice >= lastEntry.price) {
4621
4719
  self.params.logger.debug("AVERAGE_BUY_FN: rejected — currentPrice >= last entry (LONG)", {
4622
4720
  signalId: signal.id,
4623
4721
  position: signal.position,
@@ -4630,7 +4728,7 @@ const AVERAGE_BUY_FN = (self, signal, currentPrice) => {
4630
4728
  }
4631
4729
  else {
4632
4730
  // SHORT: averaging down = currentPrice must be strictly higher than last entry
4633
- if (currentPrice <= lastEntry.price) {
4731
+ if (!GLOBAL_CONFIG.CC_ENABLE_DCA_EVERYWHERE && currentPrice <= lastEntry.price) {
4634
4732
  self.params.logger.debug("AVERAGE_BUY_FN: rejected — currentPrice <= last entry (SHORT)", {
4635
4733
  signalId: signal.id,
4636
4734
  position: signal.position,
@@ -4641,7 +4739,7 @@ const AVERAGE_BUY_FN = (self, signal, currentPrice) => {
4641
4739
  return false;
4642
4740
  }
4643
4741
  }
4644
- signal._entry.push({ price: currentPrice });
4742
+ signal._entry.push({ price: currentPrice, debugTimestamp: getDebugTimestamp() });
4645
4743
  self.params.logger.info("AVERAGE_BUY_FN executed", {
4646
4744
  signalId: signal.id,
4647
4745
  position: signal.position,
@@ -6106,6 +6204,52 @@ class ClientStrategy {
6106
6204
  });
6107
6205
  return this._isStopped;
6108
6206
  }
6207
+ /**
6208
+ * Returns how much of the position is still held, as a percentage of totalInvested.
6209
+ *
6210
+ * Uses dollar-basis cost-basis replay (DCA-aware).
6211
+ * 100% means nothing was closed yet. Decreases with each partial close.
6212
+ *
6213
+ * Example: 1 entry $100, partialProfit(30%) → returns 70
6214
+ * Example: 2 entries $200, partialProfit(50%) → returns 50
6215
+ *
6216
+ * Returns 100 if no pending signal or no partial closes.
6217
+ *
6218
+ * @param symbol - Trading pair symbol
6219
+ * @returns Promise resolving to held percentage (0–100)
6220
+ */
6221
+ async getTotalPercentClosed(symbol) {
6222
+ this.params.logger.debug("ClientStrategy getTotalPercentClosed", { symbol });
6223
+ if (!this._pendingSignal) {
6224
+ return null;
6225
+ }
6226
+ const { totalClosedPercent } = getTotalClosed(this._pendingSignal);
6227
+ return 100 - totalClosedPercent;
6228
+ }
6229
+ /**
6230
+ * Returns how many dollars of cost basis are still held (not yet closed by partials).
6231
+ *
6232
+ * Equal to remainingCostBasis from getTotalClosed.
6233
+ * Full position open: equals totalInvested (entries × $100).
6234
+ * Decreases with each partial close, increases with each averageBuy().
6235
+ *
6236
+ * Example: 1 entry $100, no partials → returns 100
6237
+ * Example: 1 entry $100, partialProfit(30%) → returns 70
6238
+ * Example: 2 entries $200, partialProfit(50%) → returns 100
6239
+ *
6240
+ * Returns totalInvested if no pending signal or no partial closes.
6241
+ *
6242
+ * @param symbol - Trading pair symbol
6243
+ * @returns Promise resolving to held cost basis in dollars
6244
+ */
6245
+ async getTotalCostClosed(symbol) {
6246
+ this.params.logger.debug("ClientStrategy getTotalCostClosed", { symbol });
6247
+ if (!this._pendingSignal) {
6248
+ return null;
6249
+ }
6250
+ const { remainingCostBasis } = getTotalClosed(this._pendingSignal);
6251
+ return remainingCostBasis;
6252
+ }
6109
6253
  /**
6110
6254
  * Performs a single tick of strategy execution.
6111
6255
  *
@@ -7566,14 +7710,6 @@ class ClientStrategy {
7566
7710
  if (typeof currentPrice !== "number" || !isFinite(currentPrice) || currentPrice <= 0) {
7567
7711
  throw new Error(`ClientStrategy averageBuy: currentPrice must be a positive finite number, got ${currentPrice}`);
7568
7712
  }
7569
- // Reject if any partial closes have already been executed
7570
- if (this._pendingSignal._partial && this._pendingSignal._partial.length > 0) {
7571
- this.params.logger.debug("ClientStrategy averageBuy: rejected — partial closes already executed", {
7572
- symbol,
7573
- partialCount: this._pendingSignal._partial.length,
7574
- });
7575
- return false;
7576
- }
7577
7713
  // Execute averaging logic
7578
7714
  const result = AVERAGE_BUY_FN(this, this._pendingSignal, currentPrice);
7579
7715
  if (!result) {
@@ -8252,6 +8388,43 @@ class StrategyConnectionService {
8252
8388
  const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8253
8389
  return await strategy.getPendingSignal(symbol);
8254
8390
  };
8391
+ /**
8392
+ * Returns the percentage of the position currently held (not closed).
8393
+ * 100 = nothing has been closed (full position), 0 = fully closed.
8394
+ * Correctly accounts for DCA entries between partial closes.
8395
+ *
8396
+ * @param backtest - Whether running in backtest mode
8397
+ * @param symbol - Trading pair symbol
8398
+ * @param context - Execution context with strategyName, exchangeName, frameName
8399
+ * @returns Promise<number> - held percentage (0–100)
8400
+ */
8401
+ this.getTotalPercentClosed = async (backtest, symbol, context) => {
8402
+ this.loggerService.log("strategyConnectionService getTotalPercentClosed", {
8403
+ symbol,
8404
+ context,
8405
+ backtest,
8406
+ });
8407
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8408
+ return await strategy.getTotalPercentClosed(symbol);
8409
+ };
8410
+ /**
8411
+ * Returns the cost basis in dollars of the position currently held (not closed).
8412
+ * Correctly accounts for DCA entries between partial closes.
8413
+ *
8414
+ * @param backtest - Whether running in backtest mode
8415
+ * @param symbol - Trading pair symbol
8416
+ * @param context - Execution context with strategyName, exchangeName, frameName
8417
+ * @returns Promise<number> - held cost basis in dollars
8418
+ */
8419
+ this.getTotalCostClosed = async (backtest, symbol, context) => {
8420
+ this.loggerService.log("strategyConnectionService getTotalCostClosed", {
8421
+ symbol,
8422
+ context,
8423
+ backtest,
8424
+ });
8425
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8426
+ return await strategy.getTotalCostClosed(symbol);
8427
+ };
8255
8428
  /**
8256
8429
  * Retrieves the currently active scheduled signal for the strategy.
8257
8430
  * If no scheduled signal exists, returns null.
@@ -11655,6 +11828,41 @@ class StrategyCoreService {
11655
11828
  await this.validate(context);
11656
11829
  return await this.strategyConnectionService.getPendingSignal(backtest, symbol, context);
11657
11830
  };
11831
+ /**
11832
+ * Returns the percentage of the position currently held (not closed).
11833
+ * 100 = nothing has been closed (full position), 0 = fully closed.
11834
+ * Correctly accounts for DCA entries between partial closes.
11835
+ *
11836
+ * @param backtest - Whether running in backtest mode
11837
+ * @param symbol - Trading pair symbol
11838
+ * @param context - Execution context with strategyName, exchangeName, frameName
11839
+ * @returns Promise<number> - held percentage (0–100)
11840
+ */
11841
+ this.getTotalPercentClosed = async (backtest, symbol, context) => {
11842
+ this.loggerService.log("strategyCoreService getTotalPercentClosed", {
11843
+ symbol,
11844
+ context,
11845
+ });
11846
+ await this.validate(context);
11847
+ return await this.strategyConnectionService.getTotalPercentClosed(backtest, symbol, context);
11848
+ };
11849
+ /**
11850
+ * Returns the cost basis in dollars of the position currently held (not closed).
11851
+ * Correctly accounts for DCA entries between partial closes.
11852
+ *
11853
+ * @param backtest - Whether running in backtest mode
11854
+ * @param symbol - Trading pair symbol
11855
+ * @param context - Execution context with strategyName, exchangeName, frameName
11856
+ * @returns Promise<number> - held cost basis in dollars
11857
+ */
11858
+ this.getTotalCostClosed = async (backtest, symbol, context) => {
11859
+ this.loggerService.log("strategyCoreService getTotalCostClosed", {
11860
+ symbol,
11861
+ context,
11862
+ });
11863
+ await this.validate(context);
11864
+ return await this.strategyConnectionService.getTotalCostClosed(backtest, symbol, context);
11865
+ };
11658
11866
  /**
11659
11867
  * Retrieves the currently active scheduled signal for the symbol.
11660
11868
  * If no scheduled signal exists, returns null.
@@ -28686,6 +28894,11 @@ const TRAILING_PROFIT_METHOD_NAME = "strategy.commitTrailingTake";
28686
28894
  const BREAKEVEN_METHOD_NAME = "strategy.commitBreakeven";
28687
28895
  const ACTIVATE_SCHEDULED_METHOD_NAME = "strategy.commitActivateScheduled";
28688
28896
  const AVERAGE_BUY_METHOD_NAME = "strategy.commitAverageBuy";
28897
+ const GET_TOTAL_PERCENT_CLOSED_METHOD_NAME = "strategy.getTotalPercentClosed";
28898
+ const GET_TOTAL_COST_CLOSED_METHOD_NAME = "strategy.getTotalCostClosed";
28899
+ const GET_PENDING_SIGNAL_METHOD_NAME = "strategy.getPendingSignal";
28900
+ const GET_SCHEDULED_SIGNAL_METHOD_NAME = "strategy.getScheduledSignal";
28901
+ const GET_BREAKEVEN_METHOD_NAME = "strategy.getBreakeven";
28689
28902
  /**
28690
28903
  * Cancels the scheduled signal without stopping the strategy.
28691
28904
  *
@@ -29077,6 +29290,173 @@ async function commitAverageBuy(symbol) {
29077
29290
  const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29078
29291
  return await bt.strategyCoreService.averageBuy(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
29079
29292
  }
29293
+ /**
29294
+ * Returns the percentage of the position currently held (not closed).
29295
+ * 100 = nothing has been closed (full position), 0 = fully closed.
29296
+ * Correctly accounts for DCA entries between partial closes.
29297
+ *
29298
+ * Automatically detects backtest/live mode from execution context.
29299
+ *
29300
+ * @param symbol - Trading pair symbol
29301
+ * @returns Promise<number> - held percentage (0–100)
29302
+ *
29303
+ * @example
29304
+ * ```typescript
29305
+ * import { getTotalPercentClosed } from "backtest-kit";
29306
+ *
29307
+ * const heldPct = await getTotalPercentClosed("BTCUSDT");
29308
+ * console.log(`Holding ${heldPct}% of position`);
29309
+ * ```
29310
+ */
29311
+ async function getTotalPercentClosed(symbol) {
29312
+ bt.loggerService.info(GET_TOTAL_PERCENT_CLOSED_METHOD_NAME, {
29313
+ symbol,
29314
+ });
29315
+ if (!ExecutionContextService.hasContext()) {
29316
+ throw new Error("getTotalPercentClosed requires an execution context");
29317
+ }
29318
+ if (!MethodContextService.hasContext()) {
29319
+ throw new Error("getTotalPercentClosed requires a method context");
29320
+ }
29321
+ const { backtest: isBacktest } = bt.executionContextService.context;
29322
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29323
+ return await bt.strategyCoreService.getTotalPercentClosed(isBacktest, symbol, { exchangeName, frameName, strategyName });
29324
+ }
29325
+ /**
29326
+ * Returns the cost basis in dollars of the position currently held (not closed).
29327
+ * Correctly accounts for DCA entries between partial closes.
29328
+ *
29329
+ * Automatically detects backtest/live mode from execution context.
29330
+ *
29331
+ * @param symbol - Trading pair symbol
29332
+ * @returns Promise<number> - held cost basis in dollars
29333
+ *
29334
+ * @example
29335
+ * ```typescript
29336
+ * import { getTotalCostClosed } from "backtest-kit";
29337
+ *
29338
+ * const heldCost = await getTotalCostClosed("BTCUSDT");
29339
+ * console.log(`Holding $${heldCost} of position`);
29340
+ * ```
29341
+ */
29342
+ async function getTotalCostClosed(symbol) {
29343
+ bt.loggerService.info(GET_TOTAL_COST_CLOSED_METHOD_NAME, {
29344
+ symbol,
29345
+ });
29346
+ if (!ExecutionContextService.hasContext()) {
29347
+ throw new Error("getTotalCostClosed requires an execution context");
29348
+ }
29349
+ if (!MethodContextService.hasContext()) {
29350
+ throw new Error("getTotalCostClosed requires a method context");
29351
+ }
29352
+ const { backtest: isBacktest } = bt.executionContextService.context;
29353
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29354
+ return await bt.strategyCoreService.getTotalCostClosed(isBacktest, symbol, { exchangeName, frameName, strategyName });
29355
+ }
29356
+ /**
29357
+ * Returns the currently active pending signal for the strategy.
29358
+ * If no active signal exists, returns null.
29359
+ *
29360
+ * Automatically detects backtest/live mode from execution context.
29361
+ *
29362
+ * @param symbol - Trading pair symbol
29363
+ * @returns Promise resolving to pending signal or null
29364
+ *
29365
+ * @example
29366
+ * ```typescript
29367
+ * import { getPendingSignal } from "backtest-kit";
29368
+ *
29369
+ * const pending = await getPendingSignal("BTCUSDT");
29370
+ * if (pending) {
29371
+ * console.log("Active signal:", pending.id);
29372
+ * }
29373
+ * ```
29374
+ */
29375
+ async function getPendingSignal(symbol) {
29376
+ bt.loggerService.info(GET_PENDING_SIGNAL_METHOD_NAME, {
29377
+ symbol,
29378
+ });
29379
+ if (!ExecutionContextService.hasContext()) {
29380
+ throw new Error("getPendingSignal requires an execution context");
29381
+ }
29382
+ if (!MethodContextService.hasContext()) {
29383
+ throw new Error("getPendingSignal requires a method context");
29384
+ }
29385
+ const { backtest: isBacktest } = bt.executionContextService.context;
29386
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29387
+ return await bt.strategyCoreService.getPendingSignal(isBacktest, symbol, { exchangeName, frameName, strategyName });
29388
+ }
29389
+ /**
29390
+ * Returns the currently active scheduled signal for the strategy.
29391
+ * If no scheduled signal exists, returns null.
29392
+ *
29393
+ * Automatically detects backtest/live mode from execution context.
29394
+ *
29395
+ * @param symbol - Trading pair symbol
29396
+ * @returns Promise resolving to scheduled signal or null
29397
+ *
29398
+ * @example
29399
+ * ```typescript
29400
+ * import { getScheduledSignal } from "backtest-kit";
29401
+ *
29402
+ * const scheduled = await getScheduledSignal("BTCUSDT");
29403
+ * if (scheduled) {
29404
+ * console.log("Scheduled signal:", scheduled.id);
29405
+ * }
29406
+ * ```
29407
+ */
29408
+ async function getScheduledSignal(symbol) {
29409
+ bt.loggerService.info(GET_SCHEDULED_SIGNAL_METHOD_NAME, {
29410
+ symbol,
29411
+ });
29412
+ if (!ExecutionContextService.hasContext()) {
29413
+ throw new Error("getScheduledSignal requires an execution context");
29414
+ }
29415
+ if (!MethodContextService.hasContext()) {
29416
+ throw new Error("getScheduledSignal requires a method context");
29417
+ }
29418
+ const { backtest: isBacktest } = bt.executionContextService.context;
29419
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29420
+ return await bt.strategyCoreService.getScheduledSignal(isBacktest, symbol, { exchangeName, frameName, strategyName });
29421
+ }
29422
+ /**
29423
+ * Checks if breakeven threshold has been reached for the current pending signal.
29424
+ *
29425
+ * Returns true if price has moved far enough in profit direction to cover
29426
+ * transaction costs. Threshold is calculated as: (CC_PERCENT_SLIPPAGE + CC_PERCENT_FEE) * 2
29427
+ *
29428
+ * Automatically detects backtest/live mode from execution context.
29429
+ *
29430
+ * @param symbol - Trading pair symbol
29431
+ * @param currentPrice - Current market price to check against threshold
29432
+ * @returns Promise<boolean> - true if breakeven threshold reached, false otherwise
29433
+ *
29434
+ * @example
29435
+ * ```typescript
29436
+ * import { getBreakeven, getAveragePrice } from "backtest-kit";
29437
+ *
29438
+ * const price = await getAveragePrice("BTCUSDT");
29439
+ * const canBreakeven = await getBreakeven("BTCUSDT", price);
29440
+ * if (canBreakeven) {
29441
+ * console.log("Breakeven available");
29442
+ * }
29443
+ * ```
29444
+ */
29445
+ async function getBreakeven(symbol, currentPrice) {
29446
+ bt.loggerService.info(GET_BREAKEVEN_METHOD_NAME, {
29447
+ symbol,
29448
+ currentPrice,
29449
+ });
29450
+ if (!ExecutionContextService.hasContext()) {
29451
+ throw new Error("getBreakeven requires an execution context");
29452
+ }
29453
+ if (!MethodContextService.hasContext()) {
29454
+ throw new Error("getBreakeven requires a method context");
29455
+ }
29456
+ const { backtest: isBacktest } = bt.executionContextService.context;
29457
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29458
+ return await bt.strategyCoreService.getBreakeven(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
29459
+ }
29080
29460
 
29081
29461
  const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
29082
29462
  /**
@@ -30264,6 +30644,8 @@ const BACKTEST_METHOD_NAME_DUMP = "BacktestUtils.dump";
30264
30644
  const BACKTEST_METHOD_NAME_TASK = "BacktestUtils.task";
30265
30645
  const BACKTEST_METHOD_NAME_GET_STATUS = "BacktestUtils.getStatus";
30266
30646
  const BACKTEST_METHOD_NAME_GET_PENDING_SIGNAL = "BacktestUtils.getPendingSignal";
30647
+ const BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED = "BacktestUtils.getTotalPercentClosed";
30648
+ const BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED = "BacktestUtils.getTotalCostClosed";
30267
30649
  const BACKTEST_METHOD_NAME_GET_SCHEDULED_SIGNAL = "BacktestUtils.getScheduledSignal";
30268
30650
  const BACKTEST_METHOD_NAME_GET_BREAKEVEN = "BacktestUtils.getBreakeven";
30269
30651
  const BACKTEST_METHOD_NAME_BREAKEVEN = "Backtest.commitBreakeven";
@@ -30665,6 +31047,71 @@ class BacktestUtils {
30665
31047
  }
30666
31048
  return await bt.strategyCoreService.getPendingSignal(true, symbol, context);
30667
31049
  };
31050
+ /**
31051
+ * Returns the percentage of the position currently held (not closed).
31052
+ * 100 = nothing has been closed (full position), 0 = fully closed.
31053
+ * Correctly accounts for DCA entries between partial closes.
31054
+ *
31055
+ * @param symbol - Trading pair symbol
31056
+ * @param context - Context with strategyName, exchangeName, frameName
31057
+ * @returns Promise<number> - held percentage (0–100)
31058
+ *
31059
+ * @example
31060
+ * ```typescript
31061
+ * const heldPct = await Backtest.getTotalPercentClosed("BTCUSDT", { strategyName, exchangeName, frameName });
31062
+ * console.log(`Holding ${heldPct}% of position`);
31063
+ * ```
31064
+ */
31065
+ this.getTotalPercentClosed = async (symbol, context) => {
31066
+ bt.loggerService.info(BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED, {
31067
+ symbol,
31068
+ context,
31069
+ });
31070
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
31071
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
31072
+ {
31073
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
31074
+ riskName &&
31075
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
31076
+ riskList &&
31077
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
31078
+ actions &&
31079
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
31080
+ }
31081
+ return await bt.strategyCoreService.getTotalPercentClosed(true, symbol, context);
31082
+ };
31083
+ /**
31084
+ * Returns the cost basis in dollars of the position currently held (not closed).
31085
+ * Correctly accounts for DCA entries between partial closes.
31086
+ *
31087
+ * @param symbol - Trading pair symbol
31088
+ * @param context - Context with strategyName, exchangeName, frameName
31089
+ * @returns Promise<number> - held cost basis in dollars
31090
+ *
31091
+ * @example
31092
+ * ```typescript
31093
+ * const heldCost = await Backtest.getTotalCostClosed("BTCUSDT", { strategyName, exchangeName, frameName });
31094
+ * console.log(`Holding $${heldCost} of position`);
31095
+ * ```
31096
+ */
31097
+ this.getTotalCostClosed = async (symbol, context) => {
31098
+ bt.loggerService.info(BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED, {
31099
+ symbol,
31100
+ context,
31101
+ });
31102
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED);
31103
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED);
31104
+ {
31105
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
31106
+ riskName &&
31107
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED);
31108
+ riskList &&
31109
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED));
31110
+ actions &&
31111
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED));
31112
+ }
31113
+ return await bt.strategyCoreService.getTotalCostClosed(true, symbol, context);
31114
+ };
30668
31115
  /**
30669
31116
  * Retrieves the currently active scheduled signal for the strategy.
30670
31117
  * If no scheduled signal exists, returns null.
@@ -31380,6 +31827,8 @@ const LIVE_METHOD_NAME_DUMP = "LiveUtils.dump";
31380
31827
  const LIVE_METHOD_NAME_TASK = "LiveUtils.task";
31381
31828
  const LIVE_METHOD_NAME_GET_STATUS = "LiveUtils.getStatus";
31382
31829
  const LIVE_METHOD_NAME_GET_PENDING_SIGNAL = "LiveUtils.getPendingSignal";
31830
+ const LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED = "LiveUtils.getTotalPercentClosed";
31831
+ const LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED = "LiveUtils.getTotalCostClosed";
31383
31832
  const LIVE_METHOD_NAME_GET_SCHEDULED_SIGNAL = "LiveUtils.getScheduledSignal";
31384
31833
  const LIVE_METHOD_NAME_GET_BREAKEVEN = "LiveUtils.getBreakeven";
31385
31834
  const LIVE_METHOD_NAME_BREAKEVEN = "Live.commitBreakeven";
@@ -31750,6 +32199,73 @@ class LiveUtils {
31750
32199
  frameName: "",
31751
32200
  });
31752
32201
  };
32202
+ /**
32203
+ * Returns the percentage of the position currently held (not closed).
32204
+ * 100 = nothing has been closed (full position), 0 = fully closed.
32205
+ * Correctly accounts for DCA entries between partial closes.
32206
+ *
32207
+ * @param symbol - Trading pair symbol
32208
+ * @param context - Context with strategyName and exchangeName
32209
+ * @returns Promise<number> - held percentage (0–100)
32210
+ *
32211
+ * @example
32212
+ * ```typescript
32213
+ * const heldPct = await Live.getTotalPercentClosed("BTCUSDT", { strategyName, exchangeName });
32214
+ * console.log(`Holding ${heldPct}% of position`);
32215
+ * ```
32216
+ */
32217
+ this.getTotalPercentClosed = async (symbol, context) => {
32218
+ bt.loggerService.info(LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED, {
32219
+ symbol,
32220
+ context,
32221
+ });
32222
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
32223
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
32224
+ {
32225
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
32226
+ riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
32227
+ riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
32228
+ actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
32229
+ }
32230
+ return await bt.strategyCoreService.getTotalPercentClosed(false, symbol, {
32231
+ strategyName: context.strategyName,
32232
+ exchangeName: context.exchangeName,
32233
+ frameName: "",
32234
+ });
32235
+ };
32236
+ /**
32237
+ * Returns the cost basis in dollars of the position currently held (not closed).
32238
+ * Correctly accounts for DCA entries between partial closes.
32239
+ *
32240
+ * @param symbol - Trading pair symbol
32241
+ * @param context - Context with strategyName and exchangeName
32242
+ * @returns Promise<number> - held cost basis in dollars
32243
+ *
32244
+ * @example
32245
+ * ```typescript
32246
+ * const heldCost = await Live.getTotalCostClosed("BTCUSDT", { strategyName, exchangeName });
32247
+ * console.log(`Holding $${heldCost} of position`);
32248
+ * ```
32249
+ */
32250
+ this.getTotalCostClosed = async (symbol, context) => {
32251
+ bt.loggerService.info(LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED, {
32252
+ symbol,
32253
+ context,
32254
+ });
32255
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED);
32256
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED);
32257
+ {
32258
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
32259
+ riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED);
32260
+ riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED));
32261
+ actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED));
32262
+ }
32263
+ return await bt.strategyCoreService.getTotalCostClosed(false, symbol, {
32264
+ strategyName: context.strategyName,
32265
+ exchangeName: context.exchangeName,
32266
+ frameName: "",
32267
+ });
32268
+ };
31753
32269
  /**
31754
32270
  * Retrieves the currently active scheduled signal for the strategy.
31755
32271
  * If no scheduled signal exists, returns null.
@@ -39566,4 +40082,4 @@ const set = (object, path, value) => {
39566
40082
  }
39567
40083
  };
39568
40084
 
39569
- export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Log, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, NotificationBacktest, NotificationLive, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistLogAdapter, PersistMeasureAdapter, PersistNotificationAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PersistStorageAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Storage, StorageBacktest, StorageLive, Strategy, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, alignToInterval, checkCandles, commitActivateScheduled, commitAverageBuy, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitTrailingStop, commitTrailingTake, dumpMessages, emitters, formatPrice, formatQuantity, get, getActionSchema, getAggregatedTrades, getAveragePrice, getBacktestTimeframe, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getExchangeSchema, getFrameSchema, getMode, getNextCandles, getOrderBook, getRawCandles, getRiskSchema, getSizingSchema, getStrategySchema, getSymbol, getTimestamp, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, shutdown, stopStrategy, validate, waitForCandle, warmCandles };
40085
+ export { ActionBase, Backtest, Breakeven, Cache, Constant, Exchange, ExecutionContextService, Heat, Live, Log, Markdown, MarkdownFileBase, MarkdownFolderBase, MethodContextService, Notification, NotificationBacktest, NotificationLive, Partial, Performance, PersistBase, PersistBreakevenAdapter, PersistCandleAdapter, PersistLogAdapter, PersistMeasureAdapter, PersistNotificationAdapter, PersistPartialAdapter, PersistRiskAdapter, PersistScheduleAdapter, PersistSignalAdapter, PersistStorageAdapter, PositionSize, Report, ReportBase, Risk, Schedule, Storage, StorageBacktest, StorageLive, Strategy, Walker, addActionSchema, addExchangeSchema, addFrameSchema, addRiskSchema, addSizingSchema, addStrategySchema, addWalkerSchema, alignToInterval, checkCandles, commitActivateScheduled, commitAverageBuy, commitBreakeven, commitCancelScheduled, commitClosePending, commitPartialLoss, commitPartialProfit, commitTrailingStop, commitTrailingTake, dumpMessages, emitters, formatPrice, formatQuantity, get, getActionSchema, getAggregatedTrades, getAveragePrice, getBacktestTimeframe, getBreakeven, getCandles, getColumns, getConfig, getContext, getDate, getDefaultColumns, getDefaultConfig, getEffectivePriceOpen, getExchangeSchema, getFrameSchema, getMode, getNextCandles, getOrderBook, getPendingSignal, getRawCandles, getRiskSchema, getScheduledSignal, getSizingSchema, getStrategySchema, getSymbol, getTimestamp, getTotalClosed, getTotalCostClosed, getTotalPercentClosed, getWalkerSchema, hasTradeContext, backtest as lib, listExchangeSchema, listFrameSchema, listRiskSchema, listSizingSchema, listStrategySchema, listWalkerSchema, listenActivePing, listenActivePingOnce, listenBacktestProgress, listenBreakevenAvailable, listenBreakevenAvailableOnce, listenDoneBacktest, listenDoneBacktestOnce, listenDoneLive, listenDoneLiveOnce, listenDoneWalker, listenDoneWalkerOnce, listenError, listenExit, listenPartialLossAvailable, listenPartialLossAvailableOnce, listenPartialProfitAvailable, listenPartialProfitAvailableOnce, listenPerformance, listenRisk, listenRiskOnce, listenSchedulePing, listenSchedulePingOnce, listenSignal, listenSignalBacktest, listenSignalBacktestOnce, listenSignalLive, listenSignalLiveOnce, listenSignalOnce, listenStrategyCommit, listenStrategyCommitOnce, listenValidation, listenWalker, listenWalkerComplete, listenWalkerOnce, listenWalkerProgress, overrideActionSchema, overrideExchangeSchema, overrideFrameSchema, overrideRiskSchema, overrideSizingSchema, overrideStrategySchema, overrideWalkerSchema, parseArgs, roundTicks, set, setColumns, setConfig, setLogger, shutdown, stopStrategy, toProfitLossDto, validate, waitForCandle, warmCandles };