@samsmith2121/synthetic-leverage-engine 0.1.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/README.md +61 -0
- package/dist/src/analysis-cli.d.ts +1 -0
- package/dist/src/analysis-cli.js +59 -0
- package/dist/src/analysis.d.ts +214 -0
- package/dist/src/analysis.js +843 -0
- package/dist/src/backtest.d.ts +3 -0
- package/dist/src/backtest.js +172 -0
- package/dist/src/benchmark.d.ts +11 -0
- package/dist/src/benchmark.js +85 -0
- package/dist/src/contributions.d.ts +4 -0
- package/dist/src/contributions.js +11 -0
- package/dist/src/costs.d.ts +8 -0
- package/dist/src/costs.js +29 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +34 -0
- package/dist/src/metrics.d.ts +15 -0
- package/dist/src/metrics.js +232 -0
- package/dist/src/public-api.d.ts +89 -0
- package/dist/src/public-api.js +103 -0
- package/dist/src/reporting.d.ts +112 -0
- package/dist/src/reporting.js +156 -0
- package/dist/src/signal.d.ts +34 -0
- package/dist/src/signal.js +80 -0
- package/dist/src/simulator/adapters.d.ts +9 -0
- package/dist/src/simulator/adapters.js +99 -0
- package/dist/src/simulator/index.d.ts +4 -0
- package/dist/src/simulator/index.js +31 -0
- package/dist/src/simulator/monte-carlo.d.ts +8 -0
- package/dist/src/simulator/monte-carlo.js +94 -0
- package/dist/src/simulator/series.d.ts +6 -0
- package/dist/src/simulator/series.js +152 -0
- package/dist/src/simulator/types.d.ts +111 -0
- package/dist/src/simulator/types.js +2 -0
- package/dist/src/step.d.ts +24 -0
- package/dist/src/step.js +75 -0
- package/dist/src/types.d.ts +145 -0
- package/dist/src/types.js +2 -0
- package/dist/src/validation-cases.d.ts +21 -0
- package/dist/src/validation-cases.js +930 -0
- package/dist/src/validation-harness.d.ts +1 -0
- package/dist/src/validation-harness.js +26 -0
- package/dist/src/validation.d.ts +8 -0
- package/dist/src/validation.js +244 -0
- package/package.json +36 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runStrategyComparison = runStrategyComparison;
|
|
4
|
+
exports.runParameterSweep = runParameterSweep;
|
|
5
|
+
exports.runScenarioAnalysis = runScenarioAnalysis;
|
|
6
|
+
exports.buildRollingWindowScenarios = buildRollingWindowScenarios;
|
|
7
|
+
exports.runOneWaySensitivity = runOneWaySensitivity;
|
|
8
|
+
exports.runTwoWaySensitivity = runTwoWaySensitivity;
|
|
9
|
+
exports.buildResearchSummaries = buildResearchSummaries;
|
|
10
|
+
exports.rankStrategyRuns = rankStrategyRuns;
|
|
11
|
+
exports.buildStrategyDiagnostics = buildStrategyDiagnostics;
|
|
12
|
+
const backtest_1 = require("./backtest");
|
|
13
|
+
const metrics_1 = require("./metrics");
|
|
14
|
+
const EPSILON = 1e-12;
|
|
15
|
+
function runStrategyComparison(days, baseConfig, variants, rankBy = 'twrCagr') {
|
|
16
|
+
const runs = variants.map((variant) => runSingleStrategy(days, baseConfig, variant));
|
|
17
|
+
return {
|
|
18
|
+
runs,
|
|
19
|
+
ranking: rankStrategyRuns(runs, rankBy),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function runParameterSweep(days, baseConfig, grid, config = {}) {
|
|
23
|
+
const monteCarloBlockLengths = grid.monteCarloBlockLengths && grid.monteCarloBlockLengths.length > 0
|
|
24
|
+
? grid.monteCarloBlockLengths
|
|
25
|
+
: [undefined];
|
|
26
|
+
const rows = [];
|
|
27
|
+
for (const vixThreshold of grid.vixThresholds) {
|
|
28
|
+
for (const leverageMultiple of grid.leverageMultiples) {
|
|
29
|
+
for (const tradeSpread of grid.tradeSpreads) {
|
|
30
|
+
for (const expenseRatioAnnual of grid.expenseRatiosAnnual) {
|
|
31
|
+
for (const cashMode of grid.cashModes) {
|
|
32
|
+
for (const cashYieldDaily of grid.cashYieldDailyValues) {
|
|
33
|
+
for (const monteCarloBlockLength of monteCarloBlockLengths) {
|
|
34
|
+
const strategyName = [
|
|
35
|
+
config.baseVariantName ?? 'sweep',
|
|
36
|
+
`vix${vixThreshold}`,
|
|
37
|
+
`lev${leverageMultiple}`,
|
|
38
|
+
`spr${tradeSpread}`,
|
|
39
|
+
`er${expenseRatioAnnual}`,
|
|
40
|
+
`cash${cashMode}`,
|
|
41
|
+
`yld${cashYieldDaily}`,
|
|
42
|
+
`blk${monteCarloBlockLength ?? 'na'}`,
|
|
43
|
+
].join('__');
|
|
44
|
+
const variant = {
|
|
45
|
+
name: strategyName,
|
|
46
|
+
signal: {
|
|
47
|
+
vixThreshold,
|
|
48
|
+
leverageMultiple,
|
|
49
|
+
},
|
|
50
|
+
tradeSpread,
|
|
51
|
+
expenseRatioAnnual,
|
|
52
|
+
cashMode,
|
|
53
|
+
cashYieldDaily,
|
|
54
|
+
monteCarloBlockLength,
|
|
55
|
+
};
|
|
56
|
+
const run = runSingleStrategy(days, baseConfig, variant);
|
|
57
|
+
if (config.monteCarloConfig && monteCarloBlockLength !== undefined && run.ok) {
|
|
58
|
+
try {
|
|
59
|
+
(0, backtest_1.runMonteCarloBacktest)(days, mergeEngineConfig(baseConfig, variant), {
|
|
60
|
+
...config.monteCarloConfig,
|
|
61
|
+
blockLength: monteCarloBlockLength,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
run.warnings.push({
|
|
66
|
+
code: 'MONTE_CARLO_BLOCK_TOO_LONG',
|
|
67
|
+
message: error instanceof Error ? error.message : 'monte carlo validation failed',
|
|
68
|
+
context: {
|
|
69
|
+
blockLength: monteCarloBlockLength,
|
|
70
|
+
sourceLength: days.length,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
rows.push({
|
|
76
|
+
strategyName,
|
|
77
|
+
vixThreshold,
|
|
78
|
+
leverageMultiple,
|
|
79
|
+
tradeSpread,
|
|
80
|
+
expenseRatioAnnual,
|
|
81
|
+
cashMode,
|
|
82
|
+
cashYieldDaily,
|
|
83
|
+
monteCarloBlockLength,
|
|
84
|
+
run,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
rows,
|
|
95
|
+
ranking: rankStrategyRuns(rows.map((r) => r.run), config.rankBy ?? 'twrCagr'),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function runScenarioAnalysis(days, baseConfig, variant, scenarios, includeFullPeriod = true) {
|
|
99
|
+
const rows = [];
|
|
100
|
+
if (includeFullPeriod) {
|
|
101
|
+
const fullRun = runSingleStrategy(days, baseConfig, { ...variant, name: `${variant.name}__full_period` });
|
|
102
|
+
rows.push({
|
|
103
|
+
scenarioName: 'full_period',
|
|
104
|
+
startDate: days[0]?.date ?? '',
|
|
105
|
+
endDate: days[days.length - 1]?.date ?? '',
|
|
106
|
+
run: fullRun,
|
|
107
|
+
warnings: [...fullRun.warnings, ...buildScenarioWarnings(days)],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const scenario of scenarios) {
|
|
111
|
+
const sliced = sliceDaysByDate(days, scenario.startDate, scenario.endDate);
|
|
112
|
+
if (!sliced.ok) {
|
|
113
|
+
const warning = {
|
|
114
|
+
code: 'SWEEP_VALIDATION_FAILED',
|
|
115
|
+
message: sliced.error,
|
|
116
|
+
context: { scenarioName: scenario.name },
|
|
117
|
+
};
|
|
118
|
+
rows.push({
|
|
119
|
+
scenarioName: scenario.name,
|
|
120
|
+
startDate: scenario.startDate,
|
|
121
|
+
endDate: scenario.endDate,
|
|
122
|
+
run: {
|
|
123
|
+
ok: false,
|
|
124
|
+
strategy: variant,
|
|
125
|
+
error: sliced.error,
|
|
126
|
+
warnings: [warning],
|
|
127
|
+
},
|
|
128
|
+
warnings: [warning],
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const run = runSingleStrategy(sliced.days, baseConfig, { ...variant, name: `${variant.name}__${scenario.name}` });
|
|
133
|
+
rows.push({
|
|
134
|
+
scenarioName: scenario.name,
|
|
135
|
+
startDate: scenario.startDate,
|
|
136
|
+
endDate: scenario.endDate,
|
|
137
|
+
run,
|
|
138
|
+
warnings: [...run.warnings, ...buildScenarioWarnings(sliced.days)],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { rows };
|
|
142
|
+
}
|
|
143
|
+
function buildRollingWindowScenarios(days, windowLengthDays, stepDays) {
|
|
144
|
+
if (!Number.isInteger(windowLengthDays) || windowLengthDays <= 0) {
|
|
145
|
+
throw new Error('windowLengthDays must be a positive integer');
|
|
146
|
+
}
|
|
147
|
+
if (!Number.isInteger(stepDays) || stepDays <= 0) {
|
|
148
|
+
throw new Error('stepDays must be a positive integer');
|
|
149
|
+
}
|
|
150
|
+
const scenarios = [];
|
|
151
|
+
for (let i = 0; i + windowLengthDays - 1 < days.length; i += stepDays) {
|
|
152
|
+
const startDate = days[i].date;
|
|
153
|
+
const endDate = days[i + windowLengthDays - 1].date;
|
|
154
|
+
scenarios.push({
|
|
155
|
+
name: `rolling_${startDate}_${endDate}`,
|
|
156
|
+
startDate,
|
|
157
|
+
endDate,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return scenarios;
|
|
161
|
+
}
|
|
162
|
+
function runOneWaySensitivity(days, baseConfig, baseVariant, parameter, values, rankBy = 'twrCagr') {
|
|
163
|
+
const rows = values.map((value) => {
|
|
164
|
+
const variant = applySensitivityValue(baseVariant, parameter, value);
|
|
165
|
+
return {
|
|
166
|
+
parameter,
|
|
167
|
+
parameterValue: value,
|
|
168
|
+
run: runSingleStrategy(days, baseConfig, variant),
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
parameter,
|
|
173
|
+
rows,
|
|
174
|
+
ranking: rankStrategyRuns(rows.map((row) => row.run), rankBy),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function runTwoWaySensitivity(days, baseConfig, baseVariant, xParameter, xValues, yParameter, yValues, rankBy = 'twrCagr') {
|
|
178
|
+
if (xParameter === yParameter) {
|
|
179
|
+
throw new Error('xParameter and yParameter must differ');
|
|
180
|
+
}
|
|
181
|
+
const cells = [];
|
|
182
|
+
for (const xValue of xValues) {
|
|
183
|
+
for (const yValue of yValues) {
|
|
184
|
+
const xVariant = applySensitivityValue(baseVariant, xParameter, xValue);
|
|
185
|
+
const variant = applySensitivityValue(xVariant, yParameter, yValue);
|
|
186
|
+
cells.push({
|
|
187
|
+
xParameter,
|
|
188
|
+
xValue,
|
|
189
|
+
yParameter,
|
|
190
|
+
yValue,
|
|
191
|
+
run: runSingleStrategy(days, baseConfig, variant),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
xParameter,
|
|
197
|
+
yParameter,
|
|
198
|
+
xValues,
|
|
199
|
+
yValues,
|
|
200
|
+
cells,
|
|
201
|
+
ranking: rankStrategyRuns(cells.map((cell) => cell.run), rankBy),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function buildResearchSummaries(comparison, scenarioResult, options = {}) {
|
|
205
|
+
const summaries = [];
|
|
206
|
+
const successfulRuns = comparison.runs.filter((run) => run.ok);
|
|
207
|
+
const byTradeoff = [...successfulRuns]
|
|
208
|
+
.filter((run) => run.summary.maxDrawdown > 0 && run.summary.twrCagr !== null)
|
|
209
|
+
.sort((a, b) => compareByMetric(a.summary, b.summary, 'returnToDrawdown'))[0];
|
|
210
|
+
if (byTradeoff) {
|
|
211
|
+
summaries.push({
|
|
212
|
+
kind: 'best_growth_drawdown_tradeoff',
|
|
213
|
+
strategyName: byTradeoff.strategy.name,
|
|
214
|
+
rationale: 'Highest TWR CAGR to max drawdown ratio among successful runs.',
|
|
215
|
+
metrics: {
|
|
216
|
+
twrCagr: byTradeoff.summary.twrCagr,
|
|
217
|
+
maxDrawdown: byTradeoff.summary.maxDrawdown,
|
|
218
|
+
returnToDrawdown: safeReturnToDrawdown(byTradeoff.summary),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const drawdownLimit = options.maxDrawdownConstraint;
|
|
223
|
+
if (drawdownLimit !== undefined) {
|
|
224
|
+
const constrained = [...successfulRuns]
|
|
225
|
+
.filter((run) => run.summary.twrCagr !== null && run.summary.maxDrawdown <= drawdownLimit)
|
|
226
|
+
.sort((a, b) => compareByMetric(a.summary, b.summary, 'twrCagr'))[0];
|
|
227
|
+
if (constrained) {
|
|
228
|
+
summaries.push({
|
|
229
|
+
kind: 'highest_twr_under_drawdown_constraint',
|
|
230
|
+
strategyName: constrained.strategy.name,
|
|
231
|
+
rationale: 'Highest TWR CAGR among variants meeting configured max drawdown constraint.',
|
|
232
|
+
metrics: {
|
|
233
|
+
twrCagr: constrained.summary.twrCagr,
|
|
234
|
+
maxDrawdown: constrained.summary.maxDrawdown,
|
|
235
|
+
constraint: drawdownLimit,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const lowestCost = [...successfulRuns].sort((a, b) => {
|
|
241
|
+
const aCost = a.summary.totalTradeCost + a.summary.totalBorrowCost + a.summary.totalErCost;
|
|
242
|
+
const bCost = b.summary.totalTradeCost + b.summary.totalBorrowCost + b.summary.totalErCost;
|
|
243
|
+
if (Math.abs(aCost - bCost) <= EPSILON) {
|
|
244
|
+
return a.strategy.name.localeCompare(b.strategy.name);
|
|
245
|
+
}
|
|
246
|
+
return aCost - bCost;
|
|
247
|
+
})[0];
|
|
248
|
+
if (lowestCost) {
|
|
249
|
+
const totalCost = lowestCost.summary.totalTradeCost + lowestCost.summary.totalBorrowCost + lowestCost.summary.totalErCost;
|
|
250
|
+
summaries.push({
|
|
251
|
+
kind: 'lowest_cost',
|
|
252
|
+
strategyName: lowestCost.strategy.name,
|
|
253
|
+
rationale: 'Lowest total explicit implementation cost among successful runs.',
|
|
254
|
+
metrics: {
|
|
255
|
+
totalCost,
|
|
256
|
+
tradeCost: lowestCost.summary.totalTradeCost,
|
|
257
|
+
borrowCost: lowestCost.summary.totalBorrowCost,
|
|
258
|
+
erCost: lowestCost.summary.totalErCost,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const bestBenchmark = [...successfulRuns]
|
|
263
|
+
.filter((run) => run.summary.benchmark?.excessTwrCagr !== null && run.summary.benchmark?.excessTwrCagr !== undefined)
|
|
264
|
+
.sort((a, b) => compareByMetric(a.summary, b.summary, 'excessTwrCagr'))[0];
|
|
265
|
+
if (bestBenchmark) {
|
|
266
|
+
summaries.push({
|
|
267
|
+
kind: 'most_benchmark_outperforming',
|
|
268
|
+
strategyName: bestBenchmark.strategy.name,
|
|
269
|
+
rationale: 'Highest excess TWR CAGR over benchmark on matched dates.',
|
|
270
|
+
metrics: {
|
|
271
|
+
excessTwrCagr: bestBenchmark.summary.benchmark?.excessTwrCagr ?? null,
|
|
272
|
+
benchmarkTwrCagr: bestBenchmark.summary.benchmark?.benchmarkTwrCagr ?? null,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (scenarioResult) {
|
|
277
|
+
const robust = pickMostRobustScenarioVariant(scenarioResult);
|
|
278
|
+
if (robust) {
|
|
279
|
+
summaries.push(robust);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return summaries;
|
|
283
|
+
}
|
|
284
|
+
function rankStrategyRuns(runs, metric) {
|
|
285
|
+
const successes = runs.filter((r) => r.ok);
|
|
286
|
+
const sorted = [...successes].sort((a, b) => compareByMetric(a.summary, b.summary, metric));
|
|
287
|
+
return sorted.map((run, index) => ({
|
|
288
|
+
strategyName: run.strategy.name,
|
|
289
|
+
metric,
|
|
290
|
+
rank: index + 1,
|
|
291
|
+
value: getMetricValue(run.summary, metric),
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
function runSingleStrategy(days, baseConfig, variant) {
|
|
295
|
+
try {
|
|
296
|
+
validateVariant(variant, baseConfig);
|
|
297
|
+
const adjustedDays = applyVariantToDays(days, variant);
|
|
298
|
+
const config = mergeEngineConfig(baseConfig, variant);
|
|
299
|
+
const backtest = (0, backtest_1.runFixedBacktest)(adjustedDays, config);
|
|
300
|
+
const summary = summarizeAnalysisRun(variant.name, config.initialEquity, backtest);
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
strategy: variant,
|
|
304
|
+
summary,
|
|
305
|
+
warnings: collectRunWarnings(adjustedDays, variant, summary),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
const message = error instanceof Error ? error.message : 'unknown error';
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
strategy: variant,
|
|
313
|
+
error: message,
|
|
314
|
+
warnings: [
|
|
315
|
+
{
|
|
316
|
+
code: 'SWEEP_VALIDATION_FAILED',
|
|
317
|
+
message,
|
|
318
|
+
context: { strategyName: variant.name },
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function summarizeAnalysisRun(strategyName, initialEquity, backtest) {
|
|
325
|
+
const dailyReturns = backtest.daily.map((d) => d.capitalBaseBeforeReturn === 0 ? 0 : d.equityEnd / d.capitalBaseBeforeReturn - 1);
|
|
326
|
+
const totalInvested = initialEquity + backtest.daily.reduce((acc, day) => acc + day.contribution, 0);
|
|
327
|
+
const cashFlows = buildCashFlows(backtest.daily, initialEquity);
|
|
328
|
+
const benchmark = summarizeBenchmark(backtest.benchmark, backtest.maxDrawdown);
|
|
329
|
+
return {
|
|
330
|
+
strategyName,
|
|
331
|
+
finalEquity: backtest.finalEquity,
|
|
332
|
+
totalInvested,
|
|
333
|
+
twrCagr: backtest.twrCagr,
|
|
334
|
+
xirr: (0, metrics_1.computeXirr)(cashFlows),
|
|
335
|
+
maxDrawdown: backtest.maxDrawdown,
|
|
336
|
+
volatility: (0, metrics_1.computeVolatilityFromReturns)(dailyReturns),
|
|
337
|
+
totalTradeCost: backtest.daily.reduce((acc, d) => acc + d.tradeCost, 0),
|
|
338
|
+
totalBorrowCost: backtest.daily.reduce((acc, d) => acc + d.borrowDrag, 0),
|
|
339
|
+
totalErCost: backtest.daily.reduce((acc, d) => acc + d.expenseDrag, 0),
|
|
340
|
+
totalSwitches: countSwitches(backtest),
|
|
341
|
+
diagnostics: buildStrategyDiagnostics(backtest, totalInvested),
|
|
342
|
+
...(benchmark ? { benchmark } : {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function buildStrategyDiagnostics(backtest, totalInvested) {
|
|
346
|
+
const daily = backtest.daily;
|
|
347
|
+
const exposure = buildExposureDiagnostics(daily);
|
|
348
|
+
const transitions = buildTransitionDiagnostics(daily);
|
|
349
|
+
const costs = buildCostDiagnostics(daily, backtest.finalEquity, totalInvested);
|
|
350
|
+
const drawdown = buildDrawdownDiagnostics(daily);
|
|
351
|
+
const benchmarkRelative = buildBenchmarkRelativeDiagnostics(backtest.benchmark);
|
|
352
|
+
return {
|
|
353
|
+
exposure,
|
|
354
|
+
transitions,
|
|
355
|
+
costs,
|
|
356
|
+
drawdown,
|
|
357
|
+
...(benchmarkRelative ? { benchmarkRelative } : {}),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function summarizeBenchmark(benchmark, strategyMaxDrawdown) {
|
|
361
|
+
if (!benchmark) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
benchmarkTotalReturn: benchmark.benchmarkTotalReturn,
|
|
366
|
+
benchmarkTwrCagr: benchmark.benchmarkTwrCagr,
|
|
367
|
+
benchmarkVolatility: benchmark.benchmarkVolatility,
|
|
368
|
+
benchmarkMaxDrawdown: benchmark.benchmarkMaxDrawdown,
|
|
369
|
+
excessTotalReturn: benchmark.excessTotalReturn,
|
|
370
|
+
excessTwrCagr: benchmark.excessTwrCagr,
|
|
371
|
+
trackingError: benchmark.trackingError,
|
|
372
|
+
relativeMaxDrawdown: strategyMaxDrawdown - benchmark.benchmarkMaxDrawdown,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function countSwitches(backtest) {
|
|
376
|
+
return backtest.daily.reduce((count, day) => count + (Math.abs(day.appliedPosition - day.priorAppliedPosition) > EPSILON ? 1 : 0), 0);
|
|
377
|
+
}
|
|
378
|
+
function buildCashFlows(daily, initialEquity) {
|
|
379
|
+
if (daily.length === 0) {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
const flows = [{ date: daily[0].date, amount: -initialEquity }];
|
|
383
|
+
for (const day of daily) {
|
|
384
|
+
if (day.contribution !== 0) {
|
|
385
|
+
flows.push({ date: day.date, amount: -day.contribution });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
flows.push({ date: daily[daily.length - 1].date, amount: daily[daily.length - 1].equityEnd });
|
|
389
|
+
return flows;
|
|
390
|
+
}
|
|
391
|
+
function compareByMetric(a, b, metric) {
|
|
392
|
+
const aValue = getMetricValue(a, metric);
|
|
393
|
+
const bValue = getMetricValue(b, metric);
|
|
394
|
+
if (aValue === null && bValue === null) {
|
|
395
|
+
return a.strategyName.localeCompare(b.strategyName);
|
|
396
|
+
}
|
|
397
|
+
if (aValue === null) {
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
if (bValue === null) {
|
|
401
|
+
return -1;
|
|
402
|
+
}
|
|
403
|
+
if (metric === 'maxDrawdown' || metric === 'volatility') {
|
|
404
|
+
if (Math.abs(aValue - bValue) <= EPSILON) {
|
|
405
|
+
return a.strategyName.localeCompare(b.strategyName);
|
|
406
|
+
}
|
|
407
|
+
return aValue - bValue;
|
|
408
|
+
}
|
|
409
|
+
if (Math.abs(aValue - bValue) <= EPSILON) {
|
|
410
|
+
return a.strategyName.localeCompare(b.strategyName);
|
|
411
|
+
}
|
|
412
|
+
return bValue - aValue;
|
|
413
|
+
}
|
|
414
|
+
function getMetricValue(summary, metric) {
|
|
415
|
+
switch (metric) {
|
|
416
|
+
case 'twrCagr':
|
|
417
|
+
return summary.twrCagr;
|
|
418
|
+
case 'xirr':
|
|
419
|
+
return summary.xirr;
|
|
420
|
+
case 'maxDrawdown':
|
|
421
|
+
return summary.maxDrawdown;
|
|
422
|
+
case 'volatility':
|
|
423
|
+
return summary.volatility;
|
|
424
|
+
case 'excessTwrCagr':
|
|
425
|
+
return summary.benchmark?.excessTwrCagr ?? null;
|
|
426
|
+
case 'returnToDrawdown':
|
|
427
|
+
return safeReturnToDrawdown(summary);
|
|
428
|
+
default:
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function safeReturnToDrawdown(summary) {
|
|
433
|
+
if (summary.twrCagr === null || summary.maxDrawdown <= 0) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
return summary.twrCagr / summary.maxDrawdown;
|
|
437
|
+
}
|
|
438
|
+
function validateVariant(variant, baseConfig) {
|
|
439
|
+
if (variant.signal && Math.abs(variant.signal.leverageMultiple) > baseConfig.maxLeverage) {
|
|
440
|
+
throw new Error('signal leverageMultiple exceeds maxLeverage');
|
|
441
|
+
}
|
|
442
|
+
if (variant.cashMode === 'configured_yield' && variant.cashYieldDaily === undefined) {
|
|
443
|
+
throw new Error('configured_yield requires cashYieldDaily');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function mergeEngineConfig(baseConfig, variant) {
|
|
447
|
+
return {
|
|
448
|
+
...baseConfig,
|
|
449
|
+
...variant.engineConfigOverrides,
|
|
450
|
+
cashYieldDaily: variant.cashMode === 'zero_yield' ? 0 : variant.cashYieldDaily ?? baseConfig.cashYieldDaily,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function applyVariantToDays(days, variant) {
|
|
454
|
+
return days.map((day) => {
|
|
455
|
+
let transformedSignal;
|
|
456
|
+
if (!variant.signal) {
|
|
457
|
+
transformedSignal = day.signalInput;
|
|
458
|
+
}
|
|
459
|
+
else if (variant.signal.smaMode) {
|
|
460
|
+
// SMA mode: signalInput is 1 (price above SMA) or 0 (price below SMA).
|
|
461
|
+
// Leveraged when signalInput > vixThreshold; set vixThreshold: 0 for standard use.
|
|
462
|
+
// Using > (not <=) because the SMA signal convention is opposite to VIX: a high
|
|
463
|
+
// value (1 = above SMA = uptrend) should trigger leverage, not cash.
|
|
464
|
+
transformedSignal = day.signalInput > variant.signal.vixThreshold
|
|
465
|
+
? variant.signal.leverageMultiple
|
|
466
|
+
: variant.signal.cashExposure ?? 0;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
// VIX mode (default): lower signalInput = lower volatility = leveraged.
|
|
470
|
+
transformedSignal = day.signalInput <= variant.signal.vixThreshold
|
|
471
|
+
? variant.signal.leverageMultiple
|
|
472
|
+
: variant.signal.cashExposure ?? 0;
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...day,
|
|
476
|
+
signalInput: transformedSignal,
|
|
477
|
+
tradeSpread: variant.tradeSpread ?? day.tradeSpread,
|
|
478
|
+
expenseRatioAnnual: variant.expenseRatioAnnual ?? day.expenseRatioAnnual,
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function sliceDaysByDate(days, startDate, endDate) {
|
|
483
|
+
if (startDate > endDate) {
|
|
484
|
+
return { ok: false, error: `invalid scenario range: ${startDate} is after ${endDate}` };
|
|
485
|
+
}
|
|
486
|
+
const sliced = days.filter((day) => day.date >= startDate && day.date <= endDate);
|
|
487
|
+
if (sliced.length === 0) {
|
|
488
|
+
return { ok: false, error: `scenario range ${startDate}..${endDate} has no data` };
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, days: sliced };
|
|
491
|
+
}
|
|
492
|
+
function buildExposureDiagnostics(daily) {
|
|
493
|
+
const dayCount = daily.length;
|
|
494
|
+
if (dayCount === 0) {
|
|
495
|
+
return {
|
|
496
|
+
pctDaysCash: 0,
|
|
497
|
+
pctDaysOneX: 0,
|
|
498
|
+
pctDaysLeveraged: 0,
|
|
499
|
+
averageAppliedLeverage: 0,
|
|
500
|
+
leverageDistribution: {},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
let cashDays = 0;
|
|
504
|
+
let oneXDays = 0;
|
|
505
|
+
let leveragedDays = 0;
|
|
506
|
+
const distribution = new Map();
|
|
507
|
+
for (const day of daily) {
|
|
508
|
+
const absLev = Math.abs(day.appliedPosition);
|
|
509
|
+
const bucket = day.appliedPosition.toFixed(4);
|
|
510
|
+
distribution.set(bucket, (distribution.get(bucket) ?? 0) + 1);
|
|
511
|
+
if (absLev <= EPSILON) {
|
|
512
|
+
cashDays += 1;
|
|
513
|
+
}
|
|
514
|
+
else if (absLev <= 1 + EPSILON) {
|
|
515
|
+
oneXDays += 1;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
leveragedDays += 1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const leverageDistribution = {};
|
|
522
|
+
for (const [level, count] of [...distribution.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
523
|
+
leverageDistribution[level] = count / dayCount;
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
pctDaysCash: cashDays / dayCount,
|
|
527
|
+
pctDaysOneX: oneXDays / dayCount,
|
|
528
|
+
pctDaysLeveraged: leveragedDays / dayCount,
|
|
529
|
+
averageAppliedLeverage: daily.reduce((acc, day) => acc + Math.abs(day.appliedPosition), 0) / dayCount,
|
|
530
|
+
leverageDistribution,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function regimeForPosition(position) {
|
|
534
|
+
const absLev = Math.abs(position);
|
|
535
|
+
if (absLev <= EPSILON) {
|
|
536
|
+
return 'cash';
|
|
537
|
+
}
|
|
538
|
+
if (absLev <= 1 + EPSILON) {
|
|
539
|
+
return 'one_x';
|
|
540
|
+
}
|
|
541
|
+
return 'leveraged';
|
|
542
|
+
}
|
|
543
|
+
function buildTransitionDiagnostics(daily) {
|
|
544
|
+
const transitionCounts = new Map();
|
|
545
|
+
const holdDurations = {
|
|
546
|
+
cash: [],
|
|
547
|
+
one_x: [],
|
|
548
|
+
leveraged: [],
|
|
549
|
+
};
|
|
550
|
+
let totalSwitches = 0;
|
|
551
|
+
let lastRegime = null;
|
|
552
|
+
let currentRunLength = 0;
|
|
553
|
+
for (const day of daily) {
|
|
554
|
+
const priorRegime = regimeForPosition(day.priorAppliedPosition);
|
|
555
|
+
const currentRegime = regimeForPosition(day.appliedPosition);
|
|
556
|
+
if (priorRegime !== currentRegime) {
|
|
557
|
+
totalSwitches += 1;
|
|
558
|
+
const key = `${priorRegime}->${currentRegime}`;
|
|
559
|
+
transitionCounts.set(key, (transitionCounts.get(key) ?? 0) + 1);
|
|
560
|
+
}
|
|
561
|
+
if (lastRegime === null || currentRegime !== lastRegime) {
|
|
562
|
+
if (lastRegime !== null && currentRunLength > 0) {
|
|
563
|
+
holdDurations[lastRegime].push(currentRunLength);
|
|
564
|
+
}
|
|
565
|
+
lastRegime = currentRegime;
|
|
566
|
+
currentRunLength = 1;
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
currentRunLength += 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (lastRegime !== null && currentRunLength > 0) {
|
|
573
|
+
holdDurations[lastRegime].push(currentRunLength);
|
|
574
|
+
}
|
|
575
|
+
const transitionCountsObj = {};
|
|
576
|
+
for (const [key, count] of [...transitionCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
577
|
+
transitionCountsObj[key] = count;
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
totalSwitches,
|
|
581
|
+
transitionCounts: transitionCountsObj,
|
|
582
|
+
averageHoldingDurationByRegime: {
|
|
583
|
+
cash: average(holdDurations.cash),
|
|
584
|
+
one_x: average(holdDurations.one_x),
|
|
585
|
+
leveraged: average(holdDurations.leveraged),
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function buildCostDiagnostics(daily, finalEquity, totalInvested) {
|
|
590
|
+
const totalTradeCost = daily.reduce((acc, day) => acc + day.tradeCost, 0);
|
|
591
|
+
const totalBorrowCost = daily.reduce((acc, day) => acc + day.borrowDrag, 0);
|
|
592
|
+
const totalErCost = daily.reduce((acc, day) => acc + day.expenseDrag, 0);
|
|
593
|
+
const totalCost = totalTradeCost + totalBorrowCost + totalErCost;
|
|
594
|
+
return {
|
|
595
|
+
totalTradeCost,
|
|
596
|
+
totalBorrowCost,
|
|
597
|
+
totalErCost,
|
|
598
|
+
totalCost,
|
|
599
|
+
tradeCostShare: totalCost > 0 ? totalTradeCost / totalCost : 0,
|
|
600
|
+
borrowCostShare: totalCost > 0 ? totalBorrowCost / totalCost : 0,
|
|
601
|
+
erCostShare: totalCost > 0 ? totalErCost / totalCost : 0,
|
|
602
|
+
totalCostPctFinalEquity: finalEquity > 0 ? totalCost / finalEquity : null,
|
|
603
|
+
totalCostPctInvestedCapital: totalInvested !== undefined && totalInvested > 0 ? totalCost / totalInvested : null,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function buildDrawdownDiagnostics(daily) {
|
|
607
|
+
if (daily.length === 0) {
|
|
608
|
+
return {
|
|
609
|
+
maxDrawdown: 0,
|
|
610
|
+
maxDrawdownDurationDays: 0,
|
|
611
|
+
episodesAbove10Pct: 0,
|
|
612
|
+
episodesAbove20Pct: 0,
|
|
613
|
+
episodesAbove30Pct: 0,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
const equity = daily.map((day) => day.equityEnd);
|
|
617
|
+
let peak = equity[0];
|
|
618
|
+
let currentPeakIndex = 0;
|
|
619
|
+
let maxDrawdown = 0;
|
|
620
|
+
let maxDrawdownPeakIndex = 0;
|
|
621
|
+
let maxDrawdownTroughIndex = 0;
|
|
622
|
+
for (let i = 0; i < equity.length; i += 1) {
|
|
623
|
+
const level = equity[i];
|
|
624
|
+
if (level > peak) {
|
|
625
|
+
peak = level;
|
|
626
|
+
currentPeakIndex = i;
|
|
627
|
+
}
|
|
628
|
+
if (peak > 0) {
|
|
629
|
+
const drawdown = (peak - level) / peak;
|
|
630
|
+
if (drawdown > maxDrawdown) {
|
|
631
|
+
maxDrawdown = drawdown;
|
|
632
|
+
maxDrawdownPeakIndex = currentPeakIndex;
|
|
633
|
+
maxDrawdownTroughIndex = i;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
let recoveryIndex = maxDrawdownTroughIndex;
|
|
638
|
+
const maxPeakLevel = equity[maxDrawdownPeakIndex];
|
|
639
|
+
while (recoveryIndex < equity.length && equity[recoveryIndex] < maxPeakLevel) {
|
|
640
|
+
recoveryIndex += 1;
|
|
641
|
+
}
|
|
642
|
+
const maxDrawdownDurationDays = recoveryIndex >= equity.length
|
|
643
|
+
? equity.length - 1 - maxDrawdownPeakIndex
|
|
644
|
+
: recoveryIndex - maxDrawdownPeakIndex;
|
|
645
|
+
const drawdowns = buildDrawdownSeries(equity);
|
|
646
|
+
return {
|
|
647
|
+
maxDrawdown,
|
|
648
|
+
maxDrawdownDurationDays,
|
|
649
|
+
episodesAbove10Pct: countEpisodesAboveThreshold(drawdowns, 0.10),
|
|
650
|
+
episodesAbove20Pct: countEpisodesAboveThreshold(drawdowns, 0.20),
|
|
651
|
+
episodesAbove30Pct: countEpisodesAboveThreshold(drawdowns, 0.30),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function buildBenchmarkRelativeDiagnostics(benchmark) {
|
|
655
|
+
if (!benchmark) {
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
const excess = benchmark.strategyReturns.map((value, idx) => value - benchmark.benchmarkReturns[idx]);
|
|
659
|
+
const outperformPeriods = excess.filter((value) => value > EPSILON).length;
|
|
660
|
+
return {
|
|
661
|
+
matchedPeriods: excess.length,
|
|
662
|
+
outperformPeriods,
|
|
663
|
+
outperformPct: excess.length > 0 ? outperformPeriods / excess.length : null,
|
|
664
|
+
averageExcessReturn: excess.length > 0 ? average(excess) : null,
|
|
665
|
+
medianExcessReturn: excess.length > 0 ? median(excess) : null,
|
|
666
|
+
excessTwrCagr: benchmark.excessTwrCagr,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function buildDrawdownSeries(equity) {
|
|
670
|
+
const drawdowns = [];
|
|
671
|
+
let peak = equity[0] ?? 0;
|
|
672
|
+
for (const level of equity) {
|
|
673
|
+
if (level > peak) {
|
|
674
|
+
peak = level;
|
|
675
|
+
}
|
|
676
|
+
drawdowns.push(peak > 0 ? (peak - level) / peak : 0);
|
|
677
|
+
}
|
|
678
|
+
return drawdowns;
|
|
679
|
+
}
|
|
680
|
+
function countEpisodesAboveThreshold(drawdowns, threshold) {
|
|
681
|
+
let episodes = 0;
|
|
682
|
+
let inEpisode = false;
|
|
683
|
+
for (const value of drawdowns) {
|
|
684
|
+
if (value >= threshold) {
|
|
685
|
+
if (!inEpisode) {
|
|
686
|
+
episodes += 1;
|
|
687
|
+
inEpisode = true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
inEpisode = false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return episodes;
|
|
695
|
+
}
|
|
696
|
+
function average(values) {
|
|
697
|
+
if (values.length === 0) {
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
return values.reduce((acc, value) => acc + value, 0) / values.length;
|
|
701
|
+
}
|
|
702
|
+
function median(values) {
|
|
703
|
+
if (values.length === 0) {
|
|
704
|
+
return 0;
|
|
705
|
+
}
|
|
706
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
707
|
+
const mid = Math.floor(sorted.length / 2);
|
|
708
|
+
if (sorted.length % 2 === 0) {
|
|
709
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
710
|
+
}
|
|
711
|
+
return sorted[mid];
|
|
712
|
+
}
|
|
713
|
+
function applySensitivityValue(variant, parameter, value) {
|
|
714
|
+
const next = { ...variant };
|
|
715
|
+
switch (parameter) {
|
|
716
|
+
case 'vixThreshold':
|
|
717
|
+
next.signal = { ...(next.signal ?? { vixThreshold: 0, leverageMultiple: 1 }), vixThreshold: Number(value) };
|
|
718
|
+
break;
|
|
719
|
+
case 'leverageMultiple':
|
|
720
|
+
next.signal = {
|
|
721
|
+
...(next.signal ?? { vixThreshold: 0, leverageMultiple: 1 }),
|
|
722
|
+
leverageMultiple: Number(value),
|
|
723
|
+
};
|
|
724
|
+
break;
|
|
725
|
+
case 'tradeSpread':
|
|
726
|
+
next.tradeSpread = Number(value);
|
|
727
|
+
break;
|
|
728
|
+
case 'expenseRatioAnnual':
|
|
729
|
+
next.expenseRatioAnnual = Number(value);
|
|
730
|
+
break;
|
|
731
|
+
case 'cashYieldDaily':
|
|
732
|
+
next.cashYieldDaily = Number(value);
|
|
733
|
+
next.cashMode = 'configured_yield';
|
|
734
|
+
break;
|
|
735
|
+
case 'cashMode':
|
|
736
|
+
if (value !== 'zero_yield' && value !== 'configured_yield') {
|
|
737
|
+
throw new Error('cashMode sensitivity value must be zero_yield or configured_yield');
|
|
738
|
+
}
|
|
739
|
+
next.cashMode = value;
|
|
740
|
+
break;
|
|
741
|
+
default:
|
|
742
|
+
throw new Error(`unsupported sensitivity parameter: ${String(parameter)}`);
|
|
743
|
+
}
|
|
744
|
+
next.name = `${variant.name}__${parameter}_${String(value)}`;
|
|
745
|
+
return next;
|
|
746
|
+
}
|
|
747
|
+
function collectRunWarnings(days, variant, summary) {
|
|
748
|
+
const warnings = [];
|
|
749
|
+
const benchmarkCount = days.filter((day) => day.benchmarkReturn !== undefined).length;
|
|
750
|
+
if (benchmarkCount === 0) {
|
|
751
|
+
warnings.push({
|
|
752
|
+
code: 'BENCHMARK_UNAVAILABLE',
|
|
753
|
+
message: 'No benchmark returns were available for matched-date analytics.',
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
else if (benchmarkCount < days.length) {
|
|
757
|
+
warnings.push({
|
|
758
|
+
code: 'BENCHMARK_PARTIAL_COVERAGE',
|
|
759
|
+
message: 'Benchmark returns were only partially available; matched-date analytics used overlap only.',
|
|
760
|
+
context: {
|
|
761
|
+
benchmarkDays: benchmarkCount,
|
|
762
|
+
totalDays: days.length,
|
|
763
|
+
coveragePct: benchmarkCount / days.length,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
if (summary.xirr === null) {
|
|
768
|
+
warnings.push({
|
|
769
|
+
code: 'XIRR_UNAVAILABLE',
|
|
770
|
+
message: 'XIRR could not be computed from available cash flows.',
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
if ((variant.tradeSpread ?? 0) < 0 || (variant.expenseRatioAnnual ?? 0) < 0 || (variant.cashYieldDaily ?? 0) < 0) {
|
|
774
|
+
warnings.push({
|
|
775
|
+
code: 'UNUSUAL_ASSUMPTION',
|
|
776
|
+
message: 'Strategy includes negative spread/expense/yield assumptions; interpret cautiously.',
|
|
777
|
+
context: {
|
|
778
|
+
tradeSpread: variant.tradeSpread ?? null,
|
|
779
|
+
expenseRatioAnnual: variant.expenseRatioAnnual ?? null,
|
|
780
|
+
cashYieldDaily: variant.cashYieldDaily ?? null,
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return warnings;
|
|
785
|
+
}
|
|
786
|
+
function buildScenarioWarnings(days) {
|
|
787
|
+
if (days.length >= 2) {
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
return [
|
|
791
|
+
{
|
|
792
|
+
code: 'SCENARIO_TOO_FEW_OBSERVATIONS',
|
|
793
|
+
message: 'Scenario has fewer than 2 observations; annualized metrics may be unavailable.',
|
|
794
|
+
context: {
|
|
795
|
+
observations: days.length,
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
];
|
|
799
|
+
}
|
|
800
|
+
function pickMostRobustScenarioVariant(scenarioResult) {
|
|
801
|
+
const candidates = [];
|
|
802
|
+
const grouped = new Map();
|
|
803
|
+
for (const row of scenarioResult.rows) {
|
|
804
|
+
if (!row.run.ok || row.scenarioName === 'full_period') {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (row.run.summary.twrCagr === null) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const existing = grouped.get(row.run.strategy.name) ?? [];
|
|
811
|
+
existing.push(row.run.summary.twrCagr);
|
|
812
|
+
grouped.set(row.run.strategy.name, existing);
|
|
813
|
+
}
|
|
814
|
+
for (const [strategyName, series] of grouped.entries()) {
|
|
815
|
+
if (series.length < 2) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
candidates.push({
|
|
819
|
+
strategyName,
|
|
820
|
+
minScenarioTwr: Math.min(...series),
|
|
821
|
+
scenarioCount: series.length,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
if (candidates.length === 0) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
candidates.sort((a, b) => {
|
|
828
|
+
if (Math.abs(a.minScenarioTwr - b.minScenarioTwr) <= EPSILON) {
|
|
829
|
+
return a.strategyName.localeCompare(b.strategyName);
|
|
830
|
+
}
|
|
831
|
+
return b.minScenarioTwr - a.minScenarioTwr;
|
|
832
|
+
});
|
|
833
|
+
const winner = candidates[0];
|
|
834
|
+
return {
|
|
835
|
+
kind: 'most_robust_across_scenarios',
|
|
836
|
+
strategyName: winner.strategyName,
|
|
837
|
+
rationale: 'Highest worst-scenario TWR CAGR among variants with at least two scenario runs.',
|
|
838
|
+
metrics: {
|
|
839
|
+
minScenarioTwrCagr: winner.minScenarioTwr,
|
|
840
|
+
scenarioCount: winner.scenarioCount,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
}
|