@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.
- package/CHANGELOG.md +11 -0
- package/HARNESS.md +43 -0
- package/dist/adapters.js +24 -24
- 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/humaneval.d.ts +10 -1
- package/dist/benchmarks/humaneval.js +5 -3
- 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-PPYSEKFM.js → chunk-5H5XV76F.js} +73 -15
- package/dist/chunk-5H5XV76F.js.map +1 -0
- 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-5SBJCB6W.js → chunk-V7AEBY6U.js} +22 -22
- 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 +24 -24
- package/package.json +6 -5
- package/scripts/run-package-tests.mjs +30 -8
- package/scripts/verify-packed-consumer.mjs +1 -1
- package/scripts/verify-pier-agent.mts +1 -0
- package/src/benchmarks/_harness.ts +20 -2
- package/src/benchmarks/humaneval.test.mts +122 -0
- package/src/benchmarks/humaneval.ts +100 -27
- package/src/david-attribution.mts +78 -0
- package/src/david-goliath.mts +149 -0
- package/src/hev-improve.mts +25 -6
- package/src/humaneval-object-ablation.mts +201 -0
- package/src/live-improve-campaign-mbpp.mts +641 -0
- package/src/live-improve-campaign.mts +500 -0
- package/src/mbpp-structural.mts +12 -7
- 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 +338 -0
- package/src/rollout-ledger/settle-capture.mts +442 -0
- package/src/rollout-ledger/settle-capture.test.mts +270 -0
- package/src/stream-observe.py +45 -0
- package/src/stream-observe.tpl.html +247 -0
- package/src/supervisor-arena.mts +816 -0
- package/src/swe-arena/activation.mts +228 -0
- package/src/swe-arena/activation.test.mts +303 -0
- package/src/swe-arena/analyze.ts +211 -0
- package/src/swe-arena/arms.ts +804 -0
- package/src/swe-arena/bootstrap-meta.mts +188 -0
- package/src/swe-arena/bootstrap-meta.test.mts +51 -0
- package/src/swe-arena/briefing.mts +217 -0
- package/src/swe-arena/briefing.test.mts +178 -0
- package/src/swe-arena/calibrate.ts +217 -0
- package/src/swe-arena/capabilities.mts +76 -0
- package/src/swe-arena/capabilities.test.mts +57 -0
- package/src/swe-arena/capacity.ts +194 -0
- package/src/swe-arena/cell-evidence.mts +437 -0
- package/src/swe-arena/cell-evidence.test.mts +248 -0
- package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
- package/src/swe-arena/diagnosis-ensemble.ts +520 -0
- package/src/swe-arena/execution.test.mts +1170 -0
- 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/analyze.py +80 -0
- package/src/swe-arena/fixtures/excludes.txt +8 -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/gen1-salvage/README.md +45 -0
- package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
- package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
- package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
- package/src/swe-arena/fixtures/holdout.json +44 -0
- package/src/swe-arena/fixtures/instances.json +146 -0
- package/src/swe-arena/fixtures/ledger.jsonl +12 -0
- package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
- package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
- package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
- package/src/swe-arena/fixtures/rematch.jsonl +3 -0
- package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
- package/src/swe-arena/fixtures/rematch3.jsonl +3 -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/sup-journal-true.json +19 -0
- package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
- package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
- package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
- package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
- package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
- package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
- package/src/swe-arena/fixtures/worker-tokens.json +42 -0
- package/src/swe-arena/fixtures.ts +237 -0
- package/src/swe-arena/gepa-seat.mts +583 -0
- package/src/swe-arena/gepa-seat.test.mts +635 -0
- package/src/swe-arena/holdout-certify.mts +408 -0
- package/src/swe-arena/holdout-certify.test.mts +160 -0
- package/src/swe-arena/judge-child.mts +37 -0
- package/src/swe-arena/ledger-orphans.mts +77 -0
- package/src/swe-arena/ledger-orphans.test.mts +147 -0
- package/src/swe-arena/lineage-record.mts +164 -0
- package/src/swe-arena/lineage-record.test.mts +115 -0
- package/src/swe-arena/manifest.mts +293 -0
- package/src/swe-arena/manifest.test.mts +169 -0
- package/src/swe-arena/materialize.ts +142 -0
- package/src/swe-arena/outer-loop.mts +2795 -0
- package/src/swe-arena/outer-loop.test.mts +696 -0
- package/src/swe-arena/parity.test.mts +87 -0
- 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/proc.test.mts +174 -0
- package/src/swe-arena/proc.ts +260 -0
- package/src/swe-arena/profiles/default-author.profile.json +4 -0
- package/src/swe-arena/proposer-fanout.mts +770 -0
- package/src/swe-arena/proposer-fanout.test.mts +619 -0
- package/src/swe-arena/proposer-provenance.mts +177 -0
- package/src/swe-arena/proposer-provenance.test.mts +106 -0
- package/src/swe-arena/reconcile.ts +0 -0
- package/src/swe-arena/replay.mts +183 -0
- package/src/swe-arena/replay.test.mts +300 -0
- package/src/swe-arena/run-experiment.mts +727 -0
- package/src/swe-arena/run-report.mts +75 -0
- package/src/swe-arena/run-supervisor.mjs +297 -0
- package/src/swe-arena/run-supervisor.test.mts +500 -0
- package/src/swe-arena/score-split.mts +140 -0
- package/src/swe-arena/score-split.test.mts +123 -0
- package/src/swe-arena/serialized-judge.ts +414 -0
- package/src/swe-arena/types.ts +218 -0
- package/src/swe-code-improve.mts +328 -0
- package/src/swe-emit-patch.mts +104 -0
- package/src/swe-improve.mts +232 -0
- package/src/swe-jail.ts +2 -2
- package/src/swe-local-proof.mts +169 -0
- package/src/swe-repro-calibrate.mts +446 -0
- package/src/swe-stream.mts +1497 -0
- package/src/swe-structural.mts +245 -837
- package/dist/chunk-LRRD7NAG.js.map +0 -1
- package/dist/chunk-PPYSEKFM.js.map +0 -1
- /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-5SBJCB6W.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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# QUANT-ARENA — a self-improving trading-strategy lab
|
|
2
|
+
|
|
3
|
+
A small, fully auditable research loop: an AI strategy author writes candidate strategies, every candidate is screened for look-ahead bias, backtested walk-forward on overlapping in-sample windows against pinned benchmarks, and judged by an acceptance rule whose bar **rises with every strategy tried**.
|
|
4
|
+
Every attempt — accepted, rejected, or killed for leaking — becomes a permanent row in a lab notebook.
|
|
5
|
+
The final two years of data are a locked out-of-sample set that only a separate certification command may touch, once.
|
|
6
|
+
|
|
7
|
+
Everything is plain TypeScript you can read in an afternoon: the backtester is one file with zero dependencies, the acceptance math is one file, the data is committed CSV.
|
|
8
|
+
|
|
9
|
+
## 1. Quickstart
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# from bench/ (needs node >= 20, the `claude` CLI logged in, and `uv` on PATH
|
|
13
|
+
# — scoring runs in a pinned python environment, see "Two engines" below)
|
|
14
|
+
npx tsx src/quant-arena/quant-loop.mts --out /tmp/quant-demo --candidates 2
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That runs a full research campaign: 2 strategy authors x 2 candidates each — the committed capture of exactly this command cost $1.65 of model spend across 8 metered calls; backtests are free.
|
|
18
|
+
No API? `--skip-llm-audit` keeps everything but the adversarial code review; the mechanical look-ahead check still runs.
|
|
19
|
+
|
|
20
|
+
Run the unit tests (backtester hand-computed cases, look-ahead detection, acceptance math, window reproducibility):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx vitest run src/quant-arena
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 2. What happens when you run it
|
|
27
|
+
|
|
28
|
+
1. **Data loads.** ~8 years of daily bars for 11 tickers (an index `IDX` plus `S01`-`S10`) from `fixtures/data/insample/`. The final 2 years live in `fixtures/data/holdout/` and are **not** loaded — see step 8. The series are synthetic (regime-switching factor model, seeded, regenerable) because the free real-data source we checked licenses personal use only; `fixtures/data/PROVENANCE.md` has the details and how to drop in your own CSVs.
|
|
29
|
+
2. **Evaluation windows are drawn.** 8 overlapping 504-day (~2-year) blocks, block-bootstrap sampled from the in-sample years with a fixed seed — every candidate in the campaign is scored on the same windows, and reruns reproduce bit-identically.
|
|
30
|
+
3. **Benchmarks run.** Three pinned incumbents: buy-and-hold the index, equal-weight monthly rebalance, and a 20/100 moving-average crossover. Their per-window Sharpe ratios define the bar: "best benchmark" is the per-window maximum.
|
|
31
|
+
4. **Strategy authors write code.** Each author is a Claude call with a pinned identity (one plain, one with a quant-researcher system prompt). It gets the strategy contract, the universe summary, the benchmarks' per-window Sharpes, and the current acceptance bar — and must reply with one self-contained TypeScript module exporting `onBar(ctx)`: the harness calls it once per trading day with the bars **up to that day only** (the arrays are physically sliced, so reading the future is structurally impossible), plus the strategy's current holdings and equity, and it answers with target portfolio weights or "hold". Strategies never place orders — a single shared rebalancer (`oms.ts`) turns everyone's target weights into orders under the same sizing rule, LEAN-style `(targetWeight x equity - currentPosition) / price`, long-only. Model spend is metered into a durable cost log (`cost-ledger.jsonl`) with per-call receipts.
|
|
32
|
+
5. **Look-ahead screening, stage 1 (mechanical).** The candidate is re-run on data truncated at several cutoff days. Signals up to each cutoff must be bit-identical to the full-data run — any divergence proves the code read the future, and the candidate is killed with the divergence quoted.
|
|
33
|
+
6. **Look-ahead screening, stage 2 (adversarial).** A second, cheap model reads the source with one job: find look-ahead — indexing past `t`, whole-series statistics feeding per-day decisions, hardcoded dates that smell like memorization. Verdict is JSON; anything but a clean verdict kills the candidate, and an unparseable reply kills it too (the rule fails closed).
|
|
34
|
+
7. **Backtest and verdict.** Survivors are backtested over the whole in-sample period (next-day-open fills, 15 bps one-way costs, no shorting, no leverage) and scored per window. Two engines run: the one-file TypeScript reference engine first, as a fail-closed contract check, then the industry-standard **vectorbt** engine (a persistent python worker in a version-locked environment) produces the official numbers. A parity test suite holds the two engines to agreement on golden fixtures — exact on a no-trade book, within machine precision whenever the book holds cash, and within a documented 0.5% on fully-invested books (the engines differ only in whether fees may be financed by a slightly negative cash balance). The acceptance rule (section 3) decides. Accepted or not, the try is appended to `notebook.jsonl` with its window scores, audit evidence, code hash, and authoring cost.
|
|
35
|
+
8. **Certification, later and by hand.** When you believe a winner, run it once against the untouched final 2 years:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx tsx src/quant-arena/holdout-certify.mts --strategy <path>/strategy.ts --out /tmp/quant-demo
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It backtests in-sample + out-of-sample on one axis (so lookbacks are warm), scores only the out-of-sample days against the same three benchmarks, and appends the in-sample vs out-of-sample comparison to the notebook. A second run for the same strategy hash refuses without `--force` — an out-of-sample set answers once; re-rolling it until it agrees turns it into another in-sample set.
|
|
42
|
+
|
|
43
|
+
## 3. Why the acceptance rule is strict
|
|
44
|
+
|
|
45
|
+
If you test enough random strategies against the same data, the best one looks brilliant by luck alone.
|
|
46
|
+
Under the assumption of zero skill, the expected best Sharpe among N independent tries grows roughly like sqrt(2 ln N) — try 50 strategies and luck alone buys the winner a substantial edge.
|
|
47
|
+
So the bar a candidate must clear is not fixed: it is `0.10 + 0.15 * sqrt(2 ln N)` of mean excess Sharpe, where N counts **every** candidate ever tried in the campaign, including ones killed for leaking (this is a simplified, auditable version of the Deflated Sharpe Ratio of Bailey & Lopez de Prado, Journal of Portfolio Management, 2014).
|
|
48
|
+
A candidate must ALSO beat the best benchmark in at least 6 of the 8 windows, because one lucky two-year stretch should never carry a decision.
|
|
49
|
+
Every try is a permanent notebook row, so N can never be quietly reset — the price of another shot at the data is a higher bar for everyone after it.
|
|
50
|
+
|
|
51
|
+
## 4. Reading the notebook
|
|
52
|
+
|
|
53
|
+
`notebook.jsonl` is append-only JSON lines. Three row types:
|
|
54
|
+
|
|
55
|
+
- `quant-arena.baselines.v1` — the campaign header: seed, cost assumptions, the 8 windows with dates, and each benchmark's per-window Sharpe.
|
|
56
|
+
- `quant-arena.candidate.v1` — one per try. The fields that matter:
|
|
57
|
+
- `nTried` — this try's position in the campaign; sets its acceptance bar.
|
|
58
|
+
- `leakAudit.truncation` / `leakAudit.llm` — both screening verdicts with evidence.
|
|
59
|
+
- `eval.perWindow` — Sharpe vs best-benchmark Sharpe for each window, with dates.
|
|
60
|
+
- `verdict` — `accepted`, `rejected-no-edge` (failed the acceptance rule), `rejected-leak`, `rejected-contract` (didn't satisfy the module contract), or `rejected-error`.
|
|
61
|
+
- `reasons` — the decision spelled out, numbers included.
|
|
62
|
+
- `quant-arena.certification.v1` — the one-shot out-of-sample result, in-sample stats side by side.
|
|
63
|
+
|
|
64
|
+
A real excerpt from the committed demo campaign (`fixtures/demo-campaign/`): try #4 cleared both look-ahead screens, then lost to the benchmarks in 7 of 8 windows — and after four tries the bar it would have needed had already risen to 0.35 (condensed for width):
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
{
|
|
68
|
+
"candidateId": "cand-004-quant-researcher",
|
|
69
|
+
"proposer": "quant-researcher",
|
|
70
|
+
"nTried": 4,
|
|
71
|
+
"leakAudit": {
|
|
72
|
+
"truncation": {
|
|
73
|
+
"clean": true
|
|
74
|
+
},
|
|
75
|
+
"llm": {
|
|
76
|
+
"verdict": "clean"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"eval": {
|
|
80
|
+
"wins": 1,
|
|
81
|
+
"requiredWins": 6,
|
|
82
|
+
"meanExcessSharpe": -0.085,
|
|
83
|
+
"threshold": 0.35,
|
|
84
|
+
"perWindow": [
|
|
85
|
+
{
|
|
86
|
+
"startDate": "2017-04-06",
|
|
87
|
+
"endDate": "2019-03-12",
|
|
88
|
+
"sharpe": 0.94,
|
|
89
|
+
"bestBaselineSharpe": 1.21,
|
|
90
|
+
"excess": -0.27
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"startDate": "2018-04-18",
|
|
94
|
+
"endDate": "2020-03-23",
|
|
95
|
+
"sharpe": 0.56,
|
|
96
|
+
"bestBaselineSharpe": 0.61,
|
|
97
|
+
"excess": -0.05
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"startDate": "2018-08-06",
|
|
101
|
+
"endDate": "2020-07-09",
|
|
102
|
+
"sharpe": 0.2,
|
|
103
|
+
"bestBaselineSharpe": 0.29,
|
|
104
|
+
"excess": -0.08
|
|
105
|
+
},
|
|
106
|
+
"... 5 more windows"
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
"verdict": "rejected-no-edge",
|
|
110
|
+
"reasons": [
|
|
111
|
+
"consistency: beat the best baseline in only 1/8 windows (need 6)",
|
|
112
|
+
"multiplicity: mean excess Sharpe -0.085 < required 0.350 (bar after 4 tried candidates)"
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 5. Plugging in your own backtester and data
|
|
118
|
+
|
|
119
|
+
Scoring goes through one narrow seam: a worker process that takes `{open prices, close prices, target-weight rows, costs, windows}` as JSON lines on stdin and answers `{per-window total return / max drawdown / Sharpe / trade count, full equity curve}` on stdout — see the protocol comment at the top of `python/vbt-worker.py` and the client in `vbt-client.ts`.
|
|
120
|
+
The shipped worker is vectorbt (`Portfolio.from_orders`, target-percent sizing, shared cash, sells before buys), version-locked by `python/pyproject.toml` + `python/uv.lock`; to swap in your own engine, speak the same protocol and keep the fill model (decide at close, fill at next open, bps fees on traded dollars) or re-derive the parity fixtures in `vbt-parity.test.mts` for your model.
|
|
121
|
+
Point the data loader at your own Stooq-format CSVs (one file per ticker, `IDX.csv` as the benchmark asset, an `insample/` and a `holdout/` directory).
|
|
122
|
+
Keep the physical in-sample/out-of-sample split and the once-only certification rule — they are the point, not an implementation detail.
|
|
123
|
+
A third engine is planned but not built: event-driven certification of a winner's order stream through Nautilus Trader (`nautilus-certify.ts` is the named stub).
|
|
124
|
+
|
|
125
|
+
## Files
|
|
126
|
+
|
|
127
|
+
| file | what it is |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `types.ts` | the strategy contract (v2 `onBar` + the order types), including the no-look-ahead rule |
|
|
130
|
+
| `driver.ts` | the incremental harness: feeds `onBar` day by day with physically truncated history; wraps old batch strategies unchanged |
|
|
131
|
+
| `oms.ts` | the one shared rebalancer: target weights -> orders (strategies never place orders) |
|
|
132
|
+
| `backtest.ts` | the TypeScript reference engine: next-open fills, bps costs, no shorting — zero dependencies; contract prefilter |
|
|
133
|
+
| `vbt-client.ts` + `python/vbt-worker.py` | the official scorer: persistent vectorbt worker, pinned env (`python/uv.lock`), crash-safe request handling |
|
|
134
|
+
| `vbt-parity.test.mts` | the two engines held to agreement on golden fixtures (prints both curves on any disagreement) |
|
|
135
|
+
| `nautilus-certify.ts` | named stub for the planned event-driven certification engine (not implemented) |
|
|
136
|
+
| `windows.ts` | seeded block-bootstrap evaluation windows |
|
|
137
|
+
| `multiplicity.ts` | the rising acceptance bar (documented formula + citation) |
|
|
138
|
+
| `leak-audit.ts` | the mechanical truncation-invariance check |
|
|
139
|
+
| `quant-loop.mts` | the campaign: author -> screen -> backtest -> verdict -> notebook |
|
|
140
|
+
| `holdout-certify.mts` | the once-only out-of-sample certification |
|
|
141
|
+
| `strategies/` | the three pinned benchmarks |
|
|
142
|
+
| `fixtures/data/` | committed daily bars + provenance; `holdout/` is the locked final 2 years |
|
|
143
|
+
| `fixtures/demo-campaign/` | a real captured campaign against the v1 batch contract: notebook, authored strategies, cost receipts |
|
|
144
|
+
| `fixtures/demo-campaign-v2/` | a real captured campaign against the v2 `onBar` contract (1 candidate, honestly rejected: 0/8 windows) |
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { fillSchedule, runBacktest, statsForRange } from './backtest.ts'
|
|
3
|
+
import type { Bar } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/** Flat-price bar helper. */
|
|
6
|
+
const bar = (date: string, open: number, close = open): Bar => ({
|
|
7
|
+
date,
|
|
8
|
+
open,
|
|
9
|
+
high: Math.max(open, close),
|
|
10
|
+
low: Math.min(open, close),
|
|
11
|
+
close,
|
|
12
|
+
volume: 1000,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const dates = (n: number): string[] =>
|
|
16
|
+
Array.from({ length: n }, (_, i) => `2020-01-${String(i + 1).padStart(2, '0')}`)
|
|
17
|
+
|
|
18
|
+
const flatSeries = (n: number, price: number): Bar[] => dates(n).map((d) => bar(d, price))
|
|
19
|
+
|
|
20
|
+
const NO_COST = { costBps: 0, slippageBps: 0 }
|
|
21
|
+
|
|
22
|
+
describe('runBacktest hand-computed toy cases', () => {
|
|
23
|
+
it('all-cash (no signals) stays at equity 1 with zero trades', () => {
|
|
24
|
+
const result = runBacktest([flatSeries(5, 100)], [], NO_COST)
|
|
25
|
+
expect(result.equity).toEqual([1, 1, 1, 1, 1])
|
|
26
|
+
expect(result.stats.tradeCount).toBe(0)
|
|
27
|
+
expect(result.stats.totalReturn).toBe(0)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('full weight on a flat ticker loses exactly the round-trip-free fee', () => {
|
|
31
|
+
// 10bps cost + 5bps slippage on 1.0 traded dollars = 15bps once.
|
|
32
|
+
const result = runBacktest([flatSeries(5, 100)], [{ t: 0, weights: [1] }], { costBps: 10, slippageBps: 5 })
|
|
33
|
+
expect(result.stats.tradeCount).toBe(1)
|
|
34
|
+
expect(result.stats.totalReturn).toBeCloseTo(-0.0015, 12)
|
|
35
|
+
expect(result.equity[4]).toBeCloseTo(1 - 0.0015, 12)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('captures a known move: buy at open, ride to close', () => {
|
|
39
|
+
// Day1: open 100 -> close 110 with full weight decided at day0 close.
|
|
40
|
+
const series = [bar('2020-01-01', 100), bar('2020-01-02', 100, 110), bar('2020-01-03', 110)]
|
|
41
|
+
const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
|
|
42
|
+
expect(result.equity[1]).toBeCloseTo(1.1, 12)
|
|
43
|
+
expect(result.stats.totalReturn).toBeCloseTo(0.1, 12)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('fills at the NEXT open, not the signal-day close (no free look-ahead)', () => {
|
|
47
|
+
// Price gaps 100 -> 200 overnight after the signal. A same-close fill would
|
|
48
|
+
// double money on the gap; a next-open fill must NOT.
|
|
49
|
+
const series = [bar('2020-01-01', 100, 100), bar('2020-01-02', 200, 200), bar('2020-01-03', 300, 300)]
|
|
50
|
+
const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
|
|
51
|
+
// Bought at 200 (day2 open); close day2 = 200 -> equity 1; day3 300/200 = 1.5.
|
|
52
|
+
expect(result.equity[1]).toBeCloseTo(1, 12)
|
|
53
|
+
expect(result.equity[2]).toBeCloseTo(1.5, 12)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('max drawdown on a crafted path: peak 1.2 -> trough 0.9 = 25%', () => {
|
|
57
|
+
const series = [
|
|
58
|
+
bar('2020-01-01', 100),
|
|
59
|
+
bar('2020-01-02', 100, 120),
|
|
60
|
+
bar('2020-01-03', 120, 90),
|
|
61
|
+
bar('2020-01-04', 90, 100),
|
|
62
|
+
]
|
|
63
|
+
const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
|
|
64
|
+
expect(result.equity).toEqual([1, 1.2, 0.9, 1.0].map((v) => expect.closeTo(v, 12) as unknown as number))
|
|
65
|
+
expect(result.stats.maxDrawdown).toBeCloseTo((1.2 - 0.9) / 1.2, 12)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('splits weights across two tickers and holds cash for the rest', () => {
|
|
69
|
+
const a = [bar('2020-01-01', 100), bar('2020-01-02', 100, 110)]
|
|
70
|
+
const b = [bar('2020-01-01', 50), bar('2020-01-02', 50, 45)]
|
|
71
|
+
const result = runBacktest([a, b], [{ t: 0, weights: [0.5, 0.25] }], NO_COST)
|
|
72
|
+
// 0.5 * +10% + 0.25 * -10% + 0.25 cash = 1 + 0.05 - 0.025 = 1.025
|
|
73
|
+
expect(result.equity[1]).toBeCloseTo(1.025, 12)
|
|
74
|
+
expect(result.stats.tradeCount).toBe(2)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('is deterministic: identical inputs give identical equity paths', () => {
|
|
78
|
+
const series = [flatSeries(30, 100).map((b, i) => bar(b.date, 100 + i, 101 + i))]
|
|
79
|
+
const signals = [
|
|
80
|
+
{ t: 0, weights: [0.7] },
|
|
81
|
+
{ t: 10, weights: [0.2] },
|
|
82
|
+
{ t: 20, weights: [1] },
|
|
83
|
+
]
|
|
84
|
+
const r1 = runBacktest(series as Bar[][], signals, { costBps: 10, slippageBps: 5 })
|
|
85
|
+
const r2 = runBacktest(series as Bar[][], signals, { costBps: 10, slippageBps: 5 })
|
|
86
|
+
expect(r1.equity).toEqual(r2.equity)
|
|
87
|
+
expect(r1.stats).toEqual(r2.stats)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects shorting, leverage, and misaligned universes (fail-closed)', () => {
|
|
91
|
+
const series = [flatSeries(3, 100)]
|
|
92
|
+
expect(() => runBacktest(series, [{ t: 0, weights: [-0.1] }], NO_COST)).toThrow(/no shorting/)
|
|
93
|
+
expect(() => runBacktest(series, [{ t: 0, weights: [1.2] }], NO_COST)).toThrow(/no leverage/)
|
|
94
|
+
expect(() => runBacktest([flatSeries(3, 100), flatSeries(4, 50)], [], NO_COST)).toThrow(/unaligned/)
|
|
95
|
+
expect(() => runBacktest(series, [{ t: 5, weights: [1] }], NO_COST)).toThrow(/outside/)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('rebalances ONLY on emitted signals — positions drift in between', () => {
|
|
99
|
+
// One signal at t=0, then a price runup: the winning position is NOT
|
|
100
|
+
// trimmed back to its target weight on later days (no hidden churn).
|
|
101
|
+
const a = [bar('2020-01-01', 100), bar('2020-01-02', 100), bar('2020-01-03', 100, 200), bar('2020-01-04', 200)]
|
|
102
|
+
const result = runBacktest([a], [{ t: 0, weights: [0.5] }], NO_COST)
|
|
103
|
+
expect(result.stats.tradeCount).toBe(1)
|
|
104
|
+
// 0.5 in the ticker doubled -> equity 1.5, still only one fill ever.
|
|
105
|
+
expect(result.equity[3]).toBeCloseTo(1.5, 12)
|
|
106
|
+
const schedule = fillSchedule([{ t: 1, weights: [0.5] }], 1, 4)
|
|
107
|
+
expect(schedule.get(1)).toEqual([0.5])
|
|
108
|
+
expect(schedule.has(0)).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('statsForRange window slicing', () => {
|
|
113
|
+
it('scores only the window and carries positions in', () => {
|
|
114
|
+
// Flat first half, +10% single day in the second half.
|
|
115
|
+
const series = [
|
|
116
|
+
bar('2020-01-01', 100),
|
|
117
|
+
bar('2020-01-02', 100),
|
|
118
|
+
bar('2020-01-03', 100),
|
|
119
|
+
bar('2020-01-04', 100, 110),
|
|
120
|
+
bar('2020-01-05', 110),
|
|
121
|
+
]
|
|
122
|
+
const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
|
|
123
|
+
const firstHalf = statsForRange(result, 0, 3)
|
|
124
|
+
const secondHalf = statsForRange(result, 2, 5)
|
|
125
|
+
expect(firstHalf.totalReturn).toBeCloseTo(0, 12)
|
|
126
|
+
expect(secondHalf.totalReturn).toBeCloseTo(0.1, 12)
|
|
127
|
+
expect(secondHalf.days).toBe(3)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('rejects degenerate ranges', () => {
|
|
131
|
+
const result = runBacktest([flatSeries(5, 100)], [], NO_COST)
|
|
132
|
+
expect(() => statsForRange(result, 3, 3)).toThrow(/bad range/)
|
|
133
|
+
expect(() => statsForRange(result, 0, 99)).toThrow(/bad range/)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily-bar event-loop backtester. Zero dependencies beyond the Node stdlib
|
|
3
|
+
* (in fact: zero imports at all except the local types) so a reviewer can
|
|
4
|
+
* audit the whole execution model in one file.
|
|
5
|
+
*
|
|
6
|
+
* Execution model (conservative by construction):
|
|
7
|
+
* - A signal decided at the close of day t is FILLED at the open of day t+1.
|
|
8
|
+
* Nothing trades on the bar that produced the decision.
|
|
9
|
+
* - Every rebalance pays `costBps + slippageBps` (basis points, one-way) on
|
|
10
|
+
* the dollars traded, deducted from cash at the fill.
|
|
11
|
+
* - No shorting, no leverage: weights >= 0, sum <= 1. Violations THROW —
|
|
12
|
+
* a strategy that asks for leverage is a broken candidate, not a clamped one.
|
|
13
|
+
* - Cash earns 0. A fully-invested target therefore carries a small negative
|
|
14
|
+
* cash balance equal to accumulated fees (fees financed at 0% — this only
|
|
15
|
+
* ever understates performance, never flatters it).
|
|
16
|
+
*
|
|
17
|
+
* Determinism: pure function of (bars, signals, config). No clock, no RNG.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Bar, Signal } from './types.ts'
|
|
21
|
+
|
|
22
|
+
export interface BacktestConfig {
|
|
23
|
+
/** One-way transaction cost, basis points of traded dollars. */
|
|
24
|
+
costBps: number
|
|
25
|
+
/** One-way slippage, basis points of traded dollars. */
|
|
26
|
+
slippageBps: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const TRADING_DAYS_PER_YEAR = 252
|
|
30
|
+
|
|
31
|
+
export interface RangeStats {
|
|
32
|
+
/** First/last day index of the scored range (inclusive start, exclusive end). */
|
|
33
|
+
start: number
|
|
34
|
+
end: number
|
|
35
|
+
days: number
|
|
36
|
+
totalReturn: number
|
|
37
|
+
maxDrawdown: number
|
|
38
|
+
/** Annualized Sharpe of daily returns (rf = 0). 0 when volatility is 0. */
|
|
39
|
+
sharpe: number
|
|
40
|
+
/** Fills with |trade| > 1e-9 × equity, summed over the range. */
|
|
41
|
+
tradeCount: number
|
|
42
|
+
/** Σ traded dollars / equity at each rebalance (two-sided turnover). */
|
|
43
|
+
turnover: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BacktestResult {
|
|
47
|
+
dates: string[]
|
|
48
|
+
/** Equity at each day's close; equity[0] = 1 (all cash at the first close). */
|
|
49
|
+
equity: number[]
|
|
50
|
+
/** dailyReturns[i] = equity[i+1] / equity[i] - 1. */
|
|
51
|
+
dailyReturns: number[]
|
|
52
|
+
/** Per-day fill count / turnover fraction (index = fill day). */
|
|
53
|
+
fillsByDay: number[]
|
|
54
|
+
turnoverByDay: number[]
|
|
55
|
+
stats: RangeStats
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const WEIGHT_EPS = 1e-9
|
|
59
|
+
|
|
60
|
+
export function assertAligned(bars: Bar[][]): void {
|
|
61
|
+
if (bars.length === 0) throw new Error('backtest: empty universe')
|
|
62
|
+
const T = bars[0]!.length
|
|
63
|
+
if (T < 2) throw new Error('backtest: need at least 2 days of bars')
|
|
64
|
+
for (let k = 1; k < bars.length; k++) {
|
|
65
|
+
if (bars[k]!.length !== T) {
|
|
66
|
+
throw new Error(`backtest: ticker ${k} has ${bars[k]!.length} bars, ticker 0 has ${T} — unaligned`)
|
|
67
|
+
}
|
|
68
|
+
for (let t = 0; t < T; t++) {
|
|
69
|
+
if (bars[k]![t]!.date !== bars[0]![t]!.date) {
|
|
70
|
+
throw new Error(`backtest: date mismatch at t=${t}: ${bars[k]![t]!.date} vs ${bars[0]![t]!.date}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Validate one signal against the contract. Throws on violation. */
|
|
77
|
+
export function assertSignal(signal: Signal, tickers: number, totalDays: number): void {
|
|
78
|
+
if (!Number.isInteger(signal.t) || signal.t < 0 || signal.t >= totalDays) {
|
|
79
|
+
throw new Error(`backtest: signal.t=${signal.t} outside [0, ${totalDays})`)
|
|
80
|
+
}
|
|
81
|
+
if (signal.weights.length !== tickers) {
|
|
82
|
+
throw new Error(`backtest: signal at t=${signal.t} has ${signal.weights.length} weights, universe has ${tickers}`)
|
|
83
|
+
}
|
|
84
|
+
let sum = 0
|
|
85
|
+
for (const w of signal.weights) {
|
|
86
|
+
if (!Number.isFinite(w)) throw new Error(`backtest: non-finite weight at t=${signal.t}`)
|
|
87
|
+
if (w < -WEIGHT_EPS) throw new Error(`backtest: negative weight ${w} at t=${signal.t} — no shorting`)
|
|
88
|
+
sum += w
|
|
89
|
+
}
|
|
90
|
+
if (sum > 1 + 1e-6) throw new Error(`backtest: weights sum ${sum} > 1 at t=${signal.t} — no leverage`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Map of decision day -> target weights. A rebalance is executed ONLY at the
|
|
94
|
+
* open following an emitted signal; between signals, positions drift with
|
|
95
|
+
* prices (no silent daily re-targeting, no hidden fee drag). */
|
|
96
|
+
export function fillSchedule(signals: Signal[], tickers: number, totalDays: number): Map<number, number[]> {
|
|
97
|
+
const sorted = [...signals].sort((a, b) => a.t - b.t)
|
|
98
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
99
|
+
if (sorted[i]!.t === sorted[i - 1]!.t) {
|
|
100
|
+
throw new Error(`backtest: two signals share t=${sorted[i]!.t} — a strategy emits at most one target per day`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const schedule = new Map<number, number[]>()
|
|
104
|
+
for (const s of sorted) {
|
|
105
|
+
assertSignal(s, tickers, totalDays)
|
|
106
|
+
schedule.set(s.t, s.weights)
|
|
107
|
+
}
|
|
108
|
+
return schedule
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function runBacktest(bars: Bar[][], signals: Signal[], config: BacktestConfig): BacktestResult {
|
|
112
|
+
assertAligned(bars)
|
|
113
|
+
const N = bars.length
|
|
114
|
+
const T = bars[0]!.length
|
|
115
|
+
const fills = fillSchedule(signals, N, T)
|
|
116
|
+
const feeRate = (config.costBps + config.slippageBps) / 10_000
|
|
117
|
+
|
|
118
|
+
const equity: number[] = new Array(T).fill(0)
|
|
119
|
+
const fillsByDay: number[] = new Array(T).fill(0)
|
|
120
|
+
const turnoverByDay: number[] = new Array(T).fill(0)
|
|
121
|
+
let cash = 1
|
|
122
|
+
const pos: number[] = new Array(N).fill(0)
|
|
123
|
+
equity[0] = 1
|
|
124
|
+
|
|
125
|
+
for (let t = 1; t < T; t++) {
|
|
126
|
+
// Overnight: yesterday's close -> today's open.
|
|
127
|
+
for (let k = 0; k < N; k++) {
|
|
128
|
+
const prevClose = bars[k]![t - 1]!.close
|
|
129
|
+
if (prevClose <= 0) throw new Error(`backtest: nonpositive close for ticker ${k} at t=${t - 1}`)
|
|
130
|
+
pos[k] = pos[k]! * (bars[k]![t]!.open / prevClose)
|
|
131
|
+
}
|
|
132
|
+
// Fill the target decided at yesterday's close, at today's open.
|
|
133
|
+
const target = fills.get(t - 1)
|
|
134
|
+
if (target !== undefined) {
|
|
135
|
+
let equityOpen = cash
|
|
136
|
+
for (let k = 0; k < N; k++) equityOpen += pos[k]!
|
|
137
|
+
if (equityOpen <= 0) throw new Error(`backtest: equity wiped out at t=${t}`)
|
|
138
|
+
let traded = 0
|
|
139
|
+
let fills = 0
|
|
140
|
+
for (let k = 0; k < N; k++) {
|
|
141
|
+
const desired = target[k]! * equityOpen
|
|
142
|
+
const delta = desired - pos[k]!
|
|
143
|
+
traded += Math.abs(delta)
|
|
144
|
+
if (Math.abs(delta) > WEIGHT_EPS * equityOpen) fills++
|
|
145
|
+
cash -= delta
|
|
146
|
+
pos[k] = desired
|
|
147
|
+
}
|
|
148
|
+
const fee = traded * feeRate
|
|
149
|
+
cash -= fee
|
|
150
|
+
fillsByDay[t] = fills
|
|
151
|
+
turnoverByDay[t] = traded / equityOpen
|
|
152
|
+
}
|
|
153
|
+
// Intraday: today's open -> today's close.
|
|
154
|
+
for (let k = 0; k < N; k++) {
|
|
155
|
+
const open = bars[k]![t]!.open
|
|
156
|
+
if (open <= 0) throw new Error(`backtest: nonpositive open for ticker ${k} at t=${t}`)
|
|
157
|
+
pos[k] = pos[k]! * (bars[k]![t]!.close / open)
|
|
158
|
+
}
|
|
159
|
+
let eq = cash
|
|
160
|
+
for (let k = 0; k < N; k++) eq += pos[k]!
|
|
161
|
+
equity[t] = eq
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const dailyReturns: number[] = new Array(T - 1)
|
|
165
|
+
for (let i = 0; i < T - 1; i++) dailyReturns[i] = equity[i + 1]! / equity[i]! - 1
|
|
166
|
+
|
|
167
|
+
const result: BacktestResult = {
|
|
168
|
+
dates: bars[0]!.map((b) => b.date),
|
|
169
|
+
equity,
|
|
170
|
+
dailyReturns,
|
|
171
|
+
fillsByDay,
|
|
172
|
+
turnoverByDay,
|
|
173
|
+
stats: undefined as unknown as RangeStats,
|
|
174
|
+
}
|
|
175
|
+
result.stats = statsForRange(result, 0, T)
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Score a sub-range [start, end) of an existing backtest — the walk-forward
|
|
180
|
+
* window view. Positions carried into the window count; nothing after `end`
|
|
181
|
+
* leaks in. */
|
|
182
|
+
export function statsForRange(result: BacktestResult, start: number, end: number): RangeStats {
|
|
183
|
+
const T = result.equity.length
|
|
184
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > T || end - start < 2) {
|
|
185
|
+
throw new Error(`statsForRange: bad range [${start}, ${end}) over ${T} days`)
|
|
186
|
+
}
|
|
187
|
+
const eq = result.equity.slice(start, end)
|
|
188
|
+
const totalReturn = eq[eq.length - 1]! / eq[0]! - 1
|
|
189
|
+
let peak = eq[0]!
|
|
190
|
+
let maxDrawdown = 0
|
|
191
|
+
for (const e of eq) {
|
|
192
|
+
if (e > peak) peak = e
|
|
193
|
+
const dd = (peak - e) / peak
|
|
194
|
+
if (dd > maxDrawdown) maxDrawdown = dd
|
|
195
|
+
}
|
|
196
|
+
const returns = result.dailyReturns.slice(start, end - 1)
|
|
197
|
+
const n = returns.length
|
|
198
|
+
const mean = returns.reduce((s, r) => s + r, 0) / n
|
|
199
|
+
const variance = n > 1 ? returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (n - 1) : 0
|
|
200
|
+
const std = Math.sqrt(variance)
|
|
201
|
+
const sharpe = std > 0 ? (mean / std) * Math.sqrt(TRADING_DAYS_PER_YEAR) : 0
|
|
202
|
+
let tradeCount = 0
|
|
203
|
+
let turnover = 0
|
|
204
|
+
for (let t = start + 1; t < end; t++) {
|
|
205
|
+
tradeCount += result.fillsByDay[t]!
|
|
206
|
+
turnover += result.turnoverByDay[t]!
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
start,
|
|
210
|
+
end,
|
|
211
|
+
days: end - start,
|
|
212
|
+
totalReturn,
|
|
213
|
+
maxDrawdown,
|
|
214
|
+
sharpe,
|
|
215
|
+
tradeCount,
|
|
216
|
+
turnover,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readdir } from 'node:fs/promises'
|
|
3
|
+
import {
|
|
4
|
+
HOLDOUT_DIR,
|
|
5
|
+
HOLDOUT_START,
|
|
6
|
+
IN_SAMPLE_DIR,
|
|
7
|
+
INDEX_TICKER,
|
|
8
|
+
loadInSample,
|
|
9
|
+
parseStooqCsv,
|
|
10
|
+
} from './data.ts'
|
|
11
|
+
|
|
12
|
+
describe('parseStooqCsv', () => {
|
|
13
|
+
it('parses the vendored CSV format', () => {
|
|
14
|
+
const bars = parseStooqCsv('Date,Open,High,Low,Close,Volume\n2020-01-02,10,11,9,10.5,12345\n2020-01-03,10.5,12,10,11,999\n', 'test')
|
|
15
|
+
expect(bars).toHaveLength(2)
|
|
16
|
+
expect(bars[0]).toEqual({ date: '2020-01-02', open: 10, high: 11, low: 9, close: 10.5, volume: 12345 })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('fails loud on descending dates, bad prices, and foreign headers', () => {
|
|
20
|
+
expect(() => parseStooqCsv('Date,Open,High,Low,Close,Volume\n2020-01-03,10,11,9,10,1\n2020-01-02,10,11,9,10,1\n', 't')).toThrow(/ascending/)
|
|
21
|
+
expect(() => parseStooqCsv('Date,Open,High,Low,Close,Volume\n2020-01-02,0,11,9,10,1\n', 't')).toThrow(/nonpositive/)
|
|
22
|
+
expect(() => parseStooqCsv('Ticker,Per,Date\nX,D,2020\n', 't')).toThrow(/header/)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('vendored fixture integrity (in-sample side only — the holdout is not read here)', () => {
|
|
27
|
+
it('in-sample and holdout are physically separate directories with the same tickers', async () => {
|
|
28
|
+
const inFiles = (await readdir(IN_SAMPLE_DIR)).filter((f) => f.endsWith('.csv')).sort()
|
|
29
|
+
const outFiles = (await readdir(HOLDOUT_DIR)).filter((f) => f.endsWith('.csv')).sort()
|
|
30
|
+
expect(inFiles).toEqual(outFiles)
|
|
31
|
+
expect(inFiles).toContain(`${INDEX_TICKER}.csv`)
|
|
32
|
+
expect(inFiles.length).toBe(11)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('loadInSample aligns the universe, leads with the index, and never crosses into the holdout era', async () => {
|
|
36
|
+
const aligned = await loadInSample()
|
|
37
|
+
expect(aligned.tickers[0]).toBe(INDEX_TICKER)
|
|
38
|
+
expect(aligned.tickers).toHaveLength(11)
|
|
39
|
+
expect(aligned.bars).toHaveLength(11)
|
|
40
|
+
for (const series of aligned.bars) expect(series).toHaveLength(aligned.dates.length)
|
|
41
|
+
expect(aligned.dates.length).toBeGreaterThan(1800)
|
|
42
|
+
expect(aligned.dates[aligned.dates.length - 1]! < HOLDOUT_START).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
})
|