@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored daily-bar data: Stooq-format CSVs (Date,Open,High,Low,Close,Volume)
|
|
3
|
+
* committed under fixtures/data/. Two physically separate directories:
|
|
4
|
+
*
|
|
5
|
+
* fixtures/data/insample/ — everything the campaign loop may read.
|
|
6
|
+
* fixtures/data/holdout/ — the FINAL 2 years. Loaded ONLY by the
|
|
7
|
+
* certification path (holdout-certify.mts).
|
|
8
|
+
* quant-loop.mts never imports `loadHoldout`.
|
|
9
|
+
*
|
|
10
|
+
* See fixtures/data/PROVENANCE.md for where the series come from.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
14
|
+
import { basename, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import type { Bar } from './types.ts'
|
|
17
|
+
|
|
18
|
+
export const DATA_DIR = fileURLToPath(new URL('./fixtures/data', import.meta.url))
|
|
19
|
+
export const IN_SAMPLE_DIR = join(DATA_DIR, 'insample')
|
|
20
|
+
export const HOLDOUT_DIR = join(DATA_DIR, 'holdout')
|
|
21
|
+
/** First holdout date — insample bars must all be strictly before this. */
|
|
22
|
+
export const HOLDOUT_START = '2024-07-01'
|
|
23
|
+
/** The benchmark index ticker; always bars[0] in the aligned universe. */
|
|
24
|
+
export const INDEX_TICKER = 'IDX'
|
|
25
|
+
|
|
26
|
+
export interface AlignedBars {
|
|
27
|
+
/** tickers[0] === INDEX_TICKER; the rest sorted alphabetically. */
|
|
28
|
+
tickers: string[]
|
|
29
|
+
dates: string[]
|
|
30
|
+
/** bars[k] belongs to tickers[k]; every series shares the date axis. */
|
|
31
|
+
bars: Bar[][]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseStooqCsv(text: string, source: string): Bar[] {
|
|
35
|
+
const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
|
36
|
+
if (lines.length < 2) throw new Error(`${source}: empty CSV`)
|
|
37
|
+
const header = lines[0]!.toLowerCase()
|
|
38
|
+
if (!header.startsWith('date,open,high,low,close')) {
|
|
39
|
+
throw new Error(`${source}: unexpected header '${lines[0]}'`)
|
|
40
|
+
}
|
|
41
|
+
const bars: Bar[] = []
|
|
42
|
+
for (const line of lines.slice(1)) {
|
|
43
|
+
const cells = line.split(',')
|
|
44
|
+
if (cells.length < 5) throw new Error(`${source}: bad row '${line}'`)
|
|
45
|
+
const [date, open, high, low, close, volume] = cells
|
|
46
|
+
const bar: Bar = {
|
|
47
|
+
date: date!,
|
|
48
|
+
open: Number(open),
|
|
49
|
+
high: Number(high),
|
|
50
|
+
low: Number(low),
|
|
51
|
+
close: Number(close),
|
|
52
|
+
volume: volume !== undefined ? Number(volume) : 0,
|
|
53
|
+
}
|
|
54
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(bar.date)) throw new Error(`${source}: bad date '${bar.date}'`)
|
|
55
|
+
for (const v of [bar.open, bar.high, bar.low, bar.close]) {
|
|
56
|
+
if (!Number.isFinite(v) || v <= 0) throw new Error(`${source}: nonpositive price on ${bar.date}`)
|
|
57
|
+
}
|
|
58
|
+
bars.push(bar)
|
|
59
|
+
}
|
|
60
|
+
for (let i = 1; i < bars.length; i++) {
|
|
61
|
+
if (bars[i]!.date <= bars[i - 1]!.date) {
|
|
62
|
+
throw new Error(`${source}: dates not strictly ascending at ${bars[i]!.date}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return bars
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Load every `<TICKER>.csv` in a directory and align on the intersection of
|
|
69
|
+
* dates. The index ticker leads; the rest follow alphabetically. */
|
|
70
|
+
export async function loadBarsDir(dir: string): Promise<AlignedBars> {
|
|
71
|
+
const files = (await readdir(dir)).filter((f) => f.endsWith('.csv')).sort()
|
|
72
|
+
if (files.length === 0) throw new Error(`loadBarsDir: no CSVs in ${dir}`)
|
|
73
|
+
const byTicker = new Map<string, Bar[]>()
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const ticker = basename(file, '.csv')
|
|
76
|
+
byTicker.set(ticker, parseStooqCsv(await readFile(join(dir, file), 'utf8'), join(dir, file)))
|
|
77
|
+
}
|
|
78
|
+
if (!byTicker.has(INDEX_TICKER)) {
|
|
79
|
+
throw new Error(`loadBarsDir: ${dir} has no ${INDEX_TICKER}.csv — the universe needs its benchmark index`)
|
|
80
|
+
}
|
|
81
|
+
const tickers = [INDEX_TICKER, ...[...byTicker.keys()].filter((t) => t !== INDEX_TICKER).sort()]
|
|
82
|
+
let shared: Set<string> | null = null
|
|
83
|
+
for (const ticker of tickers) {
|
|
84
|
+
const tickerDates = new Set(byTicker.get(ticker)!.map((b) => b.date))
|
|
85
|
+
if (shared === null) {
|
|
86
|
+
shared = tickerDates
|
|
87
|
+
} else {
|
|
88
|
+
const carried: Set<string> = shared
|
|
89
|
+
shared = new Set([...carried].filter((d) => tickerDates.has(d)))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const dates = [...shared!].sort()
|
|
93
|
+
if (dates.length < 2) throw new Error(`loadBarsDir: fewer than 2 shared dates across ${dir}`)
|
|
94
|
+
const bars = tickers.map((ticker) => {
|
|
95
|
+
const wanted = new Set(dates)
|
|
96
|
+
return byTicker.get(ticker)!.filter((b) => wanted.has(b.date))
|
|
97
|
+
})
|
|
98
|
+
return { tickers, dates, bars }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The campaign loop's data. Fails loud if any bar strays into the holdout era. */
|
|
102
|
+
export async function loadInSample(): Promise<AlignedBars> {
|
|
103
|
+
const aligned = await loadBarsDir(IN_SAMPLE_DIR)
|
|
104
|
+
const last = aligned.dates[aligned.dates.length - 1]!
|
|
105
|
+
if (last >= HOLDOUT_START) {
|
|
106
|
+
throw new Error(`loadInSample: in-sample data reaches ${last}, at/past the holdout start ${HOLDOUT_START}`)
|
|
107
|
+
}
|
|
108
|
+
return aligned
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** CERTIFICATION PATH ONLY (holdout-certify.mts). The final 2 years. */
|
|
112
|
+
export async function loadHoldout(): Promise<AlignedBars> {
|
|
113
|
+
const aligned = await loadBarsDir(HOLDOUT_DIR)
|
|
114
|
+
const first = aligned.dates[0]!
|
|
115
|
+
if (first < HOLDOUT_START) {
|
|
116
|
+
throw new Error(`loadHoldout: holdout data starts ${first}, before the holdout start ${HOLDOUT_START}`)
|
|
117
|
+
}
|
|
118
|
+
return aligned
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** In-sample followed by holdout on one axis — what the certification run
|
|
122
|
+
* backtests so lookback indicators are warm when the holdout period begins.
|
|
123
|
+
* Returns the concatenated universe plus the index of the first holdout day. */
|
|
124
|
+
export function concatForCertification(insample: AlignedBars, holdout: AlignedBars): { aligned: AlignedBars; holdoutStartIndex: number } {
|
|
125
|
+
if (insample.tickers.join(',') !== holdout.tickers.join(',')) {
|
|
126
|
+
throw new Error('concatForCertification: in-sample and holdout universes differ')
|
|
127
|
+
}
|
|
128
|
+
const lastIn = insample.dates[insample.dates.length - 1]!
|
|
129
|
+
const firstOut = holdout.dates[0]!
|
|
130
|
+
if (firstOut <= lastIn) {
|
|
131
|
+
throw new Error(`concatForCertification: holdout starts ${firstOut}, not after in-sample end ${lastIn}`)
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
aligned: {
|
|
135
|
+
tickers: insample.tickers,
|
|
136
|
+
dates: [...insample.dates, ...holdout.dates],
|
|
137
|
+
bars: insample.bars.map((series, k) => [...series, ...holdout.bars[k]!]),
|
|
138
|
+
},
|
|
139
|
+
holdoutStartIndex: insample.dates.length,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { runBacktest } from './backtest.ts'
|
|
3
|
+
import { generateSignalsFromStrategy, runIncremental, strategyFromGenerateSignals } from './driver.ts'
|
|
4
|
+
import { truncationInvariance } from './leak-audit.ts'
|
|
5
|
+
import { rebalance, targetsToWeights } from './oms.ts'
|
|
6
|
+
import { generateSignals as buyHold } from './strategies/buy-hold-index/strategy.ts'
|
|
7
|
+
import { generateSignals as equalWeight } from './strategies/equal-weight/strategy.ts'
|
|
8
|
+
import { generateSignals as smaCrossover } from './strategies/sma-crossover/strategy.ts'
|
|
9
|
+
import type { Bar, Signal, Strategy, StrategyContext } from './types.ts'
|
|
10
|
+
|
|
11
|
+
/** Deterministic wiggly price path (no RNG — reproducible). */
|
|
12
|
+
function syntheticBars(n: number, tickers: number): Bar[][] {
|
|
13
|
+
return Array.from({ length: tickers }, (_, k) =>
|
|
14
|
+
Array.from({ length: n }, (_, t) => {
|
|
15
|
+
const close = 100 + 10 * Math.sin(t / 7 + k) + 0.05 * t
|
|
16
|
+
const open = 100 + 10 * Math.sin((t - 0.5) / 7 + k) + 0.05 * t
|
|
17
|
+
return {
|
|
18
|
+
date: `d${String(t).padStart(4, '0')}`,
|
|
19
|
+
open,
|
|
20
|
+
high: Math.max(open, close) + 1,
|
|
21
|
+
low: Math.min(open, close) - 1,
|
|
22
|
+
close,
|
|
23
|
+
volume: 1000,
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const symbolsFor = (bars: Bar[][]): string[] => bars.map((_, k) => (k === 0 ? 'IDX' : `T${k}`))
|
|
30
|
+
const config = (bars: Bar[][]) => ({ costBps: 10, slippageBps: 5, symbols: symbolsFor(bars) })
|
|
31
|
+
|
|
32
|
+
describe('runIncremental — structural history visibility', () => {
|
|
33
|
+
const bars = syntheticBars(120, 3)
|
|
34
|
+
|
|
35
|
+
it('every onBar call sees exactly bars[0..t] and nothing beyond', () => {
|
|
36
|
+
let calls = 0
|
|
37
|
+
const spy: Strategy = {
|
|
38
|
+
onBar(ctx: StrategyContext) {
|
|
39
|
+
calls++
|
|
40
|
+
for (let k = 0; k < ctx.history.length; k++) {
|
|
41
|
+
expect(ctx.history[k]!.length).toBe(ctx.t + 1)
|
|
42
|
+
// The future is ABSENT, not merely guarded.
|
|
43
|
+
expect(ctx.history[k]![ctx.t + 1]).toBeUndefined()
|
|
44
|
+
expect(ctx.history[k]![ctx.t]!.date).toBe(bars[k]![ctx.t]!.date)
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
runIncremental(bars, spy, config(bars))
|
|
50
|
+
expect(calls).toBe(120)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('driver equity path is bit-identical to runBacktest on the collected decisions', () => {
|
|
54
|
+
const strategy = strategyFromGenerateSignals(smaCrossover)
|
|
55
|
+
const run = runIncremental(bars, strategy, config(bars))
|
|
56
|
+
const reference = runBacktest(bars, run.decisions, { costBps: 10, slippageBps: 5 })
|
|
57
|
+
expect(run.equityByDay.length).toBe(reference.equity.length)
|
|
58
|
+
for (let t = 0; t < run.equityByDay.length; t++) {
|
|
59
|
+
expect(run.equityByDay[t]).toBe(reference.equity[t])
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('ctx.weights and ctx.equity reflect the drifted portfolio', () => {
|
|
64
|
+
const seen: Array<{ t: number; equity: number; weightSum: number }> = []
|
|
65
|
+
const holdIndex: Strategy = {
|
|
66
|
+
onBar(ctx) {
|
|
67
|
+
seen.push({ t: ctx.t, equity: ctx.equity, weightSum: ctx.weights.reduce((s, w) => s + w, 0) })
|
|
68
|
+
return ctx.t === 0 ? [{ symbol: 'IDX', weight: 1 }] : null
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
const run = runIncremental(bars, holdIndex, config(bars))
|
|
72
|
+
expect(seen[0]!.equity).toBe(1)
|
|
73
|
+
expect(seen[0]!.weightSum).toBe(0) // all cash before the first fill
|
|
74
|
+
// After the t=0 decision fills at t=1's open, the book is ~fully invested.
|
|
75
|
+
expect(seen[1]!.weightSum).toBeGreaterThan(0.99)
|
|
76
|
+
expect(run.decisions).toEqual([{ t: 0, weights: [1, 0, 0] }])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('a decision on the final bar is recorded but produces no fill', () => {
|
|
80
|
+
const lastBarOnly: Strategy = {
|
|
81
|
+
onBar(ctx) {
|
|
82
|
+
return ctx.t === bars[0]!.length - 1 ? [{ symbol: 'IDX', weight: 1 }] : null
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
const run = runIncremental(bars, lastBarOnly, config(bars))
|
|
86
|
+
expect(run.decisions.length).toBe(1)
|
|
87
|
+
expect(run.equityByDay.every((e) => e === 1)).toBe(true) // never traded
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('fail-closed on contract violations from onBar', () => {
|
|
91
|
+
const short: Strategy = { onBar: () => [{ symbol: 'IDX', weight: -0.2 }] }
|
|
92
|
+
expect(() => runIncremental(bars, short, config(bars))).toThrow(/long-only|negative/)
|
|
93
|
+
const levered: Strategy = {
|
|
94
|
+
onBar: () => [
|
|
95
|
+
{ symbol: 'IDX', weight: 0.8 },
|
|
96
|
+
{ symbol: 'T1', weight: 0.5 },
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
expect(() => runIncremental(bars, levered, config(bars))).toThrow(/sum.*> 1/)
|
|
100
|
+
const unknown: Strategy = { onBar: () => [{ symbol: 'NOPE', weight: 0.5 }] }
|
|
101
|
+
expect(() => runIncremental(bars, unknown, config(bars))).toThrow(/unknown symbol/)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('batch-compat shim — the 3 pinned baselines run unchanged', () => {
|
|
106
|
+
const bars = syntheticBars(400, 4)
|
|
107
|
+
|
|
108
|
+
for (const [name, generateSignals] of [
|
|
109
|
+
['buy-hold-index', buyHold],
|
|
110
|
+
['equal-weight', equalWeight],
|
|
111
|
+
['sma-crossover', smaCrossover],
|
|
112
|
+
] as const) {
|
|
113
|
+
it(`${name}: incremental drive reproduces the batch signals exactly`, () => {
|
|
114
|
+
const batch = generateSignals(bars)
|
|
115
|
+
const incremental = generateSignalsFromStrategy(strategyFromGenerateSignals(generateSignals))(bars)
|
|
116
|
+
expect(incremental).toEqual(batch)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('leak audit under the v2 harness', () => {
|
|
122
|
+
const bars = syntheticBars(400, 3)
|
|
123
|
+
|
|
124
|
+
/** Deliberately leaky: sizes today's weight by TOMORROW's return (same
|
|
125
|
+
* fixture class as leak-audit.test.mts, which keeps guarding the v1 path). */
|
|
126
|
+
const peekAhead = (input: Bar[][]): Signal[] => {
|
|
127
|
+
const T = input[0]!.length
|
|
128
|
+
const signals: Signal[] = []
|
|
129
|
+
for (let t = 0; t < T - 1; t++) {
|
|
130
|
+
const up = input[0]![t + 1]!.close > input[0]![t]!.close
|
|
131
|
+
signals.push({ t, weights: input.map((_, k) => (k === 0 && up ? 1 : 0)) })
|
|
132
|
+
}
|
|
133
|
+
return signals
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Leaky via whole-series statistics over the FULL sample. */
|
|
137
|
+
const fullSampleMax = (input: Bar[][]): Signal[] => {
|
|
138
|
+
const maxClose = Math.max(...input[0]!.map((b) => b.close))
|
|
139
|
+
const T = input[0]!.length
|
|
140
|
+
const signals: Signal[] = []
|
|
141
|
+
for (let t = 0; t < T; t++) {
|
|
142
|
+
signals.push({ t, weights: input.map((_, k) => (k === 0 ? input[0]![t]!.close / maxClose : 0)) })
|
|
143
|
+
}
|
|
144
|
+
return signals
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('v1 truncation audit still catches both leaky strategies as written', () => {
|
|
148
|
+
expect(truncationInvariance(peekAhead, bars, { warmupDays: 50 }).clean).toBe(false)
|
|
149
|
+
expect(truncationInvariance(fullSampleMax, bars, { warmupDays: 50 }).clean).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('the incremental harness neutralizes the batch peek-ahead (its signal can never fire)', () => {
|
|
153
|
+
// Under the driver the shim hands the strategy TRUNCATED history: the
|
|
154
|
+
// peek-ahead can only emit a signal for day t once it has seen t+1, so
|
|
155
|
+
// "today's" signal never exists at decision time — zero trades, leak
|
|
156
|
+
// structurally neutralized (and still caught as written by the v1 audit).
|
|
157
|
+
const run = runIncremental(bars, strategyFromGenerateSignals(peekAhead), config(bars))
|
|
158
|
+
expect(run.decisions).toEqual([])
|
|
159
|
+
expect(run.orders).toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('a v2-native strategy reaching past today crashes on absent data — fail-closed', () => {
|
|
163
|
+
const v2Peeker: Strategy = {
|
|
164
|
+
onBar(ctx) {
|
|
165
|
+
// ctx.history physically ends at today; tomorrow is undefined.
|
|
166
|
+
const up = ctx.history[0]![ctx.t + 1]!.close > ctx.history[0]![ctx.t]!.close
|
|
167
|
+
return up ? [{ symbol: 'IDX', weight: 1 }] : null
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
expect(() => runIncremental(bars, v2Peeker, config(bars))).toThrow()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('the incremental harness defuses whole-series leaks into causal decisions', () => {
|
|
174
|
+
// Driven bar-by-bar, "max over the whole series" becomes "max up to
|
|
175
|
+
// today": different decisions than the batch run, but causal — the
|
|
176
|
+
// truncation audit on the DRIVEN strategy is clean by construction.
|
|
177
|
+
const driven = generateSignalsFromStrategy(strategyFromGenerateSignals(fullSampleMax))
|
|
178
|
+
expect(truncationInvariance(driven, bars, { warmupDays: 50 }).clean).toBe(true)
|
|
179
|
+
expect(driven(bars)).not.toEqual(fullSampleMax(bars))
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('oms — the shared rebalancer', () => {
|
|
184
|
+
it('targetsToWeights expands sparse targets and enforces the contract', () => {
|
|
185
|
+
const symbols = ['IDX', 'A', 'B']
|
|
186
|
+
expect(targetsToWeights([{ symbol: 'A', weight: 0.6 }], symbols)).toEqual([0, 0.6, 0])
|
|
187
|
+
expect(() => targetsToWeights([{ symbol: 'A', weight: 0.6 }, { symbol: 'A', weight: 0.1 }], symbols)).toThrow(
|
|
188
|
+
/twice/,
|
|
189
|
+
)
|
|
190
|
+
expect(() => targetsToWeights([{ symbol: 'A', weight: Number.NaN }], symbols)).toThrow(/non-finite/)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('sizes orders LEAN-style: qty = (targetWeight * equity - positionValue) / price', () => {
|
|
194
|
+
const orders = rebalance({
|
|
195
|
+
targets: [
|
|
196
|
+
{ symbol: 'A', weight: 0.5 },
|
|
197
|
+
{ symbol: 'B', weight: 0 },
|
|
198
|
+
],
|
|
199
|
+
symbols: ['IDX', 'A', 'B'],
|
|
200
|
+
equity: 2,
|
|
201
|
+
prices: [100, 50, 20],
|
|
202
|
+
positionValues: [0, 0.5, 0.5],
|
|
203
|
+
t: 42,
|
|
204
|
+
tag: 'cand-x',
|
|
205
|
+
})
|
|
206
|
+
expect(orders).toEqual([
|
|
207
|
+
// buy A: (0.5 * 2 - 0.5) / 50 = 0.01 shares
|
|
208
|
+
{ clientOrderId: 'qa-t42-A', symbol: 'A', side: 'buy', qty: 0.01, type: 'market', tif: 'day', tag: 'cand-x' },
|
|
209
|
+
// sell B to flat: 0.5 / 20 = 0.025 shares
|
|
210
|
+
{ clientOrderId: 'qa-t42-B', symbol: 'B', side: 'sell', qty: 0.025, type: 'market', tif: 'day', tag: 'cand-x' },
|
|
211
|
+
])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('suppresses dust and emits nothing on a no-op rebalance', () => {
|
|
215
|
+
const orders = rebalance({
|
|
216
|
+
targets: [{ symbol: 'A', weight: 0.5 }],
|
|
217
|
+
symbols: ['IDX', 'A'],
|
|
218
|
+
equity: 1,
|
|
219
|
+
prices: [100, 50],
|
|
220
|
+
positionValues: [0, 0.5],
|
|
221
|
+
t: 7,
|
|
222
|
+
})
|
|
223
|
+
expect(orders).toEqual([])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('order ids are deterministic per (t, symbol)', () => {
|
|
227
|
+
const make = () =>
|
|
228
|
+
rebalance({
|
|
229
|
+
targets: [{ symbol: 'A', weight: 0.3 }],
|
|
230
|
+
symbols: ['IDX', 'A'],
|
|
231
|
+
equity: 1,
|
|
232
|
+
prices: [100, 50],
|
|
233
|
+
positionValues: [0, 0],
|
|
234
|
+
t: 9,
|
|
235
|
+
})
|
|
236
|
+
expect(make()).toEqual(make())
|
|
237
|
+
expect(make()[0]!.clientOrderId).toBe('qa-t9-A')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('strategies cannot smuggle orders: only weights cross the seam', () => {
|
|
241
|
+
// The driver hands onBar's return value to targetsToWeights, which
|
|
242
|
+
// rejects anything that is not {symbol, weight} rows summing sanely.
|
|
243
|
+
const bars = syntheticBars(30, 2)
|
|
244
|
+
const smuggler = {
|
|
245
|
+
onBar: () => [{ symbol: 'IDX', weight: 0.5, side: 'buy', qty: 999 }],
|
|
246
|
+
} as unknown as Strategy
|
|
247
|
+
// Extra fields are ignored — the OMS reads ONLY symbol + weight; the
|
|
248
|
+
// emitted order comes out of the shared sizing rule, not the strategy.
|
|
249
|
+
const run = runIncremental(bars, smuggler, { costBps: 10, slippageBps: 5, symbols: ['IDX', 'T1'] })
|
|
250
|
+
expect(run.orders.every((o) => o.qty < 1)).toBe(true)
|
|
251
|
+
expect(run.orders[0]!.clientOrderId).toMatch(/^qa-t0-IDX$/)
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental strategy driver — the v2 harness. Feeds a `Strategy` one bar
|
|
3
|
+
* at a time and enforces history visibility STRUCTURALLY: before every
|
|
4
|
+
* `onBar(ctx)` call the bar arrays are physically sliced to `[0..t]`, so
|
|
5
|
+
* data after the decision day does not exist in anything the strategy can
|
|
6
|
+
* reach. (A Proxy that throws on out-of-range reads was the alternative;
|
|
7
|
+
* slicing was chosen because it is simpler, allocation-cheap at this scale
|
|
8
|
+
* — slices copy references, not bars — and gives the harder guarantee:
|
|
9
|
+
* future bars are ABSENT, not merely guarded. The truncation leak audit
|
|
10
|
+
* stays in place on top of this as defense in depth, and because the v1
|
|
11
|
+
* batch contract can still leak.)
|
|
12
|
+
*
|
|
13
|
+
* Portfolio state shown to the strategy (ctx.weights / ctx.equity) evolves
|
|
14
|
+
* under EXACTLY the engine's execution model (see backtest.ts): a decision
|
|
15
|
+
* at the close of day t fills at day t+1's open, fees in bps of traded
|
|
16
|
+
* dollars, positions drift in between. `driver.test.mts` asserts the
|
|
17
|
+
* driver's equity path is bit-identical to `runBacktest` on the collected
|
|
18
|
+
* decisions.
|
|
19
|
+
*
|
|
20
|
+
* Outputs per run:
|
|
21
|
+
* - `decisions` — the decision record [(t, weights)], one row per rebalance;
|
|
22
|
+
* this is what the scorers (vectorbt worker, TS prefilter) consume.
|
|
23
|
+
* - `orders` — the OMS order stream produced by the SHARED rebalancer
|
|
24
|
+
* (oms.ts) from those decisions. Strategies never emit orders.
|
|
25
|
+
* - `equityByDay` — the driver's own equity path (close-marked).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
29
|
+
import { assertAligned } from './backtest.ts'
|
|
30
|
+
import { rebalance, targetsToWeights } from './oms.ts'
|
|
31
|
+
import type { Bar, GenerateSignals, Order, Signal, Strategy, StrategyContext, TargetPosition } from './types.ts'
|
|
32
|
+
|
|
33
|
+
export interface DriverConfig {
|
|
34
|
+
/** One-way transaction cost, basis points of traded dollars. */
|
|
35
|
+
costBps: number
|
|
36
|
+
/** One-way slippage, basis points of traded dollars. */
|
|
37
|
+
slippageBps: number
|
|
38
|
+
/** Universe tickers; symbols[0] is the index. Length must match bars. */
|
|
39
|
+
symbols: string[]
|
|
40
|
+
/** Provenance tag stamped on every order (e.g. the candidate id). */
|
|
41
|
+
tag?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DriverRun {
|
|
45
|
+
/** The decision record: one Signal per rebalance, in day order. */
|
|
46
|
+
decisions: Signal[]
|
|
47
|
+
/** OMS orders from the shared rebalancer, in emission order. */
|
|
48
|
+
orders: Order[]
|
|
49
|
+
/** Equity at each day's close under the engine's execution model. */
|
|
50
|
+
equityByDay: number[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function runIncremental(bars: Bar[][], strategy: Strategy, config: DriverConfig): DriverRun {
|
|
54
|
+
assertAligned(bars)
|
|
55
|
+
const N = bars.length
|
|
56
|
+
if (config.symbols.length !== N) {
|
|
57
|
+
throw new Error(`driver: ${config.symbols.length} symbols for ${N} bar series`)
|
|
58
|
+
}
|
|
59
|
+
if (typeof strategy?.onBar !== 'function') {
|
|
60
|
+
throw new Error('driver: strategy does not implement onBar(ctx)')
|
|
61
|
+
}
|
|
62
|
+
const T = bars[0]!.length
|
|
63
|
+
const feeRate = (config.costBps + config.slippageBps) / 10_000
|
|
64
|
+
|
|
65
|
+
const decisions: Signal[] = []
|
|
66
|
+
const orders: Order[] = []
|
|
67
|
+
const equityByDay: number[] = new Array(T).fill(0)
|
|
68
|
+
let cash = 1
|
|
69
|
+
const pos: number[] = new Array(N).fill(0)
|
|
70
|
+
/** Weights of the decision made at day t-1, pending its fill at t's open. */
|
|
71
|
+
let pendingFill: number[] | null = null
|
|
72
|
+
|
|
73
|
+
for (let t = 0; t < T; t++) {
|
|
74
|
+
if (t > 0) {
|
|
75
|
+
// Overnight: yesterday's close -> today's open (same as backtest.ts).
|
|
76
|
+
for (let k = 0; k < N; k++) {
|
|
77
|
+
pos[k] = pos[k]! * (bars[k]![t]!.open / bars[k]![t - 1]!.close)
|
|
78
|
+
}
|
|
79
|
+
if (pendingFill !== null) {
|
|
80
|
+
let equityOpen = cash
|
|
81
|
+
for (let k = 0; k < N; k++) equityOpen += pos[k]!
|
|
82
|
+
if (equityOpen <= 0) throw new Error(`driver: equity wiped out at t=${t}`)
|
|
83
|
+
let traded = 0
|
|
84
|
+
for (let k = 0; k < N; k++) {
|
|
85
|
+
const desired = pendingFill[k]! * equityOpen
|
|
86
|
+
const delta = desired - pos[k]!
|
|
87
|
+
traded += Math.abs(delta)
|
|
88
|
+
cash -= delta
|
|
89
|
+
pos[k] = desired
|
|
90
|
+
}
|
|
91
|
+
cash -= traded * feeRate
|
|
92
|
+
pendingFill = null
|
|
93
|
+
}
|
|
94
|
+
// Intraday: today's open -> today's close.
|
|
95
|
+
for (let k = 0; k < N; k++) {
|
|
96
|
+
pos[k] = pos[k]! * (bars[k]![t]!.close / bars[k]![t]!.open)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let equity = cash
|
|
100
|
+
for (let k = 0; k < N; k++) equity += pos[k]!
|
|
101
|
+
equityByDay[t] = equity
|
|
102
|
+
|
|
103
|
+
// Decision time: the close of day t. History is physically truncated.
|
|
104
|
+
const ctx: StrategyContext = {
|
|
105
|
+
symbols: config.symbols,
|
|
106
|
+
t,
|
|
107
|
+
history: bars.map((series) => series.slice(0, t + 1)),
|
|
108
|
+
weights: pos.map((p) => (equity > 0 ? p / equity : 0)),
|
|
109
|
+
equity,
|
|
110
|
+
}
|
|
111
|
+
const targets = strategy.onBar(ctx)
|
|
112
|
+
if (targets === null || targets === undefined) continue
|
|
113
|
+
if (!Array.isArray(targets)) {
|
|
114
|
+
throw new Error(`driver: onBar at t=${t} returned ${typeof targets} — expected TargetPosition[] or null`)
|
|
115
|
+
}
|
|
116
|
+
const weights = targetsToWeights(targets, config.symbols)
|
|
117
|
+
decisions.push({ t, weights })
|
|
118
|
+
const prices = bars.map((series) => series[t]!.close)
|
|
119
|
+
orders.push(
|
|
120
|
+
...rebalance({
|
|
121
|
+
targets,
|
|
122
|
+
symbols: config.symbols,
|
|
123
|
+
equity,
|
|
124
|
+
prices,
|
|
125
|
+
positionValues: [...pos],
|
|
126
|
+
t,
|
|
127
|
+
...(config.tag !== undefined ? { tag: config.tag } : {}),
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
if (t < T - 1) pendingFill = weights
|
|
131
|
+
// A decision on the final bar is recorded but can never fill — the same
|
|
132
|
+
// truth the batch engine encodes by never reading fills for t = T-1.
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { decisions, orders, equityByDay }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Batch-compat shims.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** Wrap a v1 batch strategy (`generateSignals`) as a v2 `Strategy` so the
|
|
143
|
+
* pinned baselines and legacy fixtures run UNCHANGED under the incremental
|
|
144
|
+
* harness. At every bar the batch function sees only the truncated history
|
|
145
|
+
* (the structural guarantee applies to it too) and the shim emits the
|
|
146
|
+
* signal it produced for today, if any.
|
|
147
|
+
*
|
|
148
|
+
* Note the intended consequence: a batch strategy that leaks through
|
|
149
|
+
* whole-series statistics computes them over the TRUNCATED history here,
|
|
150
|
+
* i.e. the shim converts that leak into a causal (if different) strategy.
|
|
151
|
+
* The v1 truncation audit on the raw `generateSignals` remains the guard
|
|
152
|
+
* that catches such code as written. */
|
|
153
|
+
export function strategyFromGenerateSignals(generateSignals: GenerateSignals): Strategy {
|
|
154
|
+
return {
|
|
155
|
+
onBar(ctx: StrategyContext): TargetPosition[] | null {
|
|
156
|
+
const signals = generateSignals(ctx.history)
|
|
157
|
+
const today = signals.filter((s) => s.t === ctx.t)
|
|
158
|
+
if (today.length === 0) return null
|
|
159
|
+
if (today.length > 1) throw new Error(`batch shim: ${today.length} signals for t=${ctx.t}`)
|
|
160
|
+
const weights = today[0]!.weights
|
|
161
|
+
if (weights.length !== ctx.symbols.length) {
|
|
162
|
+
throw new Error(`batch shim: ${weights.length} weights for ${ctx.symbols.length} symbols at t=${ctx.t}`)
|
|
163
|
+
}
|
|
164
|
+
// Emit every symbol explicitly (zero weight = sell to flat) so the
|
|
165
|
+
// decision is a complete portfolio statement, exactly like v1.
|
|
166
|
+
return ctx.symbols.map((symbol, k) => ({ symbol, weight: weights[k]! }))
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Adapt a v2 `Strategy` to the v1 batch shape so the existing scoring and
|
|
172
|
+
* leak-audit plumbing (`truncationInvariance`, `runBacktest`) consume it
|
|
173
|
+
* unchanged. Fees do not alter decisions unless the strategy conditions on
|
|
174
|
+
* its own equity path, so the audit drives with the campaign's default
|
|
175
|
+
* costs. */
|
|
176
|
+
export function generateSignalsFromStrategy(
|
|
177
|
+
strategy: Strategy,
|
|
178
|
+
opts?: { symbols?: string[]; costBps?: number; slippageBps?: number },
|
|
179
|
+
): GenerateSignals {
|
|
180
|
+
return (bars: Bar[][]): Signal[] => {
|
|
181
|
+
const symbols = opts?.symbols ?? bars.map((_, k) => (k === 0 ? 'IDX' : `T${String(k).padStart(2, '0')}`))
|
|
182
|
+
return runIncremental(bars, strategy, {
|
|
183
|
+
costBps: opts?.costBps ?? 10,
|
|
184
|
+
slippageBps: opts?.slippageBps ?? 5,
|
|
185
|
+
symbols,
|
|
186
|
+
}).decisions
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Module loading — one entry point for both contract generations.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export interface LoadedStrategy {
|
|
195
|
+
kind: 'v2-onBar' | 'v1-batch'
|
|
196
|
+
/** Batch view of the strategy; v2 modules are wrapped through the
|
|
197
|
+
* incremental driver so the structural history guarantee applies. */
|
|
198
|
+
generateSignals: GenerateSignals
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Import a strategy module from disk and normalize it to the batch shape
|
|
202
|
+
* the scoring + leak-audit plumbing consumes. v2 (`onBar`) wins when a
|
|
203
|
+
* module exports both. Throws when neither contract is implemented. */
|
|
204
|
+
export async function loadStrategyFile(
|
|
205
|
+
strategyPath: string,
|
|
206
|
+
opts?: { symbols?: string[]; costBps?: number; slippageBps?: number },
|
|
207
|
+
): Promise<LoadedStrategy> {
|
|
208
|
+
const mod = (await import(pathToFileURL(strategyPath).href)) as {
|
|
209
|
+
onBar?: Strategy['onBar']
|
|
210
|
+
generateSignals?: GenerateSignals
|
|
211
|
+
}
|
|
212
|
+
if (typeof mod.onBar === 'function') {
|
|
213
|
+
return { kind: 'v2-onBar', generateSignals: generateSignalsFromStrategy({ onBar: mod.onBar }, opts) }
|
|
214
|
+
}
|
|
215
|
+
if (typeof mod.generateSignals === 'function') {
|
|
216
|
+
return { kind: 'v1-batch', generateSignals: mod.generateSignals }
|
|
217
|
+
}
|
|
218
|
+
throw new Error(`strategy module exports neither onBar(ctx) nor generateSignals(bars): ${strategyPath}`)
|
|
219
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Data provenance
|
|
2
|
+
|
|
3
|
+
Every series in this directory is **synthetic**.
|
|
4
|
+
It was produced by `../../make-fixtures.mts` with fixed seed `20260722`; regenerate and diff at any time with:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
tsx src/quant-arena/make-fixtures.mts
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Why not real bars
|
|
11
|
+
|
|
12
|
+
We wanted ~10 years of real daily bars for ~10 liquid US tickers plus SPY from Stooq (`https://stooq.com/q/d/l/?s=spy.us&i=d`).
|
|
13
|
+
Two blockers, verified on 2026-07-22:
|
|
14
|
+
|
|
15
|
+
1. Stooq's terms state the data are for personal use; committing their bars to a redistributable repository is not clearly permitted.
|
|
16
|
+
2. The download endpoint sits behind a JavaScript anti-bot challenge and returned `Access denied` to non-browser clients.
|
|
17
|
+
|
|
18
|
+
So the repo ships clearly-labeled synthetic series instead: an index `IDX` (the buy-and-hold benchmark, SPY's role) and ten stocks `S01`-`S10`, generated by a regime-switching market factor plus per-stock beta, idiosyncratic volatility, and rare jumps.
|
|
19
|
+
The generator's model and parameters are documented in `make-fixtures.mts` itself.
|
|
20
|
+
|
|
21
|
+
If you have the right to use real data locally, drop your own Stooq-format CSVs (`Date,Open,High,Low,Close,Volume`, one file per ticker, `IDX.csv` = your benchmark) into `insample/` and `holdout/` — every consumer reads only this directory layout.
|
|
22
|
+
|
|
23
|
+
## Split
|
|
24
|
+
|
|
25
|
+
- `insample/` — weekdays 2016-07-01 .. 2024-06-28 (2086 days). The only data the research loop can see.
|
|
26
|
+
- `holdout/` — weekdays 2024-07-01 .. 2026-06-30 (522 days, the final 2 years). Read **only** by the certification command (`holdout-certify.mts`), never by the research loop.
|