backtest-kit 7.7.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +1804 -1761
- package/build/index.cjs +292 -2
- package/build/index.mjs +292 -3
- package/package.json +86 -86
- package/types.d.ts +93 -2
package/build/index.cjs
CHANGED
|
@@ -3865,6 +3865,27 @@ class ClientExchange {
|
|
|
3865
3865
|
const vwap = sumPriceVolume / totalVolume;
|
|
3866
3866
|
return vwap;
|
|
3867
3867
|
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Returns the close price of the last completed candle for the given interval.
|
|
3870
|
+
*
|
|
3871
|
+
* Fetches a single candle for the requested interval and returns its close price.
|
|
3872
|
+
*
|
|
3873
|
+
* @param symbol - Trading pair symbol
|
|
3874
|
+
* @param interval - Candle time interval (e.g., "1m", "1h")
|
|
3875
|
+
* @returns Promise resolving to close price of the last candle
|
|
3876
|
+
* @throws Error if no candles available
|
|
3877
|
+
*/
|
|
3878
|
+
async getClosePrice(symbol, interval) {
|
|
3879
|
+
this.params.logger.debug(`ClientExchange getClosePrice`, {
|
|
3880
|
+
symbol,
|
|
3881
|
+
interval,
|
|
3882
|
+
});
|
|
3883
|
+
const candles = await this.getCandles(symbol, interval, 1);
|
|
3884
|
+
if (candles.length === 0) {
|
|
3885
|
+
throw new Error(`ClientExchange getClosePrice: no candles data for symbol=${symbol}`);
|
|
3886
|
+
}
|
|
3887
|
+
return candles[candles.length - 1].close;
|
|
3888
|
+
}
|
|
3868
3889
|
/**
|
|
3869
3890
|
* Formats quantity according to exchange-specific rules for the given symbol.
|
|
3870
3891
|
* Applies proper decimal precision and rounding based on symbol's lot size filters.
|
|
@@ -4270,6 +4291,22 @@ class ExchangeConnectionService {
|
|
|
4270
4291
|
});
|
|
4271
4292
|
return await this.getExchange(this.methodContextService.context.exchangeName).getAveragePrice(symbol);
|
|
4272
4293
|
};
|
|
4294
|
+
/**
|
|
4295
|
+
* Returns the close price of the last completed candle for the given interval.
|
|
4296
|
+
*
|
|
4297
|
+
* Routes to exchange determined by methodContextService.context.exchangeName.
|
|
4298
|
+
*
|
|
4299
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
4300
|
+
* @param interval - Candle interval (e.g., "1h", "1d")
|
|
4301
|
+
* @returns Promise resolving to close price of the last candle
|
|
4302
|
+
*/
|
|
4303
|
+
this.getClosePrice = async (symbol, interval) => {
|
|
4304
|
+
this.loggerService.log("exchangeConnectionService getClosePrice", {
|
|
4305
|
+
symbol,
|
|
4306
|
+
interval,
|
|
4307
|
+
});
|
|
4308
|
+
return await this.getExchange(this.methodContextService.context.exchangeName).getClosePrice(symbol, interval);
|
|
4309
|
+
};
|
|
4273
4310
|
/**
|
|
4274
4311
|
* Formats price according to exchange-specific precision rules.
|
|
4275
4312
|
*
|
|
@@ -5508,6 +5545,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
5508
5545
|
_peak: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5509
5546
|
_fall: { price: signal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5510
5547
|
};
|
|
5548
|
+
{
|
|
5549
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, signal.priceOpen);
|
|
5550
|
+
signalRow._fall = { price: signal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
5551
|
+
}
|
|
5511
5552
|
// Валидируем сигнал перед возвратом
|
|
5512
5553
|
validatePendingSignal(signalRow, currentPrice);
|
|
5513
5554
|
return signalRow;
|
|
@@ -5557,6 +5598,10 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
5557
5598
|
_peak: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5558
5599
|
_fall: { price: currentPrice, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
5559
5600
|
};
|
|
5601
|
+
{
|
|
5602
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(signalRow, currentPrice);
|
|
5603
|
+
signalRow._fall = { price: currentPrice, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
5604
|
+
}
|
|
5560
5605
|
// Валидируем сигнал перед возвратом
|
|
5561
5606
|
validatePendingSignal(signalRow, currentPrice);
|
|
5562
5607
|
return signalRow;
|
|
@@ -6269,6 +6314,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_FN = async (self, scheduled, activationTimestamp
|
|
|
6269
6314
|
_peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
6270
6315
|
_fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
6271
6316
|
};
|
|
6317
|
+
{
|
|
6318
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
|
|
6319
|
+
activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
|
|
6320
|
+
}
|
|
6272
6321
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
6273
6322
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
|
|
6274
6323
|
if (!syncOpenAllowed) {
|
|
@@ -7152,6 +7201,10 @@ const ACTIVATE_SCHEDULED_SIGNAL_IN_BACKTEST_FN = async (self, scheduled, activat
|
|
|
7152
7201
|
_peak: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
7153
7202
|
_fall: { price: scheduled.priceOpen, timestamp: activationTime, pnlPercentage: 0, pnlCost: 0, pnlEntries: 0, priceClose: 0, priceOpen: 0 },
|
|
7154
7203
|
};
|
|
7204
|
+
{
|
|
7205
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(activatedSignal, activatedSignal.priceOpen);
|
|
7206
|
+
activatedSignal._fall = { price: activatedSignal.priceOpen, timestamp: activationTime, pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen };
|
|
7207
|
+
}
|
|
7155
7208
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
7156
7209
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(activationTime, activatedSignal.priceOpen, activatedSignal, self);
|
|
7157
7210
|
if (!syncOpenAllowed) {
|
|
@@ -7349,6 +7402,10 @@ const PROCESS_SCHEDULED_SIGNAL_CANDLES_FN = async (self, scheduled, candles, fra
|
|
|
7349
7402
|
_peak: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
7350
7403
|
_fall: { price: activatedSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage: 0, pnlCost: 0, priceClose: 0, priceOpen: 0, pnlEntries: 0 },
|
|
7351
7404
|
};
|
|
7405
|
+
{
|
|
7406
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
|
|
7407
|
+
pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: candle.timestamp, pnlPercentage, pnlCost, priceClose, priceOpen, pnlEntries };
|
|
7408
|
+
}
|
|
7352
7409
|
// Sync open: if external system rejects — cancel scheduled signal instead of opening
|
|
7353
7410
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(candle.timestamp, pendingSignal.priceOpen, pendingSignal, self);
|
|
7354
7411
|
if (!syncOpenAllowed) {
|
|
@@ -8793,6 +8850,10 @@ class ClientStrategy {
|
|
|
8793
8850
|
_peak: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
|
|
8794
8851
|
_fall: { price: activatedSignal.priceOpen, timestamp: currentTime, pnlPercentage: 0, pnlCost: 0, priceClose: 0, pnlEntries: 0, priceOpen: 0 },
|
|
8795
8852
|
};
|
|
8853
|
+
{
|
|
8854
|
+
const { pnlPercentage, pnlCost, pnlEntries, priceClose, priceOpen } = toProfitLossDto(pendingSignal, pendingSignal.priceOpen);
|
|
8855
|
+
pendingSignal._fall = { price: pendingSignal.priceOpen, timestamp: currentTime, pnlPercentage, pnlCost, priceClose, pnlEntries, priceOpen };
|
|
8856
|
+
}
|
|
8796
8857
|
const syncOpenAllowed = await CALL_SIGNAL_SYNC_OPEN_FN(currentTime, currentPrice, pendingSignal, this);
|
|
8797
8858
|
if (!syncOpenAllowed) {
|
|
8798
8859
|
this.params.logger.info("ClientStrategy tick: user-activated signal rejected by sync", {
|
|
@@ -14860,6 +14921,34 @@ class ExchangeCoreService {
|
|
|
14860
14921
|
backtest,
|
|
14861
14922
|
});
|
|
14862
14923
|
};
|
|
14924
|
+
/**
|
|
14925
|
+
* Returns the close price of the last completed candle for the given interval with execution context.
|
|
14926
|
+
*
|
|
14927
|
+
* @param symbol - Trading pair symbol
|
|
14928
|
+
* @param interval - Candle interval (e.g., "1m", "1h")
|
|
14929
|
+
* @param when - Timestamp for context (used in backtest mode)
|
|
14930
|
+
* @param backtest - Whether running in backtest mode
|
|
14931
|
+
* @returns Promise resolving to close price of the last candle
|
|
14932
|
+
*/
|
|
14933
|
+
this.getClosePrice = async (symbol, interval, when, backtest) => {
|
|
14934
|
+
this.loggerService.log("exchangeCoreService getClosePrice", {
|
|
14935
|
+
symbol,
|
|
14936
|
+
interval,
|
|
14937
|
+
when,
|
|
14938
|
+
backtest,
|
|
14939
|
+
});
|
|
14940
|
+
if (!MethodContextService.hasContext()) {
|
|
14941
|
+
throw new Error("exchangeCoreService getClosePrice requires a method context");
|
|
14942
|
+
}
|
|
14943
|
+
await this.validate(this.methodContextService.context.exchangeName);
|
|
14944
|
+
return await ExecutionContextService.runInContext(async () => {
|
|
14945
|
+
return await this.exchangeConnectionService.getClosePrice(symbol, interval);
|
|
14946
|
+
}, {
|
|
14947
|
+
symbol,
|
|
14948
|
+
when,
|
|
14949
|
+
backtest,
|
|
14950
|
+
});
|
|
14951
|
+
};
|
|
14863
14952
|
/**
|
|
14864
14953
|
* Formats price with execution context.
|
|
14865
14954
|
*
|
|
@@ -19328,6 +19417,24 @@ const heat_columns = [
|
|
|
19328
19417
|
format: (data) => data.avgFallPnl !== null ? functoolsKit.str(data.avgFallPnl, "%") : "N/A",
|
|
19329
19418
|
isVisible: () => true,
|
|
19330
19419
|
},
|
|
19420
|
+
{
|
|
19421
|
+
key: "sortinoRatio",
|
|
19422
|
+
label: "Sortino",
|
|
19423
|
+
format: (data) => data.sortinoRatio !== null ? functoolsKit.str(data.sortinoRatio) : "N/A",
|
|
19424
|
+
isVisible: () => true,
|
|
19425
|
+
},
|
|
19426
|
+
{
|
|
19427
|
+
key: "calmarRatio",
|
|
19428
|
+
label: "Calmar",
|
|
19429
|
+
format: (data) => data.calmarRatio !== null ? functoolsKit.str(data.calmarRatio) : "N/A",
|
|
19430
|
+
isVisible: () => true,
|
|
19431
|
+
},
|
|
19432
|
+
{
|
|
19433
|
+
key: "recoveryFactor",
|
|
19434
|
+
label: "Recovery",
|
|
19435
|
+
format: (data) => data.recoveryFactor !== null ? functoolsKit.str(data.recoveryFactor) : "N/A",
|
|
19436
|
+
isVisible: () => true,
|
|
19437
|
+
},
|
|
19331
19438
|
];
|
|
19332
19439
|
|
|
19333
19440
|
/**
|
|
@@ -20890,6 +20997,30 @@ const walker_strategy_columns = [
|
|
|
20890
20997
|
: "N/A",
|
|
20891
20998
|
isVisible: () => true,
|
|
20892
20999
|
},
|
|
21000
|
+
{
|
|
21001
|
+
key: "sortinoRatio",
|
|
21002
|
+
label: "Sortino",
|
|
21003
|
+
format: (data) => data.stats.sortinoRatio !== null
|
|
21004
|
+
? `${data.stats.sortinoRatio.toFixed(3)}`
|
|
21005
|
+
: "N/A",
|
|
21006
|
+
isVisible: () => true,
|
|
21007
|
+
},
|
|
21008
|
+
{
|
|
21009
|
+
key: "calmarRatio",
|
|
21010
|
+
label: "Calmar",
|
|
21011
|
+
format: (data) => data.stats.calmarRatio !== null
|
|
21012
|
+
? `${data.stats.calmarRatio.toFixed(3)}`
|
|
21013
|
+
: "N/A",
|
|
21014
|
+
isVisible: () => true,
|
|
21015
|
+
},
|
|
21016
|
+
{
|
|
21017
|
+
key: "recoveryFactor",
|
|
21018
|
+
label: "Recovery",
|
|
21019
|
+
format: (data) => data.stats.recoveryFactor !== null
|
|
21020
|
+
? `${data.stats.recoveryFactor.toFixed(3)}`
|
|
21021
|
+
: "N/A",
|
|
21022
|
+
isVisible: () => true,
|
|
21023
|
+
},
|
|
20893
21024
|
{
|
|
20894
21025
|
key: "firstEventTime",
|
|
20895
21026
|
label: "First Event",
|
|
@@ -21439,13 +21570,13 @@ class ReportBase {
|
|
|
21439
21570
|
* Waits for drain event if write buffer is full.
|
|
21440
21571
|
* Times out after 15 seconds and returns TIMEOUT_SYMBOL.
|
|
21441
21572
|
*/
|
|
21442
|
-
this[_d] = functoolsKit.timeout(async (line) => {
|
|
21573
|
+
this[_d] = functoolsKit.queued(functoolsKit.timeout(async (line) => {
|
|
21443
21574
|
if (!this._stream.write(line)) {
|
|
21444
21575
|
await new Promise((resolve) => {
|
|
21445
21576
|
this._stream.once("drain", resolve);
|
|
21446
21577
|
});
|
|
21447
21578
|
}
|
|
21448
|
-
}, 15000);
|
|
21579
|
+
}, 15000));
|
|
21449
21580
|
LOGGER_SERVICE$3.debug(REPORT_BASE_METHOD_NAME_CTOR, {
|
|
21450
21581
|
reportName: this.reportName,
|
|
21451
21582
|
baseDir,
|
|
@@ -21733,6 +21864,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21733
21864
|
expectedYearlyReturns: null,
|
|
21734
21865
|
avgPeakPnl: null,
|
|
21735
21866
|
avgFallPnl: null,
|
|
21867
|
+
sortinoRatio: null,
|
|
21868
|
+
calmarRatio: null,
|
|
21869
|
+
recoveryFactor: null,
|
|
21736
21870
|
};
|
|
21737
21871
|
}
|
|
21738
21872
|
const totalSignals = this._signalList.length;
|
|
@@ -21766,6 +21900,16 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21766
21900
|
// Calculate average peak and fall PNL across all signals
|
|
21767
21901
|
const avgPeakPnl = this._signalList.reduce((sum, s) => sum + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / totalSignals;
|
|
21768
21902
|
const avgFallPnl = this._signalList.reduce((sum, s) => sum + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / totalSignals;
|
|
21903
|
+
// Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
|
|
21904
|
+
const fallReturns = this._signalList.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
|
|
21905
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
21906
|
+
const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalSignals;
|
|
21907
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
21908
|
+
const sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
|
|
21909
|
+
// Avg absolute peak drawdown per signal — used as denominator for Calmar and Recovery
|
|
21910
|
+
const avgAbsFall = fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalSignals;
|
|
21911
|
+
const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
|
|
21912
|
+
const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
|
|
21769
21913
|
return {
|
|
21770
21914
|
signalList: this._signalList,
|
|
21771
21915
|
totalSignals,
|
|
@@ -21781,6 +21925,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21781
21925
|
expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
21782
21926
|
avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
|
|
21783
21927
|
avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
|
|
21928
|
+
sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
|
|
21929
|
+
calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
|
|
21930
|
+
recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
|
|
21784
21931
|
};
|
|
21785
21932
|
}
|
|
21786
21933
|
/**
|
|
@@ -21827,6 +21974,9 @@ let ReportStorage$a = class ReportStorage {
|
|
|
21827
21974
|
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
21828
21975
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
21829
21976
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
21977
|
+
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
21978
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
21979
|
+
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
21830
21980
|
].join("\n");
|
|
21831
21981
|
}
|
|
21832
21982
|
/**
|
|
@@ -22427,6 +22577,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22427
22577
|
expectedYearlyReturns: null,
|
|
22428
22578
|
avgPeakPnl: null,
|
|
22429
22579
|
avgFallPnl: null,
|
|
22580
|
+
sortinoRatio: null,
|
|
22581
|
+
calmarRatio: null,
|
|
22582
|
+
recoveryFactor: null,
|
|
22430
22583
|
};
|
|
22431
22584
|
}
|
|
22432
22585
|
const closedEvents = this._eventList.filter((e) => e.action === "closed");
|
|
@@ -22476,6 +22629,21 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22476
22629
|
const avgFallPnl = totalClosed > 0
|
|
22477
22630
|
? closedEvents.reduce((sum, e) => sum + (e.fallPnl || 0), 0) / totalClosed
|
|
22478
22631
|
: 0;
|
|
22632
|
+
// Downside per signal: fallPnl captures the worst intra-trade dip (maxDrawdown.pnlPercentage)
|
|
22633
|
+
const fallReturns = closedEvents.map((e) => e.fallPnl || 0);
|
|
22634
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
22635
|
+
let sortinoRatio = 0;
|
|
22636
|
+
if (totalClosed > 0) {
|
|
22637
|
+
const fallVariance = fallReturns.reduce((sum, r) => sum + Math.pow(r, 2), 0) / totalClosed;
|
|
22638
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
22639
|
+
sortinoRatio = fallDeviation > 0 ? avgPnl / fallDeviation : 0;
|
|
22640
|
+
}
|
|
22641
|
+
// Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
|
|
22642
|
+
const avgAbsFall = totalClosed > 0
|
|
22643
|
+
? fallReturns.reduce((sum, r) => sum + Math.abs(r), 0) / totalClosed
|
|
22644
|
+
: 0;
|
|
22645
|
+
const calmarRatio = avgAbsFall > 0 ? expectedYearlyReturns / avgAbsFall : 0;
|
|
22646
|
+
const recoveryFactor = avgAbsFall > 0 ? totalPnl / avgAbsFall : 0;
|
|
22479
22647
|
return {
|
|
22480
22648
|
eventList: this._eventList,
|
|
22481
22649
|
totalEvents: this._eventList.length,
|
|
@@ -22492,6 +22660,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22492
22660
|
expectedYearlyReturns: isUnsafe$2(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
22493
22661
|
avgPeakPnl: isUnsafe$2(avgPeakPnl) ? null : avgPeakPnl,
|
|
22494
22662
|
avgFallPnl: isUnsafe$2(avgFallPnl) ? null : avgFallPnl,
|
|
22663
|
+
sortinoRatio: isUnsafe$2(sortinoRatio) ? null : sortinoRatio,
|
|
22664
|
+
calmarRatio: isUnsafe$2(calmarRatio) ? null : calmarRatio,
|
|
22665
|
+
recoveryFactor: isUnsafe$2(recoveryFactor) ? null : recoveryFactor,
|
|
22495
22666
|
};
|
|
22496
22667
|
}
|
|
22497
22668
|
/**
|
|
@@ -22538,6 +22709,9 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
22538
22709
|
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
22539
22710
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
22540
22711
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
22712
|
+
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
22713
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
22714
|
+
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
22541
22715
|
].join("\n");
|
|
22542
22716
|
}
|
|
22543
22717
|
/**
|
|
@@ -23983,6 +24157,9 @@ let ReportStorage$7 = class ReportStorage {
|
|
|
23983
24157
|
"",
|
|
23984
24158
|
`**Best ${results.metric}:** ${formatMetric(results.bestMetric)}`,
|
|
23985
24159
|
`**Total Signals:** ${bestStrategySignals}`,
|
|
24160
|
+
`**Sortino Ratio:** ${results.bestStats?.sortinoRatio != null ? `${results.bestStats.sortinoRatio.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24161
|
+
`**Calmar Ratio:** ${results.bestStats?.calmarRatio != null ? `${results.bestStats.calmarRatio.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
24162
|
+
`**Recovery Factor:** ${results.bestStats?.recoveryFactor != null ? `${results.bestStats.recoveryFactor.toFixed(3)} (higher is better)` : "N/A"}`,
|
|
23986
24163
|
"",
|
|
23987
24164
|
"## Top Strategies Comparison",
|
|
23988
24165
|
"",
|
|
@@ -24449,6 +24626,27 @@ class HeatmapStorage {
|
|
|
24449
24626
|
avgPeakPnl = signals.reduce((acc, s) => acc + (s.signal.peakProfit?.pnlPercentage ?? 0), 0) / signals.length;
|
|
24450
24627
|
avgFallPnl = signals.reduce((acc, s) => acc + (s.signal.maxDrawdown?.pnlPercentage ?? 0), 0) / signals.length;
|
|
24451
24628
|
}
|
|
24629
|
+
// Downside per signal: maxDrawdown.pnlPercentage captures the worst intra-trade dip
|
|
24630
|
+
const fallReturns = signals.map((s) => s.signal.maxDrawdown?.pnlPercentage ?? 0);
|
|
24631
|
+
// Calculate Sortino Ratio: avgPnl / stdDev(maxDrawdown per signal)
|
|
24632
|
+
let sortinoRatio = null;
|
|
24633
|
+
if (signals.length > 0 && avgPnl !== null) {
|
|
24634
|
+
const fallVariance = fallReturns.reduce((acc, r) => acc + Math.pow(r, 2), 0) / signals.length;
|
|
24635
|
+
const fallDeviation = Math.sqrt(fallVariance);
|
|
24636
|
+
if (fallDeviation > 0) {
|
|
24637
|
+
sortinoRatio = avgPnl / fallDeviation;
|
|
24638
|
+
}
|
|
24639
|
+
}
|
|
24640
|
+
// Avg absolute peak drawdown per signal — denominator for Calmar and Recovery
|
|
24641
|
+
const avgAbsFall = signals.length > 0
|
|
24642
|
+
? fallReturns.reduce((acc, r) => acc + Math.abs(r), 0) / signals.length
|
|
24643
|
+
: 0;
|
|
24644
|
+
let calmarRatio = null;
|
|
24645
|
+
let recoveryFactor = null;
|
|
24646
|
+
if (avgAbsFall > 0 && totalPnl !== null) {
|
|
24647
|
+
calmarRatio = totalPnl / avgAbsFall;
|
|
24648
|
+
recoveryFactor = totalPnl / avgAbsFall;
|
|
24649
|
+
}
|
|
24452
24650
|
// Apply safe math checks
|
|
24453
24651
|
if (isUnsafe(winRate))
|
|
24454
24652
|
winRate = null;
|
|
@@ -24474,6 +24672,12 @@ class HeatmapStorage {
|
|
|
24474
24672
|
avgPeakPnl = null;
|
|
24475
24673
|
if (isUnsafe(avgFallPnl))
|
|
24476
24674
|
avgFallPnl = null;
|
|
24675
|
+
if (isUnsafe(sortinoRatio))
|
|
24676
|
+
sortinoRatio = null;
|
|
24677
|
+
if (isUnsafe(calmarRatio))
|
|
24678
|
+
calmarRatio = null;
|
|
24679
|
+
if (isUnsafe(recoveryFactor))
|
|
24680
|
+
recoveryFactor = null;
|
|
24477
24681
|
return {
|
|
24478
24682
|
symbol,
|
|
24479
24683
|
totalPnl,
|
|
@@ -24493,6 +24697,9 @@ class HeatmapStorage {
|
|
|
24493
24697
|
expectancy,
|
|
24494
24698
|
avgPeakPnl,
|
|
24495
24699
|
avgFallPnl,
|
|
24700
|
+
sortinoRatio,
|
|
24701
|
+
calmarRatio,
|
|
24702
|
+
recoveryFactor,
|
|
24496
24703
|
};
|
|
24497
24704
|
}
|
|
24498
24705
|
/**
|
|
@@ -29359,6 +29566,9 @@ class WalkerReportService {
|
|
|
29359
29566
|
annualizedSharpeRatio: data.stats.annualizedSharpeRatio,
|
|
29360
29567
|
certaintyRatio: data.stats.certaintyRatio,
|
|
29361
29568
|
expectedYearlyReturns: data.stats.expectedYearlyReturns,
|
|
29569
|
+
sortinoRatio: data.stats.sortinoRatio,
|
|
29570
|
+
calmarRatio: data.stats.calmarRatio,
|
|
29571
|
+
recoveryFactor: data.stats.recoveryFactor,
|
|
29362
29572
|
firstEventTime,
|
|
29363
29573
|
lastEventTime,
|
|
29364
29574
|
}, {
|
|
@@ -33548,6 +33758,7 @@ async function getBacktestTimeframe(symbol) {
|
|
|
33548
33758
|
|
|
33549
33759
|
const EXCHANGE_METHOD_NAME_GET_CANDLES = "ExchangeUtils.getCandles";
|
|
33550
33760
|
const EXCHANGE_METHOD_NAME_GET_AVERAGE_PRICE = "ExchangeUtils.getAveragePrice";
|
|
33761
|
+
const EXCHANGE_METHOD_NAME_GET_CLOSE_PRICE = "ExchangeUtils.getClosePrice";
|
|
33551
33762
|
const EXCHANGE_METHOD_NAME_FORMAT_QUANTITY = "ExchangeUtils.formatQuantity";
|
|
33552
33763
|
const EXCHANGE_METHOD_NAME_FORMAT_PRICE = "ExchangeUtils.formatPrice";
|
|
33553
33764
|
const EXCHANGE_METHOD_NAME_GET_ORDER_BOOK = "ExchangeUtils.getOrderBook";
|
|
@@ -33905,6 +34116,35 @@ class ExchangeInstance {
|
|
|
33905
34116
|
const vwap = sumPriceVolume / totalVolume;
|
|
33906
34117
|
return vwap;
|
|
33907
34118
|
};
|
|
34119
|
+
/**
|
|
34120
|
+
* Returns the close price of the last completed candle for the given interval.
|
|
34121
|
+
*
|
|
34122
|
+
* Fetches a single candle for the requested interval and returns its close price.
|
|
34123
|
+
*
|
|
34124
|
+
* @param symbol - Trading pair symbol
|
|
34125
|
+
* @param interval - Candle time interval (e.g., "1m", "1h")
|
|
34126
|
+
* @returns Promise resolving to close price of the last candle
|
|
34127
|
+
* @throws Error if no candles available
|
|
34128
|
+
*
|
|
34129
|
+
* @example
|
|
34130
|
+
* ```typescript
|
|
34131
|
+
* const instance = new ExchangeInstance("binance");
|
|
34132
|
+
* const close = await instance.getClosePrice("BTCUSDT", "1h");
|
|
34133
|
+
* console.log(close); // 50125.43
|
|
34134
|
+
* ```
|
|
34135
|
+
*/
|
|
34136
|
+
this.getClosePrice = async (symbol, interval) => {
|
|
34137
|
+
backtest.loggerService.debug(`ExchangeInstance getClosePrice`, {
|
|
34138
|
+
exchangeName: this.exchangeName,
|
|
34139
|
+
symbol,
|
|
34140
|
+
interval,
|
|
34141
|
+
});
|
|
34142
|
+
const candles = await this.getCandles(symbol, interval, 1);
|
|
34143
|
+
if (candles.length === 0) {
|
|
34144
|
+
throw new Error(`ExchangeInstance getClosePrice: no candles data for symbol=${symbol}`);
|
|
34145
|
+
}
|
|
34146
|
+
return candles[candles.length - 1].close;
|
|
34147
|
+
};
|
|
33908
34148
|
/**
|
|
33909
34149
|
* Format quantity according to exchange precision rules.
|
|
33910
34150
|
*
|
|
@@ -34262,6 +34502,28 @@ class ExchangeUtils {
|
|
|
34262
34502
|
const instance = this._getInstance(context.exchangeName);
|
|
34263
34503
|
return await instance.getAveragePrice(symbol);
|
|
34264
34504
|
};
|
|
34505
|
+
/**
|
|
34506
|
+
* Returns the close price of the last completed candle for the given interval.
|
|
34507
|
+
*
|
|
34508
|
+
* @param symbol - Trading pair symbol
|
|
34509
|
+
* @param interval - Candle time interval (e.g., "1m", "1h")
|
|
34510
|
+
* @param context - Execution context with exchange name
|
|
34511
|
+
* @returns Promise resolving to close price of the last candle
|
|
34512
|
+
* @throws Error if no candles available
|
|
34513
|
+
*
|
|
34514
|
+
* @example
|
|
34515
|
+
* ```typescript
|
|
34516
|
+
* const close = await Exchange.getClosePrice("BTCUSDT", "1h", {
|
|
34517
|
+
* exchangeName: "binance"
|
|
34518
|
+
* });
|
|
34519
|
+
* console.log(close); // 50125.43
|
|
34520
|
+
* ```
|
|
34521
|
+
*/
|
|
34522
|
+
this.getClosePrice = async (symbol, interval, context) => {
|
|
34523
|
+
backtest.exchangeValidationService.validate(context.exchangeName, EXCHANGE_METHOD_NAME_GET_CLOSE_PRICE);
|
|
34524
|
+
const instance = this._getInstance(context.exchangeName);
|
|
34525
|
+
return await instance.getClosePrice(symbol, interval);
|
|
34526
|
+
};
|
|
34265
34527
|
/**
|
|
34266
34528
|
* Format quantity according to exchange precision rules.
|
|
34267
34529
|
*
|
|
@@ -34848,6 +35110,7 @@ function getActionSchema(actionName) {
|
|
|
34848
35110
|
|
|
34849
35111
|
const GET_CANDLES_METHOD_NAME = "exchange.getCandles";
|
|
34850
35112
|
const GET_AVERAGE_PRICE_METHOD_NAME = "exchange.getAveragePrice";
|
|
35113
|
+
const GET_CLOSE_PRICE_METHOD_NAME = "exchange.getClosePrice";
|
|
34851
35114
|
const FORMAT_PRICE_METHOD_NAME = "exchange.formatPrice";
|
|
34852
35115
|
const FORMAT_QUANTITY_METHOD_NAME = "exchange.formatQuantity";
|
|
34853
35116
|
const GET_DATE_METHOD_NAME = "exchange.getDate";
|
|
@@ -34945,6 +35208,32 @@ async function getAveragePrice(symbol) {
|
|
|
34945
35208
|
}
|
|
34946
35209
|
return await backtest.exchangeConnectionService.getAveragePrice(symbol);
|
|
34947
35210
|
}
|
|
35211
|
+
/**
|
|
35212
|
+
* Returns the close price of the last completed candle for the given interval.
|
|
35213
|
+
*
|
|
35214
|
+
* @param symbol - Trading pair symbol (e.g., "BTCUSDT")
|
|
35215
|
+
* @param interval - Candle interval ("1m" | "3m" | "5m" | "15m" | "30m" | "1h" | "2h" | "4h" | "6h" | "8h")
|
|
35216
|
+
* @returns Promise resolving to close price of the last candle
|
|
35217
|
+
*
|
|
35218
|
+
* @example
|
|
35219
|
+
* ```typescript
|
|
35220
|
+
* const close = await getClosePrice("BTCUSDT", "1h");
|
|
35221
|
+
* console.log(close); // 50125.43
|
|
35222
|
+
* ```
|
|
35223
|
+
*/
|
|
35224
|
+
async function getClosePrice(symbol, interval) {
|
|
35225
|
+
backtest.loggerService.info(GET_CLOSE_PRICE_METHOD_NAME, {
|
|
35226
|
+
symbol,
|
|
35227
|
+
interval,
|
|
35228
|
+
});
|
|
35229
|
+
if (!ExecutionContextService.hasContext()) {
|
|
35230
|
+
throw new Error("getClosePrice requires an execution context");
|
|
35231
|
+
}
|
|
35232
|
+
if (!MethodContextService.hasContext()) {
|
|
35233
|
+
throw new Error("getClosePrice requires a method context");
|
|
35234
|
+
}
|
|
35235
|
+
return await backtest.exchangeConnectionService.getClosePrice(symbol, interval);
|
|
35236
|
+
}
|
|
34948
35237
|
/**
|
|
34949
35238
|
* Formats a price value according to exchange rules.
|
|
34950
35239
|
*
|
|
@@ -61690,6 +61979,7 @@ exports.getAveragePrice = getAveragePrice;
|
|
|
61690
61979
|
exports.getBacktestTimeframe = getBacktestTimeframe;
|
|
61691
61980
|
exports.getBreakeven = getBreakeven;
|
|
61692
61981
|
exports.getCandles = getCandles;
|
|
61982
|
+
exports.getClosePrice = getClosePrice;
|
|
61693
61983
|
exports.getColumns = getColumns;
|
|
61694
61984
|
exports.getConfig = getConfig;
|
|
61695
61985
|
exports.getContext = getContext;
|