@tangle-network/agent-bench 0.3.7 → 0.4.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/CHANGELOG.md +11 -0
- package/HARNESS.md +43 -0
- package/README.md +7 -0
- package/dist/adapters.js +23 -23
- package/dist/benchmarks/_harness.d.ts +1 -1
- package/dist/benchmarks/_harness.js +1 -1
- package/dist/benchmarks/aec-bench.js +2 -2
- package/dist/benchmarks/agentbench.js +2 -2
- package/dist/benchmarks/appworld.js +2 -2
- package/dist/benchmarks/bfcl.js +2 -2
- package/dist/benchmarks/commit0.js +2 -2
- package/dist/benchmarks/crag.js +2 -2
- package/dist/benchmarks/dabstep.js +2 -2
- package/dist/benchmarks/enterpriseops-gym.js +2 -2
- package/dist/benchmarks/finresearchbench.js +2 -2
- package/dist/benchmarks/nomiracl.js +2 -2
- package/dist/benchmarks/open-rag-bench.js +2 -2
- package/dist/benchmarks/programbench.js +2 -2
- package/dist/benchmarks/ragbench.js +2 -2
- package/dist/benchmarks/swe-bench.js +2 -2
- package/dist/benchmarks/t2-ragbench.js +2 -2
- package/dist/benchmarks/tau-bench-shared.js +2 -2
- package/dist/benchmarks/tau2-bench.js +3 -3
- package/dist/benchmarks/tau3-banking.js +3 -3
- package/dist/benchmarks/terminal-bench.js +2 -2
- package/dist/benchmarks/toollm.js +2 -2
- package/dist/benchmarks/webarena-verified.js +2 -2
- package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
- package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
- package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
- package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
- package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
- package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
- package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
- package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
- package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
- package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
- package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
- package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
- package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
- package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
- package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
- package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
- package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
- package/dist/{chunk-PWQVGAJB.js → chunk-V7AEBY6U.js} +21 -21
- package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
- package/dist/chunk-WSKWVEQB.js.map +1 -0
- package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
- package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
- package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
- package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
- package/dist/index.js +23 -23
- package/package.json +5 -5
- package/scripts/trata-hedge/README.md +6 -5
- package/scripts/verify-packed-consumer.mjs +1 -1
- package/src/benchmarks/_harness.ts +20 -2
- package/src/benchmarks/humaneval.test.mts +2 -2
- package/src/gate.ts +1 -1
- package/src/hev-eval.mts +5 -2
- package/src/hev-improve.mts +118 -73
- package/src/official-optimizer-config.mts +89 -0
- package/src/official-optimizer-config.test.mts +88 -0
- package/src/profiles.ts +2 -2
- package/src/quant-arena/README.md +144 -0
- package/src/quant-arena/backtest.test.mts +135 -0
- package/src/quant-arena/backtest.ts +218 -0
- package/src/quant-arena/data.test.mts +44 -0
- package/src/quant-arena/data.ts +141 -0
- package/src/quant-arena/driver.test.mts +253 -0
- package/src/quant-arena/driver.ts +219 -0
- package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
- package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
- package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
- package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
- package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
- package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
- package/src/quant-arena/holdout-certify.mts +206 -0
- package/src/quant-arena/holdout-certify.test.mts +82 -0
- package/src/quant-arena/leak-audit.test.mts +79 -0
- package/src/quant-arena/leak-audit.ts +95 -0
- package/src/quant-arena/make-fixtures.mts +161 -0
- package/src/quant-arena/multiplicity.test.mts +68 -0
- package/src/quant-arena/multiplicity.ts +87 -0
- package/src/quant-arena/nautilus-certify.ts +31 -0
- package/src/quant-arena/oms.ts +90 -0
- package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
- package/src/quant-arena/python/pyproject.toml +8 -0
- package/src/quant-arena/python/uv.lock +1297 -0
- package/src/quant-arena/python/vbt-worker.py +192 -0
- package/src/quant-arena/quant-loop.mts +813 -0
- package/src/quant-arena/quant-loop.test.mts +75 -0
- package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
- package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
- package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
- package/src/quant-arena/types.ts +133 -0
- package/src/quant-arena/vbt-client.ts +321 -0
- package/src/quant-arena/vbt-parity.test.mts +183 -0
- package/src/quant-arena/windows.test.mts +45 -0
- package/src/quant-arena/windows.ts +54 -0
- package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
- package/src/rollout-ledger/backfill-swe-arena.test.mts +342 -0
- package/src/rollout-ledger/settle-capture.mts +442 -0
- package/src/rollout-ledger/settle-capture.test.mts +270 -0
- package/src/smoke-structural-rollout.mts +15 -9
- package/src/swe-arena/activation.mts +225 -0
- package/src/swe-arena/activation.test.mts +300 -0
- package/src/swe-arena/arms.ts +30 -14
- package/src/swe-arena/briefing.mts +217 -0
- package/src/swe-arena/briefing.test.mts +178 -0
- package/src/swe-arena/calibrate.ts +102 -1
- package/src/swe-arena/cell-evidence.mts +41 -9
- package/src/swe-arena/factory-command-container.ts +284 -0
- package/src/swe-arena/factory-judge-child.mts +228 -0
- package/src/swe-arena/factory.test.mts +643 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
- package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
- package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
- package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
- package/src/swe-arena/fixtures/run-report/README.md +43 -0
- package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
- package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
- package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
- package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
- package/src/swe-arena/fixtures.ts +135 -2
- package/src/swe-arena/gepa-seat.mts +877 -0
- package/src/swe-arena/gepa-seat.test.mts +1059 -0
- package/src/swe-arena/implementation-ref.test.mts +64 -0
- package/src/swe-arena/implementation-ref.ts +62 -0
- package/src/swe-arena/ledger-orphans.mts +77 -0
- package/src/swe-arena/ledger-orphans.test.mts +147 -0
- package/src/swe-arena/outer-loop.mts +710 -33
- package/src/swe-arena/premeasured-from-cells.mts +281 -0
- package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
- package/src/swe-arena/proposer-fanout.mts +327 -31
- package/src/swe-arena/proposer-fanout.test.mts +246 -0
- package/src/swe-arena/proposer-provenance.mts +172 -0
- package/src/swe-arena/proposer-provenance.test.mts +106 -0
- package/src/swe-arena/run-experiment.mts +367 -1
- package/src/swe-arena/run-report.mts +75 -0
- package/src/swe-arena/run-supervisor.test.mts +5 -3
- package/src/swe-arena/score-split.mts +140 -0
- package/src/swe-arena/score-split.test.mts +123 -0
- package/src/swe-arena/scratch-worktree.test.mts +55 -0
- package/src/swe-arena/scratch-worktree.ts +34 -0
- package/src/swe-arena/types.ts +52 -0
- package/src/swe-code-improve.mts +24 -25
- package/src/swe-improve.mts +129 -96
- package/src/swe-local-proof.mts +6 -1
- package/src/swe-stream.mts +4 -2
- package/src/swe-structural.mts +245 -837
- package/src/tb-container-executor.test.mts +30 -6
- package/src/tb-supervisor-sidecar.mts +2 -1
- package/src/trata-gepa.mts +182 -245
- package/dist/chunk-LRRD7NAG.js.map +0 -1
- package/src/live-improve-campaign-mbpp.mts +0 -641
- package/src/live-improve-campaign.mts +0 -500
- /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
- /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
- /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
- /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
- /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
- /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
- /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
- /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
- /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
- /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
- /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
- /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
- /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
- /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
- /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
- /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
- /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
- /package/dist/{chunk-PWQVGAJB.js.map → chunk-V7AEBY6U.js.map} +0 -0
- /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
- /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
- /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
- /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
type Bar = { date: string; open: number; high: number; low: number; close: number; volume: number };
|
|
2
|
+
type Signal = { t: number; weights: number[] };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ECONOMIC RATIONALE
|
|
6
|
+
* -------------------
|
|
7
|
+
* One effect, harvested two ways: volatility is persistent and heterogeneous across names and
|
|
8
|
+
* across time, while expected returns are not reliably predictable day to day. Two robust,
|
|
9
|
+
* well-documented consequences of that fact:
|
|
10
|
+
*
|
|
11
|
+
* 1. Cross-sectional: weighting stocks inversely to their own trailing volatility (risk parity)
|
|
12
|
+
* extracts more diversification benefit than equal-weighting, because it does not let the
|
|
13
|
+
* noisiest names dominate portfolio variance. This should hold in any regime where the 10
|
|
14
|
+
* names have unequal, imperfectly-correlated volatility — i.e. most of the time.
|
|
15
|
+
* 2. Time-series: realized volatility clusters, so scaling total equity exposure down when
|
|
16
|
+
* trailing portfolio vol rises above a fixed target de-risks ahead of stress regimes without
|
|
17
|
+
* forecasting direction (the "volatility-managed portfolio" effect). This should specifically
|
|
18
|
+
* help in the calm-to-stress transition windows where buy-and-hold and SMA-crossover get hurt.
|
|
19
|
+
*
|
|
20
|
+
* Both are single, few-parameter, regime-agnostic effects (no return forecasting, no curve
|
|
21
|
+
* fitting to dates). Rebalancing only every ~21 trading days keeps turnover — and the 15bps
|
|
22
|
+
* one-way cost — well below the vol-timing edge, which shows up at monthly-or-slower horizons.
|
|
23
|
+
* No leverage is ever taken (scale is capped at 1); the model only ever de-risks toward cash.
|
|
24
|
+
*/
|
|
25
|
+
export function generateSignals(bars: Bar[][]): Signal[] {
|
|
26
|
+
const numTickers = bars.length; // 0 = IDX (untraded), 1..10 = S01..S10
|
|
27
|
+
const numStocks = numTickers - 1;
|
|
28
|
+
const numDays = bars[0].length;
|
|
29
|
+
|
|
30
|
+
const VOL_LOOKBACK = 63; // ~1 trading quarter: per-asset & portfolio realized-vol window
|
|
31
|
+
const REBAL_INTERVAL = 21; // ~monthly cadence: keeps turnover cheap relative to the vol-timing edge
|
|
32
|
+
const TARGET_VOL_ANN = 0.15; // target annualized portfolio vol; only de-risk when realized vol exceeds it
|
|
33
|
+
const ANNUALIZATION = Math.sqrt(252);
|
|
34
|
+
const MIN_HISTORY = VOL_LOOKBACK + 1;
|
|
35
|
+
|
|
36
|
+
const signals: Signal[] = [];
|
|
37
|
+
|
|
38
|
+
function logReturn(k: number, t: number): number {
|
|
39
|
+
const prev = bars[k][t - 1].close;
|
|
40
|
+
const cur = bars[k][t].close;
|
|
41
|
+
if (prev <= 0 || cur <= 0) return 0;
|
|
42
|
+
return Math.log(cur / prev);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let lastRebalance = -Infinity;
|
|
46
|
+
|
|
47
|
+
for (let t = MIN_HISTORY; t < numDays; t++) {
|
|
48
|
+
if (t - lastRebalance < REBAL_INTERVAL) continue;
|
|
49
|
+
|
|
50
|
+
// Per-asset trailing volatility over [t - VOL_LOOKBACK + 1, t] — uses only data up to t.
|
|
51
|
+
const assetVol: number[] = new Array(numStocks).fill(0);
|
|
52
|
+
let anyValid = false;
|
|
53
|
+
for (let s = 0; s < numStocks; s++) {
|
|
54
|
+
const k = s + 1;
|
|
55
|
+
const rets: number[] = [];
|
|
56
|
+
for (let d = t - VOL_LOOKBACK + 1; d <= t; d++) {
|
|
57
|
+
rets.push(logReturn(k, d));
|
|
58
|
+
}
|
|
59
|
+
const mean = rets.reduce((a, b) => a + b, 0) / rets.length;
|
|
60
|
+
const variance = rets.reduce((a, b) => a + (b - mean) * (b - mean), 0) / rets.length;
|
|
61
|
+
const vol = Math.sqrt(variance);
|
|
62
|
+
assetVol[s] = vol;
|
|
63
|
+
if (vol > 1e-8) anyValid = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!anyValid) {
|
|
67
|
+
lastRebalance = t;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Inverse-volatility (risk-parity) weights across the traded stocks.
|
|
72
|
+
const invVol = assetVol.map(v => (v > 1e-8 ? 1 / v : 0));
|
|
73
|
+
const invVolSum = invVol.reduce((a, b) => a + b, 0);
|
|
74
|
+
const riskParityWeights = invVol.map(v => (invVolSum > 0 ? v / invVolSum : 0));
|
|
75
|
+
|
|
76
|
+
// Realized vol of that risk-parity mix, used purely for the exposure-scaling decision below.
|
|
77
|
+
const portRets: number[] = [];
|
|
78
|
+
for (let d = t - VOL_LOOKBACK + 1; d <= t; d++) {
|
|
79
|
+
let dayRet = 0;
|
|
80
|
+
for (let s = 0; s < numStocks; s++) {
|
|
81
|
+
dayRet += riskParityWeights[s] * logReturn(s + 1, d);
|
|
82
|
+
}
|
|
83
|
+
portRets.push(dayRet);
|
|
84
|
+
}
|
|
85
|
+
const portMeanRet = portRets.reduce((a, b) => a + b, 0) / portRets.length;
|
|
86
|
+
const portVar = portRets.reduce((a, b) => a + (b - portMeanRet) * (b - portMeanRet), 0) / portRets.length;
|
|
87
|
+
const portVolAnn = Math.sqrt(portVar) * ANNUALIZATION;
|
|
88
|
+
|
|
89
|
+
// Vol targeting: de-lever toward cash when realized vol exceeds target; never lever above 1.
|
|
90
|
+
const scale = portVolAnn > 1e-8 ? Math.min(1, TARGET_VOL_ANN / portVolAnn) : 1;
|
|
91
|
+
|
|
92
|
+
const weights = new Array(numTickers).fill(0);
|
|
93
|
+
for (let s = 0; s < numStocks; s++) {
|
|
94
|
+
weights[s + 1] = riskParityWeights[s] * scale;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
signals.push({ t, weights });
|
|
98
|
+
lastRebalance = t;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return signals;
|
|
102
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{"version":1,"record":{"status":"pending","callId":"38ff56f6-527f-49f8-91da-1c665ad23851","channel":"driver","phase":"search.proposal","actor":"proposer-shot:quant-researcher","model":"sonnet","timestamp":1784762874717,"tags":{"candidateId":"cand-001-quant-researcher"}}}
|
|
2
|
+
{"version":1,"record":{"status":"settled","callId":"38ff56f6-527f-49f8-91da-1c665ad23851","channel":"driver","phase":"search.proposal","actor":"proposer-shot:quant-researcher","model":"sonnet","timestamp":1784762874717,"inputTokens":2,"outputTokens":13962,"cachedTokens":14853,"costUsd":0.5023599000000001,"costUnknown":false,"usageUnknown":false,"actualCostUsd":0.5023599000000001,"tags":{"candidateId":"cand-001-quant-researcher"}}}
|
|
3
|
+
{"version":1,"record":{"status":"pending","callId":"8364c35d-7568-4a46-b357-842596bad828","channel":"driver","phase":"audit.leak","actor":"leak-auditor:claude","model":"haiku","timestamp":1784763022945,"tags":{"candidateId":"cand-001-quant-researcher","attempt":"0"}}}
|
|
4
|
+
{"version":1,"record":{"status":"settled","callId":"8364c35d-7568-4a46-b357-842596bad828","channel":"driver","phase":"audit.leak","actor":"leak-auditor:claude","model":"haiku","timestamp":1784763022945,"inputTokens":10,"outputTokens":2486,"cachedTokens":10942,"costUsd":0.0736982,"costUnknown":false,"usageUnknown":false,"actualCostUsd":0.0736982,"tags":{"candidateId":"cand-001-quant-researcher","attempt":"0"}}}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"schema":"quant-arena.baselines.v1","at":"2026-07-22T23:27:54.712Z","seed":20260722,"costBps":10,"slippageBps":5,"windows":[{"start":199,"end":703,"startDate":"2017-04-06","endDate":"2019-03-12"},{"start":468,"end":972,"startDate":"2018-04-18","endDate":"2020-03-23"},{"start":546,"end":1050,"startDate":"2018-08-06","endDate":"2020-07-09"},{"start":594,"end":1098,"startDate":"2018-10-11","endDate":"2020-09-15"},{"start":680,"end":1184,"startDate":"2019-02-08","endDate":"2021-01-13"},{"start":1014,"end":1518,"startDate":"2020-05-21","endDate":"2022-04-26"},{"start":1057,"end":1561,"startDate":"2020-07-21","endDate":"2022-06-24"},{"start":1449,"end":1953,"startDate":"2022-01-20","endDate":"2023-12-26"}],"perWindowSharpe":{"buy-hold-index":[1.209402818407734,0.26747034145574655,0.02223192919061442,-0.025934457758002974,0.30763713767392015,0.1729701215613101,0.08485225461739357,0.7850882647054723],"equal-weight":[1.1461411611212873,0.6060778021251142,0.2849112842132356,0.17899050927362395,0.6606783099823891,0.5185933734753903,0.5223786523399545,1.2149406938872762],"sma-crossover":[1.1148336642070824,0.09109854752089735,-0.05350102847078379,-0.014563215257672965,0.35981476938731394,0.2503571546107575,0.20375008642895226,1.208060697185852]},"bestPerWindow":[1.209402818407734,0.6060778021251142,0.2849112842132356,0.17899050927362395,0.6606783099823891,0.5185933734753903,0.5223786523399545,1.2149406938872762],"fullSample":{"buy-hold-index":{"start":0,"end":2086,"days":2086,"totalReturn":1.1713024249031854,"maxDrawdown":0.2837597681759013,"sharpe":0.5894270856356978,"tradeCount":1,"turnover":1},"equal-weight":{"start":0,"end":2086,"days":2086,"totalReturn":2.7565834221442085,"maxDrawdown":0.260030673705944,"sharpe":0.8405199991639724,"tradeCount":1099,"turnover":6.9338160520867085},"sma-crossover":{"start":0,"end":2086,"days":2086,"totalReturn":1.774871914165919,"maxDrawdown":0.35749030377671454,"sharpe":0.6590297667154418,"tradeCount":1789,"turnover":111.15341469281668}}}
|
|
2
|
+
{"schema":"quant-arena.candidate.v1","at":"2026-07-22T23:27:54.716Z","candidateId":"cand-001-quant-researcher","proposer":"quant-researcher","authorModel":"sonnet","strategyPath":"/tmp/quant-arena-v2/bench/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts","sha256":"sha256:befe1bfde740e579288c498d01cab91771ccbe492441e94660887d166291125d","authoringCostUsd":0.5023599000000001,"nTried":1,"leakAudit":{"truncation":{"clean":true,"cutoffs":[121,514,906,1299,1691,2084],"divergence":null},"llm":{"verdict":"clean","evidence":""}},"eval":{"perWindow":[{"start":199,"end":703,"startDate":"2017-04-06","endDate":"2019-03-12","sharpe":1.176350979324448,"bestBaselineSharpe":1.209402818407734,"excess":-0.033051839083285905},{"start":468,"end":972,"startDate":"2018-04-18","endDate":"2020-03-23","sharpe":0.3540080230521845,"bestBaselineSharpe":0.6060778021251142,"excess":-0.2520697790729297},{"start":546,"end":1050,"startDate":"2018-08-06","endDate":"2020-07-09","sharpe":0.0830954098341174,"bestBaselineSharpe":0.2849112842132356,"excess":-0.2018158743791182},{"start":594,"end":1098,"startDate":"2018-10-11","endDate":"2020-09-15","sharpe":-0.159946116561702,"bestBaselineSharpe":0.17899050927362395,"excess":-0.3389366258353259},{"start":680,"end":1184,"startDate":"2019-02-08","endDate":"2021-01-13","sharpe":0.39355206968094464,"bestBaselineSharpe":0.6606783099823891,"excess":-0.26712624030144444},{"start":1014,"end":1518,"startDate":"2020-05-21","endDate":"2022-04-26","sharpe":0.06629822864040066,"bestBaselineSharpe":0.5185933734753903,"excess":-0.45229514483498956},{"start":1057,"end":1561,"startDate":"2020-07-21","endDate":"2022-06-24","sharpe":0.08314391874329552,"bestBaselineSharpe":0.5223786523399545,"excess":-0.439234733596659},{"start":1449,"end":1953,"startDate":"2022-01-20","endDate":"2023-12-26","sharpe":1.1420267333798133,"bestBaselineSharpe":1.2149406938872762,"excess":-0.0729139605074629}],"meanExcessSharpe":-0.25718052470140196,"wins":0,"requiredWins":6,"threshold":0.1},"inSampleFull":{"start":0,"end":2086,"days":2086,"totalReturn":1.0793328708686873,"maxDrawdown":0.2547803880567502,"sharpe":0.598115214813146,"tradeCount":1000,"turnover":23.81524755112226},"verdict":"rejected-no-edge","reasons":["consistency: beat the best baseline in only 0/8 windows (need 6)","multiplicity: mean excess Sharpe -0.257 < required 0.100 (bar after 1 tried candidate)"]}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "quant-arena.rollout.v1",
|
|
3
|
+
"outDir": "/tmp/quant-arena-v2/bench/src/quant-arena/fixtures/demo-campaign-v2",
|
|
4
|
+
"at": "2026-07-22T23:30:50.804Z",
|
|
5
|
+
"notebookRows": 2,
|
|
6
|
+
"strategies": [
|
|
7
|
+
{
|
|
8
|
+
"label": "baseline-buy-hold-index",
|
|
9
|
+
"campaignDir": "/tmp/quant-arena-v2/bench/src/quant-arena/fixtures/demo-campaign-v2/campaign/baseline-buy-hold-index",
|
|
10
|
+
"cells": 8,
|
|
11
|
+
"scenarios": [
|
|
12
|
+
"window-1014-1518",
|
|
13
|
+
"window-1057-1561",
|
|
14
|
+
"window-1449-1953",
|
|
15
|
+
"window-199-703",
|
|
16
|
+
"window-468-972",
|
|
17
|
+
"window-546-1050",
|
|
18
|
+
"window-594-1098",
|
|
19
|
+
"window-680-1184"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"label": "baseline-equal-weight",
|
|
24
|
+
"campaignDir": "/tmp/quant-arena-v2/bench/src/quant-arena/fixtures/demo-campaign-v2/campaign/baseline-equal-weight",
|
|
25
|
+
"cells": 8,
|
|
26
|
+
"scenarios": [
|
|
27
|
+
"window-1014-1518",
|
|
28
|
+
"window-1057-1561",
|
|
29
|
+
"window-1449-1953",
|
|
30
|
+
"window-199-703",
|
|
31
|
+
"window-468-972",
|
|
32
|
+
"window-546-1050",
|
|
33
|
+
"window-594-1098",
|
|
34
|
+
"window-680-1184"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"label": "baseline-sma-crossover",
|
|
39
|
+
"campaignDir": "/tmp/quant-arena-v2/bench/src/quant-arena/fixtures/demo-campaign-v2/campaign/baseline-sma-crossover",
|
|
40
|
+
"cells": 8,
|
|
41
|
+
"scenarios": [
|
|
42
|
+
"window-1014-1518",
|
|
43
|
+
"window-1057-1561",
|
|
44
|
+
"window-1449-1953",
|
|
45
|
+
"window-199-703",
|
|
46
|
+
"window-468-972",
|
|
47
|
+
"window-546-1050",
|
|
48
|
+
"window-594-1098",
|
|
49
|
+
"window-680-1184"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"label": "cand-001-quant-researcher",
|
|
54
|
+
"campaignDir": "/tmp/quant-arena-v2/bench/src/quant-arena/fixtures/demo-campaign-v2/campaign/cand-001-quant-researcher",
|
|
55
|
+
"cells": 8,
|
|
56
|
+
"scenarios": [
|
|
57
|
+
"window-1014-1518",
|
|
58
|
+
"window-1057-1561",
|
|
59
|
+
"window-1449-1953",
|
|
60
|
+
"window-199-703",
|
|
61
|
+
"window-468-972",
|
|
62
|
+
"window-546-1050",
|
|
63
|
+
"window-594-1098",
|
|
64
|
+
"window-680-1184"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"receipts": [
|
|
69
|
+
{
|
|
70
|
+
"callId": "38ff56f6-527f-49f8-91da-1c665ad23851",
|
|
71
|
+
"phase": "search.proposal",
|
|
72
|
+
"actor": "proposer-shot:quant-researcher",
|
|
73
|
+
"model": "sonnet",
|
|
74
|
+
"costUsd": 0.5023599000000001
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"callId": "8364c35d-7568-4a46-b357-842596bad828",
|
|
78
|
+
"phase": "audit.leak",
|
|
79
|
+
"actor": "leak-auditor:claude",
|
|
80
|
+
"model": "haiku",
|
|
81
|
+
"costUsd": 0.0736982
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export interface Bar {
|
|
2
|
+
date: string
|
|
3
|
+
open: number
|
|
4
|
+
high: number
|
|
5
|
+
low: number
|
|
6
|
+
close: number
|
|
7
|
+
volume: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StrategyContext {
|
|
11
|
+
symbols: string[]
|
|
12
|
+
t: number
|
|
13
|
+
history: Bar[][]
|
|
14
|
+
weights: number[]
|
|
15
|
+
equity: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TargetPosition {
|
|
19
|
+
symbol: string
|
|
20
|
+
weight: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ECONOMIC RATIONALE
|
|
24
|
+
// -------------------
|
|
25
|
+
// Core effect (risk parity / low-volatility anomaly): within the 10-name universe,
|
|
26
|
+
// weight each name inversely to its trailing realized volatility instead of equal
|
|
27
|
+
// weight. Lower-vol names have historically offered better risk-adjusted (not
|
|
28
|
+
// necessarily raw) returns, so tilting the book toward them raises the portfolio's
|
|
29
|
+
// Sharpe by shrinking variance faster than it gives up mean return -- a
|
|
30
|
+
// cross-sectional statistical property of the return distribution, not a level
|
|
31
|
+
// fitted to this dataset, so it should generalize out of sample.
|
|
32
|
+
//
|
|
33
|
+
// Overlay (trend-based de-risking): scale TOTAL exposure down when the index is
|
|
34
|
+
// below its own ~6-month moving average. Sustained downtrends carry the worst
|
|
35
|
+
// left-tail days; cutting exposure in that regime avoids most of that tail and
|
|
36
|
+
// improves Sharpe without needing to predict the turn. This is the same
|
|
37
|
+
// mechanism behind classic tactical/trend-following allocation (Faber 2007;
|
|
38
|
+
// time-series momentum, Moskowitz/Ooi/Pedersen 2012).
|
|
39
|
+
//
|
|
40
|
+
// Turnover control: both the vol estimate and the trend regime move slowly, so
|
|
41
|
+
// re-deciding weights only once a month (not daily) captures nearly all the
|
|
42
|
+
// signal while keeping realized turnover, and the 15bps-per-side cost, low.
|
|
43
|
+
|
|
44
|
+
const VOL_LOOKBACK = 60 // ~1 quarter of trading days for the realized-vol estimate
|
|
45
|
+
const TREND_LOOKBACK = 120 // ~6 months for the index trend line
|
|
46
|
+
const MIN_RETURNS_FOR_VOL = 10 // below this, a vol estimate is too noisy to trust -> equal weight
|
|
47
|
+
const MIN_BARS_FOR_TREND = 20 // below this, no evidence of a downtrend yet -> assume uptrend
|
|
48
|
+
const REBALANCE_PERIOD = 21 // ~1 trading month cadence, keeps churn cheap relative to the edge
|
|
49
|
+
const DEFENSIVE_FRACTION = 0.3 // exposure kept on when the index is below its trend line
|
|
50
|
+
const MAX_STOCK_WEIGHT = 0.25 // guardrail against one noisy low-vol reading dominating the book
|
|
51
|
+
const EPS = 1e-6
|
|
52
|
+
|
|
53
|
+
function stdev(returns: number[]): number {
|
|
54
|
+
const n = returns.length
|
|
55
|
+
const mean = returns.reduce((s, r) => s + r, 0) / n
|
|
56
|
+
const variance = returns.reduce((s, r) => s + (r - mean) * (r - mean), 0) / (n - 1)
|
|
57
|
+
return Math.sqrt(Math.max(variance, 0))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dailyReturns(bars: Bar[], lookback: number): number[] {
|
|
61
|
+
const start = Math.max(1, bars.length - lookback)
|
|
62
|
+
const out: number[] = []
|
|
63
|
+
for (let i = start; i < bars.length; i++) {
|
|
64
|
+
const prev = bars[i - 1].close
|
|
65
|
+
const cur = bars[i].close
|
|
66
|
+
if (prev > 0) out.push(cur / prev - 1)
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function onBar(ctx: StrategyContext): TargetPosition[] | null {
|
|
72
|
+
const { symbols, t, history } = ctx
|
|
73
|
+
|
|
74
|
+
// Establish the initial book on day 0; otherwise only re-decide on a fixed
|
|
75
|
+
// monthly cadence so 15bps churn cannot eat a vol/trend edge that itself
|
|
76
|
+
// only moves on a weeks-to-months timescale.
|
|
77
|
+
if (t !== 0 && t % REBALANCE_PERIOD !== 0) return null
|
|
78
|
+
|
|
79
|
+
// --- regime filter: is the index above its own trend line? ---
|
|
80
|
+
const idxBars = history[0]
|
|
81
|
+
let uptrend = true
|
|
82
|
+
if (idxBars.length >= MIN_BARS_FOR_TREND) {
|
|
83
|
+
const window = idxBars.slice(Math.max(0, idxBars.length - TREND_LOOKBACK))
|
|
84
|
+
const sma = window.reduce((s, b) => s + b.close, 0) / window.length
|
|
85
|
+
uptrend = idxBars[idxBars.length - 1].close > sma
|
|
86
|
+
}
|
|
87
|
+
const exposure = uptrend ? 1 : DEFENSIVE_FRACTION
|
|
88
|
+
|
|
89
|
+
// --- risk-parity book across the 10 constituents (symbols[1..10]) ---
|
|
90
|
+
const stockIdx: number[] = []
|
|
91
|
+
for (let k = 1; k < symbols.length; k++) stockIdx.push(k)
|
|
92
|
+
|
|
93
|
+
const invVols: number[] = stockIdx.map((k) => {
|
|
94
|
+
const rets = dailyReturns(history[k], VOL_LOOKBACK)
|
|
95
|
+
if (rets.length < MIN_RETURNS_FOR_VOL) return NaN
|
|
96
|
+
return 1 / (stdev(rets) + EPS)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const validMask = invVols.map((v) => !Number.isNaN(v))
|
|
100
|
+
const anyValid = validMask.some(Boolean)
|
|
101
|
+
|
|
102
|
+
let baseWeights: number[]
|
|
103
|
+
if (!anyValid) {
|
|
104
|
+
baseWeights = stockIdx.map(() => 1 / stockIdx.length)
|
|
105
|
+
} else {
|
|
106
|
+
const sumInv = invVols.reduce((s, v, i) => s + (validMask[i] ? v : 0), 0)
|
|
107
|
+
baseWeights = invVols.map((v, i) => (validMask[i] ? v / sumInv : 0))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const positions: TargetPosition[] = []
|
|
111
|
+
for (let i = 0; i < stockIdx.length; i++) {
|
|
112
|
+
const w = Math.min(baseWeights[i] * exposure, MAX_STOCK_WEIGHT)
|
|
113
|
+
positions.push({ symbol: symbols[stockIdx[i]], weight: w })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return positions
|
|
117
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holdout certification — the ONLY code path that reads fixtures/data/holdout/
|
|
3
|
+
* (the final 2 years). Operator-invoked, once per winning strategy:
|
|
4
|
+
*
|
|
5
|
+
* tsx src/quant-arena/holdout-certify.mts --strategy <path/to/strategy.ts> --out <campaign dir> [--force]
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* - the strategy is backtested over in-sample + holdout concatenated (so its
|
|
9
|
+
* lookbacks are warm when the holdout period begins), but SCORED only on
|
|
10
|
+
* the holdout days — a true walk-forward on data no candidate ever saw;
|
|
11
|
+
* - scoring is the OFFICIAL vectorbt worker (vbt-client.ts); the TS engine
|
|
12
|
+
* runs first as fail-closed contract prefilter only;
|
|
13
|
+
* - the three pinned baselines run under the identical protocol; the bar is
|
|
14
|
+
* the best baseline's holdout Sharpe;
|
|
15
|
+
* - the truncation leak audit re-runs on the full axis first (a leak that
|
|
16
|
+
* only pays off out-of-sample would otherwise slip through);
|
|
17
|
+
* - the verdict is appended to the campaign notebook with in-sample and
|
|
18
|
+
* out-of-sample stats side by side, and a certification record is written.
|
|
19
|
+
* A second run for the same strategy hash refuses without --force — the
|
|
20
|
+
* holdout answers once; re-rolling it is how holdouts die.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { existsSync } from 'node:fs'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { pathToFileURL } from 'node:url'
|
|
28
|
+
import process from 'node:process'
|
|
29
|
+
import { runBacktest, statsForRange, type BacktestConfig, type BacktestResult, type RangeStats } from './backtest.ts'
|
|
30
|
+
import { concatForCertification, loadBarsDir, loadHoldout, loadInSample } from './data.ts'
|
|
31
|
+
import { loadStrategyFile } from './driver.ts'
|
|
32
|
+
import { truncationInvariance } from './leak-audit.ts'
|
|
33
|
+
import { PINNED_BASELINES } from './quant-loop.mts'
|
|
34
|
+
import { scoreSignals, VbtWorker, type VbtWindowStats } from './vbt-client.ts'
|
|
35
|
+
import type { Bar, Signal } from './types.ts'
|
|
36
|
+
|
|
37
|
+
export const CERTIFICATION_SCHEMA = 'quant-arena.certification.v1'
|
|
38
|
+
|
|
39
|
+
/** Official scores for [0, split) and [split, T) from the vectorbt worker.
|
|
40
|
+
* The TS engine runs first as fail-closed contract prefilter and supplies
|
|
41
|
+
* turnover (not carried by the worker protocol); every other number is the
|
|
42
|
+
* worker's. */
|
|
43
|
+
async function scoreOnRanges(
|
|
44
|
+
worker: VbtWorker,
|
|
45
|
+
bars: Bar[][],
|
|
46
|
+
signals: Signal[],
|
|
47
|
+
btConfig: BacktestConfig,
|
|
48
|
+
split: number,
|
|
49
|
+
T: number,
|
|
50
|
+
): Promise<{ inSample: RangeStats; holdout: RangeStats }> {
|
|
51
|
+
const prefilter: BacktestResult = runBacktest(bars, signals, btConfig)
|
|
52
|
+
const vbt = await scoreSignals(worker, bars, signals, btConfig, [
|
|
53
|
+
[0, split],
|
|
54
|
+
[split, T],
|
|
55
|
+
])
|
|
56
|
+
const toRange = (stats: VbtWindowStats, start: number, end: number): RangeStats => ({
|
|
57
|
+
start,
|
|
58
|
+
end,
|
|
59
|
+
days: end - start,
|
|
60
|
+
totalReturn: stats.totalReturn,
|
|
61
|
+
maxDrawdown: stats.maxDD,
|
|
62
|
+
sharpe: stats.sharpe,
|
|
63
|
+
tradeCount: stats.trades,
|
|
64
|
+
turnover: statsForRange(prefilter, start, end).turnover,
|
|
65
|
+
})
|
|
66
|
+
return { inSample: toRange(vbt.windows[0]!, 0, split), holdout: toRange(vbt.windows[1]!, split, T) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CertificationRecord {
|
|
70
|
+
schema: typeof CERTIFICATION_SCHEMA
|
|
71
|
+
at: string
|
|
72
|
+
strategyPath: string
|
|
73
|
+
sha256: string
|
|
74
|
+
costBps: number
|
|
75
|
+
slippageBps: number
|
|
76
|
+
holdoutStart: string
|
|
77
|
+
holdoutEnd: string
|
|
78
|
+
truncationClean: boolean
|
|
79
|
+
inSample: RangeStats
|
|
80
|
+
holdout: RangeStats
|
|
81
|
+
baselinesHoldout: Record<string, RangeStats>
|
|
82
|
+
bestBaselineHoldoutSharpe: number
|
|
83
|
+
holdoutExcessSharpe: number
|
|
84
|
+
certified: boolean
|
|
85
|
+
reasons: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function certifyOnHoldout(opts: {
|
|
89
|
+
strategyPath: string
|
|
90
|
+
outDir: string
|
|
91
|
+
costBps?: number
|
|
92
|
+
slippageBps?: number
|
|
93
|
+
force?: boolean
|
|
94
|
+
/** Test seam ONLY: alternate data directories so the certification code
|
|
95
|
+
* path can be exercised end-to-end WITHOUT reading the real holdout. The
|
|
96
|
+
* CLI never sets these — the real run always uses the vendored split. */
|
|
97
|
+
insampleDir?: string
|
|
98
|
+
holdoutDir?: string
|
|
99
|
+
/** Reuse an already-warm vectorbt worker (tests / batch certification).
|
|
100
|
+
* When omitted, one is spawned and closed inside this call. */
|
|
101
|
+
worker?: VbtWorker
|
|
102
|
+
}): Promise<CertificationRecord> {
|
|
103
|
+
const code = await readFile(opts.strategyPath, 'utf8')
|
|
104
|
+
const sha256 = `sha256:${createHash('sha256').update(code).digest('hex')}`
|
|
105
|
+
const certDir = join(opts.outDir, 'holdout-certification')
|
|
106
|
+
await mkdir(certDir, { recursive: true })
|
|
107
|
+
const recordPath = join(certDir, `certification-${sha256.slice(7, 17)}.json`)
|
|
108
|
+
if (existsSync(recordPath) && !opts.force) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`holdout-certify: ${recordPath} already exists — the holdout answers once per strategy. ` +
|
|
111
|
+
'Re-running it turns the holdout into another in-sample set. Use --force only if the prior run was broken.',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const insample = opts.insampleDir ? await loadBarsDir(opts.insampleDir) : await loadInSample()
|
|
116
|
+
const holdout = opts.holdoutDir ? await loadBarsDir(opts.holdoutDir) : await loadHoldout()
|
|
117
|
+
const { aligned, holdoutStartIndex } = concatForCertification(insample, holdout)
|
|
118
|
+
const btConfig: BacktestConfig = { costBps: opts.costBps ?? 10, slippageBps: opts.slippageBps ?? 5 }
|
|
119
|
+
const T = aligned.dates.length
|
|
120
|
+
|
|
121
|
+
// v2 (`onBar`) and v1 (`generateSignals`) modules both certify; v2 runs
|
|
122
|
+
// through the incremental driver with structurally truncated history.
|
|
123
|
+
const { generateSignals: strategy } = await loadStrategyFile(opts.strategyPath, {
|
|
124
|
+
symbols: aligned.tickers,
|
|
125
|
+
costBps: btConfig.costBps,
|
|
126
|
+
slippageBps: btConfig.slippageBps,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const truncation = truncationInvariance(strategy, aligned.bars, { warmupDays: 120 })
|
|
130
|
+
|
|
131
|
+
const worker = opts.worker ?? new VbtWorker()
|
|
132
|
+
let inSampleStats: RangeStats
|
|
133
|
+
let holdoutStats: RangeStats
|
|
134
|
+
const baselinesHoldout: Record<string, RangeStats> = {}
|
|
135
|
+
try {
|
|
136
|
+
const scored = await scoreOnRanges(worker, aligned.bars, strategy(aligned.bars), btConfig, holdoutStartIndex, T)
|
|
137
|
+
inSampleStats = scored.inSample
|
|
138
|
+
holdoutStats = scored.holdout
|
|
139
|
+
for (const [name, baseline] of Object.entries(PINNED_BASELINES)) {
|
|
140
|
+
const baseScored = await scoreOnRanges(worker, aligned.bars, baseline(aligned.bars), btConfig, holdoutStartIndex, T)
|
|
141
|
+
baselinesHoldout[name] = baseScored.holdout
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
if (opts.worker === undefined) await worker.close()
|
|
145
|
+
}
|
|
146
|
+
const bestBaselineHoldoutSharpe = Math.max(...Object.values(baselinesHoldout).map((s) => s.sharpe))
|
|
147
|
+
const holdoutExcessSharpe = holdoutStats.sharpe - bestBaselineHoldoutSharpe
|
|
148
|
+
|
|
149
|
+
const reasons: string[] = []
|
|
150
|
+
if (!truncation.clean) {
|
|
151
|
+
reasons.push(`leak: truncation divergence at cutoff ${truncation.divergence!.cutoff}: ${truncation.divergence!.detail}`)
|
|
152
|
+
}
|
|
153
|
+
if (holdoutExcessSharpe <= 0) {
|
|
154
|
+
reasons.push(
|
|
155
|
+
`no out-of-sample edge: holdout Sharpe ${holdoutStats.sharpe.toFixed(3)} <= best baseline ${bestBaselineHoldoutSharpe.toFixed(3)}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
const certified = reasons.length === 0
|
|
159
|
+
if (certified) {
|
|
160
|
+
reasons.push(
|
|
161
|
+
`certified: holdout Sharpe ${holdoutStats.sharpe.toFixed(3)} beats best baseline ` +
|
|
162
|
+
`${bestBaselineHoldoutSharpe.toFixed(3)} on ${holdoutStats.days} untouched days`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const record: CertificationRecord = {
|
|
167
|
+
schema: CERTIFICATION_SCHEMA,
|
|
168
|
+
at: new Date().toISOString(),
|
|
169
|
+
strategyPath: opts.strategyPath,
|
|
170
|
+
sha256,
|
|
171
|
+
costBps: btConfig.costBps,
|
|
172
|
+
slippageBps: btConfig.slippageBps,
|
|
173
|
+
holdoutStart: aligned.dates[holdoutStartIndex]!,
|
|
174
|
+
holdoutEnd: aligned.dates[T - 1]!,
|
|
175
|
+
truncationClean: truncation.clean,
|
|
176
|
+
inSample: inSampleStats,
|
|
177
|
+
holdout: holdoutStats,
|
|
178
|
+
baselinesHoldout,
|
|
179
|
+
bestBaselineHoldoutSharpe,
|
|
180
|
+
holdoutExcessSharpe,
|
|
181
|
+
certified,
|
|
182
|
+
reasons,
|
|
183
|
+
}
|
|
184
|
+
await writeFile(recordPath, JSON.stringify(record, null, 2))
|
|
185
|
+
await appendFile(join(opts.outDir, 'notebook.jsonl'), JSON.stringify(record) + '\n')
|
|
186
|
+
return record
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
190
|
+
|
|
191
|
+
if (isMain) {
|
|
192
|
+
const argv = process.argv.slice(2)
|
|
193
|
+
const flag = (name: string): string | undefined => {
|
|
194
|
+
const i = argv.indexOf(name)
|
|
195
|
+
return i !== -1 ? argv[i + 1] : undefined
|
|
196
|
+
}
|
|
197
|
+
const strategyPath = flag('--strategy')
|
|
198
|
+
const outDir = flag('--out')
|
|
199
|
+
if (!strategyPath || !outDir) {
|
|
200
|
+
console.error('usage: tsx src/quant-arena/holdout-certify.mts --strategy <strategy.ts> --out <campaign dir> [--force]')
|
|
201
|
+
process.exit(2)
|
|
202
|
+
}
|
|
203
|
+
const record = await certifyOnHoldout({ strategyPath, outDir, force: argv.includes('--force') })
|
|
204
|
+
console.log(JSON.stringify({ certified: record.certified, reasons: record.reasons, holdout: record.holdout, inSample: record.inSample }, null, 2))
|
|
205
|
+
process.exitCode = record.certified ? 0 : 1
|
|
206
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
6
|
+
import { certifyOnHoldout } from './holdout-certify.mts'
|
|
7
|
+
import { VbtWorker } from './vbt-client.ts'
|
|
8
|
+
|
|
9
|
+
/** Synthetic Stooq-format CSVs on real calendar dates — the certification
|
|
10
|
+
* path exercised end-to-end WITHOUT ever reading the real vendored holdout. */
|
|
11
|
+
const isoDate = (dayOffset: number): string => {
|
|
12
|
+
const d = new Date(Date.UTC(2020, 0, 1))
|
|
13
|
+
d.setUTCDate(d.getUTCDate() + dayOffset)
|
|
14
|
+
return d.toISOString().slice(0, 10)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const csvFor = (phase: number, from: number, to: number): string => {
|
|
18
|
+
const lines = ['Date,Open,High,Low,Close,Volume']
|
|
19
|
+
for (let t = from; t < to; t++) {
|
|
20
|
+
const close = 100 + 10 * Math.sin(t / 9 + phase) + 0.03 * t
|
|
21
|
+
const open = 100 + 10 * Math.sin((t - 0.5) / 9 + phase) + 0.03 * t
|
|
22
|
+
lines.push(
|
|
23
|
+
`${isoDate(t)},${open.toFixed(4)},${(Math.max(open, close) + 1).toFixed(4)},${(Math.min(open, close) - 1).toFixed(4)},${close.toFixed(4)},1000`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n') + '\n'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BUY_HOLD_PATH = fileURLToPath(new URL('./strategies/buy-hold-index/strategy.ts', import.meta.url))
|
|
30
|
+
|
|
31
|
+
// Certification scores through the official vectorbt worker; without `uv`
|
|
32
|
+
// (the pinned env launcher) this suite skips with that reason.
|
|
33
|
+
describe.skipIf(!VbtWorker.isAvailable())('certifyOnHoldout (synthetic stand-in dirs — the real holdout stays untouched)', () => {
|
|
34
|
+
let outDir: string
|
|
35
|
+
let insampleDir: string
|
|
36
|
+
let holdoutDir: string
|
|
37
|
+
let worker: VbtWorker
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
await worker?.close()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
worker = new VbtWorker()
|
|
45
|
+
await worker.ping() // absorb cold numba JIT before test timeouts apply
|
|
46
|
+
const root = await mkdtemp(join(tmpdir(), 'quant-arena-cert-'))
|
|
47
|
+
outDir = join(root, 'out')
|
|
48
|
+
insampleDir = join(root, 'insample')
|
|
49
|
+
holdoutDir = join(root, 'holdout')
|
|
50
|
+
await mkdir(outDir, { recursive: true })
|
|
51
|
+
await mkdir(insampleDir, { recursive: true })
|
|
52
|
+
await mkdir(holdoutDir, { recursive: true })
|
|
53
|
+
for (const [ticker, phase] of [['IDX', 0], ['S01', 2]] as const) {
|
|
54
|
+
await writeFile(join(insampleDir, `${ticker}.csv`), csvFor(phase, 0, 300))
|
|
55
|
+
await writeFile(join(holdoutDir, `${ticker}.csv`), csvFor(phase, 300, 400))
|
|
56
|
+
}
|
|
57
|
+
}, 240_000)
|
|
58
|
+
|
|
59
|
+
it('scores in-sample and holdout side by side and appends a notebook row', { timeout: 120_000 }, async () => {
|
|
60
|
+
const record = await certifyOnHoldout({ strategyPath: BUY_HOLD_PATH, outDir, insampleDir, holdoutDir, worker })
|
|
61
|
+
expect(record.truncationClean).toBe(true)
|
|
62
|
+
expect(record.inSample.days).toBe(300)
|
|
63
|
+
expect(record.holdout.days).toBe(100)
|
|
64
|
+
expect(record.holdoutStart).toBe(isoDate(300))
|
|
65
|
+
// Buy-and-hold IS one of the pinned baselines, so its excess over the
|
|
66
|
+
// best baseline can never be positive: deterministically not certified.
|
|
67
|
+
expect(record.holdoutExcessSharpe).toBeLessThanOrEqual(0)
|
|
68
|
+
expect(record.certified).toBe(false)
|
|
69
|
+
expect(record.reasons[0]).toMatch(/no out-of-sample edge/)
|
|
70
|
+
const notebook = await readFile(join(outDir, 'notebook.jsonl'), 'utf8')
|
|
71
|
+
const rows = notebook.trim().split('\n').map((l) => JSON.parse(l) as { schema: string })
|
|
72
|
+
expect(rows.some((r) => r.schema === 'quant-arena.certification.v1')).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('refuses a second certification for the same strategy hash without --force', { timeout: 120_000 }, async () => {
|
|
76
|
+
await expect(certifyOnHoldout({ strategyPath: BUY_HOLD_PATH, outDir, insampleDir, holdoutDir, worker })).rejects.toThrow(
|
|
77
|
+
/answers once/,
|
|
78
|
+
)
|
|
79
|
+
const forced = await certifyOnHoldout({ strategyPath: BUY_HOLD_PATH, outDir, insampleDir, holdoutDir, worker, force: true })
|
|
80
|
+
expect(forced.schema).toBe('quant-arena.certification.v1')
|
|
81
|
+
})
|
|
82
|
+
})
|