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.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createActivator } from 'di-kit';
|
|
2
2
|
import { scoped } from 'di-scoped';
|
|
3
3
|
import { singleton } from 'di-singleton';
|
|
4
|
-
import { Subject, makeExtendable, singleshot, getErrorMessage, memoize, not, errorData, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, Source, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$
|
|
4
|
+
import { Subject, BehaviorSubject, makeExtendable, singleshot, getErrorMessage, memoize, not, errorData, trycatch, retry, queued, sleep, randomString, str, isObject, ToolRegistry, typo, and, Source, resolveDocuments, timeout, TIMEOUT_SYMBOL as TIMEOUT_SYMBOL$2, compose, waitForNext, singlerun } from 'functools-kit';
|
|
5
5
|
import * as fs from 'fs/promises';
|
|
6
6
|
import fs__default from 'fs/promises';
|
|
7
7
|
import path, { join, dirname } from 'path';
|
|
@@ -798,6 +798,13 @@ const beforeStartSubject = new Subject();
|
|
|
798
798
|
* Emits when the engine has completed processing a signal.
|
|
799
799
|
*/
|
|
800
800
|
const afterEndSubject = new Subject();
|
|
801
|
+
/**
|
|
802
|
+
* Emitter for `@backtest-kit/cli`, which notifies the application
|
|
803
|
+
* that all modules have been initialized.
|
|
804
|
+
*
|
|
805
|
+
* Send entry absolute path to the consumer
|
|
806
|
+
*/
|
|
807
|
+
const entrySubject = new BehaviorSubject();
|
|
801
808
|
|
|
802
809
|
var emitters = /*#__PURE__*/Object.freeze({
|
|
803
810
|
__proto__: null,
|
|
@@ -809,6 +816,7 @@ var emitters = /*#__PURE__*/Object.freeze({
|
|
|
809
816
|
doneBacktestSubject: doneBacktestSubject,
|
|
810
817
|
doneLiveSubject: doneLiveSubject,
|
|
811
818
|
doneWalkerSubject: doneWalkerSubject,
|
|
819
|
+
entrySubject: entrySubject,
|
|
812
820
|
errorEmitter: errorEmitter,
|
|
813
821
|
exitEmitter: exitEmitter,
|
|
814
822
|
highestProfitSubject: highestProfitSubject,
|
|
@@ -6564,7 +6572,7 @@ const INTERVAL_MINUTES$8 = {
|
|
|
6564
6572
|
* Used to indicate that the actual pendingAt will be set upon activation.
|
|
6565
6573
|
*/
|
|
6566
6574
|
const SCHEDULED_SIGNAL_PENDING_MOCK = 0;
|
|
6567
|
-
const TIMEOUT_SYMBOL = Symbol('timeout');
|
|
6575
|
+
const TIMEOUT_SYMBOL$1 = Symbol('timeout');
|
|
6568
6576
|
/**
|
|
6569
6577
|
* Calls onSignalSync callback for signal-open event.
|
|
6570
6578
|
*
|
|
@@ -6986,7 +6994,7 @@ const GET_SIGNAL_FN = trycatch(async (self) => {
|
|
|
6986
6994
|
const timeoutMs = GLOBAL_CONFIG.CC_MAX_SIGNAL_GENERATION_SECONDS * 1000;
|
|
6987
6995
|
const signal = await Promise.race([
|
|
6988
6996
|
self.params.getSignal(self.params.execution.context.symbol, self.params.execution.context.when, currentPrice),
|
|
6989
|
-
sleep(timeoutMs).then(() => TIMEOUT_SYMBOL),
|
|
6997
|
+
sleep(timeoutMs).then(() => TIMEOUT_SYMBOL$1),
|
|
6990
6998
|
]);
|
|
6991
6999
|
if (typeof signal === "symbol") {
|
|
6992
7000
|
throw new Error(`Timeout for ${self.params.method.context.strategyName} symbol=${self.params.execution.context.symbol}`);
|
|
@@ -23252,7 +23260,7 @@ class MarkdownFileBase {
|
|
|
23252
23260
|
timestamp: getContextTimestamp(),
|
|
23253
23261
|
}) + "\n";
|
|
23254
23262
|
const status = await this[WRITE_SAFE_SYMBOL$1](line);
|
|
23255
|
-
if (status === TIMEOUT_SYMBOL$
|
|
23263
|
+
if (status === TIMEOUT_SYMBOL$2) {
|
|
23256
23264
|
throw new Error(`Timeout writing to markdown ${this.markdownName}`);
|
|
23257
23265
|
}
|
|
23258
23266
|
}
|
|
@@ -23545,7 +23553,7 @@ class ReportBase {
|
|
|
23545
23553
|
timestamp: getContextTimestamp(),
|
|
23546
23554
|
}) + "\n";
|
|
23547
23555
|
const status = await this[WRITE_SAFE_SYMBOL$1](line);
|
|
23548
|
-
if (status === TIMEOUT_SYMBOL$
|
|
23556
|
+
if (status === TIMEOUT_SYMBOL$2) {
|
|
23549
23557
|
throw new Error(`Timeout writing to report ${this.reportName}`);
|
|
23550
23558
|
}
|
|
23551
23559
|
}
|
|
@@ -23706,7 +23714,7 @@ const CREATE_FILE_NAME_FN$c = (symbol, strategyName, exchangeName, frameName, ti
|
|
|
23706
23714
|
* @param value - Value to check
|
|
23707
23715
|
* @returns true if value is unsafe, false otherwise
|
|
23708
23716
|
*/
|
|
23709
|
-
function isUnsafe$
|
|
23717
|
+
function isUnsafe$4(value) {
|
|
23710
23718
|
if (typeof value !== "number") {
|
|
23711
23719
|
return true;
|
|
23712
23720
|
}
|
|
@@ -23718,6 +23726,25 @@ function isUnsafe$3(value) {
|
|
|
23718
23726
|
}
|
|
23719
23727
|
return false;
|
|
23720
23728
|
}
|
|
23729
|
+
/** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
|
|
23730
|
+
const MIN_SIGNALS_FOR_ANNUALIZATION$2 = 10;
|
|
23731
|
+
/** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
|
|
23732
|
+
* sample size is too small to estimate variance meaningfully. */
|
|
23733
|
+
const MIN_SIGNALS_FOR_RATIOS$2 = 10;
|
|
23734
|
+
/** Minimum calendar span (days) for trade-frequency extrapolation. */
|
|
23735
|
+
const MIN_CALENDAR_SPAN_DAYS$2 = 14;
|
|
23736
|
+
/** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
|
|
23737
|
+
const MAX_TRADES_PER_YEAR$2 = 365;
|
|
23738
|
+
/** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
|
|
23739
|
+
* blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
|
|
23740
|
+
* anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
|
|
23741
|
+
const MAX_EXPECTED_YEARLY_RETURNS$2 = 100;
|
|
23742
|
+
/** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
|
|
23743
|
+
const MAX_CALMAR_RATIO$2 = 1000;
|
|
23744
|
+
/** Minimum stdDev required for Sharpe/Sortino computation. Identical-returns series produce
|
|
23745
|
+
* float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously inflates
|
|
23746
|
+
* sharpe to astronomical values. Treat any stdDev below this threshold as zero. */
|
|
23747
|
+
const STDDEV_EPSILON$2 = 1e-9;
|
|
23721
23748
|
/**
|
|
23722
23749
|
* Storage class for accumulating closed signals per strategy.
|
|
23723
23750
|
* Maintains a list of all closed signals and provides methods to generate reports.
|
|
@@ -23771,65 +23798,190 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23771
23798
|
recoveryFactor: null,
|
|
23772
23799
|
};
|
|
23773
23800
|
}
|
|
23774
|
-
|
|
23775
|
-
|
|
23776
|
-
|
|
23777
|
-
//
|
|
23778
|
-
|
|
23779
|
-
|
|
23780
|
-
|
|
23781
|
-
|
|
23782
|
-
|
|
23783
|
-
const
|
|
23784
|
-
const
|
|
23785
|
-
const
|
|
23786
|
-
|
|
23787
|
-
//
|
|
23788
|
-
const
|
|
23789
|
-
|
|
23801
|
+
// Valid signal set — those with usable pendingAt AND closeTimestamp. Single source
|
|
23802
|
+
// of truth for EVERY metric in this method (counts, sums, span, equity curve,
|
|
23803
|
+
// ratios, annualization). If we used different subsets for different metrics, the
|
|
23804
|
+
// numerator of one ratio could be drawn from a different population than the
|
|
23805
|
+
// denominator of another and the report would silently lie. On clean data
|
|
23806
|
+
// validSignals === this._signalList; the filter only matters for corrupted runtime
|
|
23807
|
+
// data.
|
|
23808
|
+
const validSignals = this._signalList.filter((s) => typeof s.signal.pendingAt === "number" && s.signal.pendingAt > 0 &&
|
|
23809
|
+
typeof s.closeTimestamp === "number" && s.closeTimestamp > 0);
|
|
23810
|
+
const totalSignals = validSignals.length;
|
|
23811
|
+
const winCount = validSignals.filter((s) => s.pnl.pnlPercentage > 0).length;
|
|
23812
|
+
const lossCount = validSignals.filter((s) => s.pnl.pnlPercentage < 0).length;
|
|
23813
|
+
// Basic statistics — guard against an empty validSignals (e.g. every signal had
|
|
23814
|
+
// corrupted timestamps) so we don't divide by zero.
|
|
23815
|
+
const avgPnl = totalSignals > 0
|
|
23816
|
+
? validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / totalSignals
|
|
23817
|
+
: 0;
|
|
23818
|
+
const totalPnl = validSignals.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0);
|
|
23819
|
+
// Win rate excludes break-even trades from both numerator and denominator.
|
|
23820
|
+
const decisiveTrades = winCount + lossCount;
|
|
23821
|
+
const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
|
|
23822
|
+
// Calendar span over the same validSignals set used for ratios.
|
|
23823
|
+
let firstPendingAt = Infinity;
|
|
23824
|
+
let lastCloseAt = -Infinity;
|
|
23825
|
+
for (const s of validSignals) {
|
|
23826
|
+
if (s.signal.pendingAt < firstPendingAt)
|
|
23827
|
+
firstPendingAt = s.signal.pendingAt;
|
|
23828
|
+
if (s.closeTimestamp > lastCloseAt)
|
|
23829
|
+
lastCloseAt = s.closeTimestamp;
|
|
23830
|
+
}
|
|
23831
|
+
const calendarSpanDays = isFinite(firstPendingAt) && isFinite(lastCloseAt)
|
|
23832
|
+
? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
|
|
23833
|
+
: 0;
|
|
23834
|
+
// tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
|
|
23835
|
+
// silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
|
|
23836
|
+
// raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
|
|
23837
|
+
// for reliable annualization and surface every annualized metric as null.
|
|
23838
|
+
const rawTradesPerYear = totalSignals >= MIN_SIGNALS_FOR_ANNUALIZATION$2 &&
|
|
23839
|
+
calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$2
|
|
23840
|
+
? (totalSignals / calendarSpanDays) * 365
|
|
23841
|
+
: 0;
|
|
23842
|
+
const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$2;
|
|
23843
|
+
const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
|
|
23844
|
+
// Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1) for unbiased estimate.
|
|
23845
|
+
// Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
|
|
23846
|
+
// are too noisy to publish (high chance of spurious ±Sharpe).
|
|
23847
|
+
const returns = validSignals.map((s) => s.pnl.pnlPercentage);
|
|
23848
|
+
const canComputeRatios = totalSignals >= MIN_SIGNALS_FOR_RATIOS$2;
|
|
23849
|
+
const stdDev = canComputeRatios
|
|
23850
|
+
? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (totalSignals - 1))
|
|
23851
|
+
: 0;
|
|
23852
|
+
// Use STDDEV_EPSILON gate (not stdDev > 0) — identical-returns series produce
|
|
23853
|
+
// float-artifact stdDev (~1e-17) that's mathematically > 0 but spuriously
|
|
23854
|
+
// inflates sharpe to astronomical magnitudes (avgPnl / epsilon).
|
|
23855
|
+
const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$2
|
|
23856
|
+
? avgPnl / stdDev
|
|
23857
|
+
: null;
|
|
23858
|
+
// Annualize only when gate passes; otherwise null.
|
|
23859
|
+
const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
|
|
23860
|
+
? sharpeRatio * Math.sqrt(tradesPerYear)
|
|
23861
|
+
: null;
|
|
23862
|
+
// Equity-curve max drawdown via compounded equity (multiplicative, not additive).
|
|
23863
|
+
// Returns are per-trade on cost basis — compounding assumes equal capital allocation
|
|
23864
|
+
// per trade ("as-if 100% allocation"). Walks validSignals in chronological order
|
|
23865
|
+
// (storage is newest-first, so iterate in reverse). Using validSignals (same set as
|
|
23866
|
+
// tradesPerYear) keeps equityFinal consistent with the annualization exponent.
|
|
23867
|
+
// If equity goes ≤ 0 (e.g. leveraged short with r < -100%) — account blown,
|
|
23868
|
+
// fix DD at 100% and stop walking the curve.
|
|
23869
|
+
let equity = 1;
|
|
23870
|
+
let peak = 1;
|
|
23871
|
+
let equityMaxDrawdown = 0;
|
|
23872
|
+
let blown = false;
|
|
23873
|
+
for (let i = validSignals.length - 1; i >= 0; i--) {
|
|
23874
|
+
equity *= 1 + validSignals[i].pnl.pnlPercentage / 100;
|
|
23875
|
+
if (equity <= 0) {
|
|
23876
|
+
equityMaxDrawdown = 100;
|
|
23877
|
+
blown = true;
|
|
23878
|
+
break;
|
|
23879
|
+
}
|
|
23880
|
+
if (equity > peak)
|
|
23881
|
+
peak = equity;
|
|
23882
|
+
const dd = (peak - equity) / peak * 100;
|
|
23883
|
+
if (dd > equityMaxDrawdown)
|
|
23884
|
+
equityMaxDrawdown = dd;
|
|
23885
|
+
}
|
|
23886
|
+
const equityFinal = blown ? 0 : equity;
|
|
23887
|
+
// Compounded yearly return via geometric mean of equity curve.
|
|
23888
|
+
// equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag that
|
|
23889
|
+
// arithmetic-mean compounding ((1+avgPnl)^N) misses. If account is blown, full loss.
|
|
23890
|
+
// If the raw value would exceed MAX_EXPECTED_YEARLY_RETURNS, return null rather than
|
|
23891
|
+
// showing the cap as a real figure — capped numbers mislead users into trusting them.
|
|
23892
|
+
const expectedYearlyReturns = canAnnualize
|
|
23893
|
+
? blown
|
|
23894
|
+
? -100
|
|
23895
|
+
: (() => {
|
|
23896
|
+
// Geometric annualization uses validSignals.length (same set that defined
|
|
23897
|
+
// tradesPerYear); using totalSignals here would mismatch numerator/denominator.
|
|
23898
|
+
const raw = (Math.pow(equityFinal, tradesPerYear / validSignals.length) - 1) * 100;
|
|
23899
|
+
return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$2 ? null : raw;
|
|
23900
|
+
})()
|
|
23901
|
+
: null;
|
|
23902
|
+
// Certainty Ratio — over validSignals so wins/losses come from the same set as
|
|
23903
|
+
// winCount/lossCount/avgPnl above.
|
|
23904
|
+
const wins = validSignals.filter((s) => s.pnl.pnlPercentage > 0);
|
|
23905
|
+
const losses = validSignals.filter((s) => s.pnl.pnlPercentage < 0);
|
|
23790
23906
|
const avgWin = wins.length > 0
|
|
23791
23907
|
? wins.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / wins.length
|
|
23792
23908
|
: 0;
|
|
23793
23909
|
const avgLoss = losses.length > 0
|
|
23794
23910
|
? losses.reduce((sum, s) => sum + s.pnl.pnlPercentage, 0) / losses.length
|
|
23795
23911
|
: 0;
|
|
23796
|
-
|
|
23797
|
-
//
|
|
23798
|
-
|
|
23799
|
-
|
|
23800
|
-
|
|
23801
|
-
|
|
23802
|
-
|
|
23803
|
-
|
|
23804
|
-
|
|
23805
|
-
//
|
|
23806
|
-
|
|
23807
|
-
|
|
23808
|
-
|
|
23809
|
-
|
|
23810
|
-
const
|
|
23811
|
-
|
|
23812
|
-
|
|
23813
|
-
const
|
|
23814
|
-
|
|
23912
|
+
// Null below MIN_SIGNALS_FOR_RATIOS — on a handful of trades the win/loss
|
|
23913
|
+
// means are too noisy to publish a ratio (same sample-size gate as Sharpe/
|
|
23914
|
+
// Sortino, so the report doesn't surface certainty while withholding the rest).
|
|
23915
|
+
// Also null when no losing trades OR when |avgLoss| is below STDDEV_EPSILON
|
|
23916
|
+
// (float-artifact losses (-1e-15) would otherwise produce a spurious
|
|
23917
|
+
// astronomical certaintyRatio ≈1e14).
|
|
23918
|
+
const certaintyRatio = canComputeRatios && Math.abs(avgLoss) > STDDEV_EPSILON$2 && avgLoss < 0
|
|
23919
|
+
? avgWin / Math.abs(avgLoss)
|
|
23920
|
+
: null;
|
|
23921
|
+
// Average peak/fall PNL — over validSignals; only signals that actually have the
|
|
23922
|
+
// value contribute (no zero dilution from missing peakProfit/maxDrawdown).
|
|
23923
|
+
const peakValues = validSignals
|
|
23924
|
+
.map((s) => s.signal.peakProfit?.pnlPercentage)
|
|
23925
|
+
.filter((v) => typeof v === "number");
|
|
23926
|
+
const fallValues = validSignals
|
|
23927
|
+
.map((s) => s.signal.maxDrawdown?.pnlPercentage)
|
|
23928
|
+
.filter((v) => typeof v === "number");
|
|
23929
|
+
const avgPeakPnl = peakValues.length > 0
|
|
23930
|
+
? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
|
|
23931
|
+
: null;
|
|
23932
|
+
const avgFallPnl = fallValues.length > 0
|
|
23933
|
+
? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
|
|
23934
|
+
: null;
|
|
23935
|
+
// Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
|
|
23936
|
+
// downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
|
|
23937
|
+
// so the numerator reduces to avgPnl and the squared term to r² for r < 0.
|
|
23938
|
+
// Dividing by N_total (not N_negative) properly penalises strategies with frequent
|
|
23939
|
+
// losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
|
|
23940
|
+
// strategies.
|
|
23941
|
+
const negativeReturns = returns.filter((r) => r < 0);
|
|
23942
|
+
const sortinoRatio = (() => {
|
|
23943
|
+
if (!canComputeRatios)
|
|
23944
|
+
return null;
|
|
23945
|
+
if (negativeReturns.length === 0)
|
|
23946
|
+
return null;
|
|
23947
|
+
const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
|
|
23948
|
+
const downsideDeviation = Math.sqrt(downsideVariance);
|
|
23949
|
+
// Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
|
|
23950
|
+
return downsideDeviation > STDDEV_EPSILON$2 ? avgPnl / downsideDeviation : null;
|
|
23951
|
+
})();
|
|
23952
|
+
// Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
|
|
23953
|
+
const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
|
|
23954
|
+
? Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, expectedYearlyReturns / equityMaxDrawdown))
|
|
23955
|
+
: null;
|
|
23956
|
+
// Recovery Factor: numerator must be the compounded total return (equityFinal − 1) × 100,
|
|
23957
|
+
// not the arithmetic totalPnl — denominator (equityMaxDrawdown) is from the compounded
|
|
23958
|
+
// curve, so mixing units would inflate Recovery on long winning streaks.
|
|
23959
|
+
// Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
|
|
23960
|
+
// so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
|
|
23961
|
+
// Null when account is blown — ratio is meaningless after total loss.
|
|
23962
|
+
// Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
|
|
23963
|
+
// and explode the same way when DD is near zero.
|
|
23964
|
+
const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
|
|
23965
|
+
? null
|
|
23966
|
+
: Math.max(-MAX_CALMAR_RATIO$2, Math.min(MAX_CALMAR_RATIO$2, ((equityFinal - 1) * 100) / equityMaxDrawdown));
|
|
23815
23967
|
return {
|
|
23816
23968
|
signalList: this._signalList,
|
|
23817
23969
|
totalSignals,
|
|
23818
23970
|
winCount,
|
|
23819
23971
|
lossCount,
|
|
23820
|
-
winRate: isUnsafe$
|
|
23821
|
-
avgPnl: isUnsafe$
|
|
23822
|
-
totalPnl: isUnsafe$
|
|
23823
|
-
stdDev: isUnsafe$
|
|
23824
|
-
sharpeRatio: isUnsafe$
|
|
23825
|
-
annualizedSharpeRatio: isUnsafe$
|
|
23826
|
-
certaintyRatio: isUnsafe$
|
|
23827
|
-
expectedYearlyReturns: isUnsafe$
|
|
23828
|
-
avgPeakPnl: isUnsafe$
|
|
23829
|
-
avgFallPnl: isUnsafe$
|
|
23830
|
-
sortinoRatio: isUnsafe$
|
|
23831
|
-
calmarRatio: isUnsafe$
|
|
23832
|
-
recoveryFactor: isUnsafe$
|
|
23972
|
+
winRate: isUnsafe$4(winRate) ? null : winRate,
|
|
23973
|
+
avgPnl: isUnsafe$4(avgPnl) ? null : avgPnl,
|
|
23974
|
+
totalPnl: isUnsafe$4(totalPnl) ? null : totalPnl,
|
|
23975
|
+
stdDev: isUnsafe$4(stdDev) ? null : stdDev,
|
|
23976
|
+
sharpeRatio: isUnsafe$4(sharpeRatio) ? null : sharpeRatio,
|
|
23977
|
+
annualizedSharpeRatio: isUnsafe$4(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
|
|
23978
|
+
certaintyRatio: isUnsafe$4(certaintyRatio) ? null : certaintyRatio,
|
|
23979
|
+
expectedYearlyReturns: isUnsafe$4(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
23980
|
+
avgPeakPnl: isUnsafe$4(avgPeakPnl) ? null : avgPeakPnl,
|
|
23981
|
+
avgFallPnl: isUnsafe$4(avgFallPnl) ? null : avgFallPnl,
|
|
23982
|
+
sortinoRatio: isUnsafe$4(sortinoRatio) ? null : sortinoRatio,
|
|
23983
|
+
calmarRatio: isUnsafe$4(calmarRatio) ? null : calmarRatio,
|
|
23984
|
+
recoveryFactor: isUnsafe$4(recoveryFactor) ? null : recoveryFactor,
|
|
23833
23985
|
};
|
|
23834
23986
|
}
|
|
23835
23987
|
/**
|
|
@@ -23871,24 +24023,26 @@ let ReportStorage$a = class ReportStorage {
|
|
|
23871
24023
|
`**Total PNL:** ${stats.totalPnl === null ? "N/A" : `${stats.totalPnl > 0 ? "+" : ""}${stats.totalPnl.toFixed(2)}% (higher is better)`}`,
|
|
23872
24024
|
`**Standard Deviation:** ${stats.stdDev === null ? "N/A" : `${stats.stdDev.toFixed(3)}% (lower is better)`}`,
|
|
23873
24025
|
`**Sharpe Ratio:** ${stats.sharpeRatio === null ? "N/A" : `${stats.sharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
23874
|
-
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better
|
|
24026
|
+
`**Annualized Sharpe Ratio:** ${stats.annualizedSharpeRatio === null ? "N/A" : `${stats.annualizedSharpeRatio.toFixed(3)} (higher is better)`}`,
|
|
23875
24027
|
`**Certainty Ratio:** ${stats.certaintyRatio === null ? "N/A" : `${stats.certaintyRatio.toFixed(3)} (higher is better)`}`,
|
|
23876
|
-
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better
|
|
24028
|
+
`**Expected Yearly Returns:** ${stats.expectedYearlyReturns === null ? "N/A" : `${stats.expectedYearlyReturns > 0 ? "+" : ""}${stats.expectedYearlyReturns.toFixed(2)}% (higher is better)`}`,
|
|
23877
24029
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
23878
24030
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
23879
24031
|
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
23880
|
-
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better
|
|
24032
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
23881
24033
|
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
23882
24034
|
"",
|
|
23883
24035
|
`*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
|
|
23884
24036
|
`*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
23885
|
-
`*Annualized Sharpe Ratio:
|
|
23886
|
-
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
24037
|
+
`*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.*`,
|
|
24038
|
+
`*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".*`,
|
|
23887
24039
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
23888
|
-
`*Expected Yearly Returns:
|
|
23889
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong.
|
|
23890
|
-
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
|
|
23891
|
-
`*All metrics require 100+ signals to be statistically reliable.
|
|
24040
|
+
`*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.*`,
|
|
24041
|
+
`*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}.*`,
|
|
24042
|
+
`*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.*`,
|
|
24043
|
+
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24044
|
+
`*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.*`,
|
|
24045
|
+
`*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.*`,
|
|
23892
24046
|
].join("\n");
|
|
23893
24047
|
}
|
|
23894
24048
|
/**
|
|
@@ -24200,7 +24354,7 @@ const CREATE_FILE_NAME_FN$b = (symbol, strategyName, exchangeName, frameName, ti
|
|
|
24200
24354
|
* @param value - Value to check
|
|
24201
24355
|
* @returns true if value is unsafe, false otherwise
|
|
24202
24356
|
*/
|
|
24203
|
-
function isUnsafe$
|
|
24357
|
+
function isUnsafe$3(value) {
|
|
24204
24358
|
if (typeof value !== "number") {
|
|
24205
24359
|
return true;
|
|
24206
24360
|
}
|
|
@@ -24212,6 +24366,25 @@ function isUnsafe$2(value) {
|
|
|
24212
24366
|
}
|
|
24213
24367
|
return false;
|
|
24214
24368
|
}
|
|
24369
|
+
/** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
|
|
24370
|
+
const MIN_SIGNALS_FOR_ANNUALIZATION$1 = 10;
|
|
24371
|
+
/** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
|
|
24372
|
+
* sample size is too small to estimate variance meaningfully. */
|
|
24373
|
+
const MIN_SIGNALS_FOR_RATIOS$1 = 10;
|
|
24374
|
+
/** Minimum calendar span (days) for trade-frequency extrapolation. */
|
|
24375
|
+
const MIN_CALENDAR_SPAN_DAYS$1 = 14;
|
|
24376
|
+
/** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
|
|
24377
|
+
const MAX_TRADES_PER_YEAR$1 = 365;
|
|
24378
|
+
/** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
|
|
24379
|
+
* blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
|
|
24380
|
+
* anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
|
|
24381
|
+
const MAX_EXPECTED_YEARLY_RETURNS$1 = 100;
|
|
24382
|
+
/** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
|
|
24383
|
+
const MAX_CALMAR_RATIO$1 = 1000;
|
|
24384
|
+
/** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
|
|
24385
|
+
* float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
|
|
24386
|
+
* astronomical magnitudes (avgPnl / epsilon). */
|
|
24387
|
+
const STDDEV_EPSILON$1 = 1e-9;
|
|
24215
24388
|
/**
|
|
24216
24389
|
* Storage class for accumulating all tick events per strategy.
|
|
24217
24390
|
* Maintains a chronological list of all events (idle, opened, active, closed).
|
|
@@ -24495,84 +24668,190 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24495
24668
|
};
|
|
24496
24669
|
}
|
|
24497
24670
|
const closedEvents = this._eventList.filter((e) => e.action === "closed");
|
|
24498
|
-
|
|
24499
|
-
|
|
24500
|
-
|
|
24501
|
-
|
|
24502
|
-
|
|
24503
|
-
|
|
24671
|
+
// Valid closed set — single source of truth. Events must have numeric pnl AND valid
|
|
24672
|
+
// timestamps. Win/loss counts, returns, calendar span, equity curve — all derived
|
|
24673
|
+
// from this set so they cannot disagree.
|
|
24674
|
+
const validClosed = closedEvents.filter((e) => typeof e.pnl === "number" &&
|
|
24675
|
+
typeof e.timestamp === "number" &&
|
|
24676
|
+
e.timestamp > 0 &&
|
|
24677
|
+
typeof (e.pendingAt ?? e.timestamp) === "number");
|
|
24678
|
+
const totalClosed = validClosed.length;
|
|
24679
|
+
const winCount = validClosed.filter((e) => e.pnl > 0).length;
|
|
24680
|
+
const lossCount = validClosed.filter((e) => e.pnl < 0).length;
|
|
24681
|
+
const returns = validClosed.map((e) => e.pnl);
|
|
24682
|
+
const avgPnl = returns.length > 0
|
|
24683
|
+
? returns.reduce((sum, r) => sum + r, 0) / returns.length
|
|
24504
24684
|
: 0;
|
|
24505
|
-
const totalPnl =
|
|
24506
|
-
|
|
24507
|
-
|
|
24508
|
-
|
|
24509
|
-
|
|
24510
|
-
|
|
24511
|
-
|
|
24512
|
-
|
|
24513
|
-
|
|
24514
|
-
|
|
24515
|
-
|
|
24516
|
-
|
|
24517
|
-
|
|
24518
|
-
|
|
24519
|
-
|
|
24520
|
-
|
|
24521
|
-
|
|
24685
|
+
const totalPnl = returns.reduce((sum, r) => sum + r, 0);
|
|
24686
|
+
// Win rate excludes break-even trades from both numerator and denominator.
|
|
24687
|
+
const decisiveTrades = winCount + lossCount;
|
|
24688
|
+
const winRate = decisiveTrades > 0 ? (winCount / decisiveTrades) * 100 : 0;
|
|
24689
|
+
// Trade frequency from calendar span — gated by minimum span and sample size to
|
|
24690
|
+
// suppress absurd annualization on short / sparse runs. Span built from validClosed
|
|
24691
|
+
// so denominator (calendarSpanDays) and numerator (returns.length) come from the
|
|
24692
|
+
// same event set.
|
|
24693
|
+
let firstPendingAt = Infinity;
|
|
24694
|
+
let lastCloseAt = -Infinity;
|
|
24695
|
+
for (const e of validClosed) {
|
|
24696
|
+
const startAt = e.pendingAt ?? e.timestamp;
|
|
24697
|
+
if (startAt < firstPendingAt)
|
|
24698
|
+
firstPendingAt = startAt;
|
|
24699
|
+
if (e.timestamp > lastCloseAt)
|
|
24700
|
+
lastCloseAt = e.timestamp;
|
|
24701
|
+
}
|
|
24702
|
+
const calendarSpanDays = validClosed.length > 0
|
|
24703
|
+
? (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24)
|
|
24704
|
+
: 0;
|
|
24705
|
+
// tradesPerYear uses the RAW observed frequency — no clipping. Clipping would
|
|
24706
|
+
// silently understate Sharpe / Calmar / expectedYearlyReturns. Instead, if the
|
|
24707
|
+
// raw frequency exceeds MAX_TRADES_PER_YEAR we treat the sample as too clustered
|
|
24708
|
+
// for reliable annualization and surface every annualized metric as null.
|
|
24709
|
+
const rawTradesPerYear = returns.length >= MIN_SIGNALS_FOR_ANNUALIZATION$1 &&
|
|
24710
|
+
calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS$1
|
|
24711
|
+
? (returns.length / calendarSpanDays) * 365
|
|
24712
|
+
: 0;
|
|
24713
|
+
const canAnnualize = rawTradesPerYear > 0 && rawTradesPerYear <= MAX_TRADES_PER_YEAR$1;
|
|
24714
|
+
const tradesPerYear = canAnnualize ? rawTradesPerYear : 0;
|
|
24715
|
+
// Per-trade Sharpe Ratio (risk-free rate = 0). Sample stddev (N-1).
|
|
24716
|
+
// Per-trade ratios are gated by MIN_SIGNALS_FOR_RATIOS — below that, variance estimates
|
|
24717
|
+
// are too noisy to publish (high chance of spurious ±Sharpe).
|
|
24718
|
+
const canComputeRatios = returns.length >= MIN_SIGNALS_FOR_RATIOS$1;
|
|
24719
|
+
const stdDev = canComputeRatios
|
|
24720
|
+
? Math.sqrt(returns.reduce((sum, r) => sum + Math.pow(r - avgPnl, 2), 0) / (returns.length - 1))
|
|
24721
|
+
: 0;
|
|
24722
|
+
// STDDEV_EPSILON guard — protects against float-artifact stdDev from identical
|
|
24723
|
+
// returns producing spuriously astronomical sharpe.
|
|
24724
|
+
const sharpeRatio = canComputeRatios && stdDev > STDDEV_EPSILON$1
|
|
24725
|
+
? avgPnl / stdDev
|
|
24726
|
+
: null;
|
|
24727
|
+
// Annualize only when gate passes; otherwise null.
|
|
24728
|
+
const annualizedSharpeRatio = canAnnualize && sharpeRatio !== null
|
|
24729
|
+
? sharpeRatio * Math.sqrt(tradesPerYear)
|
|
24730
|
+
: null;
|
|
24731
|
+
// Certainty Ratio: null (not zero) when there are no losing trades — a flawless
|
|
24732
|
+
// strategy has undefined Certainty Ratio, not "worst case zero". Computed on
|
|
24733
|
+
// validClosed for consistency with other ratios.
|
|
24734
|
+
// Gated below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as Sharpe/Sortino,
|
|
24735
|
+
// so the report doesn't surface certainty on a handful of trades while
|
|
24736
|
+
// withholding the rest.
|
|
24737
|
+
let certaintyRatio = null;
|
|
24738
|
+
if (canComputeRatios && totalClosed > 0) {
|
|
24739
|
+
const wins = validClosed.filter((e) => e.pnl > 0);
|
|
24740
|
+
const losses = validClosed.filter((e) => e.pnl < 0);
|
|
24522
24741
|
const avgWin = wins.length > 0
|
|
24523
|
-
? wins.reduce((sum, e) => sum +
|
|
24742
|
+
? wins.reduce((sum, e) => sum + e.pnl, 0) / wins.length
|
|
24524
24743
|
: 0;
|
|
24525
24744
|
const avgLoss = losses.length > 0
|
|
24526
|
-
? losses.reduce((sum, e) => sum +
|
|
24745
|
+
? losses.reduce((sum, e) => sum + e.pnl, 0) / losses.length
|
|
24527
24746
|
: 0;
|
|
24528
|
-
|
|
24529
|
-
|
|
24530
|
-
|
|
24531
|
-
|
|
24532
|
-
|
|
24533
|
-
|
|
24534
|
-
|
|
24535
|
-
|
|
24536
|
-
|
|
24537
|
-
|
|
24538
|
-
|
|
24539
|
-
|
|
24540
|
-
|
|
24541
|
-
|
|
24542
|
-
|
|
24543
|
-
|
|
24544
|
-
|
|
24545
|
-
const
|
|
24546
|
-
|
|
24547
|
-
|
|
24548
|
-
|
|
24549
|
-
|
|
24550
|
-
|
|
24551
|
-
|
|
24552
|
-
|
|
24553
|
-
//
|
|
24554
|
-
const
|
|
24555
|
-
|
|
24556
|
-
|
|
24747
|
+
// STDDEV_EPSILON guard on |avgLoss| protects against float-artifact
|
|
24748
|
+
// losses producing spurious astronomical certaintyRatio.
|
|
24749
|
+
certaintyRatio = Math.abs(avgLoss) > STDDEV_EPSILON$1 && avgLoss < 0
|
|
24750
|
+
? avgWin / Math.abs(avgLoss)
|
|
24751
|
+
: null;
|
|
24752
|
+
}
|
|
24753
|
+
// Average only over signals that have the value — do not dilute the mean with zeros.
|
|
24754
|
+
// Use validClosed to keep all metric denominators consistent.
|
|
24755
|
+
const peakValues = validClosed
|
|
24756
|
+
.map((e) => e.peakPnl)
|
|
24757
|
+
.filter((v) => typeof v === "number");
|
|
24758
|
+
const fallValues = validClosed
|
|
24759
|
+
.map((e) => e.fallPnl)
|
|
24760
|
+
.filter((v) => typeof v === "number");
|
|
24761
|
+
const avgPeakPnl = peakValues.length > 0
|
|
24762
|
+
? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
|
|
24763
|
+
: null;
|
|
24764
|
+
const avgFallPnl = fallValues.length > 0
|
|
24765
|
+
? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
|
|
24766
|
+
: null;
|
|
24767
|
+
// Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
|
|
24768
|
+
// downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
|
|
24769
|
+
// so the numerator reduces to avgPnl and the squared term to r² for r < 0.
|
|
24770
|
+
// Dividing by N_total (not N_negative) properly penalises strategies with frequent
|
|
24771
|
+
// losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
|
|
24772
|
+
// strategies.
|
|
24773
|
+
const sortinoRatio = (() => {
|
|
24774
|
+
if (!canComputeRatios)
|
|
24775
|
+
return null;
|
|
24776
|
+
const negativeReturns = returns.filter((r) => r < 0);
|
|
24777
|
+
if (negativeReturns.length === 0)
|
|
24778
|
+
return null;
|
|
24779
|
+
const downsideVariance = negativeReturns.reduce((sum, r) => sum + r * r, 0) / returns.length;
|
|
24780
|
+
const downsideDeviation = Math.sqrt(downsideVariance);
|
|
24781
|
+
// Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
|
|
24782
|
+
return downsideDeviation > STDDEV_EPSILON$1 ? avgPnl / downsideDeviation : null;
|
|
24783
|
+
})();
|
|
24784
|
+
// Equity-curve max drawdown via compounded equity (multiplicative). Returns are per-trade
|
|
24785
|
+
// on cost basis — compounding assumes equal capital allocation per trade ("as-if 100%").
|
|
24786
|
+
// If equity ≤ 0 (leveraged short with r < -100%) — account blown, fix DD at 100%.
|
|
24787
|
+
// Built from validClosed (newest-first), iterated reverse for chronological order.
|
|
24788
|
+
const chronologicalReturns = [];
|
|
24789
|
+
for (let i = validClosed.length - 1; i >= 0; i--) {
|
|
24790
|
+
chronologicalReturns.push(validClosed[i].pnl);
|
|
24791
|
+
}
|
|
24792
|
+
let equity = 1;
|
|
24793
|
+
let peak = 1;
|
|
24794
|
+
let equityMaxDrawdown = 0;
|
|
24795
|
+
let blown = false;
|
|
24796
|
+
for (const r of chronologicalReturns) {
|
|
24797
|
+
equity *= 1 + r / 100;
|
|
24798
|
+
if (equity <= 0) {
|
|
24799
|
+
equityMaxDrawdown = 100;
|
|
24800
|
+
blown = true;
|
|
24801
|
+
break;
|
|
24802
|
+
}
|
|
24803
|
+
if (equity > peak)
|
|
24804
|
+
peak = equity;
|
|
24805
|
+
const dd = (peak - equity) / peak * 100;
|
|
24806
|
+
if (dd > equityMaxDrawdown)
|
|
24807
|
+
equityMaxDrawdown = dd;
|
|
24808
|
+
}
|
|
24809
|
+
const equityFinal = blown ? 0 : equity;
|
|
24810
|
+
// Compounded yearly return via geometric mean of equity curve:
|
|
24811
|
+
// equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
|
|
24812
|
+
// If account is blown, full loss. If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS,
|
|
24813
|
+
// return null rather than showing the cap — capped numbers mislead users.
|
|
24814
|
+
const expectedYearlyReturns = canAnnualize
|
|
24815
|
+
? blown
|
|
24816
|
+
? -100
|
|
24817
|
+
: (() => {
|
|
24818
|
+
const raw = (Math.pow(equityFinal, tradesPerYear / returns.length) - 1) * 100;
|
|
24819
|
+
return Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS$1 ? null : raw;
|
|
24820
|
+
})()
|
|
24821
|
+
: null;
|
|
24822
|
+
// Calmar — cap |value| at MAX_CALMAR_RATIO to prevent explosion when DD is near zero.
|
|
24823
|
+
const calmarRatio = equityMaxDrawdown > 0 && expectedYearlyReturns !== null
|
|
24824
|
+
? Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, expectedYearlyReturns / equityMaxDrawdown))
|
|
24825
|
+
: null;
|
|
24826
|
+
// Recovery Factor: numerator must be the compounded total return, not arithmetic totalPnl —
|
|
24827
|
+
// denominator is from the compounded equity curve, so mixing units inflates Recovery.
|
|
24828
|
+
// Null below MIN_SIGNALS_FOR_RATIOS — same sample-size gate as the other ratios,
|
|
24829
|
+
// so a 3-trade run doesn't surface a Recovery Factor while Sharpe/Calmar are N/A.
|
|
24830
|
+
// Null when account is blown.
|
|
24831
|
+
// Same MAX_CALMAR_RATIO clamp as Calmar — both are compounded-profit/DD ratios
|
|
24832
|
+
// and explode the same way when DD is near zero.
|
|
24833
|
+
const recoveryFactor = !canComputeRatios || blown || equityMaxDrawdown <= 0
|
|
24834
|
+
? null
|
|
24835
|
+
: Math.max(-MAX_CALMAR_RATIO$1, Math.min(MAX_CALMAR_RATIO$1, ((equityFinal - 1) * 100) / equityMaxDrawdown));
|
|
24557
24836
|
return {
|
|
24558
24837
|
eventList: this._eventList,
|
|
24559
24838
|
totalEvents: this._eventList.length,
|
|
24560
24839
|
totalClosed,
|
|
24561
24840
|
winCount,
|
|
24562
24841
|
lossCount,
|
|
24563
|
-
winRate: isUnsafe$
|
|
24564
|
-
avgPnl: isUnsafe$
|
|
24565
|
-
totalPnl: isUnsafe$
|
|
24566
|
-
stdDev: isUnsafe$
|
|
24567
|
-
sharpeRatio: isUnsafe$
|
|
24568
|
-
annualizedSharpeRatio: isUnsafe$
|
|
24569
|
-
certaintyRatio: isUnsafe$
|
|
24570
|
-
expectedYearlyReturns: isUnsafe$
|
|
24571
|
-
avgPeakPnl: isUnsafe$
|
|
24572
|
-
avgFallPnl: isUnsafe$
|
|
24573
|
-
sortinoRatio: isUnsafe$
|
|
24574
|
-
calmarRatio: isUnsafe$
|
|
24575
|
-
recoveryFactor: isUnsafe$
|
|
24842
|
+
winRate: isUnsafe$3(winRate) ? null : winRate,
|
|
24843
|
+
avgPnl: isUnsafe$3(avgPnl) ? null : avgPnl,
|
|
24844
|
+
totalPnl: isUnsafe$3(totalPnl) ? null : totalPnl,
|
|
24845
|
+
stdDev: isUnsafe$3(stdDev) ? null : stdDev,
|
|
24846
|
+
sharpeRatio: isUnsafe$3(sharpeRatio) ? null : sharpeRatio,
|
|
24847
|
+
annualizedSharpeRatio: isUnsafe$3(annualizedSharpeRatio) ? null : annualizedSharpeRatio,
|
|
24848
|
+
certaintyRatio: isUnsafe$3(certaintyRatio) ? null : certaintyRatio,
|
|
24849
|
+
expectedYearlyReturns: isUnsafe$3(expectedYearlyReturns) ? null : expectedYearlyReturns,
|
|
24850
|
+
avgPeakPnl: isUnsafe$3(avgPeakPnl) ? null : avgPeakPnl,
|
|
24851
|
+
avgFallPnl: isUnsafe$3(avgFallPnl) ? null : avgFallPnl,
|
|
24852
|
+
sortinoRatio: isUnsafe$3(sortinoRatio) ? null : sortinoRatio,
|
|
24853
|
+
calmarRatio: isUnsafe$3(calmarRatio) ? null : calmarRatio,
|
|
24854
|
+
recoveryFactor: isUnsafe$3(recoveryFactor) ? null : recoveryFactor,
|
|
24576
24855
|
};
|
|
24577
24856
|
}
|
|
24578
24857
|
/**
|
|
@@ -24620,18 +24899,20 @@ let ReportStorage$9 = class ReportStorage {
|
|
|
24620
24899
|
`**Avg Peak PNL:** ${stats.avgPeakPnl === null ? "N/A" : `${stats.avgPeakPnl > 0 ? "+" : ""}${stats.avgPeakPnl.toFixed(2)}% (higher is better)`}`,
|
|
24621
24900
|
`**Avg Max Drawdown PNL:** ${stats.avgFallPnl === null ? "N/A" : `${stats.avgFallPnl.toFixed(2)}% (closer to 0 is better)`}`,
|
|
24622
24901
|
`**Sortino Ratio:** ${stats.sortinoRatio === null ? "N/A" : `${stats.sortinoRatio.toFixed(3)} (higher is better)`}`,
|
|
24623
|
-
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better
|
|
24902
|
+
`**Calmar Ratio:** ${stats.calmarRatio === null ? "N/A" : `${stats.calmarRatio.toFixed(3)} (higher is better)`}`,
|
|
24624
24903
|
`**Recovery Factor:** ${stats.recoveryFactor === null ? "N/A" : `${stats.recoveryFactor.toFixed(3)} (higher is better)`}`,
|
|
24625
24904
|
"",
|
|
24626
24905
|
`*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
|
|
24627
24906
|
`*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
24628
|
-
`*Annualized Sharpe Ratio:
|
|
24629
|
-
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
24907
|
+
`*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.*`,
|
|
24908
|
+
`*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".*`,
|
|
24630
24909
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
24631
|
-
`*Expected Yearly Returns:
|
|
24632
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong.
|
|
24633
|
-
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
|
|
24634
|
-
`*All metrics require 100+ signals to be statistically reliable.
|
|
24910
|
+
`*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.*`,
|
|
24911
|
+
`*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}.*`,
|
|
24912
|
+
`*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.*`,
|
|
24913
|
+
`*All metrics require 100+ signals to be statistically reliable. Annualized metrics assume the observed trading frequency and market conditions persist year-round.*`,
|
|
24914
|
+
`*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.*`,
|
|
24915
|
+
`*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.*`,
|
|
24635
24916
|
].join("\n");
|
|
24636
24917
|
}
|
|
24637
24918
|
/**
|
|
@@ -25010,7 +25291,9 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25010
25291
|
*/
|
|
25011
25292
|
addOpenedEvent(data) {
|
|
25012
25293
|
const durationMs = data.signal.pendingAt - data.signal.scheduledAt;
|
|
25013
|
-
|
|
25294
|
+
// Keep fractional minutes — rounding to whole minutes zeroed out sub-30s durations,
|
|
25295
|
+
// which dragged high-frequency averages towards zero.
|
|
25296
|
+
const durationMin = durationMs / 60000;
|
|
25014
25297
|
const newEvent = {
|
|
25015
25298
|
timestamp: data.signal.pendingAt,
|
|
25016
25299
|
action: "opened",
|
|
@@ -25046,7 +25329,8 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25046
25329
|
*/
|
|
25047
25330
|
addCancelledEvent(data) {
|
|
25048
25331
|
const durationMs = data.closeTimestamp - data.signal.scheduledAt;
|
|
25049
|
-
|
|
25332
|
+
// Keep fractional minutes — rounding to whole minutes zeroed out sub-30s durations.
|
|
25333
|
+
const durationMin = durationMs / 60000;
|
|
25050
25334
|
const newEvent = {
|
|
25051
25335
|
timestamp: data.closeTimestamp,
|
|
25052
25336
|
action: "cancelled",
|
|
@@ -25102,19 +25386,33 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25102
25386
|
const totalScheduled = scheduledEvents.length;
|
|
25103
25387
|
const totalOpened = openedEvents.length;
|
|
25104
25388
|
const totalCancelled = cancelledEvents.length;
|
|
25105
|
-
//
|
|
25106
|
-
|
|
25107
|
-
//
|
|
25108
|
-
|
|
25109
|
-
|
|
25110
|
-
const
|
|
25111
|
-
|
|
25112
|
-
|
|
25389
|
+
// Rate denominators must include only scheduled events whose outcome (opened/cancelled)
|
|
25390
|
+
// is also in the buffer. Otherwise a sliding window of 250 entries can drop the
|
|
25391
|
+
// "scheduled" record before its outcome arrives, inflating rates above 100% or
|
|
25392
|
+
// causing one rate to fire without the other. Match by signalId.
|
|
25393
|
+
const scheduledIds = new Set(scheduledEvents.map((e) => e.signalId).filter((id) => typeof id === "string"));
|
|
25394
|
+
const openedFromScheduled = openedEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
|
|
25395
|
+
const cancelledFromScheduled = cancelledEvents.filter((e) => typeof e.signalId === "string" && scheduledIds.has(e.signalId));
|
|
25396
|
+
const resolvedScheduled = openedFromScheduled.length + cancelledFromScheduled.length;
|
|
25397
|
+
const cancellationRate = resolvedScheduled > 0
|
|
25398
|
+
? (cancelledFromScheduled.length / resolvedScheduled) * 100
|
|
25399
|
+
: null;
|
|
25400
|
+
const activationRate = resolvedScheduled > 0
|
|
25401
|
+
? (openedFromScheduled.length / resolvedScheduled) * 100
|
|
25402
|
+
: null;
|
|
25403
|
+
// Average durations — include only events with a numeric duration, do not dilute
|
|
25404
|
+
// the mean with zeros for missing values.
|
|
25405
|
+
const cancelledDurations = cancelledEvents
|
|
25406
|
+
.map((e) => e.duration)
|
|
25407
|
+
.filter((d) => typeof d === "number");
|
|
25408
|
+
const openedDurations = openedEvents
|
|
25409
|
+
.map((e) => e.duration)
|
|
25410
|
+
.filter((d) => typeof d === "number");
|
|
25411
|
+
const avgWaitTime = cancelledDurations.length > 0
|
|
25412
|
+
? cancelledDurations.reduce((sum, d) => sum + d, 0) / cancelledDurations.length
|
|
25113
25413
|
: null;
|
|
25114
|
-
|
|
25115
|
-
|
|
25116
|
-
? openedEvents.reduce((sum, e) => sum + (e.duration || 0), 0) /
|
|
25117
|
-
totalOpened
|
|
25414
|
+
const avgActivationTime = openedDurations.length > 0
|
|
25415
|
+
? openedDurations.reduce((sum, d) => sum + d, 0) / openedDurations.length
|
|
25118
25416
|
: null;
|
|
25119
25417
|
return {
|
|
25120
25418
|
eventList: this._eventList,
|
|
@@ -25161,13 +25459,15 @@ let ReportStorage$8 = class ReportStorage {
|
|
|
25161
25459
|
table,
|
|
25162
25460
|
"",
|
|
25163
25461
|
`**Total events:** ${stats.totalEvents}`,
|
|
25164
|
-
`**Scheduled signals:** ${stats.totalScheduled}`,
|
|
25462
|
+
`**Scheduled signals (raw):** ${stats.totalScheduled}`,
|
|
25165
25463
|
`**Opened signals:** ${stats.totalOpened}`,
|
|
25166
25464
|
`**Cancelled signals:** ${stats.totalCancelled}`,
|
|
25167
25465
|
`**Activation rate:** ${stats.activationRate === null ? "N/A" : `${stats.activationRate.toFixed(2)}% (higher is better)`}`,
|
|
25168
25466
|
`**Cancellation rate:** ${stats.cancellationRate === null ? "N/A" : `${stats.cancellationRate.toFixed(2)}% (lower is better)`}`,
|
|
25169
25467
|
`**Average activation time:** ${stats.avgActivationTime === null ? "N/A" : `${stats.avgActivationTime.toFixed(2)} minutes`}`,
|
|
25170
|
-
`**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}
|
|
25468
|
+
`**Average wait time (cancelled):** ${stats.avgWaitTime === null ? "N/A" : `${stats.avgWaitTime.toFixed(2)} minutes`}`,
|
|
25469
|
+
"",
|
|
25470
|
+
`*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.*`
|
|
25171
25471
|
].join("\n");
|
|
25172
25472
|
}
|
|
25173
25473
|
/**
|
|
@@ -25472,13 +25772,37 @@ const CREATE_FILE_NAME_FN$9 = (symbol, strategyName, exchangeName, frameName, ti
|
|
|
25472
25772
|
return `${parts.join("_")}-${timestamp}.md`;
|
|
25473
25773
|
};
|
|
25474
25774
|
/**
|
|
25475
|
-
*
|
|
25775
|
+
* Checks if a value is unsafe for display (not a number, NaN, or Infinity).
|
|
25776
|
+
*/
|
|
25777
|
+
function isUnsafe$2(value) {
|
|
25778
|
+
if (typeof value !== "number") {
|
|
25779
|
+
return true;
|
|
25780
|
+
}
|
|
25781
|
+
if (isNaN(value)) {
|
|
25782
|
+
return true;
|
|
25783
|
+
}
|
|
25784
|
+
if (!isFinite(value)) {
|
|
25785
|
+
return true;
|
|
25786
|
+
}
|
|
25787
|
+
return false;
|
|
25788
|
+
}
|
|
25789
|
+
/**
|
|
25790
|
+
* Calculates percentile value from sorted array using linear interpolation
|
|
25791
|
+
* between adjacent ranks (equivalent to numpy.percentile with default linear method).
|
|
25792
|
+
* Falls back to nearest-rank for length 0/1.
|
|
25476
25793
|
*/
|
|
25477
25794
|
function percentile(sortedArray, p) {
|
|
25478
25795
|
if (sortedArray.length === 0)
|
|
25479
25796
|
return 0;
|
|
25480
|
-
|
|
25481
|
-
|
|
25797
|
+
if (sortedArray.length === 1)
|
|
25798
|
+
return sortedArray[0];
|
|
25799
|
+
const rank = (p / 100) * (sortedArray.length - 1);
|
|
25800
|
+
const lower = Math.floor(rank);
|
|
25801
|
+
const upper = Math.ceil(rank);
|
|
25802
|
+
if (lower === upper)
|
|
25803
|
+
return sortedArray[lower];
|
|
25804
|
+
const fraction = rank - lower;
|
|
25805
|
+
return sortedArray[lower] * (1 - fraction) + sortedArray[upper] * fraction;
|
|
25482
25806
|
}
|
|
25483
25807
|
/**
|
|
25484
25808
|
* Storage class for accumulating performance metrics per strategy.
|
|
@@ -25534,10 +25858,12 @@ class PerformanceStorage {
|
|
|
25534
25858
|
const durations = events.map((e) => e.duration).sort((a, b) => a - b);
|
|
25535
25859
|
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
|
25536
25860
|
const avgDuration = totalDuration / durations.length;
|
|
25537
|
-
//
|
|
25538
|
-
|
|
25539
|
-
|
|
25540
|
-
|
|
25861
|
+
// Sample standard deviation (Bessel correction: divide by N-1, not N) — consistent
|
|
25862
|
+
// with Sharpe/Sortino calculations in Backtest/Live/Heat services.
|
|
25863
|
+
const stdDev = durations.length > 1
|
|
25864
|
+
? Math.sqrt(durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) /
|
|
25865
|
+
(durations.length - 1))
|
|
25866
|
+
: 0;
|
|
25541
25867
|
// Calculate wait times between events
|
|
25542
25868
|
const waitTimes = [];
|
|
25543
25869
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -25610,9 +25936,13 @@ class PerformanceStorage {
|
|
|
25610
25936
|
const rows = await Promise.all(sortedMetrics.map(async (metric, index) => Promise.all(visibleColumns.map((col) => col.format(metric, index)))));
|
|
25611
25937
|
const tableData = [header, separator, ...rows];
|
|
25612
25938
|
const summaryTable = tableData.map((row) => `| ${row.join(" | ")} |`).join("\n");
|
|
25613
|
-
// Calculate percentage of total time for each metric
|
|
25939
|
+
// Calculate percentage of total time for each metric. Guard against zero total
|
|
25940
|
+
// duration (all-instant operations) to avoid NaN% in the rendered report.
|
|
25614
25941
|
const percentages = sortedMetrics.map((metric) => {
|
|
25615
|
-
const
|
|
25942
|
+
const pctRaw = stats.totalDuration > 0
|
|
25943
|
+
? (metric.totalDuration / stats.totalDuration) * 100
|
|
25944
|
+
: 0;
|
|
25945
|
+
const pct = isUnsafe$2(pctRaw) ? 0 : pctRaw;
|
|
25616
25946
|
return `- **${metric.metricType}**: ${pct.toFixed(1)}% (${metric.totalDuration.toFixed(2)}ms total)`;
|
|
25617
25947
|
});
|
|
25618
25948
|
return [
|
|
@@ -26381,6 +26711,25 @@ function isUnsafe(value) {
|
|
|
26381
26711
|
}
|
|
26382
26712
|
return false;
|
|
26383
26713
|
}
|
|
26714
|
+
/** Minimum closed signals required to annualize Sharpe / yearly returns / Calmar. */
|
|
26715
|
+
const MIN_SIGNALS_FOR_ANNUALIZATION = 10;
|
|
26716
|
+
/** Minimum signals required for ANY ratio metric (Sharpe / Sortino / stdDev). Below this,
|
|
26717
|
+
* sample size is too small to estimate variance meaningfully. */
|
|
26718
|
+
const MIN_SIGNALS_FOR_RATIOS = 10;
|
|
26719
|
+
/** Minimum calendar span (days) for trade-frequency extrapolation. */
|
|
26720
|
+
const MIN_CALENDAR_SPAN_DAYS = 14;
|
|
26721
|
+
/** Hard cap on tradesPerYear — prevents absurd extrapolation from short windows / clustered trades. */
|
|
26722
|
+
const MAX_TRADES_PER_YEAR = 365;
|
|
26723
|
+
/** Hard cap on |expectedYearlyReturns| percent. Compound interest on high avgPnl × frequency
|
|
26724
|
+
* blows up to mathematically correct but business-unrealistic values. ±100% = 2x equity —
|
|
26725
|
+
* anything above this we suspect is a noisy estimate, not a genuine edge. Above the cap → null. */
|
|
26726
|
+
const MAX_EXPECTED_YEARLY_RETURNS = 100;
|
|
26727
|
+
/** Hard cap on |calmarRatio|. Prevents explosion when equityMaxDrawdown is near zero. */
|
|
26728
|
+
const MAX_CALMAR_RATIO = 1000;
|
|
26729
|
+
/** Minimum stdDev required for Sharpe/Sortino. Identical-returns series produce
|
|
26730
|
+
* float-artifact stdDev (~1e-17) that's > 0 but spuriously inflates sharpe to
|
|
26731
|
+
* astronomical magnitudes (avgPnl / epsilon). */
|
|
26732
|
+
const STDDEV_EPSILON = 1e-9;
|
|
26384
26733
|
/**
|
|
26385
26734
|
* Storage class for accumulating closed signals per strategy and generating heatmap.
|
|
26386
26735
|
* Maintains symbol-level statistics and provides portfolio-wide metrics.
|
|
@@ -26422,7 +26771,7 @@ class HeatmapStorage {
|
|
|
26422
26771
|
* - **totalPnl** — sum of `pnlPercentage` across all signals
|
|
26423
26772
|
* - **avgPnl** — arithmetic mean of `pnlPercentage`
|
|
26424
26773
|
* - **stdDev** — population standard deviation of `pnlPercentage`
|
|
26425
|
-
* - **sharpeRatio** — `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
|
|
26774
|
+
* - **sharpeRatio** — per-trade Sharpe: `avgPnl / stdDev`; requires ≥ 2 signals and `stdDev > 0`
|
|
26426
26775
|
* - **maxDrawdown** — largest cumulative loss streak (absolute value of peak negative equity)
|
|
26427
26776
|
* - **profitFactor** — `sumWins / |sumLosses|`; requires at least one win and one loss
|
|
26428
26777
|
* - **avgWin / avgLoss** — mean of positive / negative trades respectively
|
|
@@ -26438,10 +26787,12 @@ class HeatmapStorage {
|
|
|
26438
26787
|
const totalTrades = signals.length;
|
|
26439
26788
|
const winCount = signals.filter((s) => s.pnl.pnlPercentage > 0).length;
|
|
26440
26789
|
const lossCount = signals.filter((s) => s.pnl.pnlPercentage < 0).length;
|
|
26441
|
-
//
|
|
26790
|
+
// Win rate excludes break-even trades from both numerator and denominator —
|
|
26791
|
+
// they are neither wins nor losses.
|
|
26442
26792
|
let winRate = null;
|
|
26443
|
-
|
|
26444
|
-
|
|
26793
|
+
const decisiveTrades = winCount + lossCount;
|
|
26794
|
+
if (decisiveTrades > 0) {
|
|
26795
|
+
winRate = (winCount / decisiveTrades) * 100;
|
|
26445
26796
|
}
|
|
26446
26797
|
// Calculate total PNL
|
|
26447
26798
|
let totalPnl = null;
|
|
@@ -26453,36 +26804,47 @@ class HeatmapStorage {
|
|
|
26453
26804
|
if (signals.length > 0) {
|
|
26454
26805
|
avgPnl = totalPnl / signals.length;
|
|
26455
26806
|
}
|
|
26456
|
-
//
|
|
26807
|
+
// Sample standard deviation (Bessel correction: divide by N-1, not N).
|
|
26808
|
+
// Per-symbol ratios are gated by MIN_SIGNALS_FOR_RATIOS — variance estimates from
|
|
26809
|
+
// tiny samples are too noisy to publish.
|
|
26810
|
+
const canComputeRatios = signals.length >= MIN_SIGNALS_FOR_RATIOS;
|
|
26457
26811
|
let stdDev = null;
|
|
26458
|
-
if (
|
|
26459
|
-
const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / signals.length;
|
|
26812
|
+
if (canComputeRatios && avgPnl !== null) {
|
|
26813
|
+
const variance = signals.reduce((acc, s) => acc + Math.pow(s.pnl.pnlPercentage - avgPnl, 2), 0) / (signals.length - 1);
|
|
26460
26814
|
stdDev = Math.sqrt(variance);
|
|
26461
26815
|
}
|
|
26462
|
-
//
|
|
26816
|
+
// Per-trade Sharpe Ratio
|
|
26463
26817
|
let sharpeRatio = null;
|
|
26464
|
-
|
|
26818
|
+
// STDDEV_EPSILON guard — protects against float-artifact stdDev producing
|
|
26819
|
+
// spuriously astronomical sharpe on identical-returns symbols.
|
|
26820
|
+
if (avgPnl !== null && stdDev !== null && stdDev > STDDEV_EPSILON) {
|
|
26465
26821
|
sharpeRatio = avgPnl / stdDev;
|
|
26466
26822
|
}
|
|
26467
|
-
//
|
|
26823
|
+
// Equity-curve max drawdown via compounded equity ("as-if 100% allocation per trade").
|
|
26824
|
+
// Signals are stored newest-first (unshift in addSignal), so iterate in reverse.
|
|
26825
|
+
// If equity ≤ 0 — account blown, fix DD at 100%. equityFinal feeds expectedYearlyReturns.
|
|
26468
26826
|
let maxDrawdown = null;
|
|
26827
|
+
let equityFinal = 1;
|
|
26828
|
+
let blown = false;
|
|
26469
26829
|
if (signals.length > 0) {
|
|
26470
|
-
let
|
|
26471
|
-
let
|
|
26830
|
+
let equity = 1;
|
|
26831
|
+
let peak = 1;
|
|
26472
26832
|
let maxDD = 0;
|
|
26473
|
-
for (
|
|
26474
|
-
|
|
26475
|
-
if (
|
|
26476
|
-
|
|
26477
|
-
|
|
26478
|
-
|
|
26479
|
-
currentDrawdown = Math.abs(peak);
|
|
26480
|
-
if (currentDrawdown > maxDD) {
|
|
26481
|
-
maxDD = currentDrawdown;
|
|
26482
|
-
}
|
|
26833
|
+
for (let i = signals.length - 1; i >= 0; i--) {
|
|
26834
|
+
equity *= 1 + signals[i].pnl.pnlPercentage / 100;
|
|
26835
|
+
if (equity <= 0) {
|
|
26836
|
+
maxDD = 100;
|
|
26837
|
+
blown = true;
|
|
26838
|
+
break;
|
|
26483
26839
|
}
|
|
26840
|
+
if (equity > peak)
|
|
26841
|
+
peak = equity;
|
|
26842
|
+
const dd = (peak - equity) / peak * 100;
|
|
26843
|
+
if (dd > maxDD)
|
|
26844
|
+
maxDD = dd;
|
|
26484
26845
|
}
|
|
26485
26846
|
maxDrawdown = maxDD;
|
|
26847
|
+
equityFinal = blown ? 0 : equity;
|
|
26486
26848
|
}
|
|
26487
26849
|
// Calculate Profit Factor
|
|
26488
26850
|
let profitFactor = null;
|
|
@@ -26493,7 +26855,9 @@ class HeatmapStorage {
|
|
|
26493
26855
|
const sumLosses = Math.abs(signals
|
|
26494
26856
|
.filter((s) => s.pnl.pnlPercentage < 0)
|
|
26495
26857
|
.reduce((acc, s) => acc + s.pnl.pnlPercentage, 0));
|
|
26496
|
-
|
|
26858
|
+
// STDDEV_EPSILON guard — float-artifact losses (≈1e-15) would otherwise
|
|
26859
|
+
// produce spurious astronomical profitFactor (≈1e14).
|
|
26860
|
+
if (sumLosses > STDDEV_EPSILON) {
|
|
26497
26861
|
profitFactor = sumWins / sumLosses;
|
|
26498
26862
|
}
|
|
26499
26863
|
}
|
|
@@ -26533,45 +26897,110 @@ class HeatmapStorage {
|
|
|
26533
26897
|
}
|
|
26534
26898
|
}
|
|
26535
26899
|
}
|
|
26536
|
-
//
|
|
26900
|
+
// Expectancy — probabilities from observed win/loss counts (break-evens contribute 0).
|
|
26537
26901
|
let expectancy = null;
|
|
26538
|
-
if (
|
|
26539
|
-
const
|
|
26540
|
-
|
|
26902
|
+
if (totalTrades > 0 && avgWin !== null && avgLoss !== null) {
|
|
26903
|
+
const winProb = winCount / totalTrades;
|
|
26904
|
+
const lossProb = lossCount / totalTrades;
|
|
26905
|
+
expectancy = winProb * avgWin + lossProb * avgLoss;
|
|
26541
26906
|
}
|
|
26542
|
-
|
|
26907
|
+
else if (totalTrades > 0 && avgWin !== null && avgLoss === null) {
|
|
26908
|
+
// No losing trades — expectancy is just average win frequency × avgWin
|
|
26909
|
+
expectancy = (winCount / totalTrades) * avgWin;
|
|
26910
|
+
}
|
|
26911
|
+
else if (totalTrades > 0 && avgWin === null && avgLoss !== null) {
|
|
26912
|
+
expectancy = (lossCount / totalTrades) * avgLoss;
|
|
26913
|
+
}
|
|
26914
|
+
// Average only over signals that have the value — do not dilute the mean with zeros.
|
|
26543
26915
|
let avgPeakPnl = null;
|
|
26544
26916
|
let avgFallPnl = null;
|
|
26545
26917
|
if (signals.length > 0) {
|
|
26546
|
-
|
|
26547
|
-
|
|
26918
|
+
const peakValues = signals
|
|
26919
|
+
.map((s) => s.signal.peakProfit?.pnlPercentage)
|
|
26920
|
+
.filter((v) => typeof v === "number");
|
|
26921
|
+
const fallValues = signals
|
|
26922
|
+
.map((s) => s.signal.maxDrawdown?.pnlPercentage)
|
|
26923
|
+
.filter((v) => typeof v === "number");
|
|
26924
|
+
avgPeakPnl = peakValues.length > 0
|
|
26925
|
+
? peakValues.reduce((sum, v) => sum + v, 0) / peakValues.length
|
|
26926
|
+
: null;
|
|
26927
|
+
avgFallPnl = fallValues.length > 0
|
|
26928
|
+
? fallValues.reduce((sum, v) => sum + v, 0) / fallValues.length
|
|
26929
|
+
: null;
|
|
26548
26930
|
}
|
|
26549
|
-
//
|
|
26550
|
-
|
|
26551
|
-
//
|
|
26931
|
+
// Sortino (canonical, Sortino 1991): (avgPnl - MAR) / downside deviation, where
|
|
26932
|
+
// downsideDev = √( Σ min(0, r - MAR)² / N_total ). We use MAR = 0 (risk-free target),
|
|
26933
|
+
// so the numerator reduces to avgPnl and the squared term to r² for r < 0.
|
|
26934
|
+
// Dividing by N_total (not N_negative) properly penalises strategies with frequent
|
|
26935
|
+
// losses; the "modified" form (N_negative) hides frequency risk in catastrophic-tail
|
|
26936
|
+
// strategies.
|
|
26552
26937
|
let sortinoRatio = null;
|
|
26553
|
-
if (
|
|
26554
|
-
const
|
|
26555
|
-
|
|
26556
|
-
|
|
26557
|
-
|
|
26558
|
-
|
|
26559
|
-
|
|
26560
|
-
|
|
26561
|
-
|
|
26562
|
-
|
|
26563
|
-
|
|
26564
|
-
|
|
26565
|
-
const avgDurationMs = signals.reduce((sum, s) => sum + (s.closeTimestamp - s.signal.pendingAt), 0) / signals.length;
|
|
26566
|
-
const avgDurationDays = avgDurationMs / (1000 * 60 * 60 * 24);
|
|
26567
|
-
const tradesPerYear = avgDurationDays > 0 ? 365 / avgDurationDays : 0;
|
|
26568
|
-
expectedYearlyReturns = avgPnl * tradesPerYear;
|
|
26938
|
+
if (canComputeRatios && avgPnl !== null) {
|
|
26939
|
+
const negativeReturns = signals
|
|
26940
|
+
.map((s) => s.pnl.pnlPercentage)
|
|
26941
|
+
.filter((r) => r < 0);
|
|
26942
|
+
if (negativeReturns.length > 0) {
|
|
26943
|
+
const downsideVariance = negativeReturns.reduce((acc, r) => acc + r * r, 0) / signals.length;
|
|
26944
|
+
const downsideDeviation = Math.sqrt(downsideVariance);
|
|
26945
|
+
// Same epsilon guard as Sharpe — protects against float-artifact downsideDev.
|
|
26946
|
+
if (downsideDeviation > STDDEV_EPSILON) {
|
|
26947
|
+
sortinoRatio = avgPnl / downsideDeviation;
|
|
26948
|
+
}
|
|
26949
|
+
}
|
|
26569
26950
|
}
|
|
26951
|
+
// Expected yearly returns via geometric mean of equity curve.
|
|
26952
|
+
// equityFinal^(tradesPerYear / N) - 1 — accounts for volatility drag.
|
|
26953
|
+
// Gated by sample size and calendar span; if account blown → full loss.
|
|
26954
|
+
let expectedYearlyReturns = null;
|
|
26955
|
+
let tradesPerYear = null;
|
|
26956
|
+
if (signals.length >= MIN_SIGNALS_FOR_ANNUALIZATION) {
|
|
26957
|
+
let firstPendingAt = Infinity;
|
|
26958
|
+
let lastCloseAt = -Infinity;
|
|
26959
|
+
for (const s of signals) {
|
|
26960
|
+
if (s.signal.pendingAt < firstPendingAt)
|
|
26961
|
+
firstPendingAt = s.signal.pendingAt;
|
|
26962
|
+
if (s.closeTimestamp > lastCloseAt)
|
|
26963
|
+
lastCloseAt = s.closeTimestamp;
|
|
26964
|
+
}
|
|
26965
|
+
const calendarSpanDays = (lastCloseAt - firstPendingAt) / (1000 * 60 * 60 * 24);
|
|
26966
|
+
if (calendarSpanDays >= MIN_CALENDAR_SPAN_DAYS) {
|
|
26967
|
+
// tradesPerYear uses RAW observed frequency — no clipping. If the raw value
|
|
26968
|
+
// exceeds MAX_TRADES_PER_YEAR the sample is too clustered for reliable
|
|
26969
|
+
// annualization, and we leave the annualized metric null instead of silently
|
|
26970
|
+
// understating it with a clipped frequency.
|
|
26971
|
+
const rawTradesPerYear = (signals.length / calendarSpanDays) * 365;
|
|
26972
|
+
if (rawTradesPerYear <= MAX_TRADES_PER_YEAR) {
|
|
26973
|
+
tradesPerYear = rawTradesPerYear;
|
|
26974
|
+
if (blown) {
|
|
26975
|
+
expectedYearlyReturns = -100;
|
|
26976
|
+
}
|
|
26977
|
+
else {
|
|
26978
|
+
// If raw value exceeds MAX_EXPECTED_YEARLY_RETURNS, leave null rather than
|
|
26979
|
+
// show the cap — capped numbers mislead users into trusting them.
|
|
26980
|
+
const raw = (Math.pow(equityFinal, tradesPerYear / signals.length) - 1) * 100;
|
|
26981
|
+
expectedYearlyReturns = Math.abs(raw) > MAX_EXPECTED_YEARLY_RETURNS ? null : raw;
|
|
26982
|
+
}
|
|
26983
|
+
}
|
|
26984
|
+
}
|
|
26985
|
+
}
|
|
26986
|
+
// Calmar = annualized return / equity-curve max drawdown, capped at ±MAX_CALMAR_RATIO.
|
|
26987
|
+
// Recovery Factor uses the compounded total return (equityFinal-1)*100, not arithmetic
|
|
26988
|
+
// totalPnl — denominator is compounded so numerator must match. Null when account blown.
|
|
26570
26989
|
let calmarRatio = null;
|
|
26571
26990
|
let recoveryFactor = null;
|
|
26572
|
-
if (
|
|
26573
|
-
|
|
26574
|
-
|
|
26991
|
+
if (maxDrawdown !== null && maxDrawdown > 0) {
|
|
26992
|
+
if (expectedYearlyReturns !== null) {
|
|
26993
|
+
const raw = expectedYearlyReturns / maxDrawdown;
|
|
26994
|
+
calmarRatio = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, raw));
|
|
26995
|
+
}
|
|
26996
|
+
if (!blown && canComputeRatios) {
|
|
26997
|
+
// Gated below MIN_SIGNALS_FOR_RATIOS like Sharpe — a Recovery Factor on
|
|
26998
|
+
// a handful of trades is statistically meaningless, so don't surface it
|
|
26999
|
+
// per-symbol while Sharpe is N/A.
|
|
27000
|
+
// Same MAX_CALMAR_RATIO clamp as Calmar — both compounded-profit/DD ratios.
|
|
27001
|
+
const rawRec = ((equityFinal - 1) * 100) / maxDrawdown;
|
|
27002
|
+
recoveryFactor = Math.max(-MAX_CALMAR_RATIO, Math.min(MAX_CALMAR_RATIO, rawRec));
|
|
27003
|
+
}
|
|
26575
27004
|
}
|
|
26576
27005
|
// Apply safe math checks
|
|
26577
27006
|
if (isUnsafe(winRate))
|
|
@@ -26636,12 +27065,18 @@ class HeatmapStorage {
|
|
|
26636
27065
|
* 2. Sorts symbols by `sharpeRatio` descending — best performers first,
|
|
26637
27066
|
* symbols with `null` sharpeRatio placed at the end.
|
|
26638
27067
|
* 3. Computes portfolio-wide aggregates:
|
|
26639
|
-
* - `portfolioTotalPnl` — sum of
|
|
26640
|
-
*
|
|
26641
|
-
*
|
|
26642
|
-
*
|
|
26643
|
-
*
|
|
26644
|
-
*
|
|
27068
|
+
* - `portfolioTotalPnl` — sum of per-symbol `totalPnl` values, skipping `null` entries
|
|
27069
|
+
* (so a symbol with no data does not silently contribute 0). If every symbol's
|
|
27070
|
+
* `totalPnl` is null, the portfolio value is null.
|
|
27071
|
+
* - `portfolioTotalTrades` — sum of per-symbol `totalTrades`
|
|
27072
|
+
* - `portfolioSharpeRatio` — POOLED Sharpe over all trades across symbols (sample
|
|
27073
|
+
* stddev, N-1). NOT a Markowitz portfolio Sharpe — ignores cross-symbol
|
|
27074
|
+
* correlations and capital allocation. Rendered as "Pooled Sharpe" in the report.
|
|
27075
|
+
* Gated by `MIN_SIGNALS_FOR_RATIOS` on the pooled count.
|
|
27076
|
+
* - `portfolioAvgPeakPnl` / `portfolioAvgFallPnl` — trade-count-weighted means
|
|
27077
|
+
* over symbols that have non-null values.
|
|
27078
|
+
*
|
|
27079
|
+
* @returns Promise resolving to `HeatmapStatisticsModel`
|
|
26645
27080
|
*/
|
|
26646
27081
|
async getData() {
|
|
26647
27082
|
const symbols = [];
|
|
@@ -26660,31 +27095,53 @@ class HeatmapStorage {
|
|
|
26660
27095
|
return -1;
|
|
26661
27096
|
return b.sharpeRatio - a.sharpeRatio;
|
|
26662
27097
|
});
|
|
26663
|
-
//
|
|
27098
|
+
// Portfolio totals — sum only over symbols with non-null totalPnl. `s.totalPnl || 0`
|
|
27099
|
+
// would silently treat a missing value as zero and hide that some symbols had no data.
|
|
26664
27100
|
const totalSymbols = symbols.length;
|
|
26665
27101
|
let portfolioTotalPnl = null;
|
|
26666
27102
|
let portfolioTotalTrades = 0;
|
|
26667
27103
|
if (symbols.length > 0) {
|
|
26668
|
-
|
|
27104
|
+
const validTotalPnls = symbols.filter((s) => s.totalPnl !== null);
|
|
27105
|
+
portfolioTotalPnl = validTotalPnls.length > 0
|
|
27106
|
+
? validTotalPnls.reduce((acc, s) => acc + s.totalPnl, 0)
|
|
27107
|
+
: null;
|
|
26669
27108
|
portfolioTotalTrades = symbols.reduce((acc, s) => acc + s.totalTrades, 0);
|
|
26670
27109
|
}
|
|
26671
|
-
//
|
|
27110
|
+
// Pooled Sharpe over all returns across symbols. NOTE: this is NOT a Markowitz
|
|
27111
|
+
// portfolio Sharpe — it ignores cross-symbol correlations and treats trades as a
|
|
27112
|
+
// single pooled sample. Gated by MIN_SIGNALS_FOR_RATIOS so a 2-trade pool cannot
|
|
27113
|
+
// produce a noisy ±Sharpe.
|
|
26672
27114
|
let portfolioSharpeRatio = null;
|
|
26673
|
-
const
|
|
26674
|
-
|
|
26675
|
-
|
|
26676
|
-
|
|
27115
|
+
const allReturns = [];
|
|
27116
|
+
for (const signals of this.symbolData.values()) {
|
|
27117
|
+
for (const s of signals) {
|
|
27118
|
+
allReturns.push(s.pnl.pnlPercentage);
|
|
27119
|
+
}
|
|
27120
|
+
}
|
|
27121
|
+
if (allReturns.length >= MIN_SIGNALS_FOR_RATIOS) {
|
|
27122
|
+
const portfolioAvg = allReturns.reduce((acc, r) => acc + r, 0) / allReturns.length;
|
|
27123
|
+
const portfolioVariance = allReturns.reduce((acc, r) => acc + Math.pow(r - portfolioAvg, 2), 0) /
|
|
27124
|
+
(allReturns.length - 1);
|
|
27125
|
+
const portfolioStdDev = Math.sqrt(portfolioVariance);
|
|
27126
|
+
// STDDEV_EPSILON guard — same protection as per-symbol Sharpe.
|
|
27127
|
+
if (portfolioStdDev > STDDEV_EPSILON) {
|
|
27128
|
+
portfolioSharpeRatio = portfolioAvg / portfolioStdDev;
|
|
27129
|
+
}
|
|
26677
27130
|
}
|
|
26678
|
-
//
|
|
27131
|
+
// Portfolio-wide weighted average peak/fall PNL. Denominator must include only
|
|
27132
|
+
// symbols that contributed a value — otherwise trade-count-weighted mean is diluted
|
|
27133
|
+
// by symbols without the metric.
|
|
26679
27134
|
let portfolioAvgPeakPnl = null;
|
|
26680
27135
|
let portfolioAvgFallPnl = null;
|
|
26681
27136
|
const validPeak = symbols.filter((s) => s.avgPeakPnl !== null);
|
|
26682
27137
|
const validFall = symbols.filter((s) => s.avgFallPnl !== null);
|
|
26683
|
-
|
|
26684
|
-
|
|
27138
|
+
const peakTradesTotal = validPeak.reduce((acc, s) => acc + s.totalTrades, 0);
|
|
27139
|
+
const fallTradesTotal = validFall.reduce((acc, s) => acc + s.totalTrades, 0);
|
|
27140
|
+
if (validPeak.length > 0 && peakTradesTotal > 0) {
|
|
27141
|
+
portfolioAvgPeakPnl = validPeak.reduce((acc, s) => acc + s.avgPeakPnl * s.totalTrades, 0) / peakTradesTotal;
|
|
26685
27142
|
}
|
|
26686
|
-
if (validFall.length > 0 &&
|
|
26687
|
-
portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) /
|
|
27143
|
+
if (validFall.length > 0 && fallTradesTotal > 0) {
|
|
27144
|
+
portfolioAvgFallPnl = validFall.reduce((acc, s) => acc + s.avgFallPnl * s.totalTrades, 0) / fallTradesTotal;
|
|
26688
27145
|
}
|
|
26689
27146
|
// Apply safe math
|
|
26690
27147
|
if (isUnsafe(portfolioTotalPnl))
|
|
@@ -26712,7 +27169,7 @@ class HeatmapStorage {
|
|
|
26712
27169
|
* ```
|
|
26713
27170
|
* # Portfolio Heatmap: {strategyName}
|
|
26714
27171
|
*
|
|
26715
|
-
* **Total Symbols:** N | **Portfolio PNL:** X% | **
|
|
27172
|
+
* **Total Symbols:** N | **Portfolio PNL:** X% | **Pooled Sharpe:** Y | **Total Trades:** Z
|
|
26716
27173
|
*
|
|
26717
27174
|
* | col1 | col2 | ... |
|
|
26718
27175
|
* | --- | --- | ... |
|
|
@@ -26751,18 +27208,21 @@ class HeatmapStorage {
|
|
|
26751
27208
|
return [
|
|
26752
27209
|
`# Portfolio Heatmap: ${strategyName}`,
|
|
26753
27210
|
"",
|
|
26754
|
-
`**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"} | **
|
|
27211
|
+
`**Total Symbols:** ${data.totalSymbols} | **Portfolio PNL:** ${data.portfolioTotalPnl !== null ? str(data.portfolioTotalPnl, "%") : "N/A"} | **Pooled Sharpe:** ${data.portfolioSharpeRatio !== null ? str(data.portfolioSharpeRatio) : "N/A"} | **Total Trades:** ${data.portfolioTotalTrades} | **Avg Peak PNL:** ${data.portfolioAvgPeakPnl !== null ? str(data.portfolioAvgPeakPnl, "%") : "N/A"} | **Avg Max Drawdown PNL:** ${data.portfolioAvgFallPnl !== null ? str(data.portfolioAvgFallPnl, "%") : "N/A"}`,
|
|
26755
27212
|
"",
|
|
26756
27213
|
table,
|
|
26757
27214
|
"",
|
|
26758
27215
|
`*Win Rate: reliable above 200+ signals; below 30 signals a single streak can shift it by 10-20%.*`,
|
|
27216
|
+
`*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.*`,
|
|
26759
27217
|
`*Sharpe Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals per symbol.*`,
|
|
26760
|
-
`*Sortino Ratio: below 1.0 is poor, 1.0-2.0 is acceptable, above 2.0 is strong. Requires 30+ signals.*`,
|
|
27218
|
+
`*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".*`,
|
|
26761
27219
|
`*Certainty Ratio: below 1.0 means average loss exceeds average win. Above 1.5 is considered good.*`,
|
|
26762
27220
|
`*Profit Factor: below 1.0 means strategy is losing overall. Above 1.5 is considered good.*`,
|
|
26763
|
-
`*Calmar Ratio: below 0.5 is poor, 0.5-1.0 is acceptable, above 1.0 is strong.
|
|
26764
|
-
`*Recovery Factor: below 1.0 means total profit does not cover max drawdown. Above 3.0 is considered good.*`,
|
|
26765
|
-
`*All metrics require 100+ signals per symbol to be statistically reliable.
|
|
27221
|
+
`*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}.*`,
|
|
27222
|
+
`*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.*`,
|
|
27223
|
+
`*All metrics require 100+ signals per symbol to be statistically reliable. Annualized metrics assume the observed trading frequency persists year-round.*`,
|
|
27224
|
+
`*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.*`,
|
|
27225
|
+
`*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.*`,
|
|
26766
27226
|
].join("\n");
|
|
26767
27227
|
}
|
|
26768
27228
|
/**
|
|
@@ -26957,7 +27417,7 @@ class HeatMarkdownService {
|
|
|
26957
27417
|
* console.log(markdown);
|
|
26958
27418
|
* // # Portfolio Heatmap: my-strategy
|
|
26959
27419
|
* //
|
|
26960
|
-
* // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **
|
|
27420
|
+
* // **Total Symbols:** 5 | **Portfolio PNL:** +45.3% | **Pooled Sharpe:** 1.85 | **Total Trades:** 120
|
|
26961
27421
|
* //
|
|
26962
27422
|
* // | Symbol | Total PNL | Sharpe | Max DD | Trades |
|
|
26963
27423
|
* // | --- | --- | --- | --- | --- |
|
|
@@ -37109,8 +37569,9 @@ function getActionSchema(actionName) {
|
|
|
37109
37569
|
}
|
|
37110
37570
|
|
|
37111
37571
|
const WAIT_FOR_READY_METHOD_NAME = "init.waitForReady";
|
|
37112
|
-
const MAX_WAIT_SECONDS =
|
|
37572
|
+
const MAX_WAIT_SECONDS = 45;
|
|
37113
37573
|
const SECOND_DELAY = 1000;
|
|
37574
|
+
const TIMEOUT_SYMBOL = Symbol('timeout');
|
|
37114
37575
|
/**
|
|
37115
37576
|
* Blocks until the schema registries needed to start trading are populated.
|
|
37116
37577
|
*
|
|
@@ -37148,6 +37609,18 @@ const SECOND_DELAY = 1000;
|
|
|
37148
37609
|
*/
|
|
37149
37610
|
async function waitForReady(isBacktest = true) {
|
|
37150
37611
|
backtest.loggerService.info(WAIT_FOR_READY_METHOD_NAME, { isBacktest });
|
|
37612
|
+
if (entrySubject.data) {
|
|
37613
|
+
return;
|
|
37614
|
+
}
|
|
37615
|
+
if (entrySubject.hasListeners) {
|
|
37616
|
+
backtest.loggerService.debug(`${WAIT_FOR_READY_METHOD_NAME} waiting for entrySubject`);
|
|
37617
|
+
const result = await Promise.race([
|
|
37618
|
+
entrySubject.toPromise(),
|
|
37619
|
+
sleep(MAX_WAIT_SECONDS * SECOND_DELAY).then(() => TIMEOUT_SYMBOL)
|
|
37620
|
+
]);
|
|
37621
|
+
typeof result === "symbol" && console.log("waitForReady timeout");
|
|
37622
|
+
return;
|
|
37623
|
+
}
|
|
37151
37624
|
for (let i = 0; i !== MAX_WAIT_SECONDS; i++) {
|
|
37152
37625
|
const [exchangeList, frameList, strategyList] = await Promise.all([
|
|
37153
37626
|
backtest.exchangeValidationService.list(),
|
|
@@ -37178,6 +37651,9 @@ async function waitForReady(isBacktest = true) {
|
|
|
37178
37651
|
await sleep(SECOND_DELAY);
|
|
37179
37652
|
continue;
|
|
37180
37653
|
}
|
|
37654
|
+
if (i === MAX_WAIT_SECONDS - 1) {
|
|
37655
|
+
console.log("waitForReady timeout");
|
|
37656
|
+
}
|
|
37181
37657
|
break;
|
|
37182
37658
|
}
|
|
37183
37659
|
}
|
|
@@ -54969,7 +55445,7 @@ class LogJsonlUtils {
|
|
|
54969
55445
|
await this[WAIT_FOR_INIT_SYMBOL]();
|
|
54970
55446
|
const line = JSON.stringify(entry) + "\n";
|
|
54971
55447
|
const status = await this[WRITE_SAFE_SYMBOL](line);
|
|
54972
|
-
if (status === TIMEOUT_SYMBOL$
|
|
55448
|
+
if (status === TIMEOUT_SYMBOL$2) {
|
|
54973
55449
|
throw new Error(`LogJsonlUtils timeout writing to file=${this._filePath}`);
|
|
54974
55450
|
}
|
|
54975
55451
|
};
|
|
@@ -63263,6 +63739,7 @@ const CRON_METHOD_NAME_CLEAR = "CronUtils.clear";
|
|
|
63263
63739
|
const CRON_METHOD_NAME_TICK = "CronUtils._tick";
|
|
63264
63740
|
const CRON_METHOD_NAME_ENABLE = "CronUtils.enable";
|
|
63265
63741
|
const CRON_METHOD_NAME_DISABLE = "CronUtils.disable";
|
|
63742
|
+
const CRON_METHOD_NAME_DISPOSE = "CronUtils.dispose";
|
|
63266
63743
|
/**
|
|
63267
63744
|
* Local logger instance.
|
|
63268
63745
|
*
|
|
@@ -63652,6 +64129,38 @@ class CronUtils {
|
|
|
63652
64129
|
lastSubscription();
|
|
63653
64130
|
}
|
|
63654
64131
|
};
|
|
64132
|
+
/**
|
|
64133
|
+
* Hard-reset the entire `Cron` state.
|
|
64134
|
+
*
|
|
64135
|
+
* Performs in order:
|
|
64136
|
+
* 1. {@link disable} — tears down lifecycle subscriptions and resets the
|
|
64137
|
+
* `enable` singleshot so a future `enable()` re-subscribes cleanly.
|
|
64138
|
+
* 2. Wipes `_entries` — every {@link register}'ed entry is forgotten.
|
|
64139
|
+
* Disposers returned by previous `register()` calls become no-ops
|
|
64140
|
+
* (their `unregister(name)` will not find anything to remove).
|
|
64141
|
+
* 3. Wipes `_firedOnce` — all fire-once marks are dropped, so any future
|
|
64142
|
+
* re-registration of the same `name` fires again on the next matching
|
|
64143
|
+
* tick.
|
|
64144
|
+
* 4. Does **not** touch `_inFlight` — in-flight handlers continue to
|
|
64145
|
+
* settle in the background and clear their own slots via `.finally()`.
|
|
64146
|
+
* Their final `_firedOnce.add(firedKey)` writes carry old-generation
|
|
64147
|
+
* keys and are harmless (lookup uses the post-dispose generation).
|
|
64148
|
+
*
|
|
64149
|
+
* Use from a CLI/session teardown when you want to throw away every
|
|
64150
|
+
* registration along with the lifecycle wiring — e.g. between two
|
|
64151
|
+
* independent runner scopes. For "just snap the subscriptions but keep
|
|
64152
|
+
* registrations" use {@link disable} instead; for "just re-arm fire-once
|
|
64153
|
+
* marks" use {@link clear}.
|
|
64154
|
+
*
|
|
64155
|
+
* Idempotent. Safe to call multiple times and safe to call before
|
|
64156
|
+
* `enable()` / without any registrations.
|
|
64157
|
+
*/
|
|
64158
|
+
this.dispose = () => {
|
|
64159
|
+
LOGGER_SERVICE$1.info(CRON_METHOD_NAME_DISPOSE);
|
|
64160
|
+
this.disable();
|
|
64161
|
+
this._entries.clear();
|
|
64162
|
+
this._firedOnce.clear();
|
|
64163
|
+
};
|
|
63655
64164
|
}
|
|
63656
64165
|
/**
|
|
63657
64166
|
* Garbage-collect every `_firedOnce` key that belongs to the entry `name`
|