@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,930 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runModelValidationCases = runModelValidationCases;
|
|
4
|
+
const public_api_1 = require("./public-api");
|
|
5
|
+
const EPSILON = 1e-9;
|
|
6
|
+
function runModelValidationCases() {
|
|
7
|
+
const results = [];
|
|
8
|
+
const baselineDataset = buildSyntheticDataset('2007-01-01', '2023-12-29', {
|
|
9
|
+
monthlyContribution: 0,
|
|
10
|
+
smaLength: null,
|
|
11
|
+
includeBenchmark: true,
|
|
12
|
+
stressWindows: true,
|
|
13
|
+
});
|
|
14
|
+
const baselineConfig = buildEngineConfig();
|
|
15
|
+
const baselineVariant = {
|
|
16
|
+
name: 'baseline_1x_no_filters',
|
|
17
|
+
};
|
|
18
|
+
const baselineRun = (0, public_api_1.runComparison)({
|
|
19
|
+
days: baselineDataset.days,
|
|
20
|
+
config: baselineConfig,
|
|
21
|
+
strategies: [baselineVariant],
|
|
22
|
+
}).comparison.runs[0];
|
|
23
|
+
if (!baselineRun.ok) {
|
|
24
|
+
results.push({
|
|
25
|
+
id: 'A',
|
|
26
|
+
name: 'Baseline equivalence run succeeds',
|
|
27
|
+
status: 'FAIL',
|
|
28
|
+
actual: baselineRun.error,
|
|
29
|
+
expected: 'Baseline run should execute successfully.',
|
|
30
|
+
core: true,
|
|
31
|
+
});
|
|
32
|
+
return summarizeResults(results);
|
|
33
|
+
}
|
|
34
|
+
const baselineSummary = baselineRun.summary;
|
|
35
|
+
const benchmark = baselineSummary.benchmark;
|
|
36
|
+
const baselineReturnGap = benchmark ? Math.abs(totalReturn(baselineSummary.finalEquity, baselineSummary.totalInvested) - benchmark.benchmarkTotalReturn) : Number.POSITIVE_INFINITY;
|
|
37
|
+
const baselineCagrGap = benchmark ? Math.abs((baselineSummary.twrCagr ?? 0) - (benchmark.benchmarkTwrCagr ?? 0)) : Number.POSITIVE_INFINITY;
|
|
38
|
+
const baselineBorrow = baselineSummary.totalBorrowCost;
|
|
39
|
+
const baselineTrade = baselineSummary.totalTradeCost;
|
|
40
|
+
results.push(makeResult('A1', 'Baseline: strategy total return ~ benchmark', baselineReturnGap <= 1e-6, `return gap=${formatPct(baselineReturnGap)}`, 'Gap <= 0.0001%.', true));
|
|
41
|
+
results.push(makeResult('A2', 'Baseline: strategy CAGR ~ benchmark CAGR', baselineCagrGap <= 1e-6, `cagr gap=${formatPct(baselineCagrGap)}`, 'Gap <= 0.0001%.', true));
|
|
42
|
+
results.push(makeResult('A3', 'Baseline: financing drag is zero', nearlyEqual(baselineBorrow, 0), `borrow drag=${baselineBorrow.toFixed(8)}`, 'Borrow drag == 0 at 1x.', true));
|
|
43
|
+
results.push(makeResult('A4', 'Baseline: trade drag is zero', nearlyEqual(baselineTrade, 0), `trade drag=${baselineTrade.toFixed(8)}`, 'Trade drag == 0 with no turnover + zero spread.', true));
|
|
44
|
+
const regimeNoopVariant = {
|
|
45
|
+
name: 'regime_noop_1x',
|
|
46
|
+
signal: {
|
|
47
|
+
vixThreshold: 20,
|
|
48
|
+
leverageMultiple: 1,
|
|
49
|
+
cashExposure: 1,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const regimeNoopRun = (0, public_api_1.runComparison)({
|
|
53
|
+
days: baselineDataset.days.map((day) => ({ ...day, signalInput: 35 })),
|
|
54
|
+
config: baselineConfig,
|
|
55
|
+
strategies: [regimeNoopVariant],
|
|
56
|
+
}).comparison.runs[0];
|
|
57
|
+
if (!regimeNoopRun.ok) {
|
|
58
|
+
results.push(makeResult('B', 'Regime no-op run succeeds', false, regimeNoopRun.error, 'Run should execute successfully.', true));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const finalGap = Math.abs(regimeNoopRun.summary.finalEquity - baselineSummary.finalEquity);
|
|
62
|
+
const cagrGap = Math.abs((regimeNoopRun.summary.twrCagr ?? 0) - (baselineSummary.twrCagr ?? 0));
|
|
63
|
+
results.push(makeResult('B1', 'Regime no-op: final equity near baseline', finalGap <= 1e-6, `final equity gap=${finalGap.toFixed(8)}`, 'Gap <= 1e-6.', true));
|
|
64
|
+
results.push(makeResult('B2', 'Regime no-op: CAGR near baseline', cagrGap <= 1e-6, `cagr gap=${formatPct(cagrGap)}`, 'Gap <= 0.0001%.', true));
|
|
65
|
+
}
|
|
66
|
+
runCostMonotonicityChecks(results, baselineDataset.days, baselineConfig);
|
|
67
|
+
runContributionAccountingCheck(results);
|
|
68
|
+
runLeverageSmokeChecks(results);
|
|
69
|
+
runSmaSensitivityChecks(results);
|
|
70
|
+
runStressWindowChecks(results, baselineDataset.days, baselineConfig);
|
|
71
|
+
runLookAheadChecks(results);
|
|
72
|
+
runExactPathParityChecks(results);
|
|
73
|
+
runMixedRegimeNoOpChecks(results, baselineDataset.days, baselineConfig);
|
|
74
|
+
runRealisticCostChecks(results, baselineDataset.days, baselineConfig);
|
|
75
|
+
runMonteCarloValidationChecks(results, baselineDataset.days, baselineConfig);
|
|
76
|
+
runGoldenBaselineChecks(results);
|
|
77
|
+
runTimingChecks(results);
|
|
78
|
+
runLookAheadContaminationCheck(results);
|
|
79
|
+
return summarizeResults(results);
|
|
80
|
+
}
|
|
81
|
+
function runCostMonotonicityChecks(results, days, config) {
|
|
82
|
+
const base = (0, public_api_1.runComparison)({ days, config, strategies: [{ name: 'cost_base', signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } }] }).comparison.runs[0];
|
|
83
|
+
if (!base.ok) {
|
|
84
|
+
results.push(makeResult('C', 'Cost monotonicity baseline run', false, base.error, 'Baseline cost run should succeed.', true));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const cases = [
|
|
88
|
+
{ id: 'C1', name: 'Expense ratio monotonicity', variant: { name: 'high_er', expenseRatioAnnual: 0.01, signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } } },
|
|
89
|
+
{ id: 'C2', name: 'Bank spread monotonicity', variant: { name: 'higher_bank_spread', signal: { vixThreshold: 999, leverageMultiple: 2, cashExposure: 2 }, engineConfigOverrides: { initialBorrowRateDaily: 0.0004 } } },
|
|
90
|
+
{ id: 'C3', name: 'Trade spread monotonicity', variant: { name: 'high_trade_spread', tradeSpread: 0.002, signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } } },
|
|
91
|
+
];
|
|
92
|
+
for (const costCase of cases) {
|
|
93
|
+
const run = (0, public_api_1.runComparison)({ days, config, strategies: [costCase.variant] }).comparison.runs[0];
|
|
94
|
+
if (!run.ok) {
|
|
95
|
+
results.push(makeResult(costCase.id, costCase.name, false, run.error, 'Run should succeed.', true));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const comparableBase = costCase.id === 'C2'
|
|
99
|
+
? (0, public_api_1.runComparison)({
|
|
100
|
+
days,
|
|
101
|
+
config,
|
|
102
|
+
strategies: [{ name: 'bank_base', signal: { vixThreshold: 999, leverageMultiple: 2, cashExposure: 2 }, engineConfigOverrides: { initialBorrowRateDaily: 0.0001 } }],
|
|
103
|
+
}).comparison.runs[0]
|
|
104
|
+
: base;
|
|
105
|
+
if (!comparableBase.ok) {
|
|
106
|
+
results.push(makeResult(costCase.id, costCase.name, false, comparableBase.error, 'Comparable base run should succeed.', true));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const improvedFinal = run.summary.finalEquity > comparableBase.summary.finalEquity + EPSILON;
|
|
110
|
+
const improvedCagr = (run.summary.twrCagr ?? -Infinity) > (comparableBase.summary.twrCagr ?? -Infinity) + EPSILON;
|
|
111
|
+
results.push(makeResult(costCase.id, costCase.name, !(improvedFinal || improvedCagr), `base(final=${comparableBase.summary.finalEquity.toFixed(4)}, cagr=${formatPct(comparableBase.summary.twrCagr ?? 0)}), test(final=${run.summary.finalEquity.toFixed(4)}, cagr=${formatPct(run.summary.twrCagr ?? 0)})`, 'Increasing cost should not improve ending value or CAGR.', true));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function runContributionAccountingCheck(results) {
|
|
115
|
+
const contributionAmount = 250;
|
|
116
|
+
const dataset = buildSyntheticDataset('2018-01-01', '2020-12-31', {
|
|
117
|
+
monthlyContribution: contributionAmount,
|
|
118
|
+
smaLength: null,
|
|
119
|
+
includeBenchmark: false,
|
|
120
|
+
stressWindows: false,
|
|
121
|
+
});
|
|
122
|
+
const run = (0, public_api_1.runComparison)({ days: dataset.days, config: buildEngineConfig(), strategies: [{ name: 'contrib_check' }] }).comparison.runs[0];
|
|
123
|
+
if (!run.ok) {
|
|
124
|
+
results.push(makeResult('D', 'Contribution accounting run succeeds', false, run.error, 'Run should succeed.', true));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const expectedInvested = 10000 + contributionAmount * dataset.contributionMonths;
|
|
128
|
+
const diff = run.summary.totalInvested - expectedInvested;
|
|
129
|
+
results.push(makeResult('D1', 'Contribution accounting exactness', nearlyEqual(diff, 0), `reported=${run.summary.totalInvested.toFixed(2)}, expected=${expectedInvested.toFixed(2)}, diff=${diff.toFixed(6)}`, 'Invested = initial + monthly contribution × contribution months.', true));
|
|
130
|
+
}
|
|
131
|
+
function runLeverageSmokeChecks(results) {
|
|
132
|
+
const dataset = buildSyntheticDataset('2016-01-01', '2019-12-31', {
|
|
133
|
+
monthlyContribution: 0,
|
|
134
|
+
smaLength: null,
|
|
135
|
+
includeBenchmark: false,
|
|
136
|
+
stressWindows: false,
|
|
137
|
+
bullishBias: true,
|
|
138
|
+
});
|
|
139
|
+
const comparison = (0, public_api_1.runComparison)({
|
|
140
|
+
days: dataset.days,
|
|
141
|
+
config: buildEngineConfig(),
|
|
142
|
+
strategies: [
|
|
143
|
+
{ name: 'lev_1x', signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } },
|
|
144
|
+
{ name: 'lev_2x', signal: { vixThreshold: 999, leverageMultiple: 2, cashExposure: 2 } },
|
|
145
|
+
{ name: 'lev_3x', signal: { vixThreshold: 999, leverageMultiple: 3, cashExposure: 3 } },
|
|
146
|
+
],
|
|
147
|
+
}).comparison.runs;
|
|
148
|
+
const runs = comparison.filter((run) => run.ok);
|
|
149
|
+
if (runs.length !== 3) {
|
|
150
|
+
results.push(makeResult('E', 'Leverage smoke test run completeness', false, `successful runs=${runs.length}/3`, 'All leverage runs should succeed.', true));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const [oneX, twoX, threeX] = runs;
|
|
154
|
+
const volatilityMonotone = (twoX.summary.volatility ?? 0) >= (oneX.summary.volatility ?? 0) - EPSILON && (threeX.summary.volatility ?? 0) >= (twoX.summary.volatility ?? 0) - EPSILON;
|
|
155
|
+
const drawdownMonotone = twoX.summary.maxDrawdown >= oneX.summary.maxDrawdown - EPSILON && threeX.summary.maxDrawdown >= twoX.summary.maxDrawdown - EPSILON;
|
|
156
|
+
const stats = `1x(vol=${formatPct(oneX.summary.volatility ?? 0)}, dd=${formatPct(oneX.summary.maxDrawdown)}), 2x(vol=${formatPct(twoX.summary.volatility ?? 0)}, dd=${formatPct(twoX.summary.maxDrawdown)}), 3x(vol=${formatPct(threeX.summary.volatility ?? 0)}, dd=${formatPct(threeX.summary.maxDrawdown)})`;
|
|
157
|
+
results.push(makeResult('E1', 'Leverage smoke: volatility non-decreasing with leverage', volatilityMonotone, stats, 'In bullish path, higher leverage should not reduce volatility.', true));
|
|
158
|
+
results.push(makeResult('E2', 'Leverage smoke: drawdown non-decreasing with leverage', drawdownMonotone, stats, 'In bullish path, higher leverage should not reduce drawdown without strong reason.', true));
|
|
159
|
+
}
|
|
160
|
+
function runSmaSensitivityChecks(results) {
|
|
161
|
+
// F0: Bug reproduction — VIX-scale threshold silently suppresses all SMA regime switches.
|
|
162
|
+
// buildSmaSignalInputs now emits 1/0 (price above/below SMA). Without smaMode the default
|
|
163
|
+
// comparison is signalInput <= vixThreshold. With threshold=20: both 0<=20 and 1<=20 are
|
|
164
|
+
// always TRUE, so the engine never switches to cash. totalSwitches must be 0.
|
|
165
|
+
const bugReproDataset = buildSyntheticDataset('2010-01-01', '2023-12-29', {
|
|
166
|
+
monthlyContribution: 0,
|
|
167
|
+
smaLength: 100,
|
|
168
|
+
includeBenchmark: false,
|
|
169
|
+
stressWindows: true,
|
|
170
|
+
});
|
|
171
|
+
const bugReproRun = (0, public_api_1.runComparison)({
|
|
172
|
+
days: bugReproDataset.days,
|
|
173
|
+
config: buildEngineConfig(),
|
|
174
|
+
strategies: [{ name: 'f0_vix_threshold_20_no_smaMode', signal: { vixThreshold: 20, leverageMultiple: 1, cashExposure: 0 } }],
|
|
175
|
+
}).comparison.runs[0];
|
|
176
|
+
if (!bugReproRun.ok) {
|
|
177
|
+
results.push(makeResult('F0', 'SMA bug repro run succeeds', false, bugReproRun.error, 'Run must succeed.', true));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const zeroSwitches = bugReproRun.summary.totalSwitches === 0;
|
|
181
|
+
results.push(makeResult('F0', 'SMA bug repro: VIX-scale threshold (20) with 1/0 SMA signal suppresses all regime switching', zeroSwitches, `totalSwitches=${bugReproRun.summary.totalSwitches} (expected 0 — vixThreshold=20 always ≥ signalInput∈{0,1})`, 'Without smaMode the VIX-scale comparison traps both 0 and 1 on the same side; zero switches confirms the bug.', true));
|
|
182
|
+
}
|
|
183
|
+
// F1: Corrected SMA switching — smaMode: true flips the comparison to signalInput > vixThreshold.
|
|
184
|
+
// With threshold=0: signalInput=1 > 0 = leveraged (price above SMA), signalInput=0 not > 0 = cash.
|
|
185
|
+
// Each SMA length must produce actual regime switching and lengths must yield distinct outcomes.
|
|
186
|
+
const lengths = [50, 100, 200];
|
|
187
|
+
const outcomes = [];
|
|
188
|
+
for (const length of lengths) {
|
|
189
|
+
const dataset = buildSyntheticDataset('2010-01-01', '2023-12-29', {
|
|
190
|
+
monthlyContribution: 0,
|
|
191
|
+
smaLength: length,
|
|
192
|
+
includeBenchmark: false,
|
|
193
|
+
stressWindows: true,
|
|
194
|
+
});
|
|
195
|
+
const run = (0, public_api_1.runComparison)({
|
|
196
|
+
days: dataset.days,
|
|
197
|
+
config: buildEngineConfig(),
|
|
198
|
+
strategies: [{ name: `sma_${length}`, signal: { vixThreshold: 0, leverageMultiple: 1, cashExposure: 0, smaMode: true } }],
|
|
199
|
+
}).comparison.runs[0];
|
|
200
|
+
if (!run.ok) {
|
|
201
|
+
results.push(makeResult('F1', `SMA ${length} run succeeds`, false, run.error, 'Run should succeed.', true));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
outcomes.push({ length, finalEquity: run.summary.finalEquity, twrCagr: run.summary.twrCagr, totalSwitches: run.summary.totalSwitches });
|
|
205
|
+
}
|
|
206
|
+
// Every SMA length must produce at least one regime switch (cash ↔ leveraged).
|
|
207
|
+
const allSwitch = outcomes.every((o) => o.totalSwitches > 0);
|
|
208
|
+
const distinctFinals = new Set(outcomes.map((o) => o.finalEquity.toFixed(6))).size;
|
|
209
|
+
const distinctCagrs = new Set(outcomes.map((o) => (o.twrCagr ?? 0).toFixed(8))).size;
|
|
210
|
+
results.push(makeResult('F1', 'SMA smaMode: each length produces regime switching and distinct outcomes across 50/100/200', allSwitch && (distinctFinals > 1 || distinctCagrs > 1), outcomes.map((o) => `SMA${o.length}: switches=${o.totalSwitches}, final=${o.finalEquity.toFixed(2)}, cagr=${formatPct(o.twrCagr ?? 0)}`).join(' | '), 'smaMode: true must produce totalSwitches > 0 for each length; outcomes must differ across lengths.', true));
|
|
211
|
+
}
|
|
212
|
+
function runStressWindowChecks(results, days, config) {
|
|
213
|
+
const strategy = {
|
|
214
|
+
name: 'stress_window_strategy',
|
|
215
|
+
signal: { vixThreshold: 0, leverageMultiple: 2, cashExposure: 0 },
|
|
216
|
+
tradeSpread: 0.0003,
|
|
217
|
+
expenseRatioAnnual: 0.003,
|
|
218
|
+
};
|
|
219
|
+
const scenarioRows = (0, public_api_1.runScenarios)({
|
|
220
|
+
days,
|
|
221
|
+
config,
|
|
222
|
+
strategy,
|
|
223
|
+
includeFullPeriod: false,
|
|
224
|
+
scenarios: [
|
|
225
|
+
{ name: '2008_crisis', startDate: '2008-01-01', endDate: '2008-12-31' },
|
|
226
|
+
{ name: '2020_covid', startDate: '2020-01-01', endDate: '2020-12-31' },
|
|
227
|
+
{ name: '2022_bear', startDate: '2022-01-01', endDate: '2022-12-31' },
|
|
228
|
+
],
|
|
229
|
+
}).scenarios.rows;
|
|
230
|
+
for (const row of scenarioRows) {
|
|
231
|
+
if (!row.run.ok) {
|
|
232
|
+
results.push(makeResult(`G-${row.scenarioName}`, `Stress window ${row.scenarioName} executes`, false, row.run.error, 'Scenario run should complete successfully.', true));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const summary = row.run.summary;
|
|
236
|
+
results.push(makeResult(`G-${row.scenarioName}`, `Stress window ${row.scenarioName} metrics reported`, true, `return=${formatPct(totalReturn(summary.finalEquity, summary.totalInvested))}, drawdown=${formatPct(summary.maxDrawdown)}, borrow_drag=${summary.totalBorrowCost.toFixed(4)}, trade_drag=${summary.totalTradeCost.toFixed(4)}, er_drag=${summary.totalErCost.toFixed(4)}`, 'Run should complete and report return/drawdown/drag metrics.', true));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function runLookAheadChecks(results) {
|
|
240
|
+
const full = buildSyntheticDataset('2015-01-01', '2023-12-29', {
|
|
241
|
+
monthlyContribution: 0,
|
|
242
|
+
smaLength: 100,
|
|
243
|
+
includeBenchmark: false,
|
|
244
|
+
stressWindows: true,
|
|
245
|
+
});
|
|
246
|
+
const withShock = buildSyntheticDataset('2015-01-01', '2023-12-29', {
|
|
247
|
+
monthlyContribution: 0,
|
|
248
|
+
smaLength: 100,
|
|
249
|
+
includeBenchmark: false,
|
|
250
|
+
stressWindows: true,
|
|
251
|
+
postCutoffShock: { date: '2021-01-04', returnShift: 0.05 },
|
|
252
|
+
});
|
|
253
|
+
const cutoff = '2021-01-04';
|
|
254
|
+
const unaffected = full.days
|
|
255
|
+
.filter((d) => d.date < cutoff)
|
|
256
|
+
.every((day, idx) => nearlyEqual(day.signalInput, withShock.days[idx].signalInput));
|
|
257
|
+
const timingRun = (0, public_api_1.runComparison)({
|
|
258
|
+
days: full.days,
|
|
259
|
+
config: buildEngineConfig(),
|
|
260
|
+
strategies: [{ name: 'timing_guard', signal: { vixThreshold: 0, leverageMultiple: 1, cashExposure: 0, smaMode: true } }],
|
|
261
|
+
}).comparison.runs[0];
|
|
262
|
+
if (!timingRun.ok) {
|
|
263
|
+
results.push(makeResult('H1', 'Look-ahead timing run succeeds', false, timingRun.error, 'Timing validation run should succeed.', true));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
results.push(makeResult('H1', 'Look-ahead guard: pre-cutoff signals unaffected by future-return perturbation', unaffected, `cutoff=${cutoff}, pre-cutoff signal equality=${unaffected}`, 'Changing future returns should not alter earlier signals.', true));
|
|
267
|
+
const noLeak = timingRun.summary.diagnostics.transitions.totalSwitches >= 0;
|
|
268
|
+
const assumptionText = 'Engine timing assumption: signal(t) computed from day t input and applied at t+1 via appliedPosition(t)=targetPosition(t-1).';
|
|
269
|
+
results.push({
|
|
270
|
+
id: 'H2',
|
|
271
|
+
name: 'Look-ahead timing assumption inspection (manual audit aid)',
|
|
272
|
+
status: noLeak ? 'WARN' : 'FAIL',
|
|
273
|
+
actual: assumptionText,
|
|
274
|
+
expected: 'Confirm signal uses only available data and exposure switching is T+1.',
|
|
275
|
+
core: false,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function runExactPathParityChecks(results) {
|
|
279
|
+
// Hand-crafted 5-day path with explicitly known daily returns.
|
|
280
|
+
// Expected final equity is computed independently as a product of (1+r) factors —
|
|
281
|
+
// no engine logic required to derive the expected value.
|
|
282
|
+
const knownReturns = [0.01, -0.005, 0.02, -0.015, 0.0075];
|
|
283
|
+
const days = ['2020-01-02', '2020-01-03', '2020-01-06', '2020-01-07', '2020-01-08'].map((date, i) => ({
|
|
284
|
+
date,
|
|
285
|
+
assetReturn: knownReturns[i],
|
|
286
|
+
benchmarkReturn: knownReturns[i],
|
|
287
|
+
signalInput: 1,
|
|
288
|
+
borrowRateDaily: 0,
|
|
289
|
+
expenseRatioAnnual: 0,
|
|
290
|
+
tradeSpread: 0,
|
|
291
|
+
}));
|
|
292
|
+
const config = {
|
|
293
|
+
initialEquity: 10000,
|
|
294
|
+
tradeCostRate: 0,
|
|
295
|
+
maxLeverage: 3,
|
|
296
|
+
initialTargetPosition: 1,
|
|
297
|
+
initialAppliedPosition: 1,
|
|
298
|
+
initialBorrowRateDaily: 0,
|
|
299
|
+
cashYieldDaily: 0,
|
|
300
|
+
};
|
|
301
|
+
// Independently computed: $10,000 × Π(1 + rᵢ). No engine code involved.
|
|
302
|
+
const expectedFinal = knownReturns.reduce((equity, r) => equity + equity * r, 10000);
|
|
303
|
+
const run = (0, public_api_1.runComparison)({
|
|
304
|
+
days,
|
|
305
|
+
config,
|
|
306
|
+
strategies: [{ name: 'exact_path_1x_zero_cost' }],
|
|
307
|
+
}).comparison.runs[0];
|
|
308
|
+
if (!run.ok) {
|
|
309
|
+
results.push(makeResult('I', 'Exact path parity run succeeds', false, run.error, 'Run should succeed.', true));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const finalGap = Math.abs(run.summary.finalEquity - expectedFinal);
|
|
313
|
+
results.push(makeResult('I1', 'Exact known path: 1x zero-cost final equity matches independently computed product', finalGap < 1e-8, `engine=${run.summary.finalEquity.toFixed(10)}, computed=${expectedFinal.toFixed(10)}, gap=${finalGap.toExponential(3)}`, 'Final equity must equal $10,000 compounded daily at each known return to within 1e-8 — no costs, no contributions.', true));
|
|
314
|
+
const bm = run.summary.benchmark;
|
|
315
|
+
const stratReturn = totalReturn(run.summary.finalEquity, run.summary.totalInvested);
|
|
316
|
+
const bmReturn = bm ? bm.benchmarkTotalReturn : null;
|
|
317
|
+
const returnGap = bmReturn !== null ? Math.abs(stratReturn - bmReturn) : Number.POSITIVE_INFINITY;
|
|
318
|
+
results.push(makeResult('I2', 'Exact known path: 1x zero-cost strategy return equals benchmark return to machine epsilon', returnGap < 1e-8, `strategy=${formatPct(stratReturn)}, benchmark=${bmReturn !== null ? formatPct(bmReturn) : 'N/A'}, gap=${returnGap.toExponential(3)}`, 'With identical asset and benchmark returns at 1x zero cost, total returns must match exactly.', true));
|
|
319
|
+
}
|
|
320
|
+
function runMixedRegimeNoOpChecks(results, baseDays, config) {
|
|
321
|
+
// Alternate signalInput between 10 (below threshold) and 40 (above threshold).
|
|
322
|
+
// When leverageMultiple == cashExposure == 1, every day resolves to targetPosition=1
|
|
323
|
+
// regardless of which regime fires — so mixed signals must not alter the equity path.
|
|
324
|
+
const mixedDays = baseDays.map((day, i) => ({
|
|
325
|
+
...day,
|
|
326
|
+
signalInput: i % 2 === 0 ? 10 : 40,
|
|
327
|
+
}));
|
|
328
|
+
// Baseline: threshold=999 means every day is in leverage mode → leverageMultiple=1 always.
|
|
329
|
+
const alwaysLevRun = (0, public_api_1.runComparison)({
|
|
330
|
+
days: mixedDays,
|
|
331
|
+
config,
|
|
332
|
+
strategies: [{ name: 'j_always_lev_1x', signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } }],
|
|
333
|
+
}).comparison.runs[0];
|
|
334
|
+
// Test: threshold=25, even days (signalInput=10) hit leverage=1, odd days (signalInput=40) hit cash=1.
|
|
335
|
+
const mixedRun = (0, public_api_1.runComparison)({
|
|
336
|
+
days: mixedDays,
|
|
337
|
+
config,
|
|
338
|
+
strategies: [{ name: 'j_mixed_1x_1x', signal: { vixThreshold: 25, leverageMultiple: 1, cashExposure: 1 } }],
|
|
339
|
+
}).comparison.runs[0];
|
|
340
|
+
if (!alwaysLevRun.ok) {
|
|
341
|
+
results.push(makeResult('J1', 'Mixed regime no-op runs succeed', false, alwaysLevRun.error, 'Both runs should succeed.', true));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!mixedRun.ok) {
|
|
345
|
+
results.push(makeResult('J1', 'Mixed regime no-op runs succeed', false, mixedRun.error, 'Both runs should succeed.', true));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const finalGap = Math.abs(mixedRun.summary.finalEquity - alwaysLevRun.summary.finalEquity);
|
|
349
|
+
results.push(makeResult('J1', 'No-op regime: VIX 1x/1x filter with mixed signals produces identical equity to always-1x', finalGap < EPSILON, `always_1x=${alwaysLevRun.summary.finalEquity.toFixed(8)}, mixed_1x_1x=${mixedRun.summary.finalEquity.toFixed(8)}, gap=${finalGap.toExponential(3)}`, 'With leverageMultiple=1 and cashExposure=1, VIX regime switching must not alter the equity path.', true));
|
|
350
|
+
}
|
|
351
|
+
function runRealisticCostChecks(results, baseDays, config) {
|
|
352
|
+
// K1: 0.5% annual expense ratio at sustained 1x must reduce equity vs zero ER.
|
|
353
|
+
const erBaseRun = (0, public_api_1.runComparison)({
|
|
354
|
+
days: baseDays,
|
|
355
|
+
config,
|
|
356
|
+
strategies: [{ name: 'k_er_zero', signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } }],
|
|
357
|
+
}).comparison.runs[0];
|
|
358
|
+
const erCostRun = (0, public_api_1.runComparison)({
|
|
359
|
+
days: baseDays,
|
|
360
|
+
config,
|
|
361
|
+
strategies: [{ name: 'k_er_half_pct', expenseRatioAnnual: 0.005, signal: { vixThreshold: 999, leverageMultiple: 1, cashExposure: 1 } }],
|
|
362
|
+
}).comparison.runs[0];
|
|
363
|
+
if (!erBaseRun.ok) {
|
|
364
|
+
results.push(makeResult('K1', 'Realistic ER monotonicity runs succeed', false, erBaseRun.error, 'Both ER runs should succeed.', true));
|
|
365
|
+
}
|
|
366
|
+
else if (!erCostRun.ok) {
|
|
367
|
+
results.push(makeResult('K1', 'Realistic ER monotonicity runs succeed', false, erCostRun.error, 'Both ER runs should succeed.', true));
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
const erWorse = erCostRun.summary.finalEquity < erBaseRun.summary.finalEquity - EPSILON
|
|
371
|
+
&& (erCostRun.summary.twrCagr ?? 0) < (erBaseRun.summary.twrCagr ?? 0) - EPSILON;
|
|
372
|
+
results.push(makeResult('K1', 'Realistic cost: 0.5% annual ER strictly reduces final equity and CAGR over 16-year horizon', erWorse, `zero_er(final=${erBaseRun.summary.finalEquity.toFixed(4)}, cagr=${formatPct(erBaseRun.summary.twrCagr ?? 0)}), er_0.5pct(final=${erCostRun.summary.finalEquity.toFixed(4)}, cagr=${formatPct(erCostRun.summary.twrCagr ?? 0)})`, '0.5% annual expense ratio must strictly reduce both final equity and CAGR vs zero ER.', true));
|
|
373
|
+
}
|
|
374
|
+
// K2: 0.1% daily borrow rate at sustained 2x must reduce equity vs 0.01% daily.
|
|
375
|
+
// borrowRateDaily is per-day in DayInput; override directly since variants only control initialBorrowRateDaily.
|
|
376
|
+
const lowBorrowDays = baseDays.map((day) => ({ ...day, borrowRateDaily: 0.0001 }));
|
|
377
|
+
const highBorrowDays = baseDays.map((day) => ({ ...day, borrowRateDaily: 0.001 }));
|
|
378
|
+
const borrowBaseRun = (0, public_api_1.runComparison)({
|
|
379
|
+
days: lowBorrowDays,
|
|
380
|
+
config,
|
|
381
|
+
strategies: [{ name: 'k_borrow_low_2x', signal: { vixThreshold: 999, leverageMultiple: 2, cashExposure: 2 } }],
|
|
382
|
+
}).comparison.runs[0];
|
|
383
|
+
const borrowHighRun = (0, public_api_1.runComparison)({
|
|
384
|
+
days: highBorrowDays,
|
|
385
|
+
config,
|
|
386
|
+
strategies: [{ name: 'k_borrow_high_2x', signal: { vixThreshold: 999, leverageMultiple: 2, cashExposure: 2 } }],
|
|
387
|
+
}).comparison.runs[0];
|
|
388
|
+
if (!borrowBaseRun.ok) {
|
|
389
|
+
results.push(makeResult('K2', 'Realistic borrow monotonicity runs succeed', false, borrowBaseRun.error, 'Both borrow runs should succeed.', true));
|
|
390
|
+
}
|
|
391
|
+
else if (!borrowHighRun.ok) {
|
|
392
|
+
results.push(makeResult('K2', 'Realistic borrow monotonicity runs succeed', false, borrowHighRun.error, 'Both borrow runs should succeed.', true));
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const borrowWorse = borrowHighRun.summary.finalEquity < borrowBaseRun.summary.finalEquity - EPSILON;
|
|
396
|
+
results.push(makeResult('K2', 'Realistic cost: 0.1% daily borrow rate at 2x strictly reduces equity vs 0.01% daily', borrowWorse, `low_borrow_0.01pct(final=${borrowBaseRun.summary.finalEquity.toFixed(4)}), high_borrow_0.1pct(final=${borrowHighRun.summary.finalEquity.toFixed(4)})`, '10x higher daily borrow rate at sustained 2x must strictly reduce final equity over multi-year horizon.', true));
|
|
397
|
+
}
|
|
398
|
+
// K3: 0.05% trade spread under active regime switching must reduce equity vs zero spread.
|
|
399
|
+
// Use days where signalInput alternates every 10 bars so position switches 1x↔0x ~420 times.
|
|
400
|
+
const switchingDays = baseDays.map((day, i) => ({
|
|
401
|
+
...day,
|
|
402
|
+
signalInput: i % 10 === 0 ? 10 : 40,
|
|
403
|
+
}));
|
|
404
|
+
const spreadZeroRun = (0, public_api_1.runComparison)({
|
|
405
|
+
days: switchingDays,
|
|
406
|
+
config,
|
|
407
|
+
strategies: [{
|
|
408
|
+
name: 'k_spread_zero',
|
|
409
|
+
tradeSpread: 0,
|
|
410
|
+
signal: { vixThreshold: 25, leverageMultiple: 1, cashExposure: 0 },
|
|
411
|
+
}],
|
|
412
|
+
}).comparison.runs[0];
|
|
413
|
+
const spreadCostRun = (0, public_api_1.runComparison)({
|
|
414
|
+
days: switchingDays,
|
|
415
|
+
config,
|
|
416
|
+
strategies: [{
|
|
417
|
+
name: 'k_spread_realistic',
|
|
418
|
+
tradeSpread: 0.0005,
|
|
419
|
+
signal: { vixThreshold: 25, leverageMultiple: 1, cashExposure: 0 },
|
|
420
|
+
}],
|
|
421
|
+
}).comparison.runs[0];
|
|
422
|
+
if (!spreadZeroRun.ok) {
|
|
423
|
+
results.push(makeResult('K3', 'Realistic trade spread monotonicity runs succeed', false, spreadZeroRun.error, 'Both spread runs should succeed.', true));
|
|
424
|
+
}
|
|
425
|
+
else if (!spreadCostRun.ok) {
|
|
426
|
+
results.push(makeResult('K3', 'Realistic trade spread monotonicity runs succeed', false, spreadCostRun.error, 'Both spread runs should succeed.', true));
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
const spreadWorse = spreadCostRun.summary.finalEquity < spreadZeroRun.summary.finalEquity - EPSILON;
|
|
430
|
+
results.push(makeResult('K3', 'Realistic cost: 0.05% trade spread reduces equity vs zero spread under ~420 regime switches', spreadWorse, `zero_spread(final=${spreadZeroRun.summary.finalEquity.toFixed(4)}), spread_0.05pct(final=${spreadCostRun.summary.finalEquity.toFixed(4)})`, '0.05% trade spread must strictly reduce final equity when regime switching generates turnover.', true));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function runMonteCarloValidationChecks(results, baseDays, config) {
|
|
434
|
+
// Use a 5-year tail of the baseline dataset as the MC source (last 1260 trading days).
|
|
435
|
+
// This keeps MC runs fast while using realistic positive-drift data with a stress year (2022).
|
|
436
|
+
const mcDays1260 = baseDays.slice(-1260);
|
|
437
|
+
// Use a 1-year tail for tests where only distributional shape matters (not horizon length).
|
|
438
|
+
const mcDays252 = baseDays.slice(-252);
|
|
439
|
+
runMC1MedianCalibration(results, mcDays1260, config);
|
|
440
|
+
runMC2PercentileOrder(results, mcDays252, config);
|
|
441
|
+
// MC-3 uses the 1260-day source so the distribution is wide enough for
|
|
442
|
+
// seed-to-seed p50 variation to be clearly detectable at different path counts.
|
|
443
|
+
runMC3PathCountStability(results, mcDays1260, config);
|
|
444
|
+
runMC4BlockLengthSensitivity(results, mcDays252, config);
|
|
445
|
+
runMC5VarianceDrag(results);
|
|
446
|
+
}
|
|
447
|
+
// MC-1: 200-path MC median ending equity within 50%–200% of fixed backtest — calibration check.
|
|
448
|
+
function runMC1MedianCalibration(results, sourceDays, config) {
|
|
449
|
+
const fixedRun = (0, public_api_1.runComparison)({
|
|
450
|
+
days: sourceDays,
|
|
451
|
+
config,
|
|
452
|
+
strategies: [{ name: 'mc1_fixed_baseline' }],
|
|
453
|
+
}).comparison.runs[0];
|
|
454
|
+
if (!fixedRun.ok) {
|
|
455
|
+
results.push(makeResult('MC-1', 'MC-1 fixed baseline run succeeds', false, fixedRun.error, 'Fixed run must succeed.', true));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const mc = (0, public_api_1.runMonteCarlo)({
|
|
459
|
+
days: sourceDays,
|
|
460
|
+
config,
|
|
461
|
+
monteCarlo: { paths: 200, horizonDays: sourceDays.length, seed: 42, blockLength: 5 },
|
|
462
|
+
});
|
|
463
|
+
const fixedFinal = fixedRun.summary.finalEquity;
|
|
464
|
+
const mcMedian = mc.summary.medianEndingEquity;
|
|
465
|
+
const ratio = mcMedian / fixedFinal;
|
|
466
|
+
const inRange = ratio >= 0.5 && ratio <= 2.0;
|
|
467
|
+
results.push(makeResult('MC-1', 'MC-1: 200-path median ending equity is within 50%–200% of fixed backtest — MC calibrated to source data', inRange, `fixed=${fixedFinal.toFixed(2)}, mc_median=${mcMedian.toFixed(2)}, ratio=${ratio.toFixed(3)}`, 'MC median ending equity must be within 0.5×–2× of fixed backtest equity on same source data.', true));
|
|
468
|
+
}
|
|
469
|
+
// MC-2: P5 < P50 < P95 must hold strictly for ending equity, TWR CAGR, and max drawdown.
|
|
470
|
+
function runMC2PercentileOrder(results, sourceDays, config) {
|
|
471
|
+
const mc = (0, public_api_1.runMonteCarlo)({
|
|
472
|
+
days: sourceDays,
|
|
473
|
+
config,
|
|
474
|
+
monteCarlo: { paths: 100, horizonDays: sourceDays.length, seed: 42, blockLength: 5 },
|
|
475
|
+
});
|
|
476
|
+
const eq = mc.summary.endingEquity;
|
|
477
|
+
const twr = mc.summary.twrCagr;
|
|
478
|
+
const dd = mc.summary.maxDrawdown;
|
|
479
|
+
const equityOrdered = eq.p5 < eq.p50 && eq.p50 < eq.p95;
|
|
480
|
+
const twrOrdered = twr.p5 < twr.p50 && twr.p50 < twr.p95;
|
|
481
|
+
const ddOrdered = dd.p5 < dd.p50 && dd.p50 < dd.p95;
|
|
482
|
+
results.push(makeResult('MC-2', 'MC-2: P5 < P50 < P95 holds strictly for ending equity, TWR CAGR, and max drawdown', equityOrdered && twrOrdered && ddOrdered, `equity(p5=${eq.p5.toFixed(2)}, p50=${eq.p50.toFixed(2)}, p95=${eq.p95.toFixed(2)}) ` +
|
|
483
|
+
`twr(p5=${formatPct(twr.p5)}, p50=${formatPct(twr.p50)}, p95=${formatPct(twr.p95)}) ` +
|
|
484
|
+
`dd(p5=${formatPct(dd.p5)}, p50=${formatPct(dd.p50)}, p95=${formatPct(dd.p95)})`, 'All three MC summary metrics must satisfy strict percentile ordering across all paths.', true));
|
|
485
|
+
}
|
|
486
|
+
// MC-3: 300-path p50 is closer across seeds than 50-path p50 — convergence with path count.
|
|
487
|
+
// Uses 1260-day source so the equity distribution is wide enough that seed-to-seed
|
|
488
|
+
// variation at 50 paths is clearly larger than at 300 paths (sqrt(300/50) ≈ 2.45× improvement).
|
|
489
|
+
function runMC3PathCountStability(results, sourceDays, config) {
|
|
490
|
+
const mcBase = { horizonDays: sourceDays.length, blockLength: 5 };
|
|
491
|
+
// Seeds far apart to maximise independent sampling between the two paired runs.
|
|
492
|
+
const seedA = 7;
|
|
493
|
+
const seedB = 77777;
|
|
494
|
+
const low50a = (0, public_api_1.runMonteCarlo)({ days: sourceDays, config, monteCarlo: { paths: 50, seed: seedA, ...mcBase } });
|
|
495
|
+
const low50b = (0, public_api_1.runMonteCarlo)({ days: sourceDays, config, monteCarlo: { paths: 50, seed: seedB, ...mcBase } });
|
|
496
|
+
const high300a = (0, public_api_1.runMonteCarlo)({ days: sourceDays, config, monteCarlo: { paths: 300, seed: seedA, ...mcBase } });
|
|
497
|
+
const high300b = (0, public_api_1.runMonteCarlo)({ days: sourceDays, config, monteCarlo: { paths: 300, seed: seedB, ...mcBase } });
|
|
498
|
+
const diff50 = Math.abs(low50a.summary.medianEndingEquity - low50b.summary.medianEndingEquity);
|
|
499
|
+
const diff300 = Math.abs(high300a.summary.medianEndingEquity - high300b.summary.medianEndingEquity);
|
|
500
|
+
results.push(makeResult('MC-3', 'MC-3: 300-path median ending equity is more stable across seeds than 50-path median', diff300 < diff50, `50-path seed spread=${diff50.toFixed(4)}, 300-path seed spread=${diff300.toFixed(4)}`, '300 paths must produce lower seed-to-seed p50 variation than 50 paths (convergence by LLN, sqrt(300/50)≈2.45× improvement).', true));
|
|
501
|
+
}
|
|
502
|
+
// MC-4: blockLength=3 vs blockLength=21 produce meaningfully different P5-P95 equity spread.
|
|
503
|
+
function runMC4BlockLengthSensitivity(results, sourceDays, config) {
|
|
504
|
+
const mcBase = { paths: 200, horizonDays: sourceDays.length, seed: 42 };
|
|
505
|
+
const shortBlock = (0, public_api_1.runMonteCarlo)({ days: sourceDays, config, monteCarlo: { ...mcBase, blockLength: 3 } });
|
|
506
|
+
const longBlock = (0, public_api_1.runMonteCarlo)({ days: sourceDays, config, monteCarlo: { ...mcBase, blockLength: 21 } });
|
|
507
|
+
const spread3 = shortBlock.summary.endingEquity.p95 - shortBlock.summary.endingEquity.p5;
|
|
508
|
+
const spread21 = longBlock.summary.endingEquity.p95 - longBlock.summary.endingEquity.p5;
|
|
509
|
+
const spreadDiff = Math.abs(spread3 - spread21);
|
|
510
|
+
// Require at least $50 difference in the P5–P95 band — a detectable sensitivity.
|
|
511
|
+
const meaningfullyDifferent = spreadDiff > 50;
|
|
512
|
+
results.push(makeResult('MC-4', 'MC-4: blockLength=3 and blockLength=21 produce meaningfully different P5–P95 equity spread (Δ > $50)', meaningfullyDifferent, `block3 spread=${spread3.toFixed(2)}, block21 spread=${spread21.toFixed(2)}, diff=${spreadDiff.toFixed(2)}`, 'Changing block length from 3 to 21 must produce a detectable difference in the P5–P95 equity range.', true));
|
|
513
|
+
}
|
|
514
|
+
// MC-5: Variance drag — on a zero-drift ±3%/day dataset, MC median CAGR must be negative.
|
|
515
|
+
// Arithmetic mean return = 0, but geometric mean = (1.03×0.97)^(N/2) - 1 < 0.
|
|
516
|
+
// Confirms MC correctly propagates variance drag (σ²/2 per day) through compounding.
|
|
517
|
+
function runMC5VarianceDrag(results) {
|
|
518
|
+
// Build a 1-year zero-drift alternating dataset (±3% daily, ~252 trading days).
|
|
519
|
+
const dates = businessDays('2021-01-01', '2021-12-31');
|
|
520
|
+
const volatileDays = dates.map((date, i) => ({
|
|
521
|
+
date,
|
|
522
|
+
assetReturn: i % 2 === 0 ? 0.03 : -0.03,
|
|
523
|
+
signalInput: 1,
|
|
524
|
+
borrowRateDaily: 0,
|
|
525
|
+
expenseRatioAnnual: 0,
|
|
526
|
+
tradeSpread: 0,
|
|
527
|
+
}));
|
|
528
|
+
const zeroConfig = {
|
|
529
|
+
initialEquity: 10000,
|
|
530
|
+
tradeCostRate: 0,
|
|
531
|
+
maxLeverage: 3,
|
|
532
|
+
initialTargetPosition: 1,
|
|
533
|
+
initialAppliedPosition: 1,
|
|
534
|
+
initialBorrowRateDaily: 0,
|
|
535
|
+
cashYieldDaily: 0,
|
|
536
|
+
};
|
|
537
|
+
const mc = (0, public_api_1.runMonteCarlo)({
|
|
538
|
+
days: volatileDays,
|
|
539
|
+
config: zeroConfig,
|
|
540
|
+
monteCarlo: { paths: 200, horizonDays: volatileDays.length, seed: 42, blockLength: 5 },
|
|
541
|
+
});
|
|
542
|
+
const medianCagr = mc.summary.medianTwrCagr;
|
|
543
|
+
results.push(makeResult('MC-5', 'MC-5: Zero-drift ±3%/day dataset yields negative MC median CAGR — variance drag confirmed', medianCagr < 0, `mc_median_cagr=${formatPct(medianCagr)}, arithmetic_mean_daily_return=0.0000%`, 'With zero arithmetic mean return and ±3% daily volatility, MC median CAGR must be negative (variance drag ≈ σ²/2 per day ≈ −10% annual).', true));
|
|
544
|
+
}
|
|
545
|
+
function runGoldenBaselineChecks(results) {
|
|
546
|
+
const returns = [0.01, -0.02, 0.015, 0.005, -0.01];
|
|
547
|
+
const dates = ['2020-01-02', '2020-01-03', '2020-01-06', '2020-01-07', '2020-01-08'];
|
|
548
|
+
const zeroConfig = {
|
|
549
|
+
initialEquity: 10000,
|
|
550
|
+
tradeCostRate: 0,
|
|
551
|
+
maxLeverage: 3,
|
|
552
|
+
initialTargetPosition: 1,
|
|
553
|
+
initialAppliedPosition: 1,
|
|
554
|
+
initialBorrowRateDaily: 0,
|
|
555
|
+
cashYieldDaily: 0,
|
|
556
|
+
};
|
|
557
|
+
// GOLD-1: 1x leverage, zero costs, no contributions.
|
|
558
|
+
// Expected final equity: 10000 compounded by each daily return at 1x.
|
|
559
|
+
const gold1Days = dates.map((date, i) => ({
|
|
560
|
+
date,
|
|
561
|
+
assetReturn: returns[i],
|
|
562
|
+
signalInput: 1,
|
|
563
|
+
borrowRateDaily: 0,
|
|
564
|
+
expenseRatioAnnual: 0,
|
|
565
|
+
tradeSpread: 0,
|
|
566
|
+
}));
|
|
567
|
+
const gold1Expected = returns.reduce((equity, r) => equity + equity * r, 10000);
|
|
568
|
+
const gold1Run = (0, public_api_1.runComparison)({
|
|
569
|
+
days: gold1Days,
|
|
570
|
+
config: zeroConfig,
|
|
571
|
+
strategies: [{ name: 'gold1_1x_zero_cost' }],
|
|
572
|
+
}).comparison.runs[0];
|
|
573
|
+
if (!gold1Run.ok) {
|
|
574
|
+
results.push(makeResult('GOLD-1', 'Golden baseline: 1x zero-cost path runs', false, gold1Run.error, 'Run must succeed.', true));
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
const gap = Math.abs(gold1Run.summary.finalEquity - gold1Expected);
|
|
578
|
+
results.push(makeResult('GOLD-1', 'Golden baseline: 1x zero-cost 5-day path matches hand-computed expected equity', gap < 1e-6, `actual=${gold1Run.summary.finalEquity.toFixed(10)}, expected=${gold1Expected.toFixed(10)}, gap=${gap.toExponential(3)}`, `Final equity must equal $${gold1Expected.toFixed(10)} to within 1e-6.`, true));
|
|
579
|
+
}
|
|
580
|
+
// GOLD-2: 2x leverage, zero costs (borrowRateDaily=0 → borrow drag is zero at 2x), no contributions.
|
|
581
|
+
// Expected: each day's pnl = equity * 2 * dailyReturn.
|
|
582
|
+
const gold2Config = {
|
|
583
|
+
...zeroConfig,
|
|
584
|
+
initialTargetPosition: 2,
|
|
585
|
+
initialAppliedPosition: 2,
|
|
586
|
+
};
|
|
587
|
+
const gold2Days = dates.map((date, i) => ({
|
|
588
|
+
date,
|
|
589
|
+
assetReturn: returns[i],
|
|
590
|
+
signalInput: 2,
|
|
591
|
+
borrowRateDaily: 0,
|
|
592
|
+
expenseRatioAnnual: 0,
|
|
593
|
+
tradeSpread: 0,
|
|
594
|
+
}));
|
|
595
|
+
const gold2Expected = returns.reduce((equity, r) => equity + equity * 2 * r, 10000);
|
|
596
|
+
const gold2Run = (0, public_api_1.runComparison)({
|
|
597
|
+
days: gold2Days,
|
|
598
|
+
config: gold2Config,
|
|
599
|
+
strategies: [{ name: 'gold2_2x_zero_cost' }],
|
|
600
|
+
}).comparison.runs[0];
|
|
601
|
+
if (!gold2Run.ok) {
|
|
602
|
+
results.push(makeResult('GOLD-2', 'Golden baseline: 2x zero-cost path runs', false, gold2Run.error, 'Run must succeed.', true));
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
const gap = Math.abs(gold2Run.summary.finalEquity - gold2Expected);
|
|
606
|
+
results.push(makeResult('GOLD-2', 'Golden baseline: 2x zero-cost 5-day path matches hand-computed expected equity (borrow drag zero at borrowRate=0)', gap < 1e-6, `actual=${gold2Run.summary.finalEquity.toFixed(10)}, expected=${gold2Expected.toFixed(10)}, gap=${gap.toExponential(3)}`, `Final equity must equal $${gold2Expected.toFixed(10)} to within 1e-6.`, true));
|
|
607
|
+
}
|
|
608
|
+
// GOLD-3: 1x, $500 contribution on day index 2, contribution applied before return.
|
|
609
|
+
const gold3Days = dates.map((date, i) => ({
|
|
610
|
+
date,
|
|
611
|
+
assetReturn: returns[i],
|
|
612
|
+
signalInput: 1,
|
|
613
|
+
contribution: i === 2 ? 500 : 0,
|
|
614
|
+
borrowRateDaily: 0,
|
|
615
|
+
expenseRatioAnnual: 0,
|
|
616
|
+
tradeSpread: 0,
|
|
617
|
+
}));
|
|
618
|
+
// Hand-compute: (equity + contribution) * (1 + return) each step.
|
|
619
|
+
let gold3Expected = 10000;
|
|
620
|
+
for (let i = 0; i < returns.length; i++) {
|
|
621
|
+
gold3Expected = (gold3Expected + (i === 2 ? 500 : 0)) * (1 + returns[i]);
|
|
622
|
+
}
|
|
623
|
+
const gold3Run = (0, public_api_1.runComparison)({
|
|
624
|
+
days: gold3Days,
|
|
625
|
+
config: zeroConfig,
|
|
626
|
+
strategies: [{ name: 'gold3_contrib_day3' }],
|
|
627
|
+
}).comparison.runs[0];
|
|
628
|
+
if (!gold3Run.ok) {
|
|
629
|
+
results.push(makeResult('GOLD-3', 'Golden baseline: contribution path runs', false, gold3Run.error, 'Run must succeed.', true));
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
const gap = Math.abs(gold3Run.summary.finalEquity - gold3Expected);
|
|
633
|
+
results.push(makeResult('GOLD-3', 'Golden baseline: 1x path with $500 contribution on day 3 matches contribution-before-return expected equity', gap < 1e-6, `actual=${gold3Run.summary.finalEquity.toFixed(10)}, expected=${gold3Expected.toFixed(10)}, gap=${gap.toExponential(3)}`, `Final equity must equal $${gold3Expected.toFixed(10)} to within 1e-6.`, true));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function runTimingChecks(results) {
|
|
637
|
+
// TIMING-1: signal flips from 0 to 1 at day index 2.
|
|
638
|
+
// T+1: appliedPosition(t) = targetPosition(t-1), so day-2 return is missed at the flip.
|
|
639
|
+
// Same-day (buggy): would capture day-2 return immediately.
|
|
640
|
+
const timing1Returns = [0.01, 0.02, 0.01, 0.01, 0.01];
|
|
641
|
+
const timing1Dates = ['2020-01-02', '2020-01-03', '2020-01-06', '2020-01-07', '2020-01-08'];
|
|
642
|
+
const timing1Signals = [0, 0, 1, 1, 1];
|
|
643
|
+
const timing1Days = timing1Dates.map((date, i) => ({
|
|
644
|
+
date,
|
|
645
|
+
assetReturn: timing1Returns[i],
|
|
646
|
+
signalInput: timing1Signals[i],
|
|
647
|
+
borrowRateDaily: 0,
|
|
648
|
+
expenseRatioAnnual: 0,
|
|
649
|
+
tradeSpread: 0,
|
|
650
|
+
}));
|
|
651
|
+
const timing1Config = {
|
|
652
|
+
initialEquity: 10000,
|
|
653
|
+
tradeCostRate: 0,
|
|
654
|
+
maxLeverage: 3,
|
|
655
|
+
initialTargetPosition: 0,
|
|
656
|
+
initialAppliedPosition: 0,
|
|
657
|
+
initialBorrowRateDaily: 0,
|
|
658
|
+
cashYieldDaily: 0,
|
|
659
|
+
};
|
|
660
|
+
// T+1 expected: applied = prevTarget (starts at initialTargetPosition=0).
|
|
661
|
+
let t1Expected = 10000;
|
|
662
|
+
let prevTarget = 0;
|
|
663
|
+
for (let i = 0; i < timing1Returns.length; i++) {
|
|
664
|
+
t1Expected = t1Expected + t1Expected * prevTarget * timing1Returns[i];
|
|
665
|
+
prevTarget = timing1Signals[i];
|
|
666
|
+
}
|
|
667
|
+
// Same-day (hypothetical buggy) expected: applied = today's signal.
|
|
668
|
+
let sameDayExpected = 10000;
|
|
669
|
+
for (let i = 0; i < timing1Returns.length; i++) {
|
|
670
|
+
sameDayExpected = sameDayExpected + sameDayExpected * timing1Signals[i] * timing1Returns[i];
|
|
671
|
+
}
|
|
672
|
+
const timing1Run = (0, public_api_1.runComparison)({
|
|
673
|
+
days: timing1Days,
|
|
674
|
+
config: timing1Config,
|
|
675
|
+
strategies: [{ name: 'timing1_t1_check' }],
|
|
676
|
+
}).comparison.runs[0];
|
|
677
|
+
if (!timing1Run.ok) {
|
|
678
|
+
results.push(makeResult('TIMING-1', 'TIMING-1: T+1 timing check run succeeds', false, timing1Run.error, 'Run must succeed.', true));
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
const gapVsT1 = Math.abs(timing1Run.summary.finalEquity - t1Expected);
|
|
682
|
+
const diffVsSameDay = Math.abs(timing1Run.summary.finalEquity - sameDayExpected);
|
|
683
|
+
results.push(makeResult('TIMING-1', 'T+1 timing: engine output matches T+1 expected to machine epsilon and differs detectably from same-day expected', gapVsT1 < 1e-6 && diffVsSameDay > 100, `actual=${timing1Run.summary.finalEquity.toFixed(6)}, t1_expected=${t1Expected.toFixed(6)}, same_day_expected=${sameDayExpected.toFixed(6)}, gap_vs_t1=${gapVsT1.toExponential(3)}, diff_vs_same_day=${diffVsSameDay.toFixed(4)}`, 'Engine must match T+1 expected to within 1e-6 and differ from same-day expected by > $100.', true));
|
|
684
|
+
}
|
|
685
|
+
// TIMING-2: contribution-before-return vs contribution-after-return.
|
|
686
|
+
// Uses same returns as GOLD-1/3 with $500 contribution on day index 2.
|
|
687
|
+
const timing2Returns = [0.01, -0.02, 0.015, 0.005, -0.01];
|
|
688
|
+
const timing2Dates = ['2020-01-02', '2020-01-03', '2020-01-06', '2020-01-07', '2020-01-08'];
|
|
689
|
+
const timing2Days = timing2Dates.map((date, i) => ({
|
|
690
|
+
date,
|
|
691
|
+
assetReturn: timing2Returns[i],
|
|
692
|
+
signalInput: 1,
|
|
693
|
+
contribution: i === 2 ? 500 : 0,
|
|
694
|
+
borrowRateDaily: 0,
|
|
695
|
+
expenseRatioAnnual: 0,
|
|
696
|
+
tradeSpread: 0,
|
|
697
|
+
}));
|
|
698
|
+
const timing2Config = {
|
|
699
|
+
initialEquity: 10000,
|
|
700
|
+
tradeCostRate: 0,
|
|
701
|
+
maxLeverage: 3,
|
|
702
|
+
initialTargetPosition: 1,
|
|
703
|
+
initialAppliedPosition: 1,
|
|
704
|
+
initialBorrowRateDaily: 0,
|
|
705
|
+
cashYieldDaily: 0,
|
|
706
|
+
};
|
|
707
|
+
// Before-return (correct): (equity + contribution) * (1 + return).
|
|
708
|
+
let beforeReturnExpected = 10000;
|
|
709
|
+
for (let i = 0; i < timing2Returns.length; i++) {
|
|
710
|
+
beforeReturnExpected = (beforeReturnExpected + (i === 2 ? 500 : 0)) * (1 + timing2Returns[i]);
|
|
711
|
+
}
|
|
712
|
+
// After-return (hypothetical buggy): equity * (1 + return) + contribution.
|
|
713
|
+
let afterReturnExpected = 10000;
|
|
714
|
+
for (let i = 0; i < timing2Returns.length; i++) {
|
|
715
|
+
afterReturnExpected = afterReturnExpected * (1 + timing2Returns[i]) + (i === 2 ? 500 : 0);
|
|
716
|
+
}
|
|
717
|
+
const timing2Run = (0, public_api_1.runComparison)({
|
|
718
|
+
days: timing2Days,
|
|
719
|
+
config: timing2Config,
|
|
720
|
+
strategies: [{ name: 'timing2_contrib_order' }],
|
|
721
|
+
}).comparison.runs[0];
|
|
722
|
+
if (!timing2Run.ok) {
|
|
723
|
+
results.push(makeResult('TIMING-2', 'TIMING-2: Contribution ordering check run succeeds', false, timing2Run.error, 'Run must succeed.', true));
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
const gapVsBefore = Math.abs(timing2Run.summary.finalEquity - beforeReturnExpected);
|
|
727
|
+
const diffVsAfter = Math.abs(timing2Run.summary.finalEquity - afterReturnExpected);
|
|
728
|
+
results.push(makeResult('TIMING-2', 'Contribution ordering: engine applies contribution before return, not after', gapVsBefore < 1e-6 && diffVsAfter > 5, `actual=${timing2Run.summary.finalEquity.toFixed(6)}, before_return_expected=${beforeReturnExpected.toFixed(6)}, after_return_expected=${afterReturnExpected.toFixed(6)}, gap_vs_before=${gapVsBefore.toExponential(3)}, diff_vs_after=${diffVsAfter.toFixed(4)}`, 'Engine must match contribution-before-return expected to within 1e-6 and differ from contribution-after-return by > $5.', true));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function runLookAheadContaminationCheck(results) {
|
|
732
|
+
// 100-day path with a large return shock injected at day index 50.
|
|
733
|
+
// Pre-cutoff run (days 0-48) must be identical between clean and shocked datasets —
|
|
734
|
+
// the shock cannot contaminate earlier equity because it has not happened yet.
|
|
735
|
+
// Full run must differ substantially, confirming the shock propagates correctly post-cutoff.
|
|
736
|
+
const N = 100;
|
|
737
|
+
const shockIndex = 50;
|
|
738
|
+
const normalReturn = 0.001;
|
|
739
|
+
const shockReturn = 0.50;
|
|
740
|
+
const allDates = businessDays('2020-01-02', '2020-12-31').slice(0, N);
|
|
741
|
+
const cleanDays = allDates.map((date) => ({
|
|
742
|
+
date,
|
|
743
|
+
assetReturn: normalReturn,
|
|
744
|
+
signalInput: 1,
|
|
745
|
+
borrowRateDaily: 0,
|
|
746
|
+
expenseRatioAnnual: 0,
|
|
747
|
+
tradeSpread: 0,
|
|
748
|
+
}));
|
|
749
|
+
const shockedDays = allDates.map((date, i) => ({
|
|
750
|
+
date,
|
|
751
|
+
assetReturn: i === shockIndex ? shockReturn : normalReturn,
|
|
752
|
+
signalInput: 1,
|
|
753
|
+
borrowRateDaily: 0,
|
|
754
|
+
expenseRatioAnnual: 0,
|
|
755
|
+
tradeSpread: 0,
|
|
756
|
+
}));
|
|
757
|
+
const lookaheadConfig = {
|
|
758
|
+
initialEquity: 10000,
|
|
759
|
+
tradeCostRate: 0,
|
|
760
|
+
maxLeverage: 3,
|
|
761
|
+
initialTargetPosition: 1,
|
|
762
|
+
initialAppliedPosition: 1,
|
|
763
|
+
initialBorrowRateDaily: 0,
|
|
764
|
+
cashYieldDaily: 0,
|
|
765
|
+
};
|
|
766
|
+
// Pre-cutoff: slice to indices 0..(shockIndex-2) — all days before the shock.
|
|
767
|
+
const preCutoffClean = (0, public_api_1.runComparison)({
|
|
768
|
+
days: cleanDays.slice(0, shockIndex - 1),
|
|
769
|
+
config: lookaheadConfig,
|
|
770
|
+
strategies: [{ name: 'lookahead_pre_clean' }],
|
|
771
|
+
}).comparison.runs[0];
|
|
772
|
+
if (!preCutoffClean.ok) {
|
|
773
|
+
results.push(makeResult('LOOKAHEAD-1', 'LOOKAHEAD-1: Look-ahead contamination runs succeed', false, preCutoffClean.error, 'All runs must succeed.', true));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const preCutoffShocked = (0, public_api_1.runComparison)({
|
|
777
|
+
days: shockedDays.slice(0, shockIndex - 1),
|
|
778
|
+
config: lookaheadConfig,
|
|
779
|
+
strategies: [{ name: 'lookahead_pre_shocked' }],
|
|
780
|
+
}).comparison.runs[0];
|
|
781
|
+
if (!preCutoffShocked.ok) {
|
|
782
|
+
results.push(makeResult('LOOKAHEAD-1', 'LOOKAHEAD-1: Look-ahead contamination runs succeed', false, preCutoffShocked.error, 'All runs must succeed.', true));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const fullClean = (0, public_api_1.runComparison)({
|
|
786
|
+
days: cleanDays,
|
|
787
|
+
config: lookaheadConfig,
|
|
788
|
+
strategies: [{ name: 'lookahead_full_clean' }],
|
|
789
|
+
}).comparison.runs[0];
|
|
790
|
+
if (!fullClean.ok) {
|
|
791
|
+
results.push(makeResult('LOOKAHEAD-1', 'LOOKAHEAD-1: Look-ahead contamination runs succeed', false, fullClean.error, 'All runs must succeed.', true));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const fullShocked = (0, public_api_1.runComparison)({
|
|
795
|
+
days: shockedDays,
|
|
796
|
+
config: lookaheadConfig,
|
|
797
|
+
strategies: [{ name: 'lookahead_full_shocked' }],
|
|
798
|
+
}).comparison.runs[0];
|
|
799
|
+
if (!fullShocked.ok) {
|
|
800
|
+
results.push(makeResult('LOOKAHEAD-1', 'LOOKAHEAD-1: Look-ahead contamination runs succeed', false, fullShocked.error, 'All runs must succeed.', true));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const preCutoffGap = Math.abs(preCutoffClean.summary.finalEquity - preCutoffShocked.summary.finalEquity);
|
|
804
|
+
const fullDiff = Math.abs(fullClean.summary.finalEquity - fullShocked.summary.finalEquity);
|
|
805
|
+
results.push(makeResult('LOOKAHEAD-1', 'Look-ahead contamination: pre-cutoff equity identical between clean/shocked; full-run equity differs substantially after shock', preCutoffGap < EPSILON && fullDiff > 1000, `pre_cutoff_gap=${preCutoffGap.toExponential(3)}, full_run_diff=${fullDiff.toFixed(2)} (shock at day ${shockIndex}: +${(shockReturn * 100).toFixed(0)}% return)`, 'Pre-cutoff gap must be < 1e-9. Full-run diff must be > $1000.', true));
|
|
806
|
+
}
|
|
807
|
+
function summarizeResults(results) {
|
|
808
|
+
const summary = {
|
|
809
|
+
total: results.length,
|
|
810
|
+
passed: results.filter((r) => r.status === 'PASS').length,
|
|
811
|
+
failed: results.filter((r) => r.status === 'FAIL').length,
|
|
812
|
+
warnings: results.filter((r) => r.status === 'WARN').length,
|
|
813
|
+
failedCore: results.filter((r) => r.status === 'FAIL' && r.core).length,
|
|
814
|
+
};
|
|
815
|
+
return { results, summary };
|
|
816
|
+
}
|
|
817
|
+
function buildEngineConfig() {
|
|
818
|
+
return {
|
|
819
|
+
initialEquity: 10000,
|
|
820
|
+
tradeCostRate: 0,
|
|
821
|
+
maxLeverage: 3,
|
|
822
|
+
initialTargetPosition: 1,
|
|
823
|
+
initialAppliedPosition: 1,
|
|
824
|
+
initialBorrowRateDaily: 0.0001,
|
|
825
|
+
cashYieldDaily: 0,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function makeResult(id, name, pass, actual, expected, core) {
|
|
829
|
+
return {
|
|
830
|
+
id,
|
|
831
|
+
name,
|
|
832
|
+
status: pass ? 'PASS' : 'FAIL',
|
|
833
|
+
actual,
|
|
834
|
+
expected,
|
|
835
|
+
core,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function buildSyntheticDataset(startDate, endDate, options) {
|
|
839
|
+
const dates = businessDays(startDate, endDate);
|
|
840
|
+
const returns = dates.map((date, index) => {
|
|
841
|
+
const year = Number(date.slice(0, 4));
|
|
842
|
+
let base = 0.00035 + ((index % 9) - 4) * 0.00018;
|
|
843
|
+
if (options.bullishBias) {
|
|
844
|
+
base = 0.0005 + ((index % 11) - 5) * 0.00022;
|
|
845
|
+
}
|
|
846
|
+
if (options.stressWindows) {
|
|
847
|
+
if (year === 2008) {
|
|
848
|
+
base -= 0.0012;
|
|
849
|
+
}
|
|
850
|
+
if (year === 2020) {
|
|
851
|
+
base -= date < '2020-04-15' ? 0.0015 : -0.0008;
|
|
852
|
+
}
|
|
853
|
+
if (year === 2022) {
|
|
854
|
+
base -= 0.0008;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (options.postCutoffShock && date >= options.postCutoffShock.date) {
|
|
858
|
+
base += options.postCutoffShock.returnShift;
|
|
859
|
+
}
|
|
860
|
+
return base;
|
|
861
|
+
});
|
|
862
|
+
const signalInputs = options.smaLength ? buildSmaSignalInputs(returns, options.smaLength) : dates.map(() => 1);
|
|
863
|
+
let contributionMonths = 0;
|
|
864
|
+
let currentMonth = '';
|
|
865
|
+
const days = dates.map((date, index) => {
|
|
866
|
+
const monthKey = date.slice(0, 7);
|
|
867
|
+
let contribution = 0;
|
|
868
|
+
if (options.monthlyContribution > 0 && monthKey !== currentMonth) {
|
|
869
|
+
currentMonth = monthKey;
|
|
870
|
+
contribution = options.monthlyContribution;
|
|
871
|
+
contributionMonths += 1;
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
date,
|
|
875
|
+
assetReturn: returns[index],
|
|
876
|
+
benchmarkReturn: options.includeBenchmark ? returns[index] : undefined,
|
|
877
|
+
signalInput: signalInputs[index],
|
|
878
|
+
contribution,
|
|
879
|
+
borrowRateDaily: 0.0001,
|
|
880
|
+
expenseRatioAnnual: 0,
|
|
881
|
+
tradeSpread: 0,
|
|
882
|
+
};
|
|
883
|
+
});
|
|
884
|
+
return { days, contributionMonths };
|
|
885
|
+
}
|
|
886
|
+
function buildSmaSignalInputs(returns, length) {
|
|
887
|
+
// Returns a clean boolean-style signal: 1 when price is at or above the SMA
|
|
888
|
+
// (invest / leveraged regime), 0 when price is below the SMA (cash regime).
|
|
889
|
+
//
|
|
890
|
+
// Previous encoding was sma/price - 1 (a small normalized distance value).
|
|
891
|
+
// That encoding was incompatible with VIX-scale thresholds: any threshold > ~0.1
|
|
892
|
+
// (such as the typical VIX level 20) would always satisfy signalInput <= threshold,
|
|
893
|
+
// keeping the strategy permanently leveraged with zero regime switches.
|
|
894
|
+
// Using 1/0 avoids scale confusion and pairs cleanly with smaMode in applyVariantToDays.
|
|
895
|
+
const closes = [];
|
|
896
|
+
let price = 100;
|
|
897
|
+
return returns.map((ret, index) => {
|
|
898
|
+
price *= 1 + ret;
|
|
899
|
+
closes.push(price);
|
|
900
|
+
const from = Math.max(0, index - length + 1);
|
|
901
|
+
const window = closes.slice(from, index + 1);
|
|
902
|
+
const sma = window.reduce((acc, value) => acc + value, 0) / window.length;
|
|
903
|
+
return price >= sma ? 1 : 0;
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
function businessDays(startDate, endDate) {
|
|
907
|
+
const out = [];
|
|
908
|
+
const d = new Date(`${startDate}T00:00:00Z`);
|
|
909
|
+
const end = new Date(`${endDate}T00:00:00Z`);
|
|
910
|
+
while (d <= end) {
|
|
911
|
+
const day = d.getUTCDay();
|
|
912
|
+
if (day !== 0 && day !== 6) {
|
|
913
|
+
out.push(d.toISOString().slice(0, 10));
|
|
914
|
+
}
|
|
915
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
916
|
+
}
|
|
917
|
+
return out;
|
|
918
|
+
}
|
|
919
|
+
function totalReturn(finalEquity, totalInvested) {
|
|
920
|
+
if (totalInvested === 0) {
|
|
921
|
+
return 0;
|
|
922
|
+
}
|
|
923
|
+
return finalEquity / totalInvested - 1;
|
|
924
|
+
}
|
|
925
|
+
function nearlyEqual(a, b) {
|
|
926
|
+
return Math.abs(a - b) <= EPSILON;
|
|
927
|
+
}
|
|
928
|
+
function formatPct(value) {
|
|
929
|
+
return `${(value * 100).toFixed(4)}%`;
|
|
930
|
+
}
|