@tangle-network/agent-bench 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/HARNESS.md +43 -0
- package/README.md +7 -0
- package/dist/adapters.js +23 -23
- package/dist/benchmarks/_harness.d.ts +1 -1
- package/dist/benchmarks/_harness.js +1 -1
- package/dist/benchmarks/aec-bench.js +2 -2
- package/dist/benchmarks/agentbench.js +2 -2
- package/dist/benchmarks/appworld.js +2 -2
- package/dist/benchmarks/bfcl.js +2 -2
- package/dist/benchmarks/commit0.js +2 -2
- package/dist/benchmarks/crag.js +2 -2
- package/dist/benchmarks/dabstep.js +2 -2
- package/dist/benchmarks/enterpriseops-gym.js +2 -2
- package/dist/benchmarks/finresearchbench.js +2 -2
- package/dist/benchmarks/nomiracl.js +2 -2
- package/dist/benchmarks/open-rag-bench.js +2 -2
- package/dist/benchmarks/programbench.js +2 -2
- package/dist/benchmarks/ragbench.js +2 -2
- package/dist/benchmarks/swe-bench.js +2 -2
- package/dist/benchmarks/t2-ragbench.js +2 -2
- package/dist/benchmarks/tau-bench-shared.js +2 -2
- package/dist/benchmarks/tau2-bench.js +3 -3
- package/dist/benchmarks/tau3-banking.js +3 -3
- package/dist/benchmarks/terminal-bench.js +2 -2
- package/dist/benchmarks/toollm.js +2 -2
- package/dist/benchmarks/webarena-verified.js +2 -2
- package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
- package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
- package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
- package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
- package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
- package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
- package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
- package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
- package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
- package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
- package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
- package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
- package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
- package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
- package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
- package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
- package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
- package/dist/{chunk-PWQVGAJB.js → chunk-V7AEBY6U.js} +21 -21
- package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
- package/dist/chunk-WSKWVEQB.js.map +1 -0
- package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
- package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
- package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
- package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
- package/dist/index.js +23 -23
- package/package.json +5 -5
- package/scripts/trata-hedge/README.md +6 -5
- package/scripts/verify-packed-consumer.mjs +1 -1
- package/src/benchmarks/_harness.ts +20 -2
- package/src/benchmarks/humaneval.test.mts +2 -2
- package/src/gate.ts +1 -1
- package/src/hev-eval.mts +5 -2
- package/src/hev-improve.mts +118 -73
- package/src/official-optimizer-config.mts +89 -0
- package/src/official-optimizer-config.test.mts +88 -0
- package/src/profiles.ts +2 -2
- package/src/quant-arena/README.md +144 -0
- package/src/quant-arena/backtest.test.mts +135 -0
- package/src/quant-arena/backtest.ts +218 -0
- package/src/quant-arena/data.test.mts +44 -0
- package/src/quant-arena/data.ts +141 -0
- package/src/quant-arena/driver.test.mts +253 -0
- package/src/quant-arena/driver.ts +219 -0
- package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
- package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
- package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
- package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
- package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
- package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
- package/src/quant-arena/holdout-certify.mts +206 -0
- package/src/quant-arena/holdout-certify.test.mts +82 -0
- package/src/quant-arena/leak-audit.test.mts +79 -0
- package/src/quant-arena/leak-audit.ts +95 -0
- package/src/quant-arena/make-fixtures.mts +161 -0
- package/src/quant-arena/multiplicity.test.mts +68 -0
- package/src/quant-arena/multiplicity.ts +87 -0
- package/src/quant-arena/nautilus-certify.ts +31 -0
- package/src/quant-arena/oms.ts +90 -0
- package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
- package/src/quant-arena/python/pyproject.toml +8 -0
- package/src/quant-arena/python/uv.lock +1297 -0
- package/src/quant-arena/python/vbt-worker.py +192 -0
- package/src/quant-arena/quant-loop.mts +813 -0
- package/src/quant-arena/quant-loop.test.mts +75 -0
- package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
- package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
- package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
- package/src/quant-arena/types.ts +133 -0
- package/src/quant-arena/vbt-client.ts +321 -0
- package/src/quant-arena/vbt-parity.test.mts +183 -0
- package/src/quant-arena/windows.test.mts +45 -0
- package/src/quant-arena/windows.ts +54 -0
- package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
- package/src/rollout-ledger/backfill-swe-arena.test.mts +342 -0
- package/src/rollout-ledger/settle-capture.mts +442 -0
- package/src/rollout-ledger/settle-capture.test.mts +270 -0
- package/src/smoke-structural-rollout.mts +15 -9
- package/src/swe-arena/activation.mts +225 -0
- package/src/swe-arena/activation.test.mts +300 -0
- package/src/swe-arena/arms.ts +30 -14
- package/src/swe-arena/briefing.mts +217 -0
- package/src/swe-arena/briefing.test.mts +178 -0
- package/src/swe-arena/calibrate.ts +102 -1
- package/src/swe-arena/cell-evidence.mts +41 -9
- package/src/swe-arena/factory-command-container.ts +284 -0
- package/src/swe-arena/factory-judge-child.mts +228 -0
- package/src/swe-arena/factory.test.mts +643 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
- package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
- package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
- package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
- package/src/swe-arena/fixtures/run-report/README.md +43 -0
- package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
- package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
- package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
- package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
- package/src/swe-arena/fixtures.ts +135 -2
- package/src/swe-arena/gepa-seat.mts +877 -0
- package/src/swe-arena/gepa-seat.test.mts +1059 -0
- package/src/swe-arena/implementation-ref.test.mts +64 -0
- package/src/swe-arena/implementation-ref.ts +62 -0
- package/src/swe-arena/ledger-orphans.mts +77 -0
- package/src/swe-arena/ledger-orphans.test.mts +147 -0
- package/src/swe-arena/outer-loop.mts +710 -33
- package/src/swe-arena/premeasured-from-cells.mts +281 -0
- package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
- package/src/swe-arena/proposer-fanout.mts +327 -31
- package/src/swe-arena/proposer-fanout.test.mts +246 -0
- package/src/swe-arena/proposer-provenance.mts +172 -0
- package/src/swe-arena/proposer-provenance.test.mts +106 -0
- package/src/swe-arena/run-experiment.mts +367 -1
- package/src/swe-arena/run-report.mts +75 -0
- package/src/swe-arena/run-supervisor.test.mts +5 -3
- package/src/swe-arena/score-split.mts +140 -0
- package/src/swe-arena/score-split.test.mts +123 -0
- package/src/swe-arena/scratch-worktree.test.mts +55 -0
- package/src/swe-arena/scratch-worktree.ts +34 -0
- package/src/swe-arena/types.ts +52 -0
- package/src/swe-code-improve.mts +24 -25
- package/src/swe-improve.mts +129 -96
- package/src/swe-local-proof.mts +6 -1
- package/src/swe-stream.mts +4 -2
- package/src/swe-structural.mts +245 -837
- package/src/tb-container-executor.test.mts +30 -6
- package/src/tb-supervisor-sidecar.mts +2 -1
- package/src/trata-gepa.mts +182 -245
- package/dist/chunk-LRRD7NAG.js.map +0 -1
- package/src/live-improve-campaign-mbpp.mts +0 -641
- package/src/live-improve-campaign.mts +0 -500
- /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
- /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
- /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
- /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
- /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
- /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
- /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
- /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
- /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
- /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
- /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
- /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
- /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
- /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
- /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
- /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
- /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
- /package/dist/{chunk-PWQVGAJB.js.map → chunk-V7AEBY6U.js.map} +0 -0
- /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
- /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
- /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
- /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUANT-ARENA campaign loop — the improvement loop embodied for trading
|
|
3
|
+
* strategies. One command runs: strategy authors (Claude, profile-pinned)
|
|
4
|
+
* propose candidate strategies (v2 `onBar` contract, driven incrementally by
|
|
5
|
+
* driver.ts) -> every candidate passes a two-stage leak audit -> survivors
|
|
6
|
+
* are scored on K bootstrap in-sample windows against the pinned baselines
|
|
7
|
+
* -> a multiplicity-adjusted acceptance rule decides -> every try becomes a
|
|
8
|
+
* permanent lab-notebook row (notebook.jsonl).
|
|
9
|
+
*
|
|
10
|
+
* Scoring engines: the OFFICIAL per-window scores come from the vectorbt
|
|
11
|
+
* worker (vbt-client.ts -> python/vbt-worker.py). The TS engine
|
|
12
|
+
* (backtest.ts) runs first as contract prefilter + leak-audit substrate
|
|
13
|
+
* only — it throws on shorting/leverage violations and supplies turnover
|
|
14
|
+
* (which the worker protocol does not carry), but its Sharpe/return numbers
|
|
15
|
+
* are never the acceptance currency.
|
|
16
|
+
*
|
|
17
|
+
* tsx src/quant-arena/quant-loop.mts --out <dir> [--candidates 2] [--seed 20260722]
|
|
18
|
+
* [--author-model sonnet] [--audit-model haiku] [--skip-llm-audit]
|
|
19
|
+
*
|
|
20
|
+
* Kernel reuse (import, not copy — see src/swe-arena/):
|
|
21
|
+
* - cost accounting: the lib's durable CostLedger (createRunCostLedger) +
|
|
22
|
+
* crash-orphan reconcile (ledger-orphans.mts) — author/audit shots are
|
|
23
|
+
* metered paid calls with receipts in <out>/cost-ledger.jsonl.
|
|
24
|
+
* - evidence cells: one cached-result.json per (strategy x window) in the
|
|
25
|
+
* swe-arena cell shape, readable by cell-evidence.mts's loadCampaignCells.
|
|
26
|
+
* - rollout manifest: a pure reader/join over cells + ledger receipts,
|
|
27
|
+
* mirroring swe-arena/manifest.mts (loadLedgerReceipts imported from it).
|
|
28
|
+
* - proposer identity: AgentProfile-pinned authors via proposer-fanout.mts's
|
|
29
|
+
* loadAuthorProfile + the same ambient-auth-stripped shot env.
|
|
30
|
+
*
|
|
31
|
+
* The HOLDOUT (final 2 years) is never read here — see holdout-certify.mts.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from 'node:crypto'
|
|
35
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
36
|
+
import { existsSync } from 'node:fs'
|
|
37
|
+
import { join } from 'node:path'
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
39
|
+
import process from 'node:process'
|
|
40
|
+
import { createRunCostLedger, fsCampaignStorage } from '@tangle-network/agent-eval/campaign'
|
|
41
|
+
import { loadCampaignCells } from '../swe-arena/cell-evidence.mts'
|
|
42
|
+
import { reconcileCrashOrphansOnDisk } from '../swe-arena/ledger-orphans.mts'
|
|
43
|
+
import { loadLedgerReceipts } from '../swe-arena/manifest.mts'
|
|
44
|
+
import { loadAuthorProfile, type ProposerSpec } from '../swe-arena/proposer-fanout.mts'
|
|
45
|
+
import { proposerShotEnv } from '../swe-arena/outer-loop.mts'
|
|
46
|
+
import { run } from '../swe-arena/proc.ts'
|
|
47
|
+
import { runBacktest, statsForRange, type BacktestConfig, type RangeStats } from './backtest.ts'
|
|
48
|
+
import { loadInSample, type AlignedBars } from './data.ts'
|
|
49
|
+
import { loadStrategyFile } from './driver.ts'
|
|
50
|
+
import { truncationInvariance, type TruncationReport } from './leak-audit.ts'
|
|
51
|
+
import { decideAcceptance, requiredExcessSharpe, type AcceptanceDecision } from './multiplicity.ts'
|
|
52
|
+
import { scoreSignals, VbtWorker, type VbtWindowStats } from './vbt-client.ts'
|
|
53
|
+
import { bootstrapWindows, type EvalWindow } from './windows.ts'
|
|
54
|
+
import type { GenerateSignals, Signal } from './types.ts'
|
|
55
|
+
import * as buyHoldIndex from './strategies/buy-hold-index/strategy.ts'
|
|
56
|
+
import * as equalWeight from './strategies/equal-weight/strategy.ts'
|
|
57
|
+
import * as smaCrossover from './strategies/sma-crossover/strategy.ts'
|
|
58
|
+
|
|
59
|
+
export const QUANT_PROFILES_DIR = fileURLToPath(new URL('./profiles', import.meta.url))
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Config.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface QuantLoopConfig {
|
|
66
|
+
outDir: string
|
|
67
|
+
candidatesPerProposer: number
|
|
68
|
+
seed: number
|
|
69
|
+
windows: number
|
|
70
|
+
windowDays: number
|
|
71
|
+
warmupDays: number
|
|
72
|
+
costBps: number
|
|
73
|
+
slippageBps: number
|
|
74
|
+
authorModel: string
|
|
75
|
+
auditModel: string
|
|
76
|
+
skipLlmAudit: boolean
|
|
77
|
+
authorTimeoutMs: number
|
|
78
|
+
auditTimeoutMs: number
|
|
79
|
+
proposers: ProposerSpec[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const PINNED_BASELINES: Record<string, GenerateSignals> = {
|
|
83
|
+
'buy-hold-index': buyHoldIndex.generateSignals,
|
|
84
|
+
'equal-weight': equalWeight.generateSignals,
|
|
85
|
+
'sma-crossover': smaCrossover.generateSignals,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The two demo author seats: the plain author and the quant lens. */
|
|
89
|
+
export function defaultQuantProposers(): ProposerSpec[] {
|
|
90
|
+
return [
|
|
91
|
+
{ name: 'default-author', profile: 'default-author.profile.json', harness: 'claude' },
|
|
92
|
+
{
|
|
93
|
+
name: 'quant-researcher',
|
|
94
|
+
profile: join(QUANT_PROFILES_DIR, 'quant-researcher.profile.json'),
|
|
95
|
+
harness: 'claude',
|
|
96
|
+
lens:
|
|
97
|
+
'Favor ONE economically-motivated effect (trend, mean reversion, vol targeting) with few parameters. ' +
|
|
98
|
+
'State the regime in which it should work and keep turnover low enough that 15bps a side cannot eat the edge.',
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function defaultConfig(outDir: string): QuantLoopConfig {
|
|
104
|
+
return {
|
|
105
|
+
outDir,
|
|
106
|
+
candidatesPerProposer: 2,
|
|
107
|
+
seed: 20260722,
|
|
108
|
+
windows: 8,
|
|
109
|
+
windowDays: 504,
|
|
110
|
+
warmupDays: 120,
|
|
111
|
+
costBps: 10,
|
|
112
|
+
slippageBps: 5,
|
|
113
|
+
authorModel: 'sonnet',
|
|
114
|
+
auditModel: 'haiku',
|
|
115
|
+
skipLlmAudit: false,
|
|
116
|
+
authorTimeoutMs: 480_000,
|
|
117
|
+
auditTimeoutMs: 240_000,
|
|
118
|
+
proposers: defaultQuantProposers(),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Notebook rows — the permanent lab notebook. Append-only JSONL.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export const NOTEBOOK_CANDIDATE_SCHEMA = 'quant-arena.candidate.v1'
|
|
127
|
+
export const NOTEBOOK_BASELINES_SCHEMA = 'quant-arena.baselines.v1'
|
|
128
|
+
|
|
129
|
+
export type CandidateVerdict =
|
|
130
|
+
| 'accepted'
|
|
131
|
+
| 'rejected-no-edge'
|
|
132
|
+
| 'rejected-leak'
|
|
133
|
+
| 'rejected-contract'
|
|
134
|
+
| 'rejected-error'
|
|
135
|
+
|
|
136
|
+
export interface WindowScore {
|
|
137
|
+
start: number
|
|
138
|
+
end: number
|
|
139
|
+
startDate: string
|
|
140
|
+
endDate: string
|
|
141
|
+
sharpe: number
|
|
142
|
+
bestBaselineSharpe: number
|
|
143
|
+
excess: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface CandidateRow {
|
|
147
|
+
schema: typeof NOTEBOOK_CANDIDATE_SCHEMA
|
|
148
|
+
at: string
|
|
149
|
+
candidateId: string
|
|
150
|
+
proposer: string
|
|
151
|
+
authorModel: string
|
|
152
|
+
strategyPath: string | null
|
|
153
|
+
sha256: string | null
|
|
154
|
+
authoringCostUsd: number | null
|
|
155
|
+
/** Total candidates tried this campaign INCLUDING this one — the
|
|
156
|
+
* multiplicity denominator. Monotone; never resets within a notebook. */
|
|
157
|
+
nTried: number
|
|
158
|
+
leakAudit: {
|
|
159
|
+
truncation: TruncationReport | null
|
|
160
|
+
llm: { verdict: 'clean' | 'leak' | 'inconclusive'; evidence: string } | 'skipped' | null
|
|
161
|
+
}
|
|
162
|
+
eval: {
|
|
163
|
+
perWindow: WindowScore[]
|
|
164
|
+
meanExcessSharpe: number
|
|
165
|
+
wins: number
|
|
166
|
+
requiredWins: number
|
|
167
|
+
threshold: number
|
|
168
|
+
} | null
|
|
169
|
+
inSampleFull: RangeStats | null
|
|
170
|
+
verdict: CandidateVerdict
|
|
171
|
+
reasons: string[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function loadNotebookRows(notebookPath: string): Promise<Array<Record<string, unknown>>> {
|
|
175
|
+
if (!existsSync(notebookPath)) return []
|
|
176
|
+
const raw = await readFile(notebookPath, 'utf8')
|
|
177
|
+
return raw
|
|
178
|
+
.split('\n')
|
|
179
|
+
.filter((l) => l.trim().length > 0)
|
|
180
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const log = (msg: string): void => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`)
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Claude shots (author + auditor) — metered paid calls through the run ledger.
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
interface ClaudeShotOutcome {
|
|
190
|
+
text: string
|
|
191
|
+
model: string
|
|
192
|
+
inputTokens: number
|
|
193
|
+
outputTokens: number
|
|
194
|
+
cachedTokens: number
|
|
195
|
+
costUsd: number | null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type Ledger = ReturnType<typeof createRunCostLedger>
|
|
199
|
+
|
|
200
|
+
async function claudeShot(opts: {
|
|
201
|
+
prompt: string
|
|
202
|
+
model: string
|
|
203
|
+
systemPrompt?: string
|
|
204
|
+
timeoutMs: number
|
|
205
|
+
cwd: string
|
|
206
|
+
}): Promise<ClaudeShotOutcome> {
|
|
207
|
+
const argv = [
|
|
208
|
+
'-p',
|
|
209
|
+
'--output-format',
|
|
210
|
+
'json',
|
|
211
|
+
'--model',
|
|
212
|
+
opts.model,
|
|
213
|
+
// The shot is pure text generation: no filesystem, no shell, no web.
|
|
214
|
+
'--disallowed-tools',
|
|
215
|
+
'Bash,Edit,Write,Read,Glob,Grep,WebFetch,WebSearch,Task,NotebookEdit',
|
|
216
|
+
...(opts.systemPrompt ? ['--append-system-prompt', opts.systemPrompt] : []),
|
|
217
|
+
]
|
|
218
|
+
const res = await run('claude', argv, {
|
|
219
|
+
stdin: opts.prompt,
|
|
220
|
+
cwd: opts.cwd,
|
|
221
|
+
env: proposerShotEnv('claude'),
|
|
222
|
+
timeoutMs: opts.timeoutMs,
|
|
223
|
+
})
|
|
224
|
+
if (res.code !== 0) {
|
|
225
|
+
throw new Error(`claude shot exited ${res.code}${res.timedOut ? ' (timeout)' : ''}: ${(res.stderr || res.stdout).slice(0, 800)}`)
|
|
226
|
+
}
|
|
227
|
+
let parsed: Record<string, unknown>
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(res.stdout) as Record<string, unknown>
|
|
230
|
+
} catch {
|
|
231
|
+
throw new Error(`claude shot: unparseable --output-format json stdout: ${res.stdout.slice(0, 400)}`)
|
|
232
|
+
}
|
|
233
|
+
if (parsed.is_error === true) throw new Error(`claude shot errored: ${String(parsed.result).slice(0, 800)}`)
|
|
234
|
+
const usage = (parsed.usage ?? {}) as Record<string, unknown>
|
|
235
|
+
const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
236
|
+
return {
|
|
237
|
+
text: typeof parsed.result === 'string' ? parsed.result : '',
|
|
238
|
+
model: typeof parsed.model === 'string' ? parsed.model : opts.model,
|
|
239
|
+
inputTokens: num(usage.input_tokens),
|
|
240
|
+
outputTokens: num(usage.output_tokens),
|
|
241
|
+
cachedTokens: num(usage.cache_read_input_tokens),
|
|
242
|
+
costUsd: typeof parsed.total_cost_usd === 'number' ? parsed.total_cost_usd : null,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function meteredClaudeShot(
|
|
247
|
+
ledger: Ledger,
|
|
248
|
+
meta: { phase: string; actor: string; tags: Record<string, string> },
|
|
249
|
+
opts: Parameters<typeof claudeShot>[0],
|
|
250
|
+
): Promise<{ outcome: ClaudeShotOutcome; costUsd: number | null }> {
|
|
251
|
+
const paid = await ledger.runPaidCall<ClaudeShotOutcome>({
|
|
252
|
+
channel: 'driver',
|
|
253
|
+
phase: meta.phase,
|
|
254
|
+
actor: meta.actor,
|
|
255
|
+
model: opts.model,
|
|
256
|
+
tags: meta.tags,
|
|
257
|
+
execute: () => claudeShot(opts),
|
|
258
|
+
receipt: (v) => ({
|
|
259
|
+
model: v.model,
|
|
260
|
+
inputTokens: v.inputTokens,
|
|
261
|
+
outputTokens: v.outputTokens,
|
|
262
|
+
cachedTokens: v.cachedTokens,
|
|
263
|
+
...(v.costUsd !== null ? { actualCostUsd: v.costUsd } : {}),
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
if (!paid.succeeded) throw paid.error
|
|
267
|
+
return { outcome: paid.value, costUsd: paid.value.costUsd }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Authoring: prompt, extraction, hermeticity guard.
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
const CONTRACT_TEXT = `THE STRATEGY CONTRACT (v2 — incremental)
|
|
275
|
+
- Write ONE self-contained TypeScript module. NO import/require/fs/network/process — declare any types you need locally.
|
|
276
|
+
- Export exactly: export function onBar(ctx: StrategyContext): TargetPosition[] | null
|
|
277
|
+
where StrategyContext = { symbols: string[]; t: number; history: Bar[][]; weights: number[]; equity: number },
|
|
278
|
+
Bar = { date: string; open: number; high: number; low: number; close: number; volume: number },
|
|
279
|
+
and TargetPosition = { symbol: string; weight: number }.
|
|
280
|
+
- The lab calls onBar once per trading day, in order. ctx.history[k] holds the daily bars of ctx.symbols[k] from
|
|
281
|
+
day 0 THROUGH TODAY ONLY (ctx.history[k].length === ctx.t + 1) — bars after today do not exist in the array.
|
|
282
|
+
ctx.history[0] / ctx.symbols[0] is the benchmark index. ctx.weights and ctx.equity are your current drifted
|
|
283
|
+
portfolio state (equity starts at 1).
|
|
284
|
+
- Return TargetPosition[] to rebalance: weight = target fraction of equity per symbol; any symbol you omit is
|
|
285
|
+
sold to 0. Return null to hold (positions drift with prices). A rebalance fills at the NEXT day's open.
|
|
286
|
+
- You never construct orders — the lab's shared rebalancer turns your target weights into orders.
|
|
287
|
+
- No shorting, no leverage: every weight >= 0 and the weights sum to <= 1 (rest is cash at 0%). Violations kill
|
|
288
|
+
the candidate — fail-closed, not clamped.
|
|
289
|
+
- DETERMINISM / NO LOOK-AHEAD: onBar must be a pure function of ctx (no RNG, no clock, no hidden state). The lab
|
|
290
|
+
re-runs your code on truncated data; if any decision up to the cutoff changes, the candidate is killed. No
|
|
291
|
+
hardcoded calendar dates that memorize this dataset.
|
|
292
|
+
- Every fill pays 15bps one-way (cost + slippage) on traded dollars — churn is expensive.`
|
|
293
|
+
|
|
294
|
+
export function buildAuthorPrompt(args: {
|
|
295
|
+
universe: AlignedBars
|
|
296
|
+
windows: EvalWindow[]
|
|
297
|
+
baselineTable: string
|
|
298
|
+
threshold: number
|
|
299
|
+
nTried: number
|
|
300
|
+
lens?: string
|
|
301
|
+
}): string {
|
|
302
|
+
const { universe, windows, baselineTable, threshold, nTried } = args
|
|
303
|
+
return [
|
|
304
|
+
'You are proposing ONE candidate trading strategy for a research lab with a strict acceptance rule.',
|
|
305
|
+
'',
|
|
306
|
+
CONTRACT_TEXT,
|
|
307
|
+
'',
|
|
308
|
+
`UNIVERSE: ${universe.tickers.length} tickers (${universe.tickers.join(', ')}); bars[0] = ${universe.tickers[0]} (the index).`,
|
|
309
|
+
`IN-SAMPLE: ${universe.dates.length} daily bars, ${universe.dates[0]} .. ${universe.dates[universe.dates.length - 1]}.`,
|
|
310
|
+
'',
|
|
311
|
+
'ACCEPTANCE RULE (what you must beat):',
|
|
312
|
+
`- Scored on ${windows.length} overlapping ${windows[0]!.end - windows[0]!.start}-day in-sample windows.`,
|
|
313
|
+
'- You must beat the BEST pinned baseline Sharpe in at least 6 of 8 windows, AND',
|
|
314
|
+
`- your mean excess Sharpe must clear ${threshold.toFixed(3)} (the bar rises with every candidate tried; you are try #${nTried}).`,
|
|
315
|
+
'',
|
|
316
|
+
'PINNED BASELINES (annualized Sharpe per window; "best" is the per-window max):',
|
|
317
|
+
baselineTable,
|
|
318
|
+
'',
|
|
319
|
+
...(args.lens ? ['YOUR AUTHORING LENS:', args.lens, ''] : []),
|
|
320
|
+
'Reply with EXACTLY ONE fenced ```ts code block containing the module and nothing else after it.',
|
|
321
|
+
].join('\n')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Pull the strategy module out of the author's reply and enforce the
|
|
325
|
+
* self-contained rule. Fail-closed: anything ambiguous is a rejection. */
|
|
326
|
+
export function extractStrategySource(text: string): { ok: true; code: string } | { ok: false; reason: string } {
|
|
327
|
+
const blocks = [...text.matchAll(/```(?:ts|typescript)?\s*\n([\s\S]*?)```/g)].map((m) => m[1]!)
|
|
328
|
+
const withExport = blocks.filter((b) => /export\s+function\s+onBar\s*\(/.test(b))
|
|
329
|
+
if (withExport.length === 0) {
|
|
330
|
+
return { ok: false, reason: 'no fenced code block exporting `onBar` in the reply (v2 contract)' }
|
|
331
|
+
}
|
|
332
|
+
const code = withExport[withExport.length - 1]!
|
|
333
|
+
const stripped = code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
334
|
+
const banned = /\b(import|require|fetch|process|globalThis|Deno|XMLHttpRequest|eval)\b/.exec(stripped)
|
|
335
|
+
if (banned) {
|
|
336
|
+
return { ok: false, reason: `not self-contained: uses banned identifier '${banned[1]}'` }
|
|
337
|
+
}
|
|
338
|
+
return { ok: true, code }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Adversarial LLM leak audit (the deterministic truncation check lives in
|
|
343
|
+
// leak-audit.ts; both must pass).
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
const AUDIT_PROMPT_HEADER = `You are an adversarial reviewer with ONE job: find look-ahead bias or nondeterminism
|
|
347
|
+
in the trading strategy below. The contract: onBar(ctx) is called once per day; ctx.history[k] holds ONLY bars
|
|
348
|
+
0..ctx.t (the harness slices the arrays), decisions must be pure functions of ctx, and fills happen at the next
|
|
349
|
+
day's open. Hunt for:
|
|
350
|
+
- hardcoded calendar dates or magic day indexes that smell like memorizing this dataset,
|
|
351
|
+
- nondeterminism: Math.random, Date.now, or state carried between onBar calls that a re-run would not rebuild,
|
|
352
|
+
- decisions that would change when the future is truncated,
|
|
353
|
+
- any attempt to reach data beyond ctx.history (indexing past the array end, reconstructing future prices).
|
|
354
|
+
Deciding at close of ctx.t and being filled at t+1's open is LEGAL — do not flag it. Whole-history statistics over
|
|
355
|
+
ctx.history are LEGAL (the array ends at today) — do not flag them.
|
|
356
|
+
Reply with JSON ONLY: {"verdict":"clean"} or {"verdict":"leak","evidence":"<quote the offending code and why>"}.
|
|
357
|
+
|
|
358
|
+
STRATEGY SOURCE:
|
|
359
|
+
`
|
|
360
|
+
|
|
361
|
+
export function parseAuditVerdict(text: string): { verdict: 'clean' | 'leak'; evidence: string } | null {
|
|
362
|
+
const matches = [...text.matchAll(/\{[\s\S]*?"verdict"[\s\S]*?\}/g)]
|
|
363
|
+
for (const m of matches.reverse()) {
|
|
364
|
+
try {
|
|
365
|
+
const parsed = JSON.parse(m[0]) as { verdict?: string; evidence?: string }
|
|
366
|
+
if (parsed.verdict === 'clean') return { verdict: 'clean', evidence: '' }
|
|
367
|
+
if (parsed.verdict === 'leak') return { verdict: 'leak', evidence: parsed.evidence ?? '(no evidence quoted)' }
|
|
368
|
+
} catch {
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function llmLeakAudit(
|
|
376
|
+
ledger: Ledger,
|
|
377
|
+
config: QuantLoopConfig,
|
|
378
|
+
candidateId: string,
|
|
379
|
+
code: string,
|
|
380
|
+
): Promise<{ verdict: 'clean' | 'leak' | 'inconclusive'; evidence: string }> {
|
|
381
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
382
|
+
const { outcome } = await meteredClaudeShot(
|
|
383
|
+
ledger,
|
|
384
|
+
{ phase: 'audit.leak', actor: 'leak-auditor:claude', tags: { candidateId, attempt: String(attempt) } },
|
|
385
|
+
{
|
|
386
|
+
prompt: AUDIT_PROMPT_HEADER + '```ts\n' + code + '\n```',
|
|
387
|
+
model: config.auditModel,
|
|
388
|
+
timeoutMs: config.auditTimeoutMs,
|
|
389
|
+
cwd: config.outDir,
|
|
390
|
+
},
|
|
391
|
+
)
|
|
392
|
+
const verdict = parseAuditVerdict(outcome.text)
|
|
393
|
+
if (verdict !== null) return verdict
|
|
394
|
+
}
|
|
395
|
+
return { verdict: 'inconclusive', evidence: 'auditor reply unparseable twice — fail-closed' }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Evidence cells (kernel cell shape) + rollout manifest.
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
async function writeWindowCells(
|
|
403
|
+
campaignRoot: string,
|
|
404
|
+
label: string,
|
|
405
|
+
perWindow: Array<{ window: EvalWindow; stats: RangeStats; bestBaselineSharpe: number | null }>,
|
|
406
|
+
): Promise<string> {
|
|
407
|
+
const dir = join(campaignRoot, label)
|
|
408
|
+
for (let i = 0; i < perWindow.length; i++) {
|
|
409
|
+
const { window, stats, bestBaselineSharpe } = perWindow[i]!
|
|
410
|
+
const cellDir = join(dir, `window-${i}-rep-0`)
|
|
411
|
+
await mkdir(cellDir, { recursive: true })
|
|
412
|
+
const cell = {
|
|
413
|
+
scenarioId: `window-${window.start}-${window.end}`,
|
|
414
|
+
rep: 0,
|
|
415
|
+
artifact: {
|
|
416
|
+
kind: 'quant-window',
|
|
417
|
+
strategy: label,
|
|
418
|
+
windowStart: window.start,
|
|
419
|
+
windowEnd: window.end,
|
|
420
|
+
sharpe: stats.sharpe,
|
|
421
|
+
totalReturn: stats.totalReturn,
|
|
422
|
+
maxDrawdown: stats.maxDrawdown,
|
|
423
|
+
tradeCount: stats.tradeCount,
|
|
424
|
+
turnover: stats.turnover,
|
|
425
|
+
bestBaselineSharpe,
|
|
426
|
+
excessSharpe: bestBaselineSharpe === null ? null : stats.sharpe - bestBaselineSharpe,
|
|
427
|
+
},
|
|
428
|
+
costUsd: 0,
|
|
429
|
+
tokenUsage: { input: 0, output: 0 },
|
|
430
|
+
cached: true,
|
|
431
|
+
}
|
|
432
|
+
await writeFile(join(cellDir, 'cached-result.json'), JSON.stringify(cell, null, 2))
|
|
433
|
+
}
|
|
434
|
+
return dir
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export const QUANT_ROLLOUT_SCHEMA = 'quant-arena.rollout.v1'
|
|
438
|
+
|
|
439
|
+
/** Pure reader/join over what the campaign already wrote — the same posture
|
|
440
|
+
* as swe-arena/manifest.mts, over the same cell + ledger primitives. */
|
|
441
|
+
export async function writeQuantRolloutManifest(outDir: string): Promise<string> {
|
|
442
|
+
const campaignRoot = join(outDir, 'campaign')
|
|
443
|
+
const receipts = await loadLedgerReceipts(outDir)
|
|
444
|
+
const notebook = await loadNotebookRows(join(outDir, 'notebook.jsonl'))
|
|
445
|
+
const entries: Array<Record<string, unknown>> = []
|
|
446
|
+
const { readdir } = await import('node:fs/promises')
|
|
447
|
+
for (const name of (await readdir(campaignRoot).catch(() => [])).sort()) {
|
|
448
|
+
const cells = await loadCampaignCells(join(campaignRoot, name))
|
|
449
|
+
if (cells.length === 0) continue
|
|
450
|
+
entries.push({
|
|
451
|
+
label: name,
|
|
452
|
+
campaignDir: join(campaignRoot, name),
|
|
453
|
+
cells: cells.length,
|
|
454
|
+
scenarios: cells.map((c) => c.scenarioId).sort(),
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
const manifest = {
|
|
458
|
+
schema: QUANT_ROLLOUT_SCHEMA,
|
|
459
|
+
outDir,
|
|
460
|
+
at: new Date().toISOString(),
|
|
461
|
+
notebookRows: notebook.length,
|
|
462
|
+
strategies: entries,
|
|
463
|
+
receipts: receipts.map((r) => ({
|
|
464
|
+
callId: r.callId,
|
|
465
|
+
phase: (r as Record<string, unknown>).phase ?? null,
|
|
466
|
+
actor: (r as Record<string, unknown>).actor ?? null,
|
|
467
|
+
model: (r as Record<string, unknown>).model ?? null,
|
|
468
|
+
costUsd: (r as Record<string, unknown>).costUsd ?? null,
|
|
469
|
+
})),
|
|
470
|
+
}
|
|
471
|
+
const path = join(outDir, 'rollout-manifest.json')
|
|
472
|
+
await writeFile(path, JSON.stringify(manifest, null, 2))
|
|
473
|
+
return path
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// The campaign.
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
/** RangeStats assembled from the official (vectorbt) numbers plus turnover,
|
|
481
|
+
* which only the TS prefilter tracks — labeled at the one place it mixes. */
|
|
482
|
+
function rangeStatsFromVbt(stats: VbtWindowStats, start: number, end: number, turnover: number): RangeStats {
|
|
483
|
+
return {
|
|
484
|
+
start,
|
|
485
|
+
end,
|
|
486
|
+
days: end - start,
|
|
487
|
+
totalReturn: stats.totalReturn,
|
|
488
|
+
maxDrawdown: stats.maxDD,
|
|
489
|
+
sharpe: stats.sharpe,
|
|
490
|
+
tradeCount: stats.trades,
|
|
491
|
+
turnover,
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
interface ScoredStrategy {
|
|
496
|
+
signals: Signal[]
|
|
497
|
+
/** Official per-window stats (vectorbt; turnover from the TS prefilter). */
|
|
498
|
+
perWindow: RangeStats[]
|
|
499
|
+
/** Official full-range stats. */
|
|
500
|
+
full: RangeStats
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Score one decision record: TS engine first as fail-closed contract
|
|
504
|
+
* prefilter (throws on shorting/leverage/malformed signals), then the
|
|
505
|
+
* vectorbt worker for the official numbers. */
|
|
506
|
+
async function scoreStrategySignals(
|
|
507
|
+
worker: VbtWorker,
|
|
508
|
+
universe: AlignedBars,
|
|
509
|
+
signals: Signal[],
|
|
510
|
+
windows: EvalWindow[],
|
|
511
|
+
btConfig: BacktestConfig,
|
|
512
|
+
): Promise<ScoredStrategy> {
|
|
513
|
+
const prefilter = runBacktest(universe.bars, signals, btConfig)
|
|
514
|
+
const ranges = windows.map((w) => [w.start, w.end] as [number, number])
|
|
515
|
+
const vbt = await scoreSignals(worker, universe.bars, signals, btConfig, ranges)
|
|
516
|
+
const perWindow = windows.map((w, i) =>
|
|
517
|
+
rangeStatsFromVbt(vbt.windows[i]!, w.start, w.end, statsForRange(prefilter, w.start, w.end).turnover),
|
|
518
|
+
)
|
|
519
|
+
const full = rangeStatsFromVbt(vbt.full, 0, universe.dates.length, prefilter.stats.turnover)
|
|
520
|
+
return { signals, perWindow, full }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
interface BaselineEvidence {
|
|
524
|
+
perWindowSharpe: Record<string, number[]>
|
|
525
|
+
bestPerWindow: number[]
|
|
526
|
+
fullSample: Record<string, RangeStats>
|
|
527
|
+
perWindowStats: Record<string, RangeStats[]>
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function evaluateBaselines(
|
|
531
|
+
worker: VbtWorker,
|
|
532
|
+
universe: AlignedBars,
|
|
533
|
+
windows: EvalWindow[],
|
|
534
|
+
btConfig: BacktestConfig,
|
|
535
|
+
): Promise<BaselineEvidence> {
|
|
536
|
+
const perWindowSharpe: Record<string, number[]> = {}
|
|
537
|
+
const fullSample: Record<string, RangeStats> = {}
|
|
538
|
+
const perWindowStats: Record<string, RangeStats[]> = {}
|
|
539
|
+
for (const [name, strategy] of Object.entries(PINNED_BASELINES)) {
|
|
540
|
+
const scored = await scoreStrategySignals(worker, universe, strategy(universe.bars), windows, btConfig)
|
|
541
|
+
fullSample[name] = scored.full
|
|
542
|
+
perWindowStats[name] = scored.perWindow
|
|
543
|
+
perWindowSharpe[name] = scored.perWindow.map((s) => s.sharpe)
|
|
544
|
+
}
|
|
545
|
+
const bestPerWindow = windows.map((_, i) =>
|
|
546
|
+
Math.max(...Object.values(perWindowSharpe).map((sharpes) => sharpes[i]!)),
|
|
547
|
+
)
|
|
548
|
+
return { perWindowSharpe, bestPerWindow, fullSample, perWindowStats }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function baselineTable(evidence: BaselineEvidence): string {
|
|
552
|
+
const lines: string[] = []
|
|
553
|
+
for (const [name, sharpes] of Object.entries(evidence.perWindowSharpe)) {
|
|
554
|
+
lines.push(` ${name.padEnd(15)} ${sharpes.map((s) => s.toFixed(2).padStart(6)).join(' ')}`)
|
|
555
|
+
}
|
|
556
|
+
lines.push(` ${'BEST'.padEnd(15)} ${evidence.bestPerWindow.map((s) => s.toFixed(2).padStart(6)).join(' ')}`)
|
|
557
|
+
return lines.join('\n')
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function runQuantCampaign(config: QuantLoopConfig): Promise<CandidateRow[]> {
|
|
561
|
+
if (!VbtWorker.isAvailable()) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
'quant-arena: the official scorer is the vectorbt worker, which needs `uv` on PATH ' +
|
|
564
|
+
'(src/quant-arena/python/uv.lock pins the environment). The TS engine is a prefilter ' +
|
|
565
|
+
'only and cannot stand in — install uv, there is no fallback scorer.',
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
const worker = new VbtWorker()
|
|
569
|
+
try {
|
|
570
|
+
return await runQuantCampaignWithWorker(worker, config)
|
|
571
|
+
} finally {
|
|
572
|
+
await worker.close()
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function runQuantCampaignWithWorker(worker: VbtWorker, config: QuantLoopConfig): Promise<CandidateRow[]> {
|
|
577
|
+
await mkdir(config.outDir, { recursive: true })
|
|
578
|
+
const notebookPath = join(config.outDir, 'notebook.jsonl')
|
|
579
|
+
const reconciled = reconcileCrashOrphansOnDisk(config.outDir)
|
|
580
|
+
if (reconciled.length > 0) log(`reconciled ${reconciled.length} crash-orphaned ledger call(s)`)
|
|
581
|
+
const ledger = createRunCostLedger({ storage: fsCampaignStorage(), runDir: config.outDir })
|
|
582
|
+
|
|
583
|
+
const universe = await loadInSample()
|
|
584
|
+
const T = universe.dates.length
|
|
585
|
+
const windows = bootstrapWindows(T, {
|
|
586
|
+
k: config.windows,
|
|
587
|
+
windowDays: config.windowDays,
|
|
588
|
+
seed: config.seed,
|
|
589
|
+
warmupDays: config.warmupDays,
|
|
590
|
+
})
|
|
591
|
+
const btConfig: BacktestConfig = { costBps: config.costBps, slippageBps: config.slippageBps }
|
|
592
|
+
log(`in-sample: ${T} days x ${universe.tickers.length} tickers; ${windows.length} windows of ${config.windowDays}d (seed ${config.seed})`)
|
|
593
|
+
log(`scoring engine: vectorbt ${await worker.ping()} (persistent worker, numba warm)`)
|
|
594
|
+
|
|
595
|
+
const baselines = await evaluateBaselines(worker, universe, windows, btConfig)
|
|
596
|
+
await appendFile(
|
|
597
|
+
notebookPath,
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
schema: NOTEBOOK_BASELINES_SCHEMA,
|
|
600
|
+
at: new Date().toISOString(),
|
|
601
|
+
seed: config.seed,
|
|
602
|
+
costBps: config.costBps,
|
|
603
|
+
slippageBps: config.slippageBps,
|
|
604
|
+
windows: windows.map((w) => ({
|
|
605
|
+
start: w.start,
|
|
606
|
+
end: w.end,
|
|
607
|
+
startDate: universe.dates[w.start],
|
|
608
|
+
endDate: universe.dates[w.end - 1],
|
|
609
|
+
})),
|
|
610
|
+
perWindowSharpe: baselines.perWindowSharpe,
|
|
611
|
+
bestPerWindow: baselines.bestPerWindow,
|
|
612
|
+
fullSample: baselines.fullSample,
|
|
613
|
+
}) + '\n',
|
|
614
|
+
)
|
|
615
|
+
const campaignRoot = join(config.outDir, 'campaign')
|
|
616
|
+
for (const name of Object.keys(PINNED_BASELINES)) {
|
|
617
|
+
await writeWindowCells(
|
|
618
|
+
campaignRoot,
|
|
619
|
+
`baseline-${name}`,
|
|
620
|
+
windows.map((w, i) => ({
|
|
621
|
+
window: w,
|
|
622
|
+
stats: baselines.perWindowStats[name]![i]!,
|
|
623
|
+
bestBaselineSharpe: baselines.bestPerWindow[i]!,
|
|
624
|
+
})),
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const priorRows = await loadNotebookRows(notebookPath)
|
|
629
|
+
let nTried = priorRows.filter((r) => r.schema === NOTEBOOK_CANDIDATE_SCHEMA).length
|
|
630
|
+
const rows: CandidateRow[] = []
|
|
631
|
+
|
|
632
|
+
for (const proposer of config.proposers) {
|
|
633
|
+
const profile = loadAuthorProfile(proposer)
|
|
634
|
+
for (let shot = 0; shot < config.candidatesPerProposer; shot++) {
|
|
635
|
+
nTried += 1
|
|
636
|
+
const candidateId = `cand-${String(nTried).padStart(3, '0')}-${proposer.name}`
|
|
637
|
+
const threshold = requiredExcessSharpe(nTried)
|
|
638
|
+
log(`--- ${candidateId}: authoring (try #${nTried}, bar ${threshold.toFixed(3)})`)
|
|
639
|
+
|
|
640
|
+
const row: CandidateRow = {
|
|
641
|
+
schema: NOTEBOOK_CANDIDATE_SCHEMA,
|
|
642
|
+
at: new Date().toISOString(),
|
|
643
|
+
candidateId,
|
|
644
|
+
proposer: proposer.name,
|
|
645
|
+
authorModel: config.authorModel,
|
|
646
|
+
strategyPath: null,
|
|
647
|
+
sha256: null,
|
|
648
|
+
authoringCostUsd: null,
|
|
649
|
+
nTried,
|
|
650
|
+
leakAudit: { truncation: null, llm: null },
|
|
651
|
+
eval: null,
|
|
652
|
+
inSampleFull: null,
|
|
653
|
+
verdict: 'rejected-error',
|
|
654
|
+
reasons: [],
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
const prompt = buildAuthorPrompt({
|
|
659
|
+
universe,
|
|
660
|
+
windows,
|
|
661
|
+
baselineTable: baselineTable(baselines),
|
|
662
|
+
threshold,
|
|
663
|
+
nTried,
|
|
664
|
+
...(proposer.lens ? { lens: proposer.lens } : {}),
|
|
665
|
+
})
|
|
666
|
+
const { outcome, costUsd } = await meteredClaudeShot(
|
|
667
|
+
ledger,
|
|
668
|
+
{ phase: 'search.proposal', actor: `proposer-shot:${proposer.name}`, tags: { candidateId } },
|
|
669
|
+
{
|
|
670
|
+
prompt,
|
|
671
|
+
model: config.authorModel,
|
|
672
|
+
...(profile?.prompt?.systemPrompt ? { systemPrompt: profile.prompt.systemPrompt } : {}),
|
|
673
|
+
timeoutMs: config.authorTimeoutMs,
|
|
674
|
+
cwd: config.outDir,
|
|
675
|
+
},
|
|
676
|
+
)
|
|
677
|
+
row.authoringCostUsd = costUsd
|
|
678
|
+
|
|
679
|
+
const extracted = extractStrategySource(outcome.text)
|
|
680
|
+
if (!extracted.ok) {
|
|
681
|
+
row.verdict = 'rejected-contract'
|
|
682
|
+
row.reasons = [extracted.reason]
|
|
683
|
+
} else {
|
|
684
|
+
const strategyDir = join(config.outDir, 'strategies', candidateId)
|
|
685
|
+
await mkdir(strategyDir, { recursive: true })
|
|
686
|
+
const strategyPath = join(strategyDir, 'strategy.ts')
|
|
687
|
+
await writeFile(strategyPath, extracted.code)
|
|
688
|
+
row.strategyPath = strategyPath
|
|
689
|
+
row.sha256 = `sha256:${createHash('sha256').update(extracted.code).digest('hex')}`
|
|
690
|
+
|
|
691
|
+
let strategy: GenerateSignals | null = null
|
|
692
|
+
try {
|
|
693
|
+
// v2 (`onBar`) modules are wrapped through the incremental
|
|
694
|
+
// driver, which structurally truncates history per bar.
|
|
695
|
+
const loaded = await loadStrategyFile(strategyPath, {
|
|
696
|
+
symbols: universe.tickers,
|
|
697
|
+
costBps: config.costBps,
|
|
698
|
+
slippageBps: config.slippageBps,
|
|
699
|
+
})
|
|
700
|
+
strategy = loaded.generateSignals
|
|
701
|
+
} catch (cause) {
|
|
702
|
+
row.verdict = 'rejected-contract'
|
|
703
|
+
row.reasons = [(cause as Error).message.slice(0, 300)]
|
|
704
|
+
}
|
|
705
|
+
if (strategy !== null) {
|
|
706
|
+
// Leak audit stage 1: deterministic truncation invariance.
|
|
707
|
+
const truncation = truncationInvariance(strategy, universe.bars, { warmupDays: config.warmupDays })
|
|
708
|
+
row.leakAudit.truncation = truncation
|
|
709
|
+
// Leak audit stage 2: adversarial source read.
|
|
710
|
+
const llm = config.skipLlmAudit
|
|
711
|
+
? ('skipped' as const)
|
|
712
|
+
: await llmLeakAudit(ledger, config, candidateId, extracted.code)
|
|
713
|
+
row.leakAudit.llm = llm
|
|
714
|
+
const llmBad = llm !== 'skipped' && llm.verdict !== 'clean'
|
|
715
|
+
if (!truncation.clean || llmBad) {
|
|
716
|
+
row.verdict = 'rejected-leak'
|
|
717
|
+
row.reasons = [
|
|
718
|
+
...(truncation.clean
|
|
719
|
+
? []
|
|
720
|
+
: [`truncation divergence at cutoff ${truncation.divergence!.cutoff}: ${truncation.divergence!.detail}`]),
|
|
721
|
+
...(llmBad ? [`adversarial audit: ${(llm as { verdict: string; evidence: string }).evidence || (llm as { verdict: string }).verdict}`] : []),
|
|
722
|
+
]
|
|
723
|
+
} else {
|
|
724
|
+
// Eval: TS prefilter (contract enforcement) + official
|
|
725
|
+
// vectorbt scores, per window against the pinned bar.
|
|
726
|
+
const scored = await scoreStrategySignals(worker, universe, strategy(universe.bars), windows, btConfig)
|
|
727
|
+
row.inSampleFull = scored.full
|
|
728
|
+
const perWindow: WindowScore[] = windows.map((w, i) => {
|
|
729
|
+
const stats = scored.perWindow[i]!
|
|
730
|
+
return {
|
|
731
|
+
start: w.start,
|
|
732
|
+
end: w.end,
|
|
733
|
+
startDate: universe.dates[w.start]!,
|
|
734
|
+
endDate: universe.dates[w.end - 1]!,
|
|
735
|
+
sharpe: stats.sharpe,
|
|
736
|
+
bestBaselineSharpe: baselines.bestPerWindow[i]!,
|
|
737
|
+
excess: stats.sharpe - baselines.bestPerWindow[i]!,
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
await writeWindowCells(
|
|
741
|
+
campaignRoot,
|
|
742
|
+
candidateId,
|
|
743
|
+
windows.map((w, i) => ({
|
|
744
|
+
window: w,
|
|
745
|
+
stats: scored.perWindow[i]!,
|
|
746
|
+
bestBaselineSharpe: baselines.bestPerWindow[i]!,
|
|
747
|
+
})),
|
|
748
|
+
)
|
|
749
|
+
const decision: AcceptanceDecision = decideAcceptance({
|
|
750
|
+
perWindowExcess: perWindow.map((w) => w.excess),
|
|
751
|
+
nTried,
|
|
752
|
+
})
|
|
753
|
+
row.eval = {
|
|
754
|
+
perWindow,
|
|
755
|
+
meanExcessSharpe: decision.meanExcessSharpe,
|
|
756
|
+
wins: decision.wins,
|
|
757
|
+
requiredWins: decision.requiredWins,
|
|
758
|
+
threshold: decision.threshold,
|
|
759
|
+
}
|
|
760
|
+
row.verdict = decision.accepted ? 'accepted' : 'rejected-no-edge'
|
|
761
|
+
row.reasons = decision.reasons
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
} catch (cause) {
|
|
766
|
+
row.verdict = 'rejected-error'
|
|
767
|
+
row.reasons = [(cause as Error).message.slice(0, 500)]
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
await appendFile(notebookPath, JSON.stringify(row) + '\n')
|
|
771
|
+
rows.push(row)
|
|
772
|
+
log(`${candidateId}: ${row.verdict}${row.reasons.length > 0 ? ` — ${row.reasons[0]}` : ''}`)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const manifestPath = await writeQuantRolloutManifest(config.outDir)
|
|
777
|
+
const summary = ledger.summary()
|
|
778
|
+
log(`campaign done: ${rows.length} candidates, ${rows.filter((r) => r.verdict === 'accepted').length} accepted`)
|
|
779
|
+
log(`spend: $${summary.totalCostUsd.toFixed(4)} across ${summary.totalCalls} metered calls (${summary.inputTokens} in / ${summary.outputTokens} out tokens)`)
|
|
780
|
+
log(`notebook -> ${notebookPath}`)
|
|
781
|
+
log(`rollout manifest -> ${manifestPath}`)
|
|
782
|
+
return rows
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// CLI.
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
790
|
+
|
|
791
|
+
if (isMain) {
|
|
792
|
+
const argv = process.argv.slice(2)
|
|
793
|
+
const flag = (name: string): string | undefined => {
|
|
794
|
+
const i = argv.indexOf(name)
|
|
795
|
+
return i !== -1 ? argv[i + 1] : undefined
|
|
796
|
+
}
|
|
797
|
+
const outDir = flag('--out')
|
|
798
|
+
if (!outDir) {
|
|
799
|
+
console.error(
|
|
800
|
+
'usage: tsx src/quant-arena/quant-loop.mts --out <dir> [--candidates 2] [--seed 20260722] ' +
|
|
801
|
+
'[--author-model sonnet] [--audit-model haiku] [--skip-llm-audit] # SPENDS: author + audit shots',
|
|
802
|
+
)
|
|
803
|
+
process.exit(2)
|
|
804
|
+
}
|
|
805
|
+
const config = defaultConfig(outDir)
|
|
806
|
+
if (flag('--candidates')) config.candidatesPerProposer = Number(flag('--candidates'))
|
|
807
|
+
if (flag('--seed')) config.seed = Number(flag('--seed'))
|
|
808
|
+
if (flag('--author-model')) config.authorModel = flag('--author-model')!
|
|
809
|
+
if (flag('--audit-model')) config.auditModel = flag('--audit-model')!
|
|
810
|
+
if (argv.includes('--skip-llm-audit')) config.skipLlmAudit = true
|
|
811
|
+
const rows = await runQuantCampaign(config)
|
|
812
|
+
process.exitCode = rows.some((r) => r.verdict === 'rejected-error') ? 1 : 0
|
|
813
|
+
}
|