@tangle-network/agent-bench 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/HARNESS.md +43 -0
  3. package/dist/adapters.js +23 -23
  4. package/dist/benchmarks/_harness.d.ts +1 -1
  5. package/dist/benchmarks/_harness.js +1 -1
  6. package/dist/benchmarks/aec-bench.js +2 -2
  7. package/dist/benchmarks/agentbench.js +2 -2
  8. package/dist/benchmarks/appworld.js +2 -2
  9. package/dist/benchmarks/bfcl.js +2 -2
  10. package/dist/benchmarks/commit0.js +2 -2
  11. package/dist/benchmarks/crag.js +2 -2
  12. package/dist/benchmarks/dabstep.js +2 -2
  13. package/dist/benchmarks/enterpriseops-gym.js +2 -2
  14. package/dist/benchmarks/finresearchbench.js +2 -2
  15. package/dist/benchmarks/nomiracl.js +2 -2
  16. package/dist/benchmarks/open-rag-bench.js +2 -2
  17. package/dist/benchmarks/programbench.js +2 -2
  18. package/dist/benchmarks/ragbench.js +2 -2
  19. package/dist/benchmarks/swe-bench.js +2 -2
  20. package/dist/benchmarks/t2-ragbench.js +2 -2
  21. package/dist/benchmarks/tau-bench-shared.js +2 -2
  22. package/dist/benchmarks/tau2-bench.js +3 -3
  23. package/dist/benchmarks/tau3-banking.js +3 -3
  24. package/dist/benchmarks/terminal-bench.js +2 -2
  25. package/dist/benchmarks/toollm.js +2 -2
  26. package/dist/benchmarks/webarena-verified.js +2 -2
  27. package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
  28. package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
  29. package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
  30. package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
  31. package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
  32. package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
  33. package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
  34. package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
  35. package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
  36. package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
  37. package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
  38. package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
  39. package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
  40. package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
  41. package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
  42. package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
  43. package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
  44. package/dist/{chunk-PWQVGAJB.js → chunk-V7AEBY6U.js} +21 -21
  45. package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
  46. package/dist/chunk-WSKWVEQB.js.map +1 -0
  47. package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
  48. package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
  49. package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
  50. package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
  51. package/dist/index.js +23 -23
  52. package/package.json +4 -4
  53. package/scripts/verify-packed-consumer.mjs +1 -1
  54. package/src/benchmarks/_harness.ts +20 -2
  55. package/src/benchmarks/humaneval.test.mts +2 -2
  56. package/src/quant-arena/README.md +144 -0
  57. package/src/quant-arena/backtest.test.mts +135 -0
  58. package/src/quant-arena/backtest.ts +218 -0
  59. package/src/quant-arena/data.test.mts +44 -0
  60. package/src/quant-arena/data.ts +141 -0
  61. package/src/quant-arena/driver.test.mts +253 -0
  62. package/src/quant-arena/driver.ts +219 -0
  63. package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
  64. package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
  65. package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
  66. package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
  67. package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
  68. package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
  69. package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
  70. package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
  71. package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
  72. package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
  73. package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
  74. package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
  75. package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
  76. package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
  77. package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
  78. package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
  79. package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
  80. package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
  81. package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
  82. package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
  83. package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
  84. package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
  85. package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
  86. package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
  87. package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
  88. package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
  89. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
  90. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
  91. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
  92. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
  93. package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
  94. package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
  95. package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
  96. package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
  97. package/src/quant-arena/holdout-certify.mts +206 -0
  98. package/src/quant-arena/holdout-certify.test.mts +82 -0
  99. package/src/quant-arena/leak-audit.test.mts +79 -0
  100. package/src/quant-arena/leak-audit.ts +95 -0
  101. package/src/quant-arena/make-fixtures.mts +161 -0
  102. package/src/quant-arena/multiplicity.test.mts +68 -0
  103. package/src/quant-arena/multiplicity.ts +87 -0
  104. package/src/quant-arena/nautilus-certify.ts +31 -0
  105. package/src/quant-arena/oms.ts +90 -0
  106. package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
  107. package/src/quant-arena/python/pyproject.toml +8 -0
  108. package/src/quant-arena/python/uv.lock +1297 -0
  109. package/src/quant-arena/python/vbt-worker.py +192 -0
  110. package/src/quant-arena/quant-loop.mts +813 -0
  111. package/src/quant-arena/quant-loop.test.mts +75 -0
  112. package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
  113. package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
  114. package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
  115. package/src/quant-arena/types.ts +133 -0
  116. package/src/quant-arena/vbt-client.ts +321 -0
  117. package/src/quant-arena/vbt-parity.test.mts +183 -0
  118. package/src/quant-arena/windows.test.mts +45 -0
  119. package/src/quant-arena/windows.ts +54 -0
  120. package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
  121. package/src/rollout-ledger/backfill-swe-arena.test.mts +338 -0
  122. package/src/rollout-ledger/settle-capture.mts +442 -0
  123. package/src/rollout-ledger/settle-capture.test.mts +270 -0
  124. package/src/swe-arena/activation.mts +228 -0
  125. package/src/swe-arena/activation.test.mts +303 -0
  126. package/src/swe-arena/arms.ts +30 -14
  127. package/src/swe-arena/briefing.mts +217 -0
  128. package/src/swe-arena/briefing.test.mts +178 -0
  129. package/src/swe-arena/calibrate.ts +102 -1
  130. package/src/swe-arena/cell-evidence.mts +41 -9
  131. package/src/swe-arena/factory-command-container.ts +284 -0
  132. package/src/swe-arena/factory-judge-child.mts +228 -0
  133. package/src/swe-arena/factory.test.mts +643 -0
  134. package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
  135. package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
  136. package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
  137. package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
  138. package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
  139. package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
  140. package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
  141. package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
  142. package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
  143. package/src/swe-arena/fixtures/run-report/README.md +43 -0
  144. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
  145. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
  146. package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
  147. package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
  148. package/src/swe-arena/fixtures.ts +135 -2
  149. package/src/swe-arena/gepa-seat.mts +583 -0
  150. package/src/swe-arena/gepa-seat.test.mts +635 -0
  151. package/src/swe-arena/ledger-orphans.mts +77 -0
  152. package/src/swe-arena/ledger-orphans.test.mts +147 -0
  153. package/src/swe-arena/lineage-record.mts +164 -0
  154. package/src/swe-arena/lineage-record.test.mts +115 -0
  155. package/src/swe-arena/outer-loop.mts +677 -27
  156. package/src/swe-arena/premeasured-from-cells.mts +281 -0
  157. package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
  158. package/src/swe-arena/proposer-fanout.mts +295 -14
  159. package/src/swe-arena/proposer-fanout.test.mts +247 -0
  160. package/src/swe-arena/proposer-provenance.mts +177 -0
  161. package/src/swe-arena/proposer-provenance.test.mts +106 -0
  162. package/src/swe-arena/run-experiment.mts +367 -1
  163. package/src/swe-arena/run-report.mts +75 -0
  164. package/src/swe-arena/run-supervisor.test.mts +5 -3
  165. package/src/swe-arena/score-split.mts +140 -0
  166. package/src/swe-arena/score-split.test.mts +123 -0
  167. package/src/swe-arena/types.ts +52 -0
  168. package/src/swe-structural.mts +245 -837
  169. package/dist/chunk-LRRD7NAG.js.map +0 -1
  170. /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
  171. /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
  172. /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
  173. /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
  174. /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
  175. /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
  176. /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
  177. /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
  178. /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
  179. /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
  180. /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
  181. /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
  182. /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
  183. /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
  184. /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
  185. /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
  186. /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
  187. /package/dist/{chunk-PWQVGAJB.js.map → chunk-V7AEBY6U.js.map} +0 -0
  188. /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
  189. /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
  190. /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
  191. /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
+ }