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.cjs CHANGED
@@ -463,6 +463,14 @@ const GLOBAL_CONFIG = {
463
463
  * Default: true (mutex locking enabled for candle fetching)
464
464
  */
465
465
  CC_ENABLE_CANDLE_FETCH_MUTEX: true,
466
+ /**
467
+ * Enables DCA (Dollar-Cost Averaging) logic even if antirecord is not broken.
468
+ * Allows to commitAverageBuy if currentPrice is not the lowest price since entry, but still lower than priceOpen.
469
+ * 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.
470
+ *
471
+ * Default: true (DCA logic enabled everywhere, not just when antirecord is broken)
472
+ */
473
+ CC_ENABLE_DCA_EVERYWHERE: false,
466
474
  };
467
475
  const DEFAULT_CONFIG = Object.freeze({ ...GLOBAL_CONFIG });
468
476
 
@@ -3194,75 +3202,90 @@ class ExchangeConnectionService {
3194
3202
  }
3195
3203
  }
3196
3204
 
3205
+ const COST_BASIS_PER_ENTRY$3 = 100;
3197
3206
  /**
3198
3207
  * Returns the effective entry price for price calculations.
3199
3208
  *
3200
- * When the _entry array exists and has at least one element, returns
3201
- * the simple arithmetic mean of all entry prices (DCA average).
3202
- * Otherwise returns the original signal.priceOpen.
3209
+ * Uses harmonic mean (correct for fixed-dollar DCA: $100 per entry).
3203
3210
  *
3204
- * This mirrors the _trailingPriceStopLoss pattern: original price is preserved
3205
- * in signal.priceOpen (for identity/tracking), while calculations use the
3206
- * effective averaged price returned by this function.
3211
+ * When partial closes exist, replays the partial sequence to reconstruct
3212
+ * the running cost basis at each partial — no extra stored fields needed.
3207
3213
  *
3208
- * @param signal - Signal row (ISignalRow or IScheduledSignalRow)
3209
- * @returns Effective entry price for distance and PNL calculations
3214
+ * Cost basis replay:
3215
+ * costBasis starts at 0
3216
+ * for each partial[i]:
3217
+ * newEntries = entryCountAtClose[i] - entryCountAtClose[i-1] (or entryCountAtClose[0] for i=0)
3218
+ * costBasis += newEntries × $100 ← add DCA entries up to this partial
3219
+ * positionCostBasisAtClose[i] = costBasis ← snapshot BEFORE close
3220
+ * costBasis × = (1 - percent[i] / 100) ← reduce after close
3221
+ *
3222
+ * @param signal - Signal row
3223
+ * @returns Effective entry price for PNL calculations
3210
3224
  */
3211
3225
  const getEffectivePriceOpen = (signal) => {
3212
- if (signal._entry && signal._entry.length > 0) {
3213
- return signal._entry.reduce((sum, e) => sum + e.price, 0) / signal._entry.length;
3214
- }
3215
- return signal.priceOpen;
3226
+ if (!signal._entry || signal._entry.length === 0)
3227
+ return signal.priceOpen;
3228
+ const entries = signal._entry;
3229
+ const partials = signal._partial ?? [];
3230
+ // No partial exits — pure harmonic mean of all entries
3231
+ if (partials.length === 0) {
3232
+ return harmonicMean(entries.map((e) => e.price));
3233
+ }
3234
+ // Replay cost basis through all partials to get snapshot at the last one
3235
+ let costBasis = 0;
3236
+ for (let i = 0; i < partials.length; i++) {
3237
+ const prevCount = i === 0 ? 0 : partials[i - 1].entryCountAtClose;
3238
+ const newEntryCount = partials[i].entryCountAtClose - prevCount;
3239
+ costBasis += newEntryCount * COST_BASIS_PER_ENTRY$3;
3240
+ // costBasis is now positionCostBasisAtClose for partials[i]
3241
+ if (i < partials.length - 1) {
3242
+ costBasis *= 1 - partials[i].percent / 100;
3243
+ }
3244
+ }
3245
+ const lastPartial = partials[partials.length - 1];
3246
+ // Dollar cost basis remaining after the last partial close
3247
+ const remainingCostBasis = costBasis * (1 - lastPartial.percent / 100);
3248
+ // Coins remaining from the old position
3249
+ const oldCoins = remainingCostBasis / lastPartial.effectivePrice;
3250
+ // New DCA entries added AFTER the last partial close
3251
+ const newEntries = entries.slice(lastPartial.entryCountAtClose);
3252
+ // Coins from new DCA entries (each costs $100)
3253
+ const newCoins = newEntries.reduce((sum, e) => sum + 100 / e.price, 0);
3254
+ const totalCoins = oldCoins + newCoins;
3255
+ if (totalCoins === 0)
3256
+ return lastPartial.effectivePrice;
3257
+ const totalCost = remainingCostBasis + newEntries.length * 100;
3258
+ return totalCost / totalCoins;
3259
+ };
3260
+ const harmonicMean = (prices) => {
3261
+ if (prices.length === 0)
3262
+ return 0;
3263
+ return prices.length / prices.reduce((sum, p) => sum + 1 / p, 0);
3216
3264
  };
3217
3265
 
3266
+ const COST_BASIS_PER_ENTRY$2 = 100;
3218
3267
  /**
3219
3268
  * Calculates profit/loss for a closed signal with slippage and fees.
3220
3269
  *
3221
3270
  * For signals with partial closes:
3222
- * - Calculates weighted PNL: Σ(percent_i × pnl_i) for each partial + (remaining% × final_pnl)
3223
- * - Each partial close has its own slippage
3224
- * - Open fee is charged once; close fees are proportional to each partial's size
3225
- * - Total fees = CC_PERCENT_FEE (open) + Σ CC_PERCENT_FEE × (partial% / 100) × (closeWithSlip / openWithSlip)
3226
- *
3227
- * Formula breakdown:
3228
- * 1. Apply slippage to open/close prices (worse execution)
3229
- * - LONG: buy higher (+slippage), sell lower (-slippage)
3230
- * - SHORT: sell lower (-slippage), buy higher (+slippage)
3231
- * 2. Calculate raw PNL percentage
3232
- * - LONG: ((closePrice - openPrice) / openPrice) * 100
3233
- * - SHORT: ((openPrice - closePrice) / openPrice) * 100
3234
- * 3. Subtract total fees: open fee + close fee adjusted for slippage-affected execution price
3271
+ * - Weights are calculated by ACTUAL DOLLAR VALUE of each partial relative to total invested.
3272
+ * This correctly handles DCA entries that occur before or after partial closes.
3273
+ *
3274
+ * Cost basis is reconstructed by replaying the partial sequence via entryCountAtClose + percent:
3275
+ * costBasis = 0
3276
+ * for each partial[i]:
3277
+ * costBasis += (entryCountAtClose[i] - entryCountAtClose[i-1]) × $100
3278
+ * partialDollarValue[i] = (percent[i] / 100) × costBasis
3279
+ * weight[i] = partialDollarValue[i] / totalInvested
3280
+ * costBasis *= (1 - percent[i] / 100)
3281
+ *
3282
+ * Fee structure:
3283
+ * - Open fee: CC_PERCENT_FEE (charged once)
3284
+ * - Close fee: CC_PERCENT_FEE × weight × (closeWithSlip / openWithSlip) per partial/remaining
3235
3285
  *
3236
3286
  * @param signal - Closed signal with position details and optional partial history
3237
3287
  * @param priceClose - Actual close price at final exit
3238
3288
  * @returns PNL data with percentage and prices
3239
- *
3240
- * @example
3241
- * ```typescript
3242
- * // Signal without partial closes
3243
- * const pnl = toProfitLossDto(
3244
- * {
3245
- * position: "long",
3246
- * priceOpen: 100,
3247
- * },
3248
- * 110 // close at +10%
3249
- * );
3250
- * console.log(pnl.pnlPercentage); // ~9.6% (after slippage and fees)
3251
- *
3252
- * // Signal with partial closes
3253
- * const pnlPartial = toProfitLossDto(
3254
- * {
3255
- * position: "long",
3256
- * priceOpen: 100,
3257
- * _partial: [
3258
- * { type: "profit", percent: 30, price: 120 }, // +20% on 30%
3259
- * { type: "profit", percent: 40, price: 115 }, // +15% on 40%
3260
- * ],
3261
- * },
3262
- * 105 // final close at +5% for remaining 30%
3263
- * );
3264
- * // Weighted PNL = 30% × 20% + 40% × 15% + 30% × 5% = 6% + 6% + 1.5% = 13.5% (before fees)
3265
- * ```
3266
3289
  */
