@tangle-network/agent-bench 0.3.6 → 0.3.8

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.
Files changed (265) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/HARNESS.md +43 -0
  3. package/dist/adapters.js +24 -24
  4. package/dist/benchmarks/_harness.d.ts +1 -1
  5. package/dist/benchmarks/_harness.js +1 -1
  6. package/dist/benchmarks/aec-bench.js +2 -2
  7. package/dist/benchmarks/agentbench.js +2 -2
  8. package/dist/benchmarks/appworld.js +2 -2
  9. package/dist/benchmarks/bfcl.js +2 -2
  10. package/dist/benchmarks/commit0.js +2 -2
  11. package/dist/benchmarks/crag.js +2 -2
  12. package/dist/benchmarks/dabstep.js +2 -2
  13. package/dist/benchmarks/enterpriseops-gym.js +2 -2
  14. package/dist/benchmarks/finresearchbench.js +2 -2
  15. package/dist/benchmarks/humaneval.d.ts +10 -1
  16. package/dist/benchmarks/humaneval.js +5 -3
  17. package/dist/benchmarks/nomiracl.js +2 -2
  18. package/dist/benchmarks/open-rag-bench.js +2 -2
  19. package/dist/benchmarks/programbench.js +2 -2
  20. package/dist/benchmarks/ragbench.js +2 -2
  21. package/dist/benchmarks/swe-bench.js +2 -2
  22. package/dist/benchmarks/t2-ragbench.js +2 -2
  23. package/dist/benchmarks/tau-bench-shared.js +2 -2
  24. package/dist/benchmarks/tau2-bench.js +3 -3
  25. package/dist/benchmarks/tau3-banking.js +3 -3
  26. package/dist/benchmarks/terminal-bench.js +2 -2
  27. package/dist/benchmarks/toollm.js +2 -2
  28. package/dist/benchmarks/webarena-verified.js +2 -2
  29. package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
  30. package/dist/{chunk-PPYSEKFM.js → chunk-5H5XV76F.js} +73 -15
  31. package/dist/chunk-5H5XV76F.js.map +1 -0
  32. package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
  33. package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
  34. package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
  35. package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
  36. package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
  37. package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
  38. package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
  39. package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
  40. package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
  41. package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
  42. package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
  43. package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
  44. package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
  45. package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
  46. package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
  47. package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
  48. package/dist/{chunk-5SBJCB6W.js → chunk-V7AEBY6U.js} +22 -22
  49. package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
  50. package/dist/chunk-WSKWVEQB.js.map +1 -0
  51. package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
  52. package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
  53. package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
  54. package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
  55. package/dist/index.js +24 -24
  56. package/package.json +6 -5
  57. package/scripts/run-package-tests.mjs +30 -8
  58. package/scripts/verify-packed-consumer.mjs +1 -1
  59. package/scripts/verify-pier-agent.mts +1 -0
  60. package/src/benchmarks/_harness.ts +20 -2
  61. package/src/benchmarks/humaneval.test.mts +122 -0
  62. package/src/benchmarks/humaneval.ts +100 -27
  63. package/src/david-attribution.mts +78 -0
  64. package/src/david-goliath.mts +149 -0
  65. package/src/hev-improve.mts +25 -6
  66. package/src/humaneval-object-ablation.mts +201 -0
  67. package/src/live-improve-campaign-mbpp.mts +641 -0
  68. package/src/live-improve-campaign.mts +500 -0
  69. package/src/mbpp-structural.mts +12 -7
  70. package/src/quant-arena/README.md +144 -0
  71. package/src/quant-arena/backtest.test.mts +135 -0
  72. package/src/quant-arena/backtest.ts +218 -0
  73. package/src/quant-arena/data.test.mts +44 -0
  74. package/src/quant-arena/data.ts +141 -0
  75. package/src/quant-arena/driver.test.mts +253 -0
  76. package/src/quant-arena/driver.ts +219 -0
  77. package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
  78. package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
  79. package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
  80. package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
  81. package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
  82. package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
  83. package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
  84. package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
  85. package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
  86. package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
  87. package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
  88. package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
  89. package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
  90. package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
  91. package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
  92. package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
  93. package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
  94. package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
  95. package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
  96. package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
  97. package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
  98. package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
  99. package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
  100. package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
  101. package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
  102. package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
  103. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
  104. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
  105. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
  106. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
  107. package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
  108. package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
  109. package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
  110. package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
  111. package/src/quant-arena/holdout-certify.mts +206 -0
  112. package/src/quant-arena/holdout-certify.test.mts +82 -0
  113. package/src/quant-arena/leak-audit.test.mts +79 -0
  114. package/src/quant-arena/leak-audit.ts +95 -0
  115. package/src/quant-arena/make-fixtures.mts +161 -0
  116. package/src/quant-arena/multiplicity.test.mts +68 -0
  117. package/src/quant-arena/multiplicity.ts +87 -0
  118. package/src/quant-arena/nautilus-certify.ts +31 -0
  119. package/src/quant-arena/oms.ts +90 -0
  120. package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
  121. package/src/quant-arena/python/pyproject.toml +8 -0
  122. package/src/quant-arena/python/uv.lock +1297 -0
  123. package/src/quant-arena/python/vbt-worker.py +192 -0
  124. package/src/quant-arena/quant-loop.mts +813 -0
  125. package/src/quant-arena/quant-loop.test.mts +75 -0
  126. package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
  127. package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
  128. package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
  129. package/src/quant-arena/types.ts +133 -0
  130. package/src/quant-arena/vbt-client.ts +321 -0
  131. package/src/quant-arena/vbt-parity.test.mts +183 -0
  132. package/src/quant-arena/windows.test.mts +45 -0
  133. package/src/quant-arena/windows.ts +54 -0
  134. package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
  135. package/src/rollout-ledger/backfill-swe-arena.test.mts +338 -0
  136. package/src/rollout-ledger/settle-capture.mts +442 -0
  137. package/src/rollout-ledger/settle-capture.test.mts +270 -0
  138. package/src/stream-observe.py +45 -0
  139. package/src/stream-observe.tpl.html +247 -0
  140. package/src/supervisor-arena.mts +816 -0
  141. package/src/swe-arena/activation.mts +228 -0
  142. package/src/swe-arena/activation.test.mts +303 -0
  143. package/src/swe-arena/analyze.ts +211 -0
  144. package/src/swe-arena/arms.ts +804 -0
  145. package/src/swe-arena/bootstrap-meta.mts +188 -0
  146. package/src/swe-arena/bootstrap-meta.test.mts +51 -0
  147. package/src/swe-arena/briefing.mts +217 -0
  148. package/src/swe-arena/briefing.test.mts +178 -0
  149. package/src/swe-arena/calibrate.ts +217 -0
  150. package/src/swe-arena/capabilities.mts +76 -0
  151. package/src/swe-arena/capabilities.test.mts +57 -0
  152. package/src/swe-arena/capacity.ts +194 -0
  153. package/src/swe-arena/cell-evidence.mts +437 -0
  154. package/src/swe-arena/cell-evidence.test.mts +248 -0
  155. package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
  156. package/src/swe-arena/diagnosis-ensemble.ts +520 -0
  157. package/src/swe-arena/execution.test.mts +1170 -0
  158. package/src/swe-arena/factory-command-container.ts +284 -0
  159. package/src/swe-arena/factory-judge-child.mts +228 -0
  160. package/src/swe-arena/factory.test.mts +643 -0
  161. package/src/swe-arena/fixtures/analyze.py +80 -0
  162. package/src/swe-arena/fixtures/excludes.txt +8 -0
  163. package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
  164. package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
  165. package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
  166. package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
  167. package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
  168. package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
  169. package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
  170. package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
  171. package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
  172. package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
  173. package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
  174. package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
  175. package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
  176. package/src/swe-arena/fixtures/holdout.json +44 -0
  177. package/src/swe-arena/fixtures/instances.json +146 -0
  178. package/src/swe-arena/fixtures/ledger.jsonl +12 -0
  179. package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
  180. package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
  181. package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
  182. package/src/swe-arena/fixtures/rematch.jsonl +3 -0
  183. package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
  184. package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
  185. package/src/swe-arena/fixtures/run-report/README.md +43 -0
  186. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
  187. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
  188. package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
  189. package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
  190. package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
  191. package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
  192. package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
  193. package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
  194. package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
  195. package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
  196. package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
  197. package/src/swe-arena/fixtures/worker-tokens.json +42 -0
  198. package/src/swe-arena/fixtures.ts +237 -0
  199. package/src/swe-arena/gepa-seat.mts +583 -0
  200. package/src/swe-arena/gepa-seat.test.mts +635 -0
  201. package/src/swe-arena/holdout-certify.mts +408 -0
  202. package/src/swe-arena/holdout-certify.test.mts +160 -0
  203. package/src/swe-arena/judge-child.mts +37 -0
  204. package/src/swe-arena/ledger-orphans.mts +77 -0
  205. package/src/swe-arena/ledger-orphans.test.mts +147 -0
  206. package/src/swe-arena/lineage-record.mts +164 -0
  207. package/src/swe-arena/lineage-record.test.mts +115 -0
  208. package/src/swe-arena/manifest.mts +293 -0
  209. package/src/swe-arena/manifest.test.mts +169 -0
  210. package/src/swe-arena/materialize.ts +142 -0
  211. package/src/swe-arena/outer-loop.mts +2795 -0
  212. package/src/swe-arena/outer-loop.test.mts +696 -0
  213. package/src/swe-arena/parity.test.mts +87 -0
  214. package/src/swe-arena/premeasured-from-cells.mts +281 -0
  215. package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
  216. package/src/swe-arena/proc.test.mts +174 -0
  217. package/src/swe-arena/proc.ts +260 -0
  218. package/src/swe-arena/profiles/default-author.profile.json +4 -0
  219. package/src/swe-arena/proposer-fanout.mts +770 -0
  220. package/src/swe-arena/proposer-fanout.test.mts +619 -0
  221. package/src/swe-arena/proposer-provenance.mts +177 -0
  222. package/src/swe-arena/proposer-provenance.test.mts +106 -0
  223. package/src/swe-arena/reconcile.ts +0 -0
  224. package/src/swe-arena/replay.mts +183 -0
  225. package/src/swe-arena/replay.test.mts +300 -0
  226. package/src/swe-arena/run-experiment.mts +727 -0
  227. package/src/swe-arena/run-report.mts +75 -0
  228. package/src/swe-arena/run-supervisor.mjs +297 -0
  229. package/src/swe-arena/run-supervisor.test.mts +500 -0
  230. package/src/swe-arena/score-split.mts +140 -0
  231. package/src/swe-arena/score-split.test.mts +123 -0
  232. package/src/swe-arena/serialized-judge.ts +414 -0
  233. package/src/swe-arena/types.ts +218 -0
  234. package/src/swe-code-improve.mts +328 -0
  235. package/src/swe-emit-patch.mts +104 -0
  236. package/src/swe-improve.mts +232 -0
  237. package/src/swe-jail.ts +2 -2
  238. package/src/swe-local-proof.mts +169 -0
  239. package/src/swe-repro-calibrate.mts +446 -0
  240. package/src/swe-stream.mts +1497 -0
  241. package/src/swe-structural.mts +245 -837
  242. package/dist/chunk-LRRD7NAG.js.map +0 -1
  243. package/dist/chunk-PPYSEKFM.js.map +0 -1
  244. /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
  245. /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
  246. /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
  247. /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
  248. /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
  249. /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
  250. /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
  251. /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
  252. /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
  253. /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
  254. /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
  255. /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
  256. /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
  257. /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
  258. /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
  259. /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
  260. /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
  261. /package/dist/{chunk-5SBJCB6W.js.map → chunk-V7AEBY6U.js.map} +0 -0
  262. /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
  263. /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
  264. /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
  265. /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
@@ -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
+ }
@@ -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
+ })