backtest-kit 10.1.0 → 11.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 +1995 -1995
- package/build/index.cjs +747 -238
- package/build/index.mjs +751 -242
- package/package.json +86 -86
- package/types.d.ts +44 -9
package/build/index.cjs
CHANGED
|
@@ -818,6 +818,13 @@ const beforeStartSubject = new functoolsKit.Subject();
|
|
|
818
818
|
* Emits when the engine has completed processing a signal.
|
|
819
819
|
*/
|
|
820
820
|
const afterEndSubject = new functoolsKit.Subject();
|
|
821
|
+
/**
|
|
822
|
+
* Emitter for `@backtest-kit/cli`, which notifies the application
|
|
823
|
+
* that all modules have been initialized.
|
|
824
|
+
*
|
|
825
|
+
* Send entry absolute path to the consumer
|
|
826
|
+
*/
|
|
827
|
+
const entrySubject = new functoolsKit.BehaviorSubject();
|
|
821
828
|
|
|
822
829
|
var emitters = /*#__PURE__*/Object.freeze({
|
|
823
830
|
__proto__: null,
|
|
@@ -829,6 +836,7 @@ var emitters = /*#__PURE__*/Object.freeze({
|
|
|
829
836
|
doneBacktestSubject: doneBacktestSubject,
|
|
830
837
|
doneLiveSubject: doneLiveSubject,
|
|
831
838
|
doneWalkerSubject: doneWalkerSubject,
|
|
839
|
+
entrySubject: entrySubject,
|
|
832
840
|
errorEmitter: errorEmitter,
|
|
833
841
|
exitEmitter: exitEmitter,
|
|
834
842
|
highestProfitSubject: highestProfitSubject,
|
|
@@ -6584,7 +6592,7 @@ const INTERVAL_MINUTES$8 = {
|
|
|
6584
6592
|
* Used to indicate that the actual pendingAt will be set upon activation.
|
|
6585
6593
|
*/
|
|
6586
6594
|
const SCHEDULED_SIGNAL_PENDING_MOCK = 0;
|
|
6587
|
-
const TIMEOUT_SYMBOL = Symbol('timeout');
|
|
6595
|
+
const TIMEOUT_SYMBOL$1 = Symbol('timeout');
|
|
6588
6596
|
/**
|
|
6589
6597
|
* Calls onSignalSync callback for signal-open event.
|
|
6590
6598
|
*
|
|
@@ -7006,7 +7014,7 @@ const GET_SIGNAL_FN = functoolsKit.trycatch(async (self) => {
|
|
|
7006
7014
|
const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
|
|
7007
7015
|
const signal = await Promise.race([
|
|
7008
7016
|
self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
|
|
7009
|
-
functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL),
|
|
7017
|
+
functoolsKit.sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
|
|
7010
7018
|
]);
|
|
7011
7019
|
if (typeof signal === "symbol") {
|
|
7012
7020
|
throw new Error(`Timeout for ${self.params.method.context.strategyName} symbol=${self.params.execution.context.symbol}`);
|
|
@@ -23726,7 +23734,7 @@ const CREATE_FILE_NAME_FN$c = (symbol, strategyName, exchangeName, frameName, ti
|
|
|
23726
23734
|
* @param value - Value to check
|
|
23727
23735
|
* @returns true if value is unsafe, false otherwise
|
|
23728
23736
|
*/
|
|
23729
|
-
function isUnsafe$
|
|
23737
|
+
function isUnsafe$4(value) {
|
|
23730
23738
|
if (typeof value !== "number") {
|
|
23731
23739
|
return true;
|
|
23732
23740
|
}
|
|
@@ -23738,6 +23746,25 @@ function isUnsafe$3(value) {
|
|
|
23738
23746
|
}
|
|
23739
23747
|
return false;
|
|
23740
23748
|
}
|
|
23749
|
+
/** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
|
|
23750
|
+
const MIN_SIGNALS_FOR_ANNUALIZATION$2 = 10;
|
|
23751
|
+
/** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
|
|
23752
|
+
* sample size is too small to estimate variance meaningfully. */
|
|
23753
|
+
const MIN_SIGNALS_FOR_RATIOS$2 = 10;
|
|
23754
|
+
/** Minimum calendar span (days) for trade-frequency extrapolation. */
|
|
23755
|
+
const MIN_CALENDAR_SPAN_DAYS$2 = 14;
|
|
23756
|
+
/** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
|
|
23757
|
+
const MAX_TRADES_PER_YEAR$2 = 365;
|
|
23758
|
+
/** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
|
|
23759
|
+
* blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
|
|
23760
|
+
* anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
|
|
23761
|
+
const MAX_EXPECTED_YEARLY_RETURNS$2 = 100;
|
|
23762
|
+
/** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
|
|
23763
|
+
const MAX_CALMAR_RATIO$2 = 1000;
|
|
23764
|
+
/** Minimum stdDev required for Sharpe/Sortino computation. Identical-returns series produce
|
|
23765
|
+
* float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously inflates
|
|
23766
|
+
* sharpe to astronomical values. Treat any stdDev below this threshold as zero. */
|
|
23767
|
+
const STDDEV_EPSILON$2 = 1e-9;
|
|
23741
23768
|
/**
|
|
23742
23769
|
* Storage class for accumulating closed signals per strategy.
|
|
23743
23770
|
* Maintains a list of all closed signals and provides methods to generate reports.
|
|
@@ -23791,65 +23818,190 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23791
23818
|
recoveryFactor: null,
|
|
23792
23819
|
};
|
|
23793
23820
|
}
|
|
23794
|
-
|
|
23795
|
-
|
|
23796
|
-
|
|
23797
|
-
//
|
|
23798
|
-
|
|
23799
|
-
|
|
23800
|
-
|
|
23801
|
-
|
|
23802
|
-
|
|
23803
|
-
const
|
|
23804
|
-
const
|
|
23805
|
-
const
|
|
23806
|
-
|
|
23807
|
-
//
|
|
23808
|
-
const
|
|
23809
|
-
|
|
23821
|
+
// Valid signal set — those with usable pendingAt AND closeTimestamp. Single source
|
|
23822
|
+
// of truth for EVERY metric in this method (counts, sums, span, equity curve,
|
|
23823
|
+
// ratios, annualization). If we used different subsets for different metrics, the
|
|
23824
|
+
// numerator of one ratio could be drawn from a different population than the
|
|
23825
|
+
// denominator of another and the report would silently lie. On clean data
|
|
23826
|
+
// validSignals === this._signalList; the filter only matters for corrupted runtime
|
|
23827
|
+
// data.
|
|
23828
|
+
const validSignals = this._signalList.filter((s) => typeof s.signal.pendingAt === "number" && s.signal.pendingAt > 0 &&
|
|
23829
|
+
typeof s.closeTimestamp === "number" && s.closeTimestamp > 0);
|
|
23830
|
+
const totalSignals = validSignals.length;
|
|
23831
|
+
const winCount = validSignals.filter((s) => s.pnl.pnlPercentage > 0).length;
|
|
23832
|
+
const lossCount = validSignals.filter((s) => s.pnl.pnlPercentage < 0).length;
|
|
23833
|
+
// Basic statistics — guard against an empty validSignals (e.g. every signal had
|
|
23834
|
+
// corrupted timestamps) so we don't divide by zero.
|
|
23835
|
+
const avgPnl = totalSignals > 0
|
|
23836
|
+
? validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / totalSignals
|
|
23837
|
+
: 0;
|
|
23838
|
+
const totalPnl = validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0);
|
|
23839
|
+
// Win rate excludes break-even trades from both numerator and denominator.
|
|
23840
|
+
const decisiveTrades = winCount + lossCount;
|
|
23841
|
+
const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
|
|
23842
|
+
// Calendar span over the same validSignals set used for ratios.
|
|
23843
|
+
let firstPendingAt = Infinity;
|
|
23844
|
+
let lastCloseAt = -Infinity;
|
|
23845
|
+
for (const s of validSignals) {
|
|
23846
|
+
if (s.signal.pendingAt < firstPendingAt)
|
|
23847
|
+
firstPendingAt = s.signal.pendingAt;
|
|
23848
|
+
if (s.closeTimestamp > lastCloseAt)
|
|
23849
|
+
lastCloseAt = s.closeTimestamp;
|
|
23850
|
+
}
|
|
23851
|
+
const calendarSpanDays = isFinite(firstPendingAt) && isFinite(lastCloseAt)
|
|
23852
|
+
? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
|
|
23853
|
+
: 0;
|
|
23854
|
+
// tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
|
|
23855
|
+
// silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
|
|
23856
|
+
// raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
|
|
23857
|
+
// for reliable annualization and surface every annualized metric as null.
|
|
23858
|
+
const rawTradesPerYear = totalSignals >= MIN_SIGNALS_FOR_ANNUALIZATION$2 &&
|
|
23859
|
+
calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$2
|
|
23860
|
+
? (totalSignals / calendarSpanDays) * 365
|
|
23861
|
+
: 0;
|
|
23862
|
+
const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$2;
|
|
23863
|
+
const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
|
|
23864
|
+
// Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1) for unbiased estimate.
|
|
23865
|
+
// Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
|
|
23866
|
+
// are too noisy to publish (high chance of spurious ±Sharpe).
|
|
23867
|
+
const returns = validSignals.map((s) => s.pnl.pnlPercentage);
|
|
23868
|
+
const canComputeRatios = totalSignals >= MIN_SIGNALS_FOR_RATIOS$2;
|
|
23869
|
+
const stdDev = canComputeRatios
|
|
23870
|
+
? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (totalSignals - 1))
|
|
23871
|
+
: 0;
|
|
23872
|
+
// Use STDDEV_EPSILON gate (not stdDev > 0) — identical-returns series produce
|
|
23873
|
+
// float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously
|
|
23874
|
+
// inflates sharpe to astronomical magnitudes (avgPnl / epsilon).
|
|
23875
|
+
const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$2
|
|
23876
|
+
? avgPnl / stdDev
|
|
23877
|
+
: null;
|
|
23878
|
+
// Annualize only when gate passes; otherwise null.
|
|
23879
|
+
const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
|
|
23880
|
+
? sharpeRatio * Math.sqrt(tradesPerYear)
|
|
23881
|
+
: null;
|
|
23882
|
+
// Equity-curve max drawdown via compounded equity (multiplicative, not additive).
|
|
23883
|
+
// Returns are per-trade on cost basis — compounding assumes equal capital allocation
|
|
23884
|
+
// per trade ("as-if 100% allocation"). Walks validSignals in chronological order
|
|
23885
|
+
// (storage is newest-first, so iterate in reverse). Using validSignals (same set as
|
|
23886
|
+
// tradesPerYear) keeps equityFinal consistent with the annualization exponent.
|
|
23887
|
+
// If equity goes ≤ 0 (e.g. leveraged short with r < -100%) — account blown,
|
|
23888
|
+
// fix DD at 100% and stop walking the curve.
|
|
23889
|
+
let equity = 1;
|
|
23890
|
+
let peak = 1;
|
|
23891
|
+
let equityMaxDrawdown = 0;
|
|
23892
|
+
let blown = false;
|
|
23893
|
+
for (let i = validSignals.length - 1; i >= 0; i--) {
|
|
23894
|
+
equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
|
|
23895
|
+
if (equity <= 0) {
|
|
23896
|
+
equityMaxDrawdown = 100;
|
|
23897
|
+
blown = true;
|
|
23898
|
+
break;
|
|
23899
|
+
}
|
|
23900
|
+
if (equity > peak)
|
|
23901
|
+
peak = equity;
|
|
23902
|
+
const dd = (peak - equity) / peak * 100;
|
|
23903
|
+
if (dd > equityMaxDrawdown)
|
|
23904
|
+
equityMaxDrawdown = dd;
|
|
23905
|
+
}
|
|
23906
|
+
const equityFinal = blown ? 0 : equity;
|
|
23907
|
+
// Compounded yearly return via geometric mean of equity curve.
|
|
23908
|
+
// equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag that
|
|
23909
|
+
// arithmetic-mean compounding ((1+avgPnl)^N) misses. If account is blown, full loss.
|
|
23910
|
+
// If the raw value would exceed MAX_EXPECTED_YEARLY_RETURNS, return null rather than
|
|
23911
|
+
// showing the cap as a real figure — capped numbers mislead users into trusting them.
|
|
23912
|
+
const expectedYearlyReturns = canAnnualize
|
|
23913
|
+
? blown
|
|
23914
|
+
? -100
|
|
23915
|
+
: (() => {
|
|
23916
|
+
// Geometric annualization uses validSignals.length (same set that defined
|
|
23917
|
+
// tradesPerYear); using totalSignals here would mismatch numerator/denominator.
|
|
23918
|
+
const raw = (Math.pow(equityFinal, tradesPerYear / validSignals.length) - 1) * 100;
|
|
23919
|
+
return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$2 ? null : raw;
|
|
23920
|
+
})()
|
|
23921
|
+
: null;
|
|
23922
|
+
// Certainty Ratio — over validSignals so wins/losses come from the same set as
|
|
23923
|
+
// winCount/lossCount/avgPnl above.
|
|
23924
|
+
const wins = validSignals.filter((s) => s.pnl.pnlPercentage > 0);
|
|
23925
|
+
const losses = validSignals.filter((s) => s.pnl.pnlPercentage < 0);
|
|
23810
23926
|
const avgWin = wins.length > 0
|
|
23811
23927
|
? wins.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / wins.length
|
|
23812
23928
|
: 0;
|
|
23813
23929
|
const avgLoss = losses.length > 0
|
|
23814
23930
|
? losses.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / losses.length
|
|
23815
23931
|
: 0;
|
|
23816
|
-
|
|
23817
|
-
//
|
|
23818
|
-
|
|
23819
|
-
|
|
23820
|
-
|
|
23821
|
-
|
|
23822
|
-
|
|
23823
|
-
|
|
23824
|
-
|
|
23825
|
-
//
|
|
23826
|
-
|
|
23827
|
-
|
|
23828
|
-
|
|
23829
|
-
|
|
23830
|
-
const
|
|
23831
|
-
|
|
23832
|
-
|
|
23833
|
-
const
|
|
23834
|
-
|
|
23932
|
+
// Null below MIN_SIGNALS_FOR_RATIOS — on a handful of trades the win/loss
|
|
23933
|
+
// means are too noisy to publish a ratio (same sample-size gate as Sharpe/
|
|
23934
|
+
// Sortino, so the report doesn't surface certainty while withholding the rest).
|
|
23935
|
+
// Also null when no losing trades OR when |avgLoss| is below STDDEV_EPSILON
|
|
23936
|
+
// (float-artifact losses (-1e-15) would otherwise produce a spurious
|
|
23937
|
+
// astronomical certaintyRatio ≈1e14).
|
|
23938
|
+
const certaintyRatio = canComputeRatios && Math.abs(avgLoss) > STDDEV_EPSILON$2 && avgLoss < 0
|
|
23939
|
+
? avgWin / Math.abs(avgLoss)
|
|
23940
|
+
: null;
|
|
23941
|
+
// Average peak/fall PNL — over validSignals; only signals that actually have the
|
|
23942
|
+
// value contribute (no zero dilution from missing peakProfit/maxDrawdown).
|
|
23943
|
+
const peakValues = validSignals
|
|
23944
|
+
.map((s) => s.signal.peakProfit?.pnlPercentage)
|
|
23945
|
+
.filter((v) => typeof v === "number");
|
|
23946
|
+
const fallValues = validSignals
|
|
23947
|
+
.map((s) => s.signal.maxDrawdown?.pnlPercentage)
|
|
23948
|
+
.filter((v) => typeof v === "number");
|
|
23949
|
+
const avgPeakPnl = peakValues.length > 0
|
|
23950
|
+
? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
|
|
23951
|
+
: null;
|
|
23952
|
+
const avgFallPnl = fallValues.length > 0
|
|
23953
|
+
? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
|
|
23954
|
+
: null;
|
|
23955
|
+
// Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
|
|
23956
|
+
// downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
|
|
23957
|
+
// so the numerator reduces to avgPnl and the squared term to r² for r < 0.
|
|
23958
|
+
// Dividing by N_total (not N_negative) properly penalises strategies with frequent
|
|
23959
|
+
// losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
|
|
23960
|
+
// strategies.
|
|
23961
|
+
const negativeReturns = returns.filter((r) => r < 0);
|
|
23962
|
+
const sortinoRatio = (() => {
|
|
23963
|
+
if (!canComputeRatios)
|
|
23964
|
+
return null;
|
|
23965
|
+
if (negativeReturns.length === 0)
|
|
23966
|
+
return null;
|
|
23967
|
+
const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
|
|
23968
|
+
const downsideDeviation = Math.sqrt(downsideVariance);
|
|
23969
|
+
// Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
|
|
23970
|
+
return downsideDeviation > STDDEV_EPSILON$2 ? avgPnl / downsideDeviation : null;
|
|
23971
|
+
})();
|
|
23972
|
+
// Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
|
|
23973
|
+
const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
|
|
23974
|
+
? Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, expectedYearlyReturns / equityMaxDrawdown))
|
|
23975
|
+
: null;
|
|
23976
|
+
// Recovery Factor: numerator must be the compounded total return (equityFinal − 1) × 100,
|
|
23977
|
+
// not the arithmetic totalPnl — denominator (equityMaxDrawdown) is from the compounded
|
|
23978
|
+
// curve, so mixing units would inflate Recovery on long winning streaks.
|
|
23979
|
+
// Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
|
|
23980
|
+
// so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
|
|
23981
|
+
// Null when account is blown — ratio is meaningless after total loss.
|
|
23982
|
+
// Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
|
|
23983
|
+
// and explode the same way when DD is near zero.
|
|
23984
|
+
const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
|
|
23985
|
+
? null
|
|
23986
|
+
: Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, ((equityFinal - 1) * 100) / equityMaxDrawdown));
|
|
23835
23987
|
return {
|
|
23836
23988
|
signalList: this._signalList,
|
|
23837
23989
|
totalSignals,
|
|
23838
23990
|
winCount,
|
|
23839
23991
|
lossCount,
|
|
23840
|
-
winRate: isUnsafe$
|
|
23841
|
-
avgPnl: isUnsafe$
|
|
23842
|
-
totalPnl: isUnsafe$
|
|
23843
|
-
stdDev: isUnsafe$
|
|
23844
|
-
sharpeRatio: isUnsafe$
|
|
23845
|
-
annualizedSharpeRatio: isUnsafe$
|
|
23846
|
-
certaintyRatio: isUnsafe$
|
|
23847
|
-
expectedYearlyReturns: isUnsafe$
|
|
23848
|
-
avgPeakPnl: isUnsafe$
|
|
23849
|
-
avgFallPnl: isUnsafe$
|
|
23850
|
-
sortinoRatio: isUnsafe$
|
|
23851
|
-
calmarRatio: isUnsafe$
|
|
23852
|
-
recoveryFactor: isUnsafe$
|
|
23992
|
+
winRate: isUnsafe$4(winRate) ? null : winRate,
|
|
23993
|
+
avgPnl: isUnsafe$4(avgPnl) ? null : avgPnl,
|
|
23994
|
+
totalPnl: isUnsafe$4(totalPnl) ? null : totalPnl,
|
|
23995
|
+
stdDev: isUnsafe$4(stdDev) ? null : stdDev,
|
|
23996
|
+
sharpeRatio: isUnsafe$4(sharpeRatio) ? null : sharpeRatio,
|
|
23997
|
+
annualizedSharpeRatio: isUnsafe$4(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
|
|
23998
|
+
certaintyRatio: isUnsafe$4(certaintyRatio) ? null : certaintyRatio,
|
|
23999
|
+
expectedYearlyReturns: isUnsafe$4(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
24000
|
+
avgPeakPnl: isUnsafe$4(avgPeakPnl) ? null : avgPeakPnl,
|
|
24001
|
+
avgFallPnl: isUnsafe$4(avgFallPnl) ? null : avgFallPnl,
|
|
24002
|
+
sortinoRatio: isUnsafe$4(sortinoRatio) ? null : sortinoRatio,
|
|
24003
|
+
calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
|
|
24004
|
+
recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
|
|
23853
24005
|
};
|
|
23854
24006
|
}
|
|
23855
24007
|
/**
|
|
@@ -23891,24 +24043,26 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23891
24043
|
`**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
|
|
23892
24044
|
`**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
23893
24045
|
`**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
23894
|
-
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better
|
|
24046
|
+
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
23895
24047
|
`**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
|
|
23896
|
-
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better
|
|
24048
|
+
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
23897
24049
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
23898
24050
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
23899
24051
|
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
23900
|
-
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better
|
|
24052
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
23901
24053
|
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
23902
24054
|
"",
|
|
23903
24055
|
`*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
|
|
23904
24056
|
`*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
23905
|
-
`*Annualized Sharpe Ratio:
|
|
23906
|
-
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
24057
|
+
`*Annualized Sharpe Ratio: per-trade Sharpe × √tradesPerYear; tradesPerYear = signals × 365 / calendarSpanDays. N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION$2} signals and span ≥${MIN_CALENDAR_SPAN_DAYS$2} days. Assumes returns are iid — autocorrelated strategies are overstated.*`,
|
|
24058
|
+
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
23907
24059
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
23908
|
-
`*Expected Yearly Returns:
|
|
23909
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong.
|
|
23910
|
-
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
|
|
23911
|
-
`*All metrics require 100+ signals to be statistically reliable.
|
|
24060
|
+
`*Expected Yearly Returns: compounded geometric return from the equity curve, annualized by tradesPerYear. Same gating as Annualized Sharpe. Capped at ±${MAX_EXPECTED_YEARLY_RETURNS$2}% — values above the cap return N/A.*`,
|
|
24061
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is compounded equity-curve max drawdown. Capped at ±${MAX_CALMAR_RATIO$2}.*`,
|
|
24062
|
+
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good. Uses compounded total return as numerator.*`,
|
|
24063
|
+
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24064
|
+
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per trade** (no sizing, no portfolio fraction). Per-trade pnlPercentage is treated as a return on full equity. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures. The framework does not track portfolio-level sizing, so these metrics represent a theoretical upper bound under full allocation.*`,
|
|
24065
|
+
`*Negative values for Sharpe / Sortino / Calmar / Recovery / Expected Yearly Returns indicate a losing strategy (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
|
|
23912
24066
|
].join("\n");
|
|
23913
24067
|
}
|
|
23914
24068
|
/**
|
|
@@ -24220,7 +24374,7 @@ const CREATE_FILE_NAME_FN$b = (symbol, strategyName, exchangeName, frameName, ti
|
|
|
24220
24374
|
* @param value - Value to check
|
|
24221
24375
|
* @returns true if value is unsafe, false otherwise
|
|
24222
24376
|
*/
|
|
24223
|
-
function isUnsafe$
|
|
24377
|
+
function isUnsafe$3(value) {
|
|
24224
24378
|
if (typeof value !== "number") {
|
|
24225
24379
|
return true;
|
|
24226
24380
|
}
|
|
@@ -24232,6 +24386,25 @@ function isUnsafe$2(value) {
|
|
|
24232
24386
|
}
|
|
24233
24387
|
return false;
|
|
24234
24388
|
}
|
|
24389
|
+
/** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
|
|
24390
|
+
const MIN_SIGNALS_FOR_ANNUALIZATION$1 = 10;
|
|
24391
|
+
/** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
|
|
24392
|
+
* sample size is too small to estimate variance meaningfully. */
|
|
24393
|
+
const MIN_SIGNALS_FOR_RATIOS$1 = 10;
|
|
24394
|
+
/** Minimum calendar span (days) for trade-frequency extrapolation. */
|
|
24395
|
+
const MIN_CALENDAR_SPAN_DAYS$1 = 14;
|
|
24396
|
+
/** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
|
|
24397
|
+
const MAX_TRADES_PER_YEAR$1 = 365;
|
|
24398
|
+
/** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
|
|
24399
|
+
* blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
|
|
24400
|
+
* anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
|
|
24401
|
+
const MAX_EXPECTED_YEARLY_RETURNS$1 = 100;
|
|
24402
|
+
/** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
|
|
24403
|
+
const MAX_CALMAR_RATIO$1 = 1000;
|
|
24404
|
+
/** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
|
|
24405
|
+
* float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
|
|
24406
|
+
* astronomical magnitudes (avgPnl / epsilon). */
|
|
24407
|
+
const STDDEV_EPSILON$1 = 1e-9;
|
|
24235
24408
|
/**
|
|
24236
24409
|
* Storage class for accumulating all tick events per strategy.
|
|
24237
24410
|
* Maintains a chronological list of all events (idle, opened, active, closed).
|
|
@@ -24515,84 +24688,190 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24515
24688
|
};
|
|
24516
24689
|
}
|
|
24517
24690
|
const closedEvents = this._eventList.filter((e) => e.action === "closed");
|
|
24518
|
-
|
|
24519
|
-
|
|
24520
|
-
|
|
24521
|
-
|
|
24522
|
-
|
|
24523
|
-
|
|
24691
|
+
// Valid closed set — single source of truth. Events must have numeric pnl AND valid
|
|
24692
|
+
// timestamps. Win/loss counts, returns, calendar span, equity curve — all derived
|
|
24693
|
+
// from this set so they cannot disagree.
|
|
24694
|
+
const validClosed = closedEvents.filter((e) => typeof e.pnl === "number" &&
|
|
24695
|
+
typeof e.timestamp === "number" &&
|
|
24696
|
+
e.timestamp > 0 &&
|
|
24697
|
+
typeof (e.pendingAt ?? e.timestamp) === "number");
|
|
24698
|
+
const totalClosed = validClosed.length;
|
|
24699
|
+
const winCount = validClosed.filter((e) => e.pnl > 0).length;
|
|
24700
|
+
const lossCount = validClosed.filter((e) => e.pnl < 0).length;
|
|
24701
|
+
const returns = validClosed.map((e) => e.pnl);
|
|
24702
|
+
const avgPnl = returns.length > 0
|
|
24703
|
+
? returns.reduce((sum, r) => sum + r, 0) / returns.length
|
|
24524
24704
|
: 0;
|
|
24525
|
-
const totalPnl =
|
|
24526
|
-
|
|
24527
|
-
|
|
24528
|
-
|
|
24529
|
-
|
|
24530
|
-
|
|
24531
|
-
|
|
24532
|
-
|
|
24533
|
-
|
|
24534
|
-
|
|
24535
|
-
|
|
24536
|
-
|
|
24537
|
-
|
|
24538
|
-
|
|
24539
|
-
|
|
24540
|
-
|
|
24541
|
-
|
|
24705
|
+
const totalPnl = returns.reduce((sum, r) => sum + r, 0);
|
|
24706
|
+
// Win rate excludes break-even trades from both numerator and denominator.
|
|
24707
|
+
const decisiveTrades = winCount + lossCount;
|
|
24708
|
+
const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
|
|
24709
|
+
// Trade frequency from calendar span — gated by minimum span and sample size to
|
|
24710
|
+
// suppress absurd annualization on short / sparse runs. Span built from validClosed
|
|
24711
|
+
// so denominator (calendarSpanDays) and numerator (returns.length) come from the
|
|
24712
|
+
// same event set.
|
|
24713
|
+
let firstPendingAt = Infinity;
|
|
24714
|
+
let lastCloseAt = -Infinity;
|
|
24715
|
+
for (const e of validClosed) {
|
|
24716
|
+
const startAt = e.pendingAt ?? e.timestamp;
|
|
24717
|
+
if (startAt < firstPendingAt)
|
|
24718
|
+
firstPendingAt = startAt;
|
|
24719
|
+
if (e.timestamp > lastCloseAt)
|
|
24720
|
+
lastCloseAt = e.timestamp;
|
|
24721
|
+
}
|
|
24722
|
+
const calendarSpanDays = validClosed.length > 0
|
|
24723
|
+
? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
|
|
24724
|
+
: 0;
|
|
24725
|
+
// tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
|
|
24726
|
+
// silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
|
|
24727
|
+
// raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
|
|
24728
|
+
// for reliable annualization and surface every annualized metric as null.
|
|
24729
|
+
const rawTradesPerYear = returns.length >= MIN_SIGNALS_FOR_ANNUALIZATION$1 &&
|
|
24730
|
+
calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$1
|
|
24731
|
+
? (returns.length / calendarSpanDays) * 365
|
|
24732
|
+
: 0;
|
|
24733
|
+
const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$1;
|
|
24734
|
+
const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
|
|
24735
|
+
// Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1).
|
|
24736
|
+
// Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
|
|
24737
|
+
// are too noisy to publish (high chance of spurious ±Sharpe).
|
|
24738
|
+
const canComputeRatios = returns.length >= MIN_SIGNALS_FOR_RATIOS$1;
|
|
24739
|
+
const stdDev = canComputeRatios
|
|
24740
|
+
? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (returns.length - 1))
|
|
24741
|
+
: 0;
|
|
24742
|
+
// STDDEV_EPSILON guard — protects against float-artifact stdDev from identical
|
|
24743
|
+
// returns producing spuriously astronomical sharpe.
|
|
24744
|
+
const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$1
|
|
24745
|
+
? avgPnl / stdDev
|
|
24746
|
+
: null;
|
|
24747
|
+
// Annualize only when gate passes; otherwise null.
|
|
24748
|
+
const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
|
|
24749
|
+
? sharpeRatio * Math.sqrt(tradesPerYear)
|
|
24750
|
+
: null;
|
|
24751
|
+
// Certainty Ratio: null (not zero) when there are no losing trades — a flawless
|
|
24752
|
+
// strategy has undefined Certainty Ratio, not "worst case zero". Computed on
|
|
24753
|
+
// validClosed for consistency with other ratios.
|
|
24754
|
+
// Gated below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as Sharpe/Sortino,
|
|
24755
|
+
// so the report doesn't surface certainty on a handful of trades while
|
|
24756
|
+
// withholding the rest.
|
|
24757
|
+
let certaintyRatio = null;
|
|
24758
|
+
if (canComputeRatios && totalClosed > 0) {
|
|
24759
|
+
const wins = validClosed.filter((e) => e.pnl > 0);
|
|
24760
|
+
const losses = validClosed.filter((e) => e.pnl < 0);
|
|
24542
24761
|
const avgWin = wins.length > 0
|
|
24543
|
-
? wins.reduce((sum, e) => sum +
|
|
24762
|
+
? wins.reduce((sum, e) => sum + e.pnl, 0) / wins.length
|
|
24544
24763
|
: 0;
|
|
24545
24764
|
const avgLoss = losses.length > 0
|
|
24546
|
-
? losses.reduce((sum, e) => sum +
|
|
24765
|
+
? losses.reduce((sum, e) => sum + e.pnl, 0) / losses.length
|
|
24547
24766
|
: 0;
|
|
24548
|
-
|
|
24549
|
-
|
|
24550
|
-
|
|
24551
|
-
|
|
24552
|
-
|
|
24553
|
-
|
|
24554
|
-
|
|
24555
|
-
|
|
24556
|
-
|
|
24557
|
-
|
|
24558
|
-
|
|
24559
|
-
|
|
24560
|
-
|
|
24561
|
-
|
|
24562
|
-
|
|
24563
|
-
|
|
24564
|
-
|
|
24565
|
-
const
|
|
24566
|
-
|
|
24567
|
-
|
|
24568
|
-
|
|
24569
|
-
|
|
24570
|
-
|
|
24571
|
-
|
|
24572
|
-
|
|
24573
|
-
//
|
|
24574
|
-
const
|
|
24575
|
-
|
|
24576
|
-
|
|
24767
|
+
// STDDEV_EPSILON guard on |avgLoss| protects against float-artifact
|
|
24768
|
+
// losses producing spurious astronomical certaintyRatio.
|
|
24769
|
+
certaintyRatio = Math.abs(avgLoss) > STDDEV_EPSILON$1 && avgLoss < 0
|
|
24770
|
+
? avgWin / Math.abs(avgLoss)
|
|
24771
|
+
: null;
|
|
24772
|
+
}
|
|
24773
|
+
// Average only over signals that have the value — do not dilute the mean with zeros.
|
|
24774
|
+
// Use validClosed to keep all metric denominators consistent.
|
|
24775
|
+
const peakValues = validClosed
|
|
24776
|
+
.map((e) => e.peakPnl)
|
|
24777
|
+
.filter((v) => typeof v === "number");
|
|
24778
|
+
const fallValues = validClosed
|
|
24779
|
+
.map((e) => e.fallPnl)
|
|
24780
|
+
.filter((v) => typeof v === "number");
|
|
24781
|
+
const avgPeakPnl = peakValues.length > 0
|
|
24782
|
+
? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
|
|
24783
|
+
: null;
|
|
24784
|
+
const avgFallPnl = fallValues.length > 0
|
|
24785
|
+
? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
|
|
24786
|
+
: null;
|
|
24787
|
+
// Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
|
|
24788
|
+
// downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
|
|
24789
|
+
// so the numerator reduces to avgPnl and the squared term to r² for r < 0.
|
|
24790
|
+
// Dividing by N_total (not N_negative) properly penalises strategies with frequent
|
|
24791
|
+
// losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
|
|
24792
|
+
// strategies.
|
|
24793
|
+
const sortinoRatio = (() => {
|
|
24794
|
+
if (!canComputeRatios)
|
|
24795
|
+
return null;
|
|
24796
|
+
const negativeReturns = returns.filter((r) => r < 0);
|
|
24797
|
+
if (negativeReturns.length === 0)
|
|
24798
|
+
return null;
|
|
24799
|
+
const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
|
|
24800
|
+
const downsideDeviation = Math.sqrt(downsideVariance);
|
|
24801
|
+
// Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
|
|
24802
|
+
return downsideDeviation > STDDEV_EPSILON$1 ? avgPnl / downsideDeviation : null;
|
|
24803
|
+
})();
|
|
24804
|
+
// Equity-curve max drawdown via compounded equity (multiplicative). Returns are per-trade
|
|
24805
|
+
// on cost basis — compounding assumes equal capital allocation per trade ("as-if 100%").
|
|
24806
|
+
// If equity ≤ 0 (leveraged short with r < -100%) — account blown, fix DD at 100%.
|
|
24807
|
+
// Built from validClosed (newest-first), iterated reverse for chronological order.
|
|
24808
|
+
const chronologicalReturns = [];
|
|
24809
|
+
for (let i = validClosed.length - 1; i >= 0; i--) {
|
|
24810
|
+
chronologicalReturns.push(validClosed[i].pnl);
|
|
24811
|
+
}
|
|
24812
|
+
let equity = 1;
|
|
24813
|
+
let peak = 1;
|
|
24814
|
+
let equityMaxDrawdown = 0;
|
|
24815
|
+
let blown = false;
|
|
24816
|
+
for (const r of chronologicalReturns) {
|
|
24817
|
+
equity *= 1 + r / 100;
|
|
24818
|
+
if (equity <= 0) {
|
|
24819
|
+
equityMaxDrawdown = 100;
|
|
24820
|
+
blown = true;
|
|
24821
|
+
break;
|
|
24822
|
+
}
|
|
24823
|
+
if (equity > peak)
|
|
24824
|
+
peak = equity;
|
|
24825
|
+
const dd = (peak - equity) / peak * 100;
|
|
24826
|
+
if (dd > equityMaxDrawdown)
|
|
24827
|
+
equityMaxDrawdown = dd;
|
|
24828
|
+
}
|
|
24829
|
+
const equityFinal = blown ? 0 : equity;
|
|
24830
|
+
// Compounded yearly return via geometric mean of equity curve:
|
|
24831
|
+
// equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
|
|
24832
|
+
// If account is blown, full loss. If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS,
|
|
24833
|
+
// return null rather than showing the cap — capped numbers mislead users.
|
|
24834
|
+
const expectedYearlyReturns = canAnnualize
|
|
24835
|
+
? blown
|
|
24836
|
+
? -100
|
|
24837
|
+
: (() => {
|
|
24838
|
+
const raw = (Math.pow(equityFinal, tradesPerYear / returns.length) - 1) * 100;
|
|
24839
|
+
return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$1 ? null : raw;
|
|
24840
|
+
})()
|
|
24841
|
+
: null;
|
|
24842
|
+
// Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
|
|
24843
|
+
const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
|
|
24844
|
+
? Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, expectedYearlyReturns / equityMaxDrawdown))
|
|
24845
|
+
: null;
|
|
24846
|
+
// Recovery Factor: numerator must be the compounded total return, not arithmetic totalPnl —
|
|
24847
|
+
// denominator is from the compounded equity curve, so mixing units inflates Recovery.
|
|
24848
|
+
// Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
|
|
24849
|
+
// so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
|
|
24850
|
+
// Null when account is blown.
|
|
24851
|
+
// Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
|
|
24852
|
+
// and explode the same way when DD is near zero.
|
|
24853
|
+
const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
|
|
24854
|
+
? null
|
|
24855
|
+
: Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, ((equityFinal - 1) * 100) / equityMaxDrawdown));
|
|
24577
24856
|
return {
|
|
24578
24857
|
eventList: this._eventList,
|
|
24579
24858
|
totalEvents: this._eventList.length,
|
|
24580
24859
|
totalClosed,
|
|
24581
24860
|
winCount,
|
|
24582
24861
|
lossCount,
|
|
24583
|
-
winRate: isUnsafe$
|
|
24584
|
-
avgPnl: isUnsafe$
|
|
24585
|
-
totalPnl: isUnsafe$
|
|
24586
|
-
stdDev: isUnsafe$
|
|
24587
|
-
sharpeRatio: isUnsafe$
|
|
24588
|
-
annualizedSharpeRatio: isUnsafe$
|
|
24589
|
-
certaintyRatio: isUnsafe$
|
|
24590
|
-
expectedYearlyReturns: isUnsafe$
|
|
24591
|
-
avgPeakPnl: isUnsafe$
|
|
24592
|
-
avgFallPnl: isUnsafe$
|
|
24593
|
-
sortinoRatio: isUnsafe$
|
|
24594
|
-
calmarRatio: isUnsafe$
|
|
24595
|
-
recoveryFactor: isUnsafe$
|
|
24862
|
+
winRate: isUnsafe$3(winRate) ? null : winRate,
|
|
24863
|
+
avgPnl: isUnsafe$3(avgPnl) ? null : avgPnl,
|
|
24864
|
+
totalPnl: isUnsafe$3(totalPnl) ? null : totalPnl,
|
|
24865
|
+
stdDev: isUnsafe$3(stdDev) ? null : stdDev,
|
|
24866
|
+
sharpeRatio: isUnsafe$3(sharpeRatio) ? null : sharpeRatio,
|
|
24867
|
+
annualizedSharpeRatio: isUnsafe$3(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
|
|
24868
|
+
certaintyRatio: isUnsafe$3(certaintyRatio) ? null : certaintyRatio,
|
|
24869
|
+
expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
24870
|
+
avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
|
|
24871
|
+
avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
|
|
24872
|
+
sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
|
|
24873
|
+
calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
|
|
24874
|
+
recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
|
|
24596
24875
|
};
|
|
24597
24876
|
}
|
|
24598
24877
|
/**
|
|
@@ -24640,18 +24919,20 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24640
24919
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
24641
24920
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
24642
24921
|
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
24643
|
-
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better
|
|
24922
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
24644
24923
|
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
24645
24924
|
"",
|
|
24646
24925
|
`*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
|
|
24647
24926
|
`*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
24648
|
-
`*Annualized Sharpe Ratio:
|
|
24649
|
-
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
24927
|
+
`*Annualized Sharpe Ratio: per-trade Sharpe × √tradesPerYear; tradesPerYear = signals × 365 / calendarSpanDays. N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION$1} signals and span ≥${MIN_CALENDAR_SPAN_DAYS$1} days. Assumes returns are iid — autocorrelated strategies are overstated.*`,
|
|
24928
|
+
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
24650
24929
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
24651
|
-
`*Expected Yearly Returns:
|
|
24652
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong.
|
|
24653
|
-
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
|
|
24654
|
-
`*All metrics require 100+ signals to be statistically reliable.
|
|
24930
|
+
`*Expected Yearly Returns: compounded geometric return from the equity curve, annualized by tradesPerYear. Same gating as Annualized Sharpe. Capped at ±${MAX_EXPECTED_YEARLY_RETURNS$1}% — values above the cap return N/A.*`,
|
|
24931
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is compounded equity-curve max drawdown. Capped at ±${MAX_CALMAR_RATIO$1}.*`,
|
|
24932
|
+
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good. Uses compounded total return as numerator.*`,
|
|
24933
|
+
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24934
|
+
`*IMPORTANT: Equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per trade** (no sizing, no portfolio fraction). Per-trade pnlPercentage is treated as a return on full equity. If your strategy risks X% of capital per trade, the realized portfolio return / drawdown will be roughly X/100 of the reported figures. The framework does not track portfolio-level sizing, so these metrics represent a theoretical upper bound under full allocation.*`,
|
|
24935
|
+
`*Negative values for Sharpe / Sortino / Calmar / Recovery / Expected Yearly Returns indicate a losing strategy (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
|
|
24655
24936
|
].join("\n");
|
|
24656
24937
|
}
|
|
24657
24938
|
/**
|
|
@@ -25030,7 +25311,9 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25030
25311
|
*/
|
|
25031
25312
|
addOpenedEvent(data) {
|
|
25032
25313
|
const durationMs = data.signal.pendingAt - data.signal.scheduledAt;
|
|
25033
|
-
|
|
25314
|
+
// Keep fractional minutes — rounding to whole minutes zeroed out sub-30s durations,
|
|
25315
|
+
// which dragged high-frequency averages towards zero.
|
|
25316
|
+
const durationMin = durationMs / 60000;
|
|
25034
25317
|
const newEvent = {
|
|
25035
25318
|
timestamp: data.signal.pendingAt,
|
|
25036
25319
|
action: "opened",
|
|
@@ -25066,7 +25349,8 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25066
25349
|
*/
|
|
25067
25350
|
addCancelledEvent(data) {
|
|
25068
25351
|
const durationMs = data.closeTimestamp - data.signal.scheduledAt;
|
|
25069
|
-
|
|
25352
|
+
// Keep fractional minutes — rounding to whole minutes zeroed out sub-30s durations.
|
|
25353
|
+
const durationMin = durationMs / 60000;
|
|
25070
25354
|
const newEvent = {
|
|
25071
25355
|
timestamp: data.closeTimestamp,
|
|
25072
25356
|
action: "cancelled",
|
|
@@ -25122,19 +25406,33 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25122
25406
|
const totalScheduled = scheduledEvents.length;
|
|
25123
25407
|
const totalOpened = openedEvents.length;
|
|
25124
25408
|
const totalCancelled = cancelledEvents.length;
|
|
25125
|
-
//
|
|
25126
|
-
|
|
25127
|
-
//
|
|
25128
|
-
|
|
25129
|
-
|
|
25130
|
-
const
|
|
25131
|
-
|
|
25132
|
-
|
|
25409
|
+
// Rate denominators must include only scheduled events whose outcome (opened/cancelled)
|
|
25410
|
+
// is also in the buffer. Otherwise a sliding window of 250 entries can drop the
|
|
25411
|
+
// "scheduled" record before its outcome arrives, inflating rates above 100% or
|
|
25412
|
+
// causing one rate to fire without the other. Match by signalId.
|
|
25413
|
+
const scheduledIds = new Set(scheduledEvents.map((e) => e.signalId).filter((id) => typeof id === "string"));
|
|
25414
|
+
const openedFromScheduled = openedEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
|
|
25415
|
+
const cancelledFromScheduled = cancelledEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
|
|
25416
|
+
const resolvedScheduled = openedFromScheduled.length + cancelledFromScheduled.length;
|
|
25417
|
+
const cancellationRate = resolvedScheduled > 0
|
|
25418
|
+
? (cancelledFromScheduled.length / resolvedScheduled) * 100
|
|
25419
|
+
: null;
|
|
25420
|
+
const activationRate = resolvedScheduled > 0
|
|
25421
|
+
? (openedFromScheduled.length / resolvedScheduled) * 100
|
|
25422
|
+
: null;
|
|
25423
|
+
// Average durations — include only events with a numeric duration, do not dilute
|
|
25424
|
+
// the mean with zeros for missing values.
|
|
25425
|
+
const cancelledDurations = cancelledEvents
|
|
25426
|
+
.map((e) => e.duration)
|
|
25427
|
+
.filter((d) => typeof d === "number");
|
|
25428
|
+
const openedDurations = openedEvents
|
|
25429
|
+
.map((e) => e.duration)
|
|
25430
|
+
.filter((d) => typeof d === "number");
|
|
25431
|
+
const avgWaitTime = cancelledDurations.length > 0
|
|
25432
|
+
? cancelledDurations.reduce((sum, d) => sum + d, 0) / cancelledDurations.length
|
|
25133
25433
|
: null;
|
|
25134
|
-
|
|
25135
|
-
|
|
25136
|
-
? openedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
|
|
25137
|
-
totalOpened
|
|
25434
|
+
const avgActivationTime = openedDurations.length > 0
|
|
25435
|
+
? openedDurations.reduce((sum, d) => sum + d, 0) / openedDurations.length
|
|
25138
25436
|
: null;
|
|
25139
25437
|
return {
|
|
25140
25438
|
eventList: this._eventList,
|
|
@@ -25181,13 +25479,15 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25181
25479
|
table,
|
|
25182
25480
|
"",
|
|
25183
25481
|
`**Total events:** ${stats.totalEvents}`,
|
|
25184
|
-
`**Scheduled signals:** ${stats.totalScheduled}`,
|
|
25482
|
+
`**Scheduled signals (raw):** ${stats.totalScheduled}`,
|
|
25185
25483
|
`**Opened signals:** ${stats.totalOpened}`,
|
|
25186
25484
|
`**Cancelled signals:** ${stats.totalCancelled}`,
|
|
25187
25485
|
`**Activation rate:** ${stats.activationRate === null ? "N/A" : `${stats.activationRate.toFixed(2)}% (higher is better)`}`,
|
|
25188
25486
|
`**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`,
|
|
25189
25487
|
`**Average activation time:** ${stats.avgActivationTime === null ? "N/A" : `${stats.avgActivationTime.toFixed(2)} minutes`}`,
|
|
25190
|
-
`**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}
|
|
25488
|
+
`**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`,
|
|
25489
|
+
"",
|
|
25490
|
+
`*Activation / Cancellation rates are computed over scheduled signals whose outcome (opened or cancelled) is also in the buffer — matched by signalId. "Scheduled signals (raw)" above is the unmatched count and may include records whose outcome has not yet arrived or was evicted from the buffer.*`
|
|
25191
25491
|
].join("\n");
|
|
25192
25492
|
}
|
|
25193
25493
|
/**
|
|
@@ -25492,13 +25792,37 @@ const CREATE_FILE_NAME_FN$9 = (symbol, strategyName, exchangeName, frameName, ti
|
|
|
25492
25792
|
return `${parts.join("_")}-${timestamp}.md`;
|
|
25493
25793
|
};
|
|
25494
25794
|
/**
|
|
25495
|
-
*
|
|
25795
|
+
* Checks if a value is unsafe for display (not a number, NaN, or Infinity).
|
|
25796
|
+
*/
|
|
25797
|
+
function isUnsafe$2(value) {
|
|
25798
|
+
if (typeof value !== "number") {
|
|
25799
|
+
return true;
|
|
25800
|
+
}
|
|
25801
|
+
if (isNaN(value)) {
|
|
25802
|
+
return true;
|
|
25803
|
+
}
|
|
25804
|
+
if (!isFinite(value)) {
|
|
25805
|
+
return true;
|
|
25806
|
+
}
|
|
25807
|
+
return false;
|
|
25808
|
+
}
|
|
25809
|
+
/**
|
|
25810
|
+
* Calculates percentile value from sorted array using linear interpolation
|
|
25811
|
+
* between adjacent ranks (equivalent to numpy.percentile with default linear method).
|
|
25812
|
+
* Falls back to nearest-rank for length 0/1.
|
|
25496
25813
|
*/
|
|
25497
25814
|
function percentile(sortedArray, p) {
|
|
25498
25815
|
if (sortedArray.length === 0)
|
|
25499
25816
|
return 0;
|
|
25500
|
-
|
|
25501
|
-
|
|
25817
|
+
if (sortedArray.length === 1)
|
|
25818
|
+
return sortedArray[0];
|
|
25819
|
+
const rank = (p / 100) * (sortedArray.length - 1);
|
|
25820
|
+
const lower = Math.floor(rank);
|
|
25821
|
+
const upper = Math.ceil(rank);
|
|
25822
|
+
if (lower === upper)
|
|
25823
|
+
return sortedArray[lower];
|
|
25824
|
+
const fraction = rank - lower;
|
|
25825
|
+
return sortedArray[lower] * (1 - fraction) + sortedArray[upper] * fraction;
|
|
25502
25826
|
}
|
|
25503
25827
|
/**
|
|
25504
25828
|
* Storage class for accumulating performance metrics per strategy.
|
|
@@ -25554,10 +25878,12 @@ class PerformanceStorage {
|
|
|
25554
25878
|
const durations = events.map((e) => e.duration).sort((a, b) => a - b);
|
|
25555
25879
|
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
|
25556
25880
|
const avgDuration = totalDuration / durations.length;
|
|
25557
|
-
//
|
|
25558
|
-
|
|
25559
|
-
|
|
25560
|
-
|
|
25881
|
+
// Sample standard deviation (Bessel correction: divide by N-1, not N) — consistent
|
|
25882
|
+
// with Sharpe/Sortino calculations in Backtest/Live/Heat services.
|
|
25883
|
+
const stdDev = durations.length > 1
|
|
25884
|
+
? Math.sqrt(durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) /
|
|
25885
|
+
(durations.length - 1))
|
|
25886
|
+
: 0;
|
|
25561
25887
|
// Calculate wait times between events
|
|
25562
25888
|
const waitTimes = [];
|
|
25563
25889
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -25630,9 +25956,13 @@ class PerformanceStorage {
|
|
|
25630
25956
|
const rows = await Promise.all(sortedMetrics.map(async (metric, index) => Promise.all(visibleColumns.map((col) => col.format(metric, index)))));
|
|
25631
25957
|
const tableData = [header, separator, ...rows];
|
|
25632
25958
|
const summaryTable = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
|
|
25633
|
-
// Calculate percentage of total time for each metric
|
|
25959
|
+
// Calculate percentage of total time for each metric. Guard against zero total
|
|
25960
|
+
// duration (all-instant operations) to avoid NaN% in the rendered report.
|
|
25634
25961
|
const percentages = sortedMetrics.map((metric) => {
|
|
25635
|
-
const
|
|
25962
|
+
const pctRaw = stats.totalDuration > 0
|
|
25963
|
+
? (metric.totalDuration / stats.totalDuration) * 100
|
|
25964
|
+
: 0;
|
|
25965
|
+
const pct = isUnsafe$2(pctRaw) ? 0 : pctRaw;
|
|
25636
25966
|
return `- **${metric.metricType}**: ${pct.toFixed(1)}% (${metric.totalDuration.toFixed(2)}ms total)`;
|
|
25637
25967
|
});
|
|
25638
25968
|
return [
|
|
@@ -26401,6 +26731,25 @@ function isUnsafe(value) {
|
|
|
26401
26731
|
}
|
|
26402
26732
|
return false;
|
|
26403
26733
|
}
|
|
26734
|
+
/** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
|
|
26735
|
+
const MIN_SIGNALS_FOR_ANNUALIZATION = 10;
|
|
26736
|
+
/** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
|
|
26737
|
+
* sample size is too small to estimate variance meaningfully. */
|
|
26738
|
+
const MIN_SIGNALS_FOR_RATIOS = 10;
|
|
26739
|
+
/** Minimum calendar span (days) for trade-frequency extrapolation. */
|
|
26740
|
+
const MIN_CALENDAR_SPAN_DAYS = 14;
|
|
26741
|
+
/** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
|
|
26742
|
+
const MAX_TRADES_PER_YEAR = 365;
|
|
26743
|
+
/** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
|
|
26744
|
+
* blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
|
|
26745
|
+
* anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
|
|
26746
|
+
const MAX_EXPECTED_YEARLY_RETURNS = 100;
|
|
26747
|
+
/** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
|
|
26748
|
+
const MAX_CALMAR_RATIO = 1000;
|
|
26749
|
+
/** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
|
|
26750
|
+
* float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
|
|
26751
|
+
* astronomical magnitudes (avgPnl / epsilon). */
|
|
26752
|
+
const STDDEV_EPSILON = 1e-9;
|
|
26404
26753
|
/**
|
|
26405
26754
|
* Storage class for accumulating closed signals per strategy and generating heatmap.
|
|
26406
26755
|
* Maintains symbol-level statistics and provides portfolio-wide metrics.
|
|
@@ -26442,7 +26791,7 @@ class HeatmapStorage {
|
|
|
26442
26791
|
* - **totalPnl** — sum of `pnlPercentage` across all signals
|
|
26443
26792
|
* - **avgPnl** — arithmetic mean of `pnlPercentage`
|
|
26444
26793
|
* - **stdDev** — population standard deviation of `pnlPercentage`
|
|
26445
|
-
* - **sharpeRatio** — `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
|
|
26794
|
+
* - **sharpeRatio** — per-trade Sharpe: `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
|
|
26446
26795
|
* - **maxDrawdown** — largest cumulative loss streak (absolute value of peak negative equity)
|
|
26447
26796
|
* - **profitFactor** — `sumWins / |sumLosses|`; requires at least one win and one loss
|
|
26448
26797
|
* - **avgWin / avgLoss** — mean of positive / negative trades respectively
|
|
@@ -26458,10 +26807,12 @@ class HeatmapStorage {
|
|
|
26458
26807
|
const totalTrades = signals.length;
|
|
26459
26808
|
const winCount = signals.filter((s) => s.pnl.pnlPercentage > 0).length;
|
|
26460
26809
|
const lossCount = signals.filter((s) => s.pnl.pnlPercentage < 0).length;
|
|
26461
|
-
//
|
|
26810
|
+
// Win rate excludes break-even trades from both numerator and denominator —
|
|
26811
|
+
// they are neither wins nor losses.
|
|
26462
26812
|
let winRate = null;
|
|
26463
|
-
|
|
26464
|
-
|
|
26813
|
+
const decisiveTrades = winCount + lossCount;
|
|
26814
|
+
if (decisiveTrades > 0) {
|
|
26815
|
+
winRate = (winCount / decisiveTrades) * 100;
|
|
26465
26816
|
}
|
|
26466
26817
|
// Calculate total PNL
|
|
26467
26818
|
let totalPnl = null;
|
|
@@ -26473,36 +26824,47 @@ class HeatmapStorage {
|
|
|
26473
26824
|
if (signals.length > 0) {
|
|
26474
26825
|
avgPnl = totalPnl / signals.length;
|
|
26475
26826
|
}
|
|
26476
|
-
//
|
|
26827
|
+
// Sample standard deviation (Bessel correction: divide by N-1, not N).
|
|
26828
|
+
// Per-symbol ratios are gated by MIN_SIGNALS_FOR_RATIOS — variance estimates from
|
|
26829
|
+
// tiny samples are too noisy to publish.
|
|
26830
|
+
const canComputeRatios = signals.length >= MIN_SIGNALS_FOR_RATIOS;
|
|
26477
26831
|
let stdDev = null;
|
|
26478
|
-
if (
|
|
26479
|
-
const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / signals.length;
|
|
26832
|
+
if (canComputeRatios && avgPnl !== null) {
|
|
26833
|
+
const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / (signals.length - 1);
|
|
26480
26834
|
stdDev = Math.sqrt(variance);
|
|
26481
26835
|
}
|
|
26482
|
-
//
|
|
26836
|
+
// Per-trade Sharpe Ratio
|
|
26483
26837
|
let sharpeRatio = null;
|
|
26484
|
-
|
|
26838
|
+
// STDDEV_EPSILON guard — protects against float-artifact stdDev producing
|
|
26839
|
+
// spuriously astronomical sharpe on identical-returns symbols.
|
|
26840
|
+
if (avgPnl !== null && stdDev !== null && stdDev > STDDEV_EPSILON) {
|
|
26485
26841
|
sharpeRatio = avgPnl / stdDev;
|
|
26486
26842
|
}
|
|
26487
|
-
//
|
|
26843
|
+
// Equity-curve max drawdown via compounded equity ("as-if 100% allocation per trade").
|
|
26844
|
+
// Signals are stored newest-first (unshift in addSignal), so iterate in reverse.
|
|
26845
|
+
// If equity ≤ 0 — account blown, fix DD at 100%. equityFinal feeds expectedYearlyReturns.
|
|
26488
26846
|
let maxDrawdown = null;
|
|
26847
|
+
let equityFinal = 1;
|
|
26848
|
+
let blown = false;
|
|
26489
26849
|
if (signals.length > 0) {
|
|
26490
|
-
let
|
|
26491
|
-
let
|
|
26850
|
+
let equity = 1;
|
|
26851
|
+
let peak = 1;
|
|
26492
26852
|
let maxDD = 0;
|
|
26493
|
-
for (
|
|
26494
|
-
|
|
26495
|
-
if (
|
|
26496
|
-
|
|
26497
|
-
|
|
26498
|
-
|
|
26499
|
-
currentDrawdown = Math.abs(peak);
|
|
26500
|
-
if (currentDrawdown > maxDD) {
|
|
26501
|
-
maxDD = currentDrawdown;
|
|
26502
|
-
}
|
|
26853
|
+
for (let i = signals.length - 1; i >= 0; i--) {
|
|
26854
|
+
equity *= 1 + signals[i].pnl.pnlPercentage / 100;
|
|
26855
|
+
if (equity <= 0) {
|
|
26856
|
+
maxDD = 100;
|
|
26857
|
+
blown = true;
|
|
26858
|
+
break;
|
|
26503
26859
|
}
|
|
26860
|
+
if (equity > peak)
|
|
26861
|
+
peak = equity;
|
|
26862
|
+
const dd = (peak - equity) / peak * 100;
|
|
26863
|
+
if (dd > maxDD)
|
|
26864
|
+
maxDD = dd;
|
|
26504
26865
|
}
|
|
26505
26866
|
maxDrawdown = maxDD;
|
|
26867
|
+
equityFinal = blown ? 0 : equity;
|
|
26506
26868
|
}
|
|
26507
26869
|
// Calculate Profit Factor
|
|
26508
26870
|
let profitFactor = null;
|
|
@@ -26513,7 +26875,9 @@ class HeatmapStorage {
|
|
|
26513
26875
|
const sumLosses = Math.abs(signals
|
|
26514
26876
|
.filter((s) => s.pnl.pnlPercentage < 0)
|
|
26515
26877
|
.reduce((acc, s) => acc + s.pnl.pnlPercentage, 0));
|
|
26516
|
-
|
|
26878
|
+
// STDDEV_EPSILON guard — float-artifact losses (≈1e-15) would otherwise
|
|
26879
|
+
// produce spurious astronomical profitFactor (≈1e14).
|
|
26880
|
+
if (sumLosses > STDDEV_EPSILON) {
|
|
26517
26881
|
profitFactor = sumWins / sumLosses;
|
|
26518
26882
|
}
|
|
26519
26883
|
}
|
|
@@ -26553,45 +26917,110 @@ class HeatmapStorage {
|
|
|
26553
26917
|
}
|
|
26554
26918
|
}
|
|
26555
26919
|
}
|
|
26556
|
-
//
|
|
26920
|
+
// Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
|
|
26557
26921
|
let expectancy = null;
|
|
26558
|
-
if (
|
|
26559
|
-
const
|
|
26560
|
-
|
|
26922
|
+
if (totalTrades > 0 && avgWin !== null && avgLoss !== null) {
|
|
26923
|
+
const winProb = winCount / totalTrades;
|
|
26924
|
+
const lossProb = lossCount / totalTrades;
|
|
26925
|
+
expectancy = winProb * avgWin + lossProb * avgLoss;
|
|
26561
26926
|
}
|
|
26562
|
-
|
|
26927
|
+
else if (totalTrades > 0 && avgWin !== null && avgLoss === null) {
|
|
26928
|
+
// No losing trades — expectancy is just average win frequency × avgWin
|
|
26929
|
+
expectancy = (winCount / totalTrades) * avgWin;
|
|
26930
|
+
}
|
|
26931
|
+
else if (totalTrades > 0 && avgWin === null && avgLoss !== null) {
|
|
26932
|
+
expectancy = (lossCount / totalTrades) * avgLoss;
|
|
26933
|
+
}
|
|
26934
|
+
// Average only over signals that have the value — do not dilute the mean with zeros.
|
|
26563
26935
|
let avgPeakPnl = null;
|
|
26564
26936
|
let avgFallPnl = null;
|
|
26565
26937
|
if (signals.length > 0) {
|
|
26566
|
-
|
|
26567
|
-
|
|
26938
|
+
const peakValues = signals
|
|
26939
|
+
.map((s) => s.signal.peakProfit?.pnlPercentage)
|
|
26940
|
+
.filter((v) => typeof v === "number");
|
|
26941
|
+
const fallValues = signals
|
|
26942
|
+
.map((s) => s.signal.maxDrawdown?.pnlPercentage)
|
|
26943
|
+
.filter((v) => typeof v === "number");
|
|
26944
|
+
avgPeakPnl = peakValues.length > 0
|
|
26945
|
+
? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
|
|
26946
|
+
: null;
|
|
26947
|
+
avgFallPnl = fallValues.length > 0
|
|
26948
|
+
? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
|
|
26949
|
+
: null;
|
|
26568
26950
|
}
|
|
26569
|
-
//
|
|
26570
|
-
|
|
26571
|
-
//
|
|
26951
|
+
// Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
|
|
26952
|
+
// downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
|
|
26953
|
+
// so the numerator reduces to avgPnl and the squared term to r² for r < 0.
|
|
26954
|
+
// Dividing by N_total (not N_negative) properly penalises strategies with frequent
|
|
26955
|
+
// losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
|
|
26956
|
+
// strategies.
|
|
26572
26957
|
let sortinoRatio = null;
|
|
26573
|
-
if (
|
|
26574
|
-
const
|
|
26575
|
-
|
|
26576
|
-
|
|
26577
|
-
|
|
26578
|
-
|
|
26579
|
-
|
|
26580
|
-
|
|
26581
|
-
|
|
26582
|
-
|
|
26583
|
-
|
|
26584
|
-
|
|
26585
|
-
const avgDurationMs = signals.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / signals.length;
|
|
26586
|
-
const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
|
|
26587
|
-
const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
|
|
26588
|
-
expectedYearlyReturns = avgPnl * tradesPerYear;
|
|
26958
|
+
if (canComputeRatios && avgPnl !== null) {
|
|
26959
|
+
const negativeReturns = signals
|
|
26960
|
+
.map((s) => s.pnl.pnlPercentage)
|
|
26961
|
+
.filter((r) => r < 0);
|
|
26962
|
+
if (negativeReturns.length > 0) {
|
|
26963
|
+
const downsideVariance = negativeReturns.reduce((acc, r) => acc + r * r, 0) / signals.length;
|
|
26964
|
+
const downsideDeviation = Math.sqrt(downsideVariance);
|
|
26965
|
+
// Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
|
|
26966
|
+
if (downsideDeviation > STDDEV_EPSILON) {
|
|
26967
|
+
sortinoRatio = avgPnl / downsideDeviation;
|
|
26968
|
+
}
|
|
26969
|
+
}
|
|
26589
26970
|
}
|
|
26971
|
+
// Expected yearly returns via geometric mean of equity curve.
|
|
26972
|
+
// equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
|
|
26973
|
+
// Gated by sample size and calendar span; if account blown → full loss.
|
|
26974
|
+
let expectedYearlyReturns = null;
|
|
26975
|
+
let tradesPerYear = null;
|
|
26976
|
+
if (signals.length >= MIN_SIGNALS_FOR_ANNUALIZATION) {
|
|
26977
|
+
let firstPendingAt = Infinity;
|
|
26978
|
+
let lastCloseAt = -Infinity;
|
|
26979
|
+
for (const s of signals) {
|
|
26980
|
+
if (s.signal.pendingAt < firstPendingAt)
|
|
26981
|
+
firstPendingAt = s.signal.pendingAt;
|
|
26982
|
+
if (s.closeTimestamp > lastCloseAt)
|
|
26983
|
+
lastCloseAt = s.closeTimestamp;
|
|
26984
|
+
}
|
|
26985
|
+
const calendarSpanDays = (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24);
|
|
26986
|
+
if (calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
|
|
26987
|
+
// tradesPerYear uses RAW observed frequency — no clipping. If the raw value
|
|
26988
|
+
// exceeds MAX_TRADES_PER_YEAR the sample is too clustered for reliable
|
|
26989
|
+
// annualization, and we leave the annualized metric null instead of silently
|
|
26990
|
+
// understating it with a clipped frequency.
|
|
26991
|
+
const rawTradesPerYear = (signals.length / calendarSpanDays) * 365;
|
|
26992
|
+
if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
|
|
26993
|
+
tradesPerYear = rawTradesPerYear;
|
|
26994
|
+
if (blown) {
|
|
26995
|
+
expectedYearlyReturns = -100;
|
|
26996
|
+
}
|
|
26997
|
+
else {
|
|
26998
|
+
// If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS, leave null rather than
|
|
26999
|
+
// show the cap — capped numbers mislead users into trusting them.
|
|
27000
|
+
const raw = (Math.pow(equityFinal, tradesPerYear / signals.length) - 1) * 100;
|
|
27001
|
+
expectedYearlyReturns = Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
|
|
27002
|
+
}
|
|
27003
|
+
}
|
|
27004
|
+
}
|
|
27005
|
+
}
|
|
27006
|
+
// Calmar = annualized return / equity-curve max drawdown, capped at ±MAX_CALMAR_RATIO.
|
|
27007
|
+
// Recovery Factor uses the compounded total return (equityFinal-1)*100, not arithmetic
|
|
27008
|
+
// totalPnl — denominator is compounded so numerator must match. Null when account blown.
|
|
26590
27009
|
let calmarRatio = null;
|
|
26591
27010
|
let recoveryFactor = null;
|
|
26592
|
-
if (
|
|
26593
|
-
|
|
26594
|
-
|
|
27011
|
+
if (maxDrawdown !== null && maxDrawdown > 0) {
|
|
27012
|
+
if (expectedYearlyReturns !== null) {
|
|
27013
|
+
const raw = expectedYearlyReturns / maxDrawdown;
|
|
27014
|
+
calmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, raw));
|
|
27015
|
+
}
|
|
27016
|
+
if (!blown && canComputeRatios) {
|
|
27017
|
+
// Gated below MIN_SIGNALS_FOR_RATIOS like Sharpe — a Recovery Factor on
|
|
27018
|
+
// a handful of trades is statistically meaningless, so don't surface it
|
|
27019
|
+
// per-symbol while Sharpe is N/A.
|
|
27020
|
+
// Same MAX_CALMAR_RATIO clamp as Calmar — both compounded-profit/DD ratios.
|
|
27021
|
+
const rawRec = ((equityFinal - 1) * 100) / maxDrawdown;
|
|
27022
|
+
recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
|
|
27023
|
+
}
|
|
26595
27024
|
}
|
|
26596
27025
|
// Apply safe math checks
|
|
26597
27026
|
if (isUnsafe(winRate))
|
|
@@ -26656,12 +27085,18 @@ class HeatmapStorage {
|
|
|
26656
27085
|
* 2. Sorts symbols by `sharpeRatio` descending — best performers first,
|
|
26657
27086
|
* symbols with `null` sharpeRatio placed at the end.
|
|
26658
27087
|
* 3. Computes portfolio-wide aggregates:
|
|
26659
|
-
* - `portfolioTotalPnl` — sum of
|
|
26660
|
-
*
|
|
26661
|
-
*
|
|
26662
|
-
*
|
|
26663
|
-
*
|
|
26664
|
-
*
|
|
27088
|
+
* - `portfolioTotalPnl` — sum of per-symbol `totalPnl` values, skipping `null` entries
|
|
27089
|
+
* (so a symbol with no data does not silently contribute 0). If every symbol's
|
|
27090
|
+
* `totalPnl` is null, the portfolio value is null.
|
|
27091
|
+
* - `portfolioTotalTrades` — sum of per-symbol `totalTrades`
|
|
27092
|
+
* - `portfolioSharpeRatio` — POOLED Sharpe over all trades across symbols (sample
|
|
27093
|
+
* stddev, N-1). NOT a Markowitz portfolio Sharpe — ignores cross-symbol
|
|
27094
|
+
* correlations and capital allocation. Rendered as "Pooled Sharpe" in the report.
|
|
27095
|
+
* Gated by `MIN_SIGNALS_FOR_RATIOS` on the pooled count.
|
|
27096
|
+
* - `portfolioAvgPeakPnl` / `portfolioAvgFallPnl` — trade-count-weighted means
|
|
27097
|
+
* over symbols that have non-null values.
|
|
27098
|
+
*
|
|
27099
|
+
* @returns Promise resolving to `HeatmapStatisticsModel`
|
|
26665
27100
|
*/
|
|
26666
27101
|
async getData() {
|
|
26667
27102
|
const symbols = [];
|
|
@@ -26680,31 +27115,53 @@ class HeatmapStorage {
|
|
|
26680
27115
|
return -1;
|
|
26681
27116
|
return b.sharpeRatio - a.sharpeRatio;
|
|
26682
27117
|
});
|
|
26683
|
-
//
|
|
27118
|
+
// Portfolio totals — sum only over symbols with non-null totalPnl. `s.totalPnl || 0`
|
|
27119
|
+
// would silently treat a missing value as zero and hide that some symbols had no data.
|
|
26684
27120
|
const totalSymbols = symbols.length;
|
|
26685
27121
|
let portfolioTotalPnl = null;
|
|
26686
27122
|
let portfolioTotalTrades = 0;
|
|
26687
27123
|
if (symbols.length > 0) {
|
|
26688
|
-
|
|
27124
|
+
const validTotalPnls = symbols.filter((s) => s.totalPnl !== null);
|
|
27125
|
+
portfolioTotalPnl = validTotalPnls.length > 0
|
|
27126
|
+
? validTotalPnls.reduce((acc, s) => acc + s.totalPnl, 0)
|
|
27127
|
+
: null;
|
|
26689
27128
|
portfolioTotalTrades = symbols.reduce((acc, s) => acc + s.totalTrades, 0);
|
|
26690
27129
|
}
|
|
26691
|
-
//
|
|
27130
|
+
// Pooled Sharpe over all returns across symbols. NOTE: this is NOT a Markowitz
|
|
27131
|
+
// portfolio Sharpe — it ignores cross-symbol correlations and treats trades as a
|
|
27132
|
+
// single pooled sample. Gated by MIN_SIGNALS_FOR_RATIOS so a 2-trade pool cannot
|
|
27133
|
+
// produce a noisy ±Sharpe.
|
|
26692
27134
|
let portfolioSharpeRatio = null;
|
|
26693
|
-
const
|
|
26694
|
-
|
|
26695
|
-
|
|
26696
|
-
|
|
27135
|
+
const allReturns = [];
|
|
27136
|
+
for (const signals of this.symbolData.values()) {
|
|
27137
|
+
for (const s of signals) {
|
|
27138
|
+
allReturns.push(s.pnl.pnlPercentage);
|
|
27139
|
+
}
|
|
27140
|
+
}
|
|
27141
|
+
if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
|
|
27142
|
+
const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
|
|
27143
|
+
const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
|
|
27144
|
+
(allReturns.length - 1);
|
|
27145
|
+
const portfolioStdDev = Math.sqrt(portfolioVariance);
|
|
27146
|
+
// STDDEV_EPSILON guard — same protection as per-symbol Sharpe.
|
|
27147
|
+
if (portfolioStdDev > STDDEV_EPSILON) {
|
|
27148
|
+
portfolioSharpeRatio = portfolioAvg / portfolioStdDev;
|
|
27149
|
+
}
|
|
26697
27150
|
}
|
|
26698
|
-
//
|
|
27151
|
+
// Portfolio-wide weighted average peak/fall PNL. Denominator must include only
|
|
27152
|
+
// symbols that contributed a value — otherwise trade-count-weighted mean is diluted
|
|
27153
|
+
// by symbols without the metric.
|
|
26699
27154
|
let portfolioAvgPeakPnl = null;
|
|
26700
27155
|
let portfolioAvgFallPnl = null;
|
|
26701
27156
|
const validPeak = symbols.filter((s) => s.avgPeakPnl !== null);
|
|
26702
27157
|
const validFall = symbols.filter((s) => s.avgFallPnl !== null);
|
|
26703
|
-
|
|
26704
|
-
|
|
27158
|
+
const peakTradesTotal = validPeak.reduce((acc, s) => acc + s.totalTrades, 0);
|
|
27159
|
+
const fallTradesTotal = validFall.reduce((acc, s) => acc + s.totalTrades, 0);
|
|
27160
|
+
if (validPeak.length > 0 && peakTradesTotal > 0) {
|
|
27161
|
+
portfolioAvgPeakPnl = validPeak.reduce((acc, s) => acc + s.avgPeakPnl * s.totalTrades, 0) / peakTradesTotal;
|
|
26705
27162
|
}
|
|
26706
|
-
if (validFall.length > 0 &&
|
|
26707
|
-
portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) /
|
|
27163
|
+
if (validFall.length > 0 && fallTradesTotal > 0) {
|
|
27164
|
+
portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
|
|
26708
27165
|
}
|
|
26709
27166
|
// Apply safe math
|
|
26710
27167
|
if (isUnsafe(portfolioTotalPnl))
|
|
@@ -26732,7 +27189,7 @@ class HeatmapStorage {
|
|
|
26732
27189
|
* ```
|
|
26733
27190
|
* # Portfolio Heatmap: {strategyName}
|
|
26734
27191
|
*
|
|
26735
|
-
* **Total Symbols:** N | **Portfolio PNL:** X% | **
|
|
27192
|
+
* **Total Symbols:** N | **Portfolio PNL:** X% | **Pooled Sharpe:** Y | **Total Trades:** Z
|
|
26736
27193
|
*
|
|
26737
27194
|
* | col1 | col2 | ... |
|
|
26738
27195
|
* | --- | --- | ... |
|
|
@@ -26771,18 +27228,21 @@ class HeatmapStorage {
|
|
|
26771
27228
|
return [
|
|
26772
27229
|
`# Portfolio Heatmap: ${strategyName}`,
|
|
26773
27230
|
"",
|
|
26774
|
-
`**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"} | **
|
|
27231
|
+
`**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? functoolsKit.str(data.portfolioTotalPnl, "%") : "N/A"} | **Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? functoolsKit.str(data.portfolioSharpeRatio) : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades} | **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? functoolsKit.str(data.portfolioAvgPeakPnl, "%") : "N/A"} | **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? functoolsKit.str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
|
|
26775
27232
|
"",
|
|
26776
27233
|
table,
|
|
26777
27234
|
"",
|
|
26778
27235
|
`*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
|
|
27236
|
+
`*Pooled Sharpe: Sharpe computed over all trades across symbols treated as one sample. NOT a Markowitz portfolio Sharpe — ignores cross-symbol correlations and capital allocation. N/A unless ≥${MIN_SIGNALS_FOR_RATIOS} pooled trades.*`,
|
|
26779
27237
|
`*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
|
|
26780
|
-
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
27238
|
+
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals. N/A when no losing trades — Sortino is mathematically undefined (infinite) and we cannot distinguish "truly flawless" from "lucky streak so far".*`,
|
|
26781
27239
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
26782
27240
|
`*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
|
|
26783
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong.
|
|
26784
|
-
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
|
|
26785
|
-
`*All metrics require 100+ signals per symbol to be statistically reliable.
|
|
27241
|
+
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong. Denominator is compounded equity-curve max drawdown. N/A unless ≥${MIN_SIGNALS_FOR_ANNUALIZATION} signals per symbol and span ≥${MIN_CALENDAR_SPAN_DAYS} days. Capped at ±${MAX_CALMAR_RATIO}.*`,
|
|
27242
|
+
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good. Uses compounded total return as numerator.*`,
|
|
27243
|
+
`*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
|
|
27244
|
+
`*IMPORTANT: Per-symbol equity curve, Expected Yearly Returns, Calmar, Recovery and Max Drawdown all assume **100% capital allocation per trade** (no sizing, no portfolio fraction). If your strategy risks X% of capital per trade, the realized return / drawdown will be roughly X/100 of the reported figures. The framework does not track portfolio-level sizing, so these metrics represent a theoretical upper bound under full allocation.*`,
|
|
27245
|
+
`*Negative values for Sharpe / Sortino / Calmar / Recovery indicate a losing symbol (avgPnl < 0 or totalPnl < 0). "Higher is better" still applies — closer to zero is less bad, positive is profitable.*`,
|
|
26786
27246
|
].join("\n");
|
|
26787
27247
|
}
|
|
26788
27248
|
/**
|
|
@@ -26977,7 +27437,7 @@ class HeatMarkdownService {
|
|
|
26977
27437
|
* console.log(markdown);
|
|
26978
27438
|
* // # Portfolio Heatmap: my-strategy
|
|
26979
27439
|
* //
|
|
26980
|
-
* // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **
|
|
27440
|
+
* // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **Pooled Sharpe:** 1.85 | **Total Trades:** 120
|
|
26981
27441
|
* //
|
|
26982
27442
|
* // | Symbol | Total PNL | Sharpe | Max DD | Trades |
|
|
26983
27443
|
* // | --- | --- | --- | --- | --- |
|
|
@@ -37129,8 +37589,9 @@ function getActionSchema(actionName) {
|
|
|
37129
37589
|
}
|
|
37130
37590
|
|
|
37131
37591
|
const WAIT_FOR_READY_METHOD_NAME = "init.waitForReady";
|
|
37132
|
-
const MAX_WAIT_SECONDS =
|
|
37592
|
+
const MAX_WAIT_SECONDS = 45;
|
|
37133
37593
|
const SECOND_DELAY = 1000;
|
|
37594
|
+
const TIMEOUT_SYMBOL = Symbol('timeout');
|
|
37134
37595
|
/**
|
|
37135
37596
|
* Blocks until the schema registries needed to start trading are populated.
|
|
37136
37597
|
*
|
|
@@ -37168,6 +37629,18 @@ const SECOND_DELAY = 1000;
|
|
|
37168
37629
|
*/
|
|
37169
37630
|
async function waitForReady(isBacktest = true) {
|
|
37170
37631
|
backtest.loggerService.info(WAIT_FOR_READY_METHOD_NAME, { isBacktest });
|
|
37632
|
+
if (entrySubject.data) {
|
|
37633
|
+
return;
|
|
37634
|
+
}
|
|
37635
|
+
if (entrySubject.hasListeners) {
|
|
37636
|
+
backtest.loggerService.debug(`${WAIT_FOR_READY_METHOD_NAME} waiting for entrySubject`);
|
|
37637
|
+
const result = await Promise.race([
|
|
37638
|
+
entrySubject.toPromise(),
|
|
37639
|
+
functoolsKit.sleep(MAX_WAIT_SECONDS * SECOND_DELAY).then(() => TIMEOUT_SYMBOL)
|
|
37640
|
+
]);
|
|
37641
|
+
typeof result === "symbol" && console.log("waitForReady timeout");
|
|
37642
|
+
return;
|
|
37643
|
+
}
|
|
37171
37644
|
for (let i = 0; i !== MAX_WAIT_SECONDS; i++) {
|
|
37172
37645
|
const [exchangeList, frameList, strategyList] = await Promise.all([
|
|
37173
37646
|
backtest.exchangeValidationService.list(),
|
|
@@ -37198,6 +37671,9 @@ async function waitForReady(isBacktest = true) {
|
|
|
37198
37671
|
await functoolsKit.sleep(SECOND_DELAY);
|
|
37199
37672
|
continue;
|
|
37200
37673
|
}
|
|
37674
|
+
if (i === MAX_WAIT_SECONDS - 1) {
|
|
37675
|
+
console.log("waitForReady timeout");
|
|
37676
|
+
}
|
|
37201
37677
|
break;
|
|
37202
37678
|
}
|
|
37203
37679
|
}
|
|
@@ -63283,6 +63759,7 @@ const CRON_METHOD_NAME_CLEAR = "CronUtils.clear";
|
|
|
63283
63759
|
const CRON_METHOD_NAME_TICK = "CronUtils._tick";
|
|
63284
63760
|
const CRON_METHOD_NAME_ENABLE = "CronUtils.enable";
|
|
63285
63761
|
const CRON_METHOD_NAME_DISABLE = "CronUtils.disable";
|
|
63762
|
+
const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
|
|
63286
63763
|
/**
|
|
63287
63764
|
* Local logger instance.
|
|
63288
63765
|
*
|
|
@@ -63672,6 +64149,38 @@ class CronUtils {
|
|
|
63672
64149
|
lastSubscription();
|
|
63673
64150
|
}
|
|
63674
64151
|
};
|
|
64152
|
+
/**
|
|
64153
|
+
* Hard-reset the entire `Cron` state.
|
|
64154
|
+
*
|
|
64155
|
+
* Performs in order:
|
|
64156
|
+
* 1. {@link disable} — tears down lifecycle subscriptions and resets the
|
|
64157
|
+
* `enable` singleshot so a future `enable()` re-subscribes cleanly.
|
|
64158
|
+
* 2. Wipes `_entries` — every {@link register}'ed entry is forgotten.
|
|
64159
|
+
* Disposers returned by previous `register()` calls become no-ops
|
|
64160
|
+
* (their `unregister(name)` will not find anything to remove).
|
|
64161
|
+
* 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
|
|
64162
|
+
* re-registration of the same `name` fires again on the next matching
|
|
64163
|
+
* tick.
|
|
64164
|
+
* 4. Does **not** touch `_inFlight` — in-flight handlers continue to
|
|
64165
|
+
* settle in the background and clear their own slots via `.finally()`.
|
|
64166
|
+
* Their final `_firedOnce.add(firedKey)` writes carry old-generation
|
|
64167
|
+
* keys and are harmless (lookup uses the post-dispose generation).
|
|
64168
|
+
*
|
|
64169
|
+
* Use from a CLI/session teardown when you want to throw away every
|
|
64170
|
+
* registration along with the lifecycle wiring — e.g. between two
|
|
64171
|
+
* independent runner scopes. For "just snap the subscriptions but keep
|
|
64172
|
+
* registrations" use {@link disable} instead; for "just re-arm fire-once
|
|
64173
|
+
* marks" use {@link clear}.
|
|
64174
|
+
*
|
|
64175
|
+
* Idempotent. Safe to call multiple times and safe to call before
|
|
64176
|
+
* `enable()` / without any registrations.
|
|
64177
|
+
*/
|
|
64178
|
+
this.dispose = () => {
|
|
64179
|
+
LOGGER_SERVICE$1.info(CRON_METHOD_NAME_DISPOSE);
|
|
64180
|
+
this.disable();
|
|
64181
|
+
this._entries.clear();
|
|
64182
|
+
this._firedOnce.clear();
|
|
64183
|
+
};
|
|
63675
64184
|
}
|
|
63676
64185
|
/**
|
|
63677
64186
|
* Garbage-collect every `_firedOnce` key that belongs to the entry `name`
|