3267
3290
  const toProfitLossDto = (signal, priceClose) => {
3268
3291
  const priceOpen = getEffectivePriceOpen(signal);
@@ -3271,47 +3294,65 @@ const toProfitLossDto = (signal, priceClose) => {
3271
3294
  let totalWeightedPnl = 0;
3272
3295
  // Open fee is paid once for the whole position
3273
3296
  let totalFees = GLOBAL_CONFIG.CC_PERCENT_FEE;
3274
- // priceOpenWithSlippage is the same for all partials compute once
3275
- const priceOpenWithSlippage = signal.position === "long"
3276
- ? priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3277
- : priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3297
+ // Total invested capital = number of DCA entries × $100 per entry
3298
+ const totalInvested = signal._entry ? signal._entry.length * 100 : 100;
3299
+ let closedDollarValue = 0;
3300
+ // Running cost basis replayed from entryCountAtClose + percent
3301
+ let costBasis = 0;
3278
3302
  // Calculate PNL for each partial close
3279
- for (const partial of signal._partial) {
3280
- const partialPercent = partial.percent;
3303
+ for (let i = 0; i < signal._partial.length; i++) {
3304
+ const partial = signal._partial[i];
3305
+ // Add DCA entries that existed at this partial but not at the previous one
3306
+ const prevCount = i === 0 ? 0 : signal._partial[i - 1].entryCountAtClose;
3307
+ const newEntryCount = partial.entryCountAtClose - prevCount;
3308
+ costBasis += newEntryCount * COST_BASIS_PER_ENTRY$2;
3309
+ // Real dollar value sold in this partial
3310
+ const partialDollarValue = (partial.percent / 100) * costBasis;
3311
+ // Weight relative to total invested capital
3312
+ const weight = partialDollarValue / totalInvested;
3313
+ closedDollarValue += partialDollarValue;
3314
+ // Reduce cost basis after close
3315
+ costBasis *= 1 - partial.percent / 100;
3316
+ // Use the effective entry price snapshot captured at the time of this partial close
3317
+ const priceOpenWithSlippage = signal.position === "long"
3318
+ ? partial.effectivePrice * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3319
+ : partial.effectivePrice * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3281
3320
  const priceCloseWithSlippage = signal.position === "long"
3282
3321
  ? partial.price * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3283
3322
  : partial.price * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3284
- // Calculate PNL for this partial
3285
3323
  const partialPnl = signal.position === "long"
3286
3324
  ? ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100
3287
3325
  : ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
3288
- // Weight by percentage of position closed
3289
- totalWeightedPnl += (partialPercent / 100) * partialPnl;
3290
- // Close fee is proportional to the size of this partial and adjusted for slippage
3291
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (partialPercent / 100) * (priceCloseWithSlippage / priceOpenWithSlippage);
3292
- }
3293
- // Calculate PNL for remaining position (if any)
3294
- // Compute totalClosed from _partial array
3295
- const totalClosed = signal._partial.reduce((sum, p) => sum + p.percent, 0);
3296
- if (totalClosed > 100) {
3297
- throw new Error(`Partial closes exceed 100%: ${totalClosed}% (signal id: ${signal.id})`);
3298
- }
3299
- const remainingPercent = 100 - totalClosed;
3300
- if (remainingPercent > 0) {
3326
+ totalWeightedPnl += weight * partialPnl;
3327
+ // Close fee proportional to real dollar weight
3328
+ totalFees +=
3329
+ GLOBAL_CONFIG.CC_PERCENT_FEE *
3330
+ weight *
3331
+ (priceCloseWithSlippage / priceOpenWithSlippage);
3332
+ }
3333
+ if (closedDollarValue > totalInvested + 0.001) {
3334
+ throw new Error(`Partial closes dollar value (${closedDollarValue.toFixed(4)}) exceeds total invested (${totalInvested}) — signal id: ${signal.id}`);
3335
+ }
3336
+ // Remaining position
3337
+ const remainingDollarValue = totalInvested - closedDollarValue;
3338
+ const remainingWeight = remainingDollarValue / totalInvested;
3339
+ if (remainingWeight > 0) {
3340
+ // Use current effective price — reflects all DCA including post-partial entries
3341
+ const remainingOpenWithSlippage = signal.position === "long"
3342
+ ? priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3343
+ : priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3301
3344
  const priceCloseWithSlippage = signal.position === "long"
3302
3345
  ? priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100)
3303
3346
  : priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3304
- // Calculate PNL for remaining
3305
3347
  const remainingPnl = signal.position === "long"
3306
- ? ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100
3307
- : ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
3308
- // Weight by remaining percentage
3309
- totalWeightedPnl += (remainingPercent / 100) * remainingPnl;
3310
- // Close fee is proportional to the remaining size and adjusted for slippage
3311
- totalFees += GLOBAL_CONFIG.CC_PERCENT_FEE * (remainingPercent / 100) * (priceCloseWithSlippage / priceOpenWithSlippage);
3348
+ ? ((priceCloseWithSlippage - remainingOpenWithSlippage) / remainingOpenWithSlippage) * 100
3349
+ : ((remainingOpenWithSlippage - priceCloseWithSlippage) / remainingOpenWithSlippage) * 100;
3350
+ totalWeightedPnl += remainingWeight * remainingPnl;
3351
+ totalFees +=
3352
+ GLOBAL_CONFIG.CC_PERCENT_FEE *
3353
+ remainingWeight *
3354
+ (priceCloseWithSlippage / remainingOpenWithSlippage);
3312
3355
  }
3313
- // Subtract total fees from weighted PNL
3314
- // totalFees = CC_PERCENT_FEE (open) + Σ CC_PERCENT_FEE × (partialPercent/100) × (closeWithSlip/openWithSlip)
3315
3356
  const pnlPercentage = totalWeightedPnl - totalFees;
3316
3357
  return {
3317
3358
  pnlPercentage,
@@ -3323,33 +3364,24 @@ const toProfitLossDto = (signal, priceClose) => {
3323
3364
  let priceOpenWithSlippage;
3324
3365
  let priceCloseWithSlippage;
3325
3366
  if (signal.position === "long") {
3326
- // LONG: покупаем дороже, продаем дешевле
3327
3367
  priceOpenWithSlippage = priceOpen * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3328
3368
  priceCloseWithSlippage = priceClose * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3329
3369
  }
3330
3370
  else {
3331
- // SHORT: продаем дешевле, покупаем дороже
3332
3371
  priceOpenWithSlippage = priceOpen * (1 - GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3333
3372
  priceCloseWithSlippage = priceClose * (1 + GLOBAL_CONFIG.CC_PERCENT_SLIPPAGE / 100);
3334
3373
  }
3335
- // Открытие: комиссия от цены входа; закрытие: комиссия от фактической цены выхода (с учётом slippage)
3336
- const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE * (1 + priceCloseWithSlippage / priceOpenWithSlippage);
3374
+ const totalFee = GLOBAL_CONFIG.CC_PERCENT_FEE *
3375
+ (1 + priceCloseWithSlippage / priceOpenWithSlippage);
3337
3376
  let pnlPercentage;
3338
3377
  if (signal.position === "long") {
3339
- // LONG: прибыль при росте цены
3340
3378
  pnlPercentage =
3341
- ((priceCloseWithSlippage - priceOpenWithSlippage) /
3342
- priceOpenWithSlippage) *
3343
- 100;
3379
+ ((priceCloseWithSlippage - priceOpenWithSlippage) / priceOpenWithSlippage) * 100;
3344
3380
  }
3345
3381
  else {
3346
- // SHORT: прибыль при падении цены
3347
3382
  pnlPercentage =
3348
- ((priceOpenWithSlippage - priceCloseWithSlippage) /
3349
- priceOpenWithSlippage) *
3350
- 100;
3383
+ ((priceOpenWithSlippage - priceCloseWithSlippage) / priceOpenWithSlippage) * 100;
3351
3384
  }
3352
- // Вычитаем комиссии
3353
3385
  pnlPercentage -= totalFee;
3354
3386
  return {
3355
3387
  pnlPercentage,
@@ -3405,6 +3437,54 @@ const toPlainString = (content) => {
3405
3437
  return text.trim();
3406
3438
  };
3407
3439
 
3440
+ const COST_BASIS_PER_ENTRY$1 = 100;
3441
+ /**
3442
+ * Returns the total closed state of a position using cost-basis replay.
3443
+ *
3444
+ * Correctly accounts for DCA entries added between partial closes via averageBuy().
3445
+ * Simple percent summation (sum of _partial[i].percent) is INCORRECT when averageBuy()
3446
+ * is called between partials — this function uses the same cost-basis replay as
3447
+ * toProfitLossDto to compute the true dollar-weighted closed fraction.
3448
+ *
3449
+ * Cost-basis replay:
3450
+ * costBasis = 0
3451
+ * for each partial[i]:
3452
+ * costBasis += (entryCountAtClose[i] - entryCountAtClose[i-1]) × $100
3453
+ * closedDollar += (percent[i] / 100) × costBasis
3454
+ * costBasis ×= (1 - percent[i] / 100)
3455
+ * // then add entries added AFTER the last partial
3456
+ * costBasis += (currentEntryCount - lastPartialEntryCount) × $100
3457
+ *
3458
+ * @param signal - Signal row with _partial and _entry arrays
3459
+ * @returns Object with totalClosedPercent (0–100+) and remainingCostBasis (dollar value still open)
3460
+ */
3461
+ const getTotalClosed = (signal) => {
3462
+ const partials = signal._partial ?? [];
3463
+ const currentEntryCount = signal._entry?.length ?? 1;
3464
+ const totalInvested = currentEntryCount * COST_BASIS_PER_ENTRY$1;
3465
+ if (partials.length === 0) {
3466
+ return {
3467
+ totalClosedPercent: 0,
3468
+ remainingCostBasis: totalInvested,
3469
+ };
3470
+ }
3471
+ let costBasis = 0;
3472
+ let closedDollarValue = 0;
3473
+ for (let i = 0; i < partials.length; i++) {
3474
+ const prevCount = i === 0 ? 0 : partials[i - 1].entryCountAtClose;
3475
+ costBasis += (partials[i].entryCountAtClose - prevCount) * COST_BASIS_PER_ENTRY$1;
3476
+ closedDollarValue += (partials[i].percent / 100) * costBasis;
3477
+ costBasis *= 1 - partials[i].percent / 100;
3478
+ }
3479
+ // Add entries added AFTER the last partial (not yet accounted for in the loop)
3480
+ const lastEntryCount = partials[partials.length - 1].entryCountAtClose;
3481
+ costBasis += (currentEntryCount - lastEntryCount) * COST_BASIS_PER_ENTRY$1;
3482
+ return {
3483
+ totalClosedPercent: totalInvested > 0 ? (closedDollarValue / totalInvested) * 100 : 0,
3484
+ remainingCostBasis: costBasis,
3485
+ };
3486
+ };
3487
+
3408
3488
  /**
3409
3489
  * Wraps a function to execute it outside of the current execution context if one exists.
3410
3490
  *
@@ -3447,6 +3527,19 @@ const beginTime = (run) => (...args) => {
3447
3527
  return fn();
3448
3528
  };
3449
3529
 
3530
+ /**
3531
+ * Retrieves the current timestamp for debugging purposes.
3532
+ * 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.
3533
+ * Can be empty (undefined) if not called from strategy async context, as it's intended for debugging and not critical for logic.
3534
+ * @return {number | undefined} The current timestamp in milliseconds from the execution context, or undefined if not available.
3535
+ */
3536
+ const getDebugTimestamp = () => {
3537
+ if (ExecutionContextService.hasContext()) {
3538
+ return bt.executionContextService.context.when.getTime();
3539
+ }
3540
+ return undefined;
3541
+ };
3542
+
3450
3543
  const INTERVAL_MINUTES$6 = {
3451
3544
  "1m": 1,
3452
3545
  "3m": 3,
@@ -3455,6 +3548,7 @@ const INTERVAL_MINUTES$6 = {
3455
3548
  "30m": 30,
3456
3549
  "1h": 60,
3457
3550
  };
3551
+ const COST_BASIS_PER_ENTRY = 100;
3458
3552
  /**
3459
3553
  * Mock value for scheduled signal pendingAt timestamp.
3460
3554
  * Used to indicate that the actual pendingAt will be set upon activation.
@@ -4056,7 +4150,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
4056
4150
  scheduledAt: currentTime,
4057
4151
  pendingAt: currentTime, // Для immediate signal оба времени одинаковые
4058
4152
  _isScheduled: false,
4059
- _entry: [{ price: signal.priceOpen }],
4153
+ _entry: [{ price: signal.priceOpen, debugTimestamp: currentTime }],
4060
4154
  };
4061
4155
  // Валидируем сигнал перед возвратом
4062
4156
  VALIDATE_SIGNAL_FN(signalRow, currentPrice, false);
@@ -4078,7 +4172,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
4078
4172
  scheduledAt: currentTime,
4079
4173
  pendingAt: SCHEDULED_SIGNAL_PENDING_MOCK, // Временно, обновится при активации
4080
4174
  _isScheduled: true,
4081
- _entry: [{ price: signal.priceOpen }],
4175
+ _entry: [{ price: signal.priceOpen, debugTimestamp: currentTime }],
4082
4176
  };
4083
4177
  // Валидируем сигнал перед возвратом
4084
4178
  VALIDATE_SIGNAL_FN(scheduledSignalRow, currentPrice, true);
@@ -4096,7 +4190,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
4096
4190
  scheduledAt: currentTime,
4097
4191
  pendingAt: currentTime, // Для immediate signal оба времени одинаковые
4098
4192
  _isScheduled: false,
4099
- _entry: [{ price: currentPrice }],
4193
+ _entry: [{ price: currentPrice, debugTimestamp: currentTime }],
4100
4194
  };
4101
4195
  // Валидируем сигнал перед возвратом
4102
4196
  VALIDATE_SIGNAL_FN(signalRow, currentPrice, false);
@@ -4170,37 +4264,39 @@ const PARTIAL_PROFIT_FN = (self, signal, percentToClose, currentPrice) => {
4170
4264
  // Initialize partial array if not present
4171
4265
  if (!signal._partial)
4172
4266
  signal._partial = [];
4173
- // Calculate current totals (computed values)
4174
- const tpClosed = signal._partial
4175
- .filter((p) => p.type === "profit")
4176
- .reduce((sum, p) => sum + p.percent, 0);
4177
- const slClosed = signal._partial
4178
- .filter((p) => p.type === "loss")
4179
- .reduce((sum, p) => sum + p.percent, 0);
4180
- const totalClosed = tpClosed + slClosed;
4181
- // Check if would exceed 100% total closed
4182
- const newTotalClosed = totalClosed + percentToClose;
4183
- if (newTotalClosed > 100) {
4184
- self.params.logger.warn("PARTIAL_PROFIT_FN: would exceed 100% closed, skipping", {
4267
+ // Check if would exceed 100% total closed (dollar-basis, DCA-aware)
4268
+ const { totalClosedPercent, remainingCostBasis } = getTotalClosed(signal);
4269
+ const totalInvested = (signal._entry?.length ?? 1) * COST_BASIS_PER_ENTRY;
4270
+ const newPartialDollar = (percentToClose / 100) * remainingCostBasis;
4271
+ const newTotalClosedDollar = (totalClosedPercent / 100) * totalInvested + newPartialDollar;
4272
+ if (newTotalClosedDollar > totalInvested) {
4273
+ self.params.logger.warn("PARTIAL_PROFIT_FN: would exceed 100% closed (dollar basis), skipping", {
4185
4274
  signalId: signal.id,
4186
- currentTotalClosed: totalClosed,
4275
+ totalClosedPercent,
4276
+ remainingCostBasis,
4187
4277
  percentToClose,
4188
- newTotalClosed,
4278
+ newPartialDollar,
4279
+ totalInvested,
4189
4280
  });
4190
4281
  return false;
4191
4282
  }
4283
+ // Capture effective entry price at the moment of partial close (for DCA-aware PNL)
4284
+ const effectivePrice = getEffectivePriceOpen(signal);
4285
+ const entryCountAtClose = signal._entry ? signal._entry.length : 1;
4192
4286
  // Add new partial close entry
4193
4287
  signal._partial.push({
4194
4288
  type: "profit",
4195
4289
  percent: percentToClose,
4290
+ entryCountAtClose,
4196
4291
  price: currentPrice,
4292
+ debugTimestamp: getDebugTimestamp(),
4293
+ effectivePrice,
4197
4294
  });
4198
4295
  self.params.logger.info("PARTIAL_PROFIT_FN executed", {
4199
4296
  signalId: signal.id,
4200
4297
  percentClosed: percentToClose,
4201
- totalClosed: newTotalClosed,
4298
+ totalClosedPercent: totalClosedPercent + (newPartialDollar / totalInvested) * 100,
4202
4299
  currentPrice,
4203
- tpClosed: tpClosed + percentToClose,
4204
4300
  });
4205
4301
  return true;
4206
4302
  };
@@ -4208,37 +4304,39 @@ const PARTIAL_LOSS_FN = (self, signal, percentToClose, currentPrice) => {
4208
4304
  // Initialize partial array if not present
4209
4305
  if (!signal._partial)
4210
4306
  signal._partial = [];
4211
- // Calculate current totals (computed values)
4212
- const tpClosed = signal._partial
4213
- .filter((p) => p.type === "profit")
4214
- .reduce((sum, p) => sum + p.percent, 0);
4215
- const slClosed = signal._partial
4216
- .filter((p) => p.type === "loss")
4217
- .reduce((sum, p) => sum + p.percent, 0);
4218
- const totalClosed = tpClosed + slClosed;
4219
- // Check if would exceed 100% total closed
4220
- const newTotalClosed = totalClosed + percentToClose;
4221
- if (newTotalClosed > 100) {
4222
- self.params.logger.warn("PARTIAL_LOSS_FN: would exceed 100% closed, skipping", {
4307
+ // Check if would exceed 100% total closed (dollar-basis, DCA-aware)
4308
+ const { totalClosedPercent, remainingCostBasis } = getTotalClosed(signal);
4309
+ const totalInvested = (signal._entry?.length ?? 1) * COST_BASIS_PER_ENTRY;
4310
+ const newPartialDollar = (percentToClose / 100) * remainingCostBasis;
4311
+ const newTotalClosedDollar = (totalClosedPercent / 100) * totalInvested + newPartialDollar;
4312
+ if (newTotalClosedDollar > totalInvested) {
4313
+ self.params.logger.warn("PARTIAL_LOSS_FN: would exceed 100% closed (dollar basis), skipping", {
4223
4314
  signalId: signal.id,
4224
- currentTotalClosed: totalClosed,
4315
+ totalClosedPercent,
4316
+ remainingCostBasis,
4225
4317
  percentToClose,
4226
- newTotalClosed,
4318
+ newPartialDollar,
4319
+ totalInvested,
4227
4320
  });
4228
4321
  return false;
4229
4322
  }
4323
+ // Capture effective entry price at the moment of partial close (for DCA-aware PNL)
4324
+ const effectivePrice = getEffectivePriceOpen(signal);
4325
+ const entryCountAtClose = signal._entry ? signal._entry.length : 1;
4230
4326
  // Add new partial close entry
4231
4327
  signal._partial.push({
4232
4328
  type: "loss",
4233
4329
  percent: percentToClose,
4234
4330
  price: currentPrice,
4331
+ entryCountAtClose,
4332
+ effectivePrice,
4333
+ debugTimestamp: getDebugTimestamp(),
4235
4334
  });
4236
4335
  self.params.logger.warn("PARTIAL_LOSS_FN executed", {
4237
4336
  signalId: signal.id,
4238
4337
  percentClosed: percentToClose,
4239
- totalClosed: newTotalClosed,
4338
+ totalClosedPercent: totalClosedPercent + (newPartialDollar / totalInvested) * 100,
4240
4339
  currentPrice,
4241
- slClosed: slClosed + percentToClose,
4242
4340
  });
4243
4341
  return true;
4244
4342
  };
@@ -4632,12 +4730,12 @@ const BREAKEVEN_FN = (self, signal, currentPrice) => {
4632
4730
  const AVERAGE_BUY_FN = (self, signal, currentPrice) => {
4633
4731
  // Ensure _entry is initialized (handles signals loaded from disk without _entry)
4634
4732
  if (!signal._entry || signal._entry.length === 0) {
4635
- signal._entry = [{ price: signal.priceOpen }];
4733
+ signal._entry = [{ price: signal.priceOpen, debugTimestamp: getDebugTimestamp() }];
4636
4734
  }
4637
4735
  const lastEntry = signal._entry[signal._entry.length - 1];
4638
4736
  if (signal.position === "long") {
4639
4737
  // LONG: averaging down = currentPrice must be strictly lower than last entry
4640
- if (currentPrice >= lastEntry.price) {
4738
+ if (!GLOBAL_CONFIG.CC_ENABLE_DCA_EVERYWHERE && currentPrice >= lastEntry.price) {
4641
4739
  self.params.logger.debug("AVERAGE_BUY_FN: rejected — currentPrice >= last entry (LONG)", {
4642
4740
  signalId: signal.id,
4643
4741
  position: signal.position,
@@ -4650,7 +4748,7 @@ const AVERAGE_BUY_FN = (self, signal, currentPrice) => {
4650
4748
  }
4651
4749
  else {
4652
4750
  // SHORT: averaging down = currentPrice must be strictly higher than last entry
4653
- if (currentPrice <= lastEntry.price) {
4751
+ if (!GLOBAL_CONFIG.CC_ENABLE_DCA_EVERYWHERE && currentPrice <= lastEntry.price) {
4654
4752
  self.params.logger.debug("AVERAGE_BUY_FN: rejected — currentPrice <= last entry (SHORT)", {
4655
4753
  signalId: signal.id,
4656
4754
  position: signal.position,
@@ -4661,7 +4759,7 @@ const AVERAGE_BUY_FN = (self, signal, currentPrice) => {
4661
4759
  return false;
4662
4760
  }
4663
4761
  }
4664
- signal._entry.push({ price: currentPrice });
4762
+ signal._entry.push({ price: currentPrice, debugTimestamp: getDebugTimestamp() });
4665
4763
  self.params.logger.info("AVERAGE_BUY_FN executed", {
4666
4764
  signalId: signal.id,
4667
4765
  position: signal.position,
@@ -6126,6 +6224,52 @@ class ClientStrategy {
6126
6224
  });
6127
6225
  return this._isStopped;
6128
6226
  }
6227
+ /**
6228
+ * Returns how much of the position is still held, as a percentage of totalInvested.
6229
+ *
6230
+ * Uses dollar-basis cost-basis replay (DCA-aware).
6231
+ * 100% means nothing was closed yet. Decreases with each partial close.
6232
+ *
6233
+ * Example: 1 entry $100, partialProfit(30%) → returns 70
6234
+ * Example: 2 entries $200, partialProfit(50%) → returns 50
6235
+ *
6236
+ * Returns 100 if no pending signal or no partial closes.
6237
+ *
6238
+ * @param symbol - Trading pair symbol
6239
+ * @returns Promise resolving to held percentage (0–100)
6240
+ */
6241
+ async getTotalPercentClosed(symbol) {
6242
+ this.params.logger.debug("ClientStrategy getTotalPercentClosed", { symbol });
6243
+ if (!this._pendingSignal) {
6244
+ return null;
6245
+ }
6246
+ const { totalClosedPercent } = getTotalClosed(this._pendingSignal);
6247
+ return 100 - totalClosedPercent;
6248
+ }
6249
+ /**
6250
+ * Returns how many dollars of cost basis are still held (not yet closed by partials).
6251
+ *
6252
+ * Equal to remainingCostBasis from getTotalClosed.
6253
+ * Full position open: equals totalInvested (entries × $100).
6254
+ * Decreases with each partial close, increases with each averageBuy().
6255
+ *
6256
+ * Example: 1 entry $100, no partials → returns 100
6257
+ * Example: 1 entry $100, partialProfit(30%) → returns 70
6258
+ * Example: 2 entries $200, partialProfit(50%) → returns 100
6259
+ *
6260
+ * Returns totalInvested if no pending signal or no partial closes.
6261
+ *
6262
+ * @param symbol - Trading pair symbol
6263
+ * @returns Promise resolving to held cost basis in dollars
6264
+ */
6265
+ async getTotalCostClosed(symbol) {
6266
+ this.params.logger.debug("ClientStrategy getTotalCostClosed", { symbol });
6267
+ if (!this._pendingSignal) {
6268
+ return null;
6269
+ }
6270
+ const { remainingCostBasis } = getTotalClosed(this._pendingSignal);
6271
+ return remainingCostBasis;
6272
+ }
6129
6273
  /**
6130
6274
  * Performs a single tick of strategy execution.
6131
6275
  *
@@ -7586,14 +7730,6 @@ class ClientStrategy {
7586
7730
  if (typeof currentPrice !== "number" || !isFinite(currentPrice) || currentPrice <= 0) {
7587
7731
  throw new Error(`ClientStrategy averageBuy: currentPrice must be a positive finite number, got ${currentPrice}`);
7588
7732
  }
7589
- // Reject if any partial closes have already been executed
7590
- if (this._pendingSignal._partial && this._pendingSignal._partial.length > 0) {
7591
- this.params.logger.debug("ClientStrategy averageBuy: rejected — partial closes already executed", {
7592
- symbol,
7593
- partialCount: this._pendingSignal._partial.length,
7594
- });
7595
- return false;
7596
- }
7597
7733
  // Execute averaging logic
7598
7734
  const result = AVERAGE_BUY_FN(this, this._pendingSignal, currentPrice);
7599
7735
  if (!result) {
@@ -8272,6 +8408,43 @@ class StrategyConnectionService {
8272
8408
  const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8273
8409
  return await strategy.getPendingSignal(symbol);
8274
8410
  };
8411
+ /**
8412
+ * Returns the percentage of the position currently held (not closed).
8413
+ * 100 = nothing has been closed (full position), 0 = fully closed.
8414
+ * Correctly accounts for DCA entries between partial closes.
8415
+ *
8416
+ * @param backtest - Whether running in backtest mode
8417
+ * @param symbol - Trading pair symbol
8418
+ * @param context - Execution context with strategyName, exchangeName, frameName
8419
+ * @returns Promise<number> - held percentage (0–100)
8420
+ */
8421
+ this.getTotalPercentClosed = async (backtest, symbol, context) => {
8422
+ this.loggerService.log("strategyConnectionService getTotalPercentClosed", {
8423
+ symbol,
8424
+ context,
8425
+ backtest,
8426
+ });
8427
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8428
+ return await strategy.getTotalPercentClosed(symbol);
8429
+ };
8430
+ /**
8431
+ * Returns the cost basis in dollars of the position currently held (not closed).
8432
+ * Correctly accounts for DCA entries between partial closes.
8433
+ *
8434
+ * @param backtest - Whether running in backtest mode
8435
+ * @param symbol - Trading pair symbol
8436
+ * @param context - Execution context with strategyName, exchangeName, frameName
8437
+ * @returns Promise<number> - held cost basis in dollars
8438
+ */
8439
+ this.getTotalCostClosed = async (backtest, symbol, context) => {
8440
+ this.loggerService.log("strategyConnectionService getTotalCostClosed", {
8441
+ symbol,
8442
+ context,
8443
+ backtest,
8444
+ });
8445
+ const strategy = this.getStrategy(symbol, context.strategyName, context.exchangeName, context.frameName, backtest);
8446
+ return await strategy.getTotalCostClosed(symbol);
8447
+ };
8275
8448
  /**
8276
8449
  * Retrieves the currently active scheduled signal for the strategy.
8277
8450
  * If no scheduled signal exists, returns null.
@@ -11675,6 +11848,41 @@ class StrategyCoreService {
11675
11848
  await this.validate(context);
11676
11849
  return await this.strategyConnectionService.getPendingSignal(backtest, symbol, context);
11677
11850
  };
11851
+ /**
11852
+ * Returns the percentage of the position currently held (not closed).
11853
+ * 100 = nothing has been closed (full position), 0 = fully closed.
11854
+ * Correctly accounts for DCA entries between partial closes.
11855
+ *
11856
+ * @param backtest - Whether running in backtest mode
11857
+ * @param symbol - Trading pair symbol
11858
+ * @param context - Execution context with strategyName, exchangeName, frameName
11859
+ * @returns Promise<number> - held percentage (0–100)
11860
+ */
11861
+ this.getTotalPercentClosed = async (backtest, symbol, context) => {
11862
+ this.loggerService.log("strategyCoreService getTotalPercentClosed", {
11863
+ symbol,
11864
+ context,
11865
+ });
11866
+ await this.validate(context);
11867
+ return await this.strategyConnectionService.getTotalPercentClosed(backtest, symbol, context);
11868
+ };
11869
+ /**
11870
+ * Returns the cost basis in dollars of the position currently held (not closed).
11871
+ * Correctly accounts for DCA entries between partial closes.
11872
+ *
11873
+ * @param backtest - Whether running in backtest mode
11874
+ * @param symbol - Trading pair symbol
11875
+ * @param context - Execution context with strategyName, exchangeName, frameName
11876
+ * @returns Promise<number> - held cost basis in dollars
11877
+ */
11878
+ this.getTotalCostClosed = async (backtest, symbol, context) => {
11879
+ this.loggerService.log("strategyCoreService getTotalCostClosed", {
11880
+ symbol,
11881
+ context,
11882
+ });
11883
+ await this.validate(context);
11884
+ return await this.strategyConnectionService.getTotalCostClosed(backtest, symbol, context);
11885
+ };
11678
11886
  /**
11679
11887
  * Retrieves the currently active scheduled signal for the symbol.
11680
11888
  * If no scheduled signal exists, returns null.
@@ -28706,6 +28914,11 @@ const TRAILING_PROFIT_METHOD_NAME = "strategy.commitTrailingTake";
28706
28914
  const BREAKEVEN_METHOD_NAME = "strategy.commitBreakeven";
28707
28915
  const ACTIVATE_SCHEDULED_METHOD_NAME = "strategy.commitActivateScheduled";
28708
28916
  const AVERAGE_BUY_METHOD_NAME = "strategy.commitAverageBuy";
28917
+ const GET_TOTAL_PERCENT_CLOSED_METHOD_NAME = "strategy.getTotalPercentClosed";
28918
+ const GET_TOTAL_COST_CLOSED_METHOD_NAME = "strategy.getTotalCostClosed";
28919
+ const GET_PENDING_SIGNAL_METHOD_NAME = "strategy.getPendingSignal";
28920
+ const GET_SCHEDULED_SIGNAL_METHOD_NAME = "strategy.getScheduledSignal";
28921
+ const GET_BREAKEVEN_METHOD_NAME = "strategy.getBreakeven";
28709
28922
  /**
28710
28923
  * Cancels the scheduled signal without stopping the strategy.
28711
28924
  *
@@ -29097,6 +29310,173 @@ async function commitAverageBuy(symbol) {
29097
29310
  const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29098
29311
  return await bt.strategyCoreService.averageBuy(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
29099
29312
  }
29313
+ /**
29314
+ * Returns the percentage of the position currently held (not closed).
29315
+ * 100 = nothing has been closed (full position), 0 = fully closed.
29316
+ * Correctly accounts for DCA entries between partial closes.
29317
+ *
29318
+ * Automatically detects backtest/live mode from execution context.
29319
+ *
29320
+ * @param symbol - Trading pair symbol
29321
+ * @returns Promise<number> - held percentage (0–100)
29322
+ *
29323
+ * @example
29324
+ * ```typescript
29325
+ * import { getTotalPercentClosed } from "backtest-kit";
29326
+ *
29327
+ * const heldPct = await getTotalPercentClosed("BTCUSDT");
29328
+ * console.log(`Holding ${heldPct}% of position`);
29329
+ * ```
29330
+ */
29331
+ async function getTotalPercentClosed(symbol) {
29332
+ bt.loggerService.info(GET_TOTAL_PERCENT_CLOSED_METHOD_NAME, {
29333
+ symbol,
29334
+ });
29335
+ if (!ExecutionContextService.hasContext()) {
29336
+ throw new Error("getTotalPercentClosed requires an execution context");
29337
+ }
29338
+ if (!MethodContextService.hasContext()) {
29339
+ throw new Error("getTotalPercentClosed requires a method context");
29340
+ }
29341
+ const { backtest: isBacktest } = bt.executionContextService.context;
29342
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29343
+ return await bt.strategyCoreService.getTotalPercentClosed(isBacktest, symbol, { exchangeName, frameName, strategyName });
29344
+ }
29345
+ /**
29346
+ * Returns the cost basis in dollars of the position currently held (not closed).
29347
+ * Correctly accounts for DCA entries between partial closes.
29348
+ *
29349
+ * Automatically detects backtest/live mode from execution context.
29350
+ *
29351
+ * @param symbol - Trading pair symbol
29352
+ * @returns Promise<number> - held cost basis in dollars
29353
+ *
29354
+ * @example
29355
+ * ```typescript
29356
+ * import { getTotalCostClosed } from "backtest-kit";
29357
+ *
29358
+ * const heldCost = await getTotalCostClosed("BTCUSDT");
29359
+ * console.log(`Holding $${heldCost} of position`);
29360
+ * ```
29361
+ */
29362
+ async function getTotalCostClosed(symbol) {
29363
+ bt.loggerService.info(GET_TOTAL_COST_CLOSED_METHOD_NAME, {
29364
+ symbol,
29365
+ });
29366
+ if (!ExecutionContextService.hasContext()) {
29367
+ throw new Error("getTotalCostClosed requires an execution context");
29368
+ }
29369
+ if (!MethodContextService.hasContext()) {
29370
+ throw new Error("getTotalCostClosed requires a method context");
29371
+ }
29372
+ const { backtest: isBacktest } = bt.executionContextService.context;
29373
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29374
+ return await bt.strategyCoreService.getTotalCostClosed(isBacktest, symbol, { exchangeName, frameName, strategyName });
29375
+ }
29376
+ /**
29377
+ * Returns the currently active pending signal for the strategy.
29378
+ * If no active signal exists, returns null.
29379
+ *
29380
+ * Automatically detects backtest/live mode from execution context.
29381
+ *
29382
+ * @param symbol - Trading pair symbol
29383
+ * @returns Promise resolving to pending signal or null
29384
+ *
29385
+ * @example
29386
+ * ```typescript
29387
+ * import { getPendingSignal } from "backtest-kit";
29388
+ *
29389
+ * const pending = await getPendingSignal("BTCUSDT");
29390
+ * if (pending) {
29391
+ * console.log("Active signal:", pending.id);
29392
+ * }
29393
+ * ```
29394
+ */
29395
+ async function getPendingSignal(symbol) {
29396
+ bt.loggerService.info(GET_PENDING_SIGNAL_METHOD_NAME, {
29397
+ symbol,
29398
+ });
29399
+ if (!ExecutionContextService.hasContext()) {
29400
+ throw new Error("getPendingSignal requires an execution context");
29401
+ }
29402
+ if (!MethodContextService.hasContext()) {
29403
+ throw new Error("getPendingSignal requires a method context");
29404
+ }
29405
+ const { backtest: isBacktest } = bt.executionContextService.context;
29406
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29407
+ return await bt.strategyCoreService.getPendingSignal(isBacktest, symbol, { exchangeName, frameName, strategyName });
29408
+ }
29409
+ /**
29410
+ * Returns the currently active scheduled signal for the strategy.
29411
+ * If no scheduled signal exists, returns null.
29412
+ *
29413
+ * Automatically detects backtest/live mode from execution context.
29414
+ *
29415
+ * @param symbol - Trading pair symbol
29416
+ * @returns Promise resolving to scheduled signal or null
29417
+ *
29418
+ * @example
29419
+ * ```typescript
29420
+ * import { getScheduledSignal } from "backtest-kit";
29421
+ *
29422
+ * const scheduled = await getScheduledSignal("BTCUSDT");
29423
+ * if (scheduled) {
29424
+ * console.log("Scheduled signal:", scheduled.id);
29425
+ * }
29426
+ * ```
29427
+ */
29428
+ async function getScheduledSignal(symbol) {
29429
+ bt.loggerService.info(GET_SCHEDULED_SIGNAL_METHOD_NAME, {
29430
+ symbol,
29431
+ });
29432
+ if (!ExecutionContextService.hasContext()) {
29433
+ throw new Error("getScheduledSignal requires an execution context");
29434
+ }
29435
+ if (!MethodContextService.hasContext()) {
29436
+ throw new Error("getScheduledSignal requires a method context");
29437
+ }
29438
+ const { backtest: isBacktest } = bt.executionContextService.context;
29439
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29440
+ return await bt.strategyCoreService.getScheduledSignal(isBacktest, symbol, { exchangeName, frameName, strategyName });
29441
+ }
29442
+ /**
29443
+ * Checks if breakeven threshold has been reached for the current pending signal.
29444
+ *
29445
+ * Returns true if price has moved far enough in profit direction to cover
29446
+ * transaction costs. Threshold is calculated as: (CC_PERCENT_SLIPPAGE + CC_PERCENT_FEE) * 2
29447
+ *
29448
+ * Automatically detects backtest/live mode from execution context.
29449
+ *
29450
+ * @param symbol - Trading pair symbol
29451
+ * @param currentPrice - Current market price to check against threshold
29452
+ * @returns Promise<boolean> - true if breakeven threshold reached, false otherwise
29453
+ *
29454
+ * @example
29455
+ * ```typescript
29456
+ * import { getBreakeven, getAveragePrice } from "backtest-kit";
29457
+ *
29458
+ * const price = await getAveragePrice("BTCUSDT");
29459
+ * const canBreakeven = await getBreakeven("BTCUSDT", price);
29460
+ * if (canBreakeven) {
29461
+ * console.log("Breakeven available");
29462
+ * }
29463
+ * ```
29464
+ */
29465
+ async function getBreakeven(symbol, currentPrice) {
29466
+ bt.loggerService.info(GET_BREAKEVEN_METHOD_NAME, {
29467
+ symbol,
29468
+ currentPrice,
29469
+ });
29470
+ if (!ExecutionContextService.hasContext()) {
29471
+ throw new Error("getBreakeven requires an execution context");
29472
+ }
29473
+ if (!MethodContextService.hasContext()) {
29474
+ throw new Error("getBreakeven requires a method context");
29475
+ }
29476
+ const { backtest: isBacktest } = bt.executionContextService.context;
29477
+ const { exchangeName, frameName, strategyName } = bt.methodContextService.context;
29478
+ return await bt.strategyCoreService.getBreakeven(isBacktest, symbol, currentPrice, { exchangeName, frameName, strategyName });
29479
+ }
29100
29480
 
29101
29481
  const STOP_STRATEGY_METHOD_NAME = "control.stopStrategy";
29102
29482
  /**
@@ -30284,6 +30664,8 @@ const BACKTEST_METHOD_NAME_DUMP = "BacktestUtils.dump";
30284
30664
  const BACKTEST_METHOD_NAME_TASK = "BacktestUtils.task";
30285
30665
  const BACKTEST_METHOD_NAME_GET_STATUS = "BacktestUtils.getStatus";
30286
30666
  const BACKTEST_METHOD_NAME_GET_PENDING_SIGNAL = "BacktestUtils.getPendingSignal";
30667
+ const BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED = "BacktestUtils.getTotalPercentClosed";
30668
+ const BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED = "BacktestUtils.getTotalCostClosed";
30287
30669
  const BACKTEST_METHOD_NAME_GET_SCHEDULED_SIGNAL = "BacktestUtils.getScheduledSignal";
30288
30670
  const BACKTEST_METHOD_NAME_GET_BREAKEVEN = "BacktestUtils.getBreakeven";
30289
30671
  const BACKTEST_METHOD_NAME_BREAKEVEN = "Backtest.commitBreakeven";
@@ -30685,6 +31067,71 @@ class BacktestUtils {
30685
31067
  }
30686
31068
  return await bt.strategyCoreService.getPendingSignal(true, symbol, context);
30687
31069
  };
31070
+ /**
31071
+ * Returns the percentage of the position currently held (not closed).
31072
+ * 100 = nothing has been closed (full position), 0 = fully closed.
31073
+ * Correctly accounts for DCA entries between partial closes.
31074
+ *
31075
+ * @param symbol - Trading pair symbol
31076
+ * @param context - Context with strategyName, exchangeName, frameName
31077
+ * @returns Promise<number> - held percentage (0–100)
31078
+ *
31079
+ * @example
31080
+ * ```typescript
31081
+ * const heldPct = await Backtest.getTotalPercentClosed("BTCUSDT", { strategyName, exchangeName, frameName });
31082
+ * console.log(`Holding ${heldPct}% of position`);
31083
+ * ```
31084
+ */
31085
+ this.getTotalPercentClosed = async (symbol, context) => {
31086
+ bt.loggerService.info(BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED, {
31087
+ symbol,
31088
+ context,
31089
+ });
31090
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
31091
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
31092
+ {
31093
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
31094
+ riskName &&
31095
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
31096
+ riskList &&
31097
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
31098
+ actions &&
31099
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
31100
+ }
31101
+ return await bt.strategyCoreService.getTotalPercentClosed(true, symbol, context);
31102
+ };
31103
+ /**
31104
+ * Returns the cost basis in dollars of the position currently held (not closed).
31105
+ * Correctly accounts for DCA entries between partial closes.
31106
+ *
31107
+ * @param symbol - Trading pair symbol
31108
+ * @param context - Context with strategyName, exchangeName, frameName
31109
+ * @returns Promise<number> - held cost basis in dollars
31110
+ *
31111
+ * @example
31112
+ * ```typescript
31113
+ * const heldCost = await Backtest.getTotalCostClosed("BTCUSDT", { strategyName, exchangeName, frameName });
31114
+ * console.log(`Holding $${heldCost} of position`);
31115
+ * ```
31116
+ */
31117
+ this.getTotalCostClosed = async (symbol, context) => {
31118
+ bt.loggerService.info(BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED, {
31119
+ symbol,
31120
+ context,
31121
+ });
31122
+ bt.strategyValidationService.validate(context.strategyName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED);
31123
+ bt.exchangeValidationService.validate(context.exchangeName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED);
31124
+ {
31125
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
31126
+ riskName &&
31127
+ bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED);
31128
+ riskList &&
31129
+ riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED));
31130
+ actions &&
31131
+ actions.forEach((actionName) => bt.actionValidationService.validate(actionName, BACKTEST_METHOD_NAME_GET_TOTAL_COST_CLOSED));
31132
+ }
31133
+ return await bt.strategyCoreService.getTotalCostClosed(true, symbol, context);
31134
+ };
30688
31135
  /**
30689
31136
  * Retrieves the currently active scheduled signal for the strategy.
30690
31137
  * If no scheduled signal exists, returns null.
@@ -31400,6 +31847,8 @@ const LIVE_METHOD_NAME_DUMP = "LiveUtils.dump";
31400
31847
  const LIVE_METHOD_NAME_TASK = "LiveUtils.task";
31401
31848
  const LIVE_METHOD_NAME_GET_STATUS = "LiveUtils.getStatus";
31402
31849
  const LIVE_METHOD_NAME_GET_PENDING_SIGNAL = "LiveUtils.getPendingSignal";
31850
+ const LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED = "LiveUtils.getTotalPercentClosed";
31851
+ const LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED = "LiveUtils.getTotalCostClosed";
31403
31852
  const LIVE_METHOD_NAME_GET_SCHEDULED_SIGNAL = "LiveUtils.getScheduledSignal";
31404
31853
  const LIVE_METHOD_NAME_GET_BREAKEVEN = "LiveUtils.getBreakeven";
31405
31854
  const LIVE_METHOD_NAME_BREAKEVEN = "Live.commitBreakeven";
@@ -31770,6 +32219,73 @@ class LiveUtils {
31770
32219
  frameName: "",
31771
32220
  });
31772
32221
  };
32222
+ /**
32223
+ * Returns the percentage of the position currently held (not closed).
32224
+ * 100 = nothing has been closed (full position), 0 = fully closed.
32225
+ * Correctly accounts for DCA entries between partial closes.
32226
+ *
32227
+ * @param symbol - Trading pair symbol
32228
+ * @param context - Context with strategyName and exchangeName
32229
+ * @returns Promise<number> - held percentage (0–100)
32230
+ *
32231
+ * @example
32232
+ * ```typescript
32233
+ * const heldPct = await Live.getTotalPercentClosed("BTCUSDT", { strategyName, exchangeName });
32234
+ * console.log(`Holding ${heldPct}% of position`);
32235
+ * ```
32236
+ */
32237
+ this.getTotalPercentClosed = async (symbol, context) => {
32238
+ bt.loggerService.info(LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED, {
32239
+ symbol,
32240
+ context,
32241
+ });
32242
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
32243
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
32244
+ {
32245
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
32246
+ riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED);
32247
+ riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
32248
+ actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_GET_TOTAL_PERCENT_CLOSED));
32249
+ }
32250
+ return await bt.strategyCoreService.getTotalPercentClosed(false, symbol, {
32251
+ strategyName: context.strategyName,
32252
+ exchangeName: context.exchangeName,
32253
+ frameName: "",
32254
+ });
32255
+ };
32256
+ /**
32257
+ * Returns the cost basis in dollars of the position currently held (not closed).
32258
+ * Correctly accounts for DCA entries between partial closes.
32259
+ *
32260
+ * @param symbol - Trading pair symbol
32261
+ * @param context - Context with strategyName and exchangeName
32262
+ * @returns Promise<number> - held cost basis in dollars
32263
+ *
32264
+ * @example
32265
+ * ```typescript
32266
+ * const heldCost = await Live.getTotalCostClosed("BTCUSDT", { strategyName, exchangeName });
32267
+ * console.log(`Holding $${heldCost} of position`);
32268
+ * ```
32269
+ */
32270
+ this.getTotalCostClosed = async (symbol, context) => {
32271
+ bt.loggerService.info(LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED, {
32272
+ symbol,
32273
+ context,
32274
+ });
32275
+ bt.strategyValidationService.validate(context.strategyName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED);
32276
+ bt.exchangeValidationService.validate(context.exchangeName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED);
32277
+ {
32278
+ const { riskName, riskList, actions } = bt.strategySchemaService.get(context.strategyName);
32279
+ riskName && bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED);
32280
+ riskList && riskList.forEach((riskName) => bt.riskValidationService.validate(riskName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED));
32281
+ actions && actions.forEach((actionName) => bt.actionValidationService.validate(actionName, LIVE_METHOD_NAME_GET_TOTAL_COST_CLOSED));
32282
+ }
32283
+ return await bt.strategyCoreService.getTotalCostClosed(false, symbol, {
32284
+ strategyName: context.strategyName,
32285
+ exchangeName: context.exchangeName,
32286
+ frameName: "",
32287
+ });
32288
+ };
31773
32289
  /**
31774
32290
  * Retrieves the currently active scheduled signal for the strategy.
31775
32291
  * If no scheduled signal exists, returns null.
@@ -39653,6 +40169,7 @@ exports.getActionSchema = getActionSchema;
39653
40169
  exports.getAggregatedTrades = getAggregatedTrades;
39654
40170
  exports.getAveragePrice = getAveragePrice;
39655
40171
  exports.getBacktestTimeframe = getBacktestTimeframe;
40172
+ exports.getBreakeven = getBreakeven;
39656
40173
  exports.getCandles = getCandles;
39657
40174
  exports.getColumns = getColumns;
39658
40175
  exports.getConfig = getConfig;
@@ -39660,17 +40177,23 @@ exports.getContext = getContext;
39660
40177
  exports.getDate = getDate;
39661
40178
  exports.getDefaultColumns = getDefaultColumns;
39662
40179
  exports.getDefaultConfig = getDefaultConfig;
40180
+ exports.getEffectivePriceOpen = getEffectivePriceOpen;
39663
40181
  exports.getExchangeSchema = getExchangeSchema;
39664
40182
  exports.getFrameSchema = getFrameSchema;
39665
40183
  exports.getMode = getMode;
39666
40184
  exports.getNextCandles = getNextCandles;
39667
40185
  exports.getOrderBook = getOrderBook;
40186
+ exports.getPendingSignal = getPendingSignal;
39668
40187
  exports.getRawCandles = getRawCandles;
39669
40188
  exports.getRiskSchema = getRiskSchema;
40189
+ exports.getScheduledSignal = getScheduledSignal;
39670
40190
  exports.getSizingSchema = getSizingSchema;
39671
40191
  exports.getStrategySchema = getStrategySchema;
39672
40192
  exports.getSymbol = getSymbol;
39673
40193
  exports.getTimestamp = getTimestamp;
40194
+ exports.getTotalClosed = getTotalClosed;
40195
+ exports.getTotalCostClosed = getTotalCostClosed;
40196
+ exports.getTotalPercentClosed = getTotalPercentClosed;
39674
40197
  exports.getWalkerSchema = getWalkerSchema;
39675
40198
  exports.hasTradeContext = hasTradeContext;
39676
40199
  exports.lib = backtest;
@@ -39730,6 +40253,7 @@ exports.setConfig = setConfig;
39730
40253
  exports.setLogger = setLogger;
39731
40254
  exports.shutdown = shutdown;
39732
40255
  exports.stopStrategy = stopStrategy;
40256
+ exports.toProfitLossDto = toProfitLossDto;
39733
40257
  exports.validate = validate;
39734
40258
  exports.waitForCandle = waitForCandle;
39735
40259
  exports.warmCandles = warmCandles;