@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.
Files changed (211) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/HARNESS.md +43 -0
  3. package/README.md +7 -0
  4. package/dist/adapters.js +23 -23
  5. package/dist/benchmarks/_harness.d.ts +1 -1
  6. package/dist/benchmarks/_harness.js +1 -1
  7. package/dist/benchmarks/aec-bench.js +2 -2
  8. package/dist/benchmarks/agentbench.js +2 -2
  9. package/dist/benchmarks/appworld.js +2 -2
  10. package/dist/benchmarks/bfcl.js +2 -2
  11. package/dist/benchmarks/commit0.js +2 -2
  12. package/dist/benchmarks/crag.js +2 -2
  13. package/dist/benchmarks/dabstep.js +2 -2
  14. package/dist/benchmarks/enterpriseops-gym.js +2 -2
  15. package/dist/benchmarks/finresearchbench.js +2 -2
  16. package/dist/benchmarks/nomiracl.js +2 -2
  17. package/dist/benchmarks/open-rag-bench.js +2 -2
  18. package/dist/benchmarks/programbench.js +2 -2
  19. package/dist/benchmarks/ragbench.js +2 -2
  20. package/dist/benchmarks/swe-bench.js +2 -2
  21. package/dist/benchmarks/t2-ragbench.js +2 -2
  22. package/dist/benchmarks/tau-bench-shared.js +2 -2
  23. package/dist/benchmarks/tau2-bench.js +3 -3
  24. package/dist/benchmarks/tau3-banking.js +3 -3
  25. package/dist/benchmarks/terminal-bench.js +2 -2
  26. package/dist/benchmarks/toollm.js +2 -2
  27. package/dist/benchmarks/webarena-verified.js +2 -2
  28. package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
  29. package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
  30. package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
  31. package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
  32. package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
  33. package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
  34. package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
  35. package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
  36. package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
  37. package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
  38. package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
  39. package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
  40. package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
  41. package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
  42. package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
  43. package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
  44. package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
  45. package/dist/{chunk-PWQVGAJB.js → chunk-V7AEBY6U.js} +21 -21
  46. package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
  47. package/dist/chunk-WSKWVEQB.js.map +1 -0
  48. package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
  49. package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
  50. package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
  51. package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
  52. package/dist/index.js +23 -23
  53. package/package.json +5 -5
  54. package/scripts/trata-hedge/README.md +6 -5
  55. package/scripts/verify-packed-consumer.mjs +1 -1
  56. package/src/benchmarks/_harness.ts +20 -2
  57. package/src/benchmarks/humaneval.test.mts +2 -2
  58. package/src/gate.ts +1 -1
  59. package/src/hev-eval.mts +5 -2
  60. package/src/hev-improve.mts +118 -73
  61. package/src/official-optimizer-config.mts +89 -0
  62. package/src/official-optimizer-config.test.mts +88 -0
  63. package/src/profiles.ts +2 -2
  64. package/src/quant-arena/README.md +144 -0
  65. package/src/quant-arena/backtest.test.mts +135 -0
  66. package/src/quant-arena/backtest.ts +218 -0
  67. package/src/quant-arena/data.test.mts +44 -0
  68. package/src/quant-arena/data.ts +141 -0
  69. package/src/quant-arena/driver.test.mts +253 -0
  70. package/src/quant-arena/driver.ts +219 -0
  71. package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
  72. package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
  73. package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
  74. package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
  75. package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
  76. package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
  77. package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
  78. package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
  79. package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
  80. package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
  81. package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
  82. package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
  83. package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
  84. package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
  85. package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
  86. package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
  87. package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
  88. package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
  89. package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
  90. package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
  91. package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
  92. package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
  93. package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
  94. package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
  95. package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
  96. package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
  97. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
  98. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
  99. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
  100. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
  101. package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
  102. package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
  103. package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
  104. package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
  105. package/src/quant-arena/holdout-certify.mts +206 -0
  106. package/src/quant-arena/holdout-certify.test.mts +82 -0
  107. package/src/quant-arena/leak-audit.test.mts +79 -0
  108. package/src/quant-arena/leak-audit.ts +95 -0
  109. package/src/quant-arena/make-fixtures.mts +161 -0
  110. package/src/quant-arena/multiplicity.test.mts +68 -0
  111. package/src/quant-arena/multiplicity.ts +87 -0
  112. package/src/quant-arena/nautilus-certify.ts +31 -0
  113. package/src/quant-arena/oms.ts +90 -0
  114. package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
  115. package/src/quant-arena/python/pyproject.toml +8 -0
  116. package/src/quant-arena/python/uv.lock +1297 -0
  117. package/src/quant-arena/python/vbt-worker.py +192 -0
  118. package/src/quant-arena/quant-loop.mts +813 -0
  119. package/src/quant-arena/quant-loop.test.mts +75 -0
  120. package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
  121. package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
  122. package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
  123. package/src/quant-arena/types.ts +133 -0
  124. package/src/quant-arena/vbt-client.ts +321 -0
  125. package/src/quant-arena/vbt-parity.test.mts +183 -0
  126. package/src/quant-arena/windows.test.mts +45 -0
  127. package/src/quant-arena/windows.ts +54 -0
  128. package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
  129. package/src/rollout-ledger/backfill-swe-arena.test.mts +342 -0
  130. package/src/rollout-ledger/settle-capture.mts +442 -0
  131. package/src/rollout-ledger/settle-capture.test.mts +270 -0
  132. package/src/smoke-structural-rollout.mts +15 -9
  133. package/src/swe-arena/activation.mts +225 -0
  134. package/src/swe-arena/activation.test.mts +300 -0
  135. package/src/swe-arena/arms.ts +30 -14
  136. package/src/swe-arena/briefing.mts +217 -0
  137. package/src/swe-arena/briefing.test.mts +178 -0
  138. package/src/swe-arena/calibrate.ts +102 -1
  139. package/src/swe-arena/cell-evidence.mts +41 -9
  140. package/src/swe-arena/factory-command-container.ts +284 -0
  141. package/src/swe-arena/factory-judge-child.mts +228 -0
  142. package/src/swe-arena/factory.test.mts +643 -0
  143. package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
  144. package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
  145. package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
  146. package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
  147. package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
  148. package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
  149. package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
  150. package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
  151. package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
  152. package/src/swe-arena/fixtures/run-report/README.md +43 -0
  153. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
  154. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
  155. package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
  156. package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
  157. package/src/swe-arena/fixtures.ts +135 -2
  158. package/src/swe-arena/gepa-seat.mts +877 -0
  159. package/src/swe-arena/gepa-seat.test.mts +1059 -0
  160. package/src/swe-arena/implementation-ref.test.mts +64 -0
  161. package/src/swe-arena/implementation-ref.ts +62 -0
  162. package/src/swe-arena/ledger-orphans.mts +77 -0
  163. package/src/swe-arena/ledger-orphans.test.mts +147 -0
  164. package/src/swe-arena/outer-loop.mts +710 -33
  165. package/src/swe-arena/premeasured-from-cells.mts +281 -0
  166. package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
  167. package/src/swe-arena/proposer-fanout.mts +327 -31
  168. package/src/swe-arena/proposer-fanout.test.mts +246 -0
  169. package/src/swe-arena/proposer-provenance.mts +172 -0
  170. package/src/swe-arena/proposer-provenance.test.mts +106 -0
  171. package/src/swe-arena/run-experiment.mts +367 -1
  172. package/src/swe-arena/run-report.mts +75 -0
  173. package/src/swe-arena/run-supervisor.test.mts +5 -3
  174. package/src/swe-arena/score-split.mts +140 -0
  175. package/src/swe-arena/score-split.test.mts +123 -0
  176. package/src/swe-arena/scratch-worktree.test.mts +55 -0
  177. package/src/swe-arena/scratch-worktree.ts +34 -0
  178. package/src/swe-arena/types.ts +52 -0
  179. package/src/swe-code-improve.mts +24 -25
  180. package/src/swe-improve.mts +129 -96
  181. package/src/swe-local-proof.mts +6 -1
  182. package/src/swe-stream.mts +4 -2
  183. package/src/swe-structural.mts +245 -837
  184. package/src/tb-container-executor.test.mts +30 -6
  185. package/src/tb-supervisor-sidecar.mts +2 -1
  186. package/src/trata-gepa.mts +182 -245
  187. package/dist/chunk-LRRD7NAG.js.map +0 -1
  188. package/src/live-improve-campaign-mbpp.mts +0 -641
  189. package/src/live-improve-campaign.mts +0 -500
  190. /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
  191. /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
  192. /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
  193. /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
  194. /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
  195. /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
  196. /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
  197. /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
  198. /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
  199. /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
  200. /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
  201. /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
  202. /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
  203. /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
  204. /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
  205. /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
  206. /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
  207. /package/dist/{chunk-PWQVGAJB.js.map → chunk-V7AEBY6U.js.map} +0 -0
  208. /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
  209. /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
  210. /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
  211. /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { fillSchedule, runBacktest, statsForRange } from './backtest.ts'
3
+ import type { Bar } from './types.ts'
4
+
5
+ /** Flat-price bar helper. */
6
+ const bar = (date: string, open: number, close = open): Bar => ({
7
+ date,
8
+ open,
9
+ high: Math.max(open, close),
10
+ low: Math.min(open, close),
11
+ close,
12
+ volume: 1000,
13
+ })
14
+
15
+ const dates = (n: number): string[] =>
16
+ Array.from({ length: n }, (_, i) => `2020-01-${String(i + 1).padStart(2, '0')}`)
17
+
18
+ const flatSeries = (n: number, price: number): Bar[] => dates(n).map((d) => bar(d, price))
19
+
20
+ const NO_COST = { costBps: 0, slippageBps: 0 }
21
+
22
+ describe('runBacktest hand-computed toy cases', () => {
23
+ it('all-cash (no signals) stays at equity 1 with zero trades', () => {
24
+ const result = runBacktest([flatSeries(5, 100)], [], NO_COST)
25
+ expect(result.equity).toEqual([1, 1, 1, 1, 1])
26
+ expect(result.stats.tradeCount).toBe(0)
27
+ expect(result.stats.totalReturn).toBe(0)
28
+ })
29
+
30
+ it('full weight on a flat ticker loses exactly the round-trip-free fee', () => {
31
+ // 10bps cost + 5bps slippage on 1.0 traded dollars = 15bps once.
32
+ const result = runBacktest([flatSeries(5, 100)], [{ t: 0, weights: [1] }], { costBps: 10, slippageBps: 5 })
33
+ expect(result.stats.tradeCount).toBe(1)
34
+ expect(result.stats.totalReturn).toBeCloseTo(-0.0015, 12)
35
+ expect(result.equity[4]).toBeCloseTo(1 - 0.0015, 12)
36
+ })
37
+
38
+ it('captures a known move: buy at open, ride to close', () => {
39
+ // Day1: open 100 -> close 110 with full weight decided at day0 close.
40
+ const series = [bar('2020-01-01', 100), bar('2020-01-02', 100, 110), bar('2020-01-03', 110)]
41
+ const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
42
+ expect(result.equity[1]).toBeCloseTo(1.1, 12)
43
+ expect(result.stats.totalReturn).toBeCloseTo(0.1, 12)
44
+ })
45
+
46
+ it('fills at the NEXT open, not the signal-day close (no free look-ahead)', () => {
47
+ // Price gaps 100 -> 200 overnight after the signal. A same-close fill would
48
+ // double money on the gap; a next-open fill must NOT.
49
+ const series = [bar('2020-01-01', 100, 100), bar('2020-01-02', 200, 200), bar('2020-01-03', 300, 300)]
50
+ const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
51
+ // Bought at 200 (day2 open); close day2 = 200 -> equity 1; day3 300/200 = 1.5.
52
+ expect(result.equity[1]).toBeCloseTo(1, 12)
53
+ expect(result.equity[2]).toBeCloseTo(1.5, 12)
54
+ })
55
+
56
+ it('max drawdown on a crafted path: peak 1.2 -> trough 0.9 = 25%', () => {
57
+ const series = [
58
+ bar('2020-01-01', 100),
59
+ bar('2020-01-02', 100, 120),
60
+ bar('2020-01-03', 120, 90),
61
+ bar('2020-01-04', 90, 100),
62
+ ]
63
+ const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
64
+ expect(result.equity).toEqual([1, 1.2, 0.9, 1.0].map((v) => expect.closeTo(v, 12) as unknown as number))
65
+ expect(result.stats.maxDrawdown).toBeCloseTo((1.2 - 0.9) / 1.2, 12)
66
+ })
67
+
68
+ it('splits weights across two tickers and holds cash for the rest', () => {
69
+ const a = [bar('2020-01-01', 100), bar('2020-01-02', 100, 110)]
70
+ const b = [bar('2020-01-01', 50), bar('2020-01-02', 50, 45)]
71
+ const result = runBacktest([a, b], [{ t: 0, weights: [0.5, 0.25] }], NO_COST)
72
+ // 0.5 * +10% + 0.25 * -10% + 0.25 cash = 1 + 0.05 - 0.025 = 1.025
73
+ expect(result.equity[1]).toBeCloseTo(1.025, 12)
74
+ expect(result.stats.tradeCount).toBe(2)
75
+ })
76
+
77
+ it('is deterministic: identical inputs give identical equity paths', () => {
78
+ const series = [flatSeries(30, 100).map((b, i) => bar(b.date, 100 + i, 101 + i))]
79
+ const signals = [
80
+ { t: 0, weights: [0.7] },
81
+ { t: 10, weights: [0.2] },
82
+ { t: 20, weights: [1] },
83
+ ]
84
+ const r1 = runBacktest(series as Bar[][], signals, { costBps: 10, slippageBps: 5 })
85
+ const r2 = runBacktest(series as Bar[][], signals, { costBps: 10, slippageBps: 5 })
86
+ expect(r1.equity).toEqual(r2.equity)
87
+ expect(r1.stats).toEqual(r2.stats)
88
+ })
89
+
90
+ it('rejects shorting, leverage, and misaligned universes (fail-closed)', () => {
91
+ const series = [flatSeries(3, 100)]
92
+ expect(() => runBacktest(series, [{ t: 0, weights: [-0.1] }], NO_COST)).toThrow(/no shorting/)
93
+ expect(() => runBacktest(series, [{ t: 0, weights: [1.2] }], NO_COST)).toThrow(/no leverage/)
94
+ expect(() => runBacktest([flatSeries(3, 100), flatSeries(4, 50)], [], NO_COST)).toThrow(/unaligned/)
95
+ expect(() => runBacktest(series, [{ t: 5, weights: [1] }], NO_COST)).toThrow(/outside/)
96
+ })
97
+
98
+ it('rebalances ONLY on emitted signals — positions drift in between', () => {
99
+ // One signal at t=0, then a price runup: the winning position is NOT
100
+ // trimmed back to its target weight on later days (no hidden churn).
101
+ const a = [bar('2020-01-01', 100), bar('2020-01-02', 100), bar('2020-01-03', 100, 200), bar('2020-01-04', 200)]
102
+ const result = runBacktest([a], [{ t: 0, weights: [0.5] }], NO_COST)
103
+ expect(result.stats.tradeCount).toBe(1)
104
+ // 0.5 in the ticker doubled -> equity 1.5, still only one fill ever.
105
+ expect(result.equity[3]).toBeCloseTo(1.5, 12)
106
+ const schedule = fillSchedule([{ t: 1, weights: [0.5] }], 1, 4)
107
+ expect(schedule.get(1)).toEqual([0.5])
108
+ expect(schedule.has(0)).toBe(false)
109
+ })
110
+ })
111
+
112
+ describe('statsForRange window slicing', () => {
113
+ it('scores only the window and carries positions in', () => {
114
+ // Flat first half, +10% single day in the second half.
115
+ const series = [
116
+ bar('2020-01-01', 100),
117
+ bar('2020-01-02', 100),
118
+ bar('2020-01-03', 100),
119
+ bar('2020-01-04', 100, 110),
120
+ bar('2020-01-05', 110),
121
+ ]
122
+ const result = runBacktest([series], [{ t: 0, weights: [1] }], NO_COST)
123
+ const firstHalf = statsForRange(result, 0, 3)
124
+ const secondHalf = statsForRange(result, 2, 5)
125
+ expect(firstHalf.totalReturn).toBeCloseTo(0, 12)
126
+ expect(secondHalf.totalReturn).toBeCloseTo(0.1, 12)
127
+ expect(secondHalf.days).toBe(3)
128
+ })
129
+
130
+ it('rejects degenerate ranges', () => {
131
+ const result = runBacktest([flatSeries(5, 100)], [], NO_COST)
132
+ expect(() => statsForRange(result, 3, 3)).toThrow(/bad range/)
133
+ expect(() => statsForRange(result, 0, 99)).toThrow(/bad range/)
134
+ })
135
+ })
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Daily-bar event-loop backtester. Zero dependencies beyond the Node stdlib
3
+ * (in fact: zero imports at all except the local types) so a reviewer can
4
+ * audit the whole execution model in one file.
5
+ *
6
+ * Execution model (conservative by construction):
7
+ * - A signal decided at the close of day t is FILLED at the open of day t+1.
8
+ * Nothing trades on the bar that produced the decision.
9
+ * - Every rebalance pays `costBps + slippageBps` (basis points, one-way) on
10
+ * the dollars traded, deducted from cash at the fill.
11
+ * - No shorting, no leverage: weights >= 0, sum <= 1. Violations THROW —
12
+ * a strategy that asks for leverage is a broken candidate, not a clamped one.
13
+ * - Cash earns 0. A fully-invested target therefore carries a small negative
14
+ * cash balance equal to accumulated fees (fees financed at 0% — this only
15
+ * ever understates performance, never flatters it).
16
+ *
17
+ * Determinism: pure function of (bars, signals, config). No clock, no RNG.
18
+ */
19
+
20
+ import type { Bar, Signal } from './types.ts'
21
+
22
+ export interface BacktestConfig {
23
+ /** One-way transaction cost, basis points of traded dollars. */
24
+ costBps: number
25
+ /** One-way slippage, basis points of traded dollars. */
26
+ slippageBps: number
27
+ }
28
+
29
+ export const TRADING_DAYS_PER_YEAR = 252
30
+
31
+ export interface RangeStats {
32
+ /** First/last day index of the scored range (inclusive start, exclusive end). */
33
+ start: number
34
+ end: number
35
+ days: number
36
+ totalReturn: number
37
+ maxDrawdown: number
38
+ /** Annualized Sharpe of daily returns (rf = 0). 0 when volatility is 0. */
39
+ sharpe: number
40
+ /** Fills with |trade| > 1e-9 × equity, summed over the range. */
41
+ tradeCount: number
42
+ /** Σ traded dollars / equity at each rebalance (two-sided turnover). */
43
+ turnover: number
44
+ }
45
+
46
+ export interface BacktestResult {
47
+ dates: string[]
48
+ /** Equity at each day's close; equity[0] = 1 (all cash at the first close). */
49
+ equity: number[]
50
+ /** dailyReturns[i] = equity[i+1] / equity[i] - 1. */
51
+ dailyReturns: number[]
52
+ /** Per-day fill count / turnover fraction (index = fill day). */
53
+ fillsByDay: number[]
54
+ turnoverByDay: number[]
55
+ stats: RangeStats
56
+ }
57
+
58
+ const WEIGHT_EPS = 1e-9
59
+
60
+ export function assertAligned(bars: Bar[][]): void {
61
+ if (bars.length === 0) throw new Error('backtest: empty universe')
62
+ const T = bars[0]!.length
63
+ if (T < 2) throw new Error('backtest: need at least 2 days of bars')
64
+ for (let k = 1; k < bars.length; k++) {
65
+ if (bars[k]!.length !== T) {
66
+ throw new Error(`backtest: ticker ${k} has ${bars[k]!.length} bars, ticker 0 has ${T} — unaligned`)
67
+ }
68
+ for (let t = 0; t < T; t++) {
69
+ if (bars[k]![t]!.date !== bars[0]![t]!.date) {
70
+ throw new Error(`backtest: date mismatch at t=${t}: ${bars[k]![t]!.date} vs ${bars[0]![t]!.date}`)
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ /** Validate one signal against the contract. Throws on violation. */
77
+ export function assertSignal(signal: Signal, tickers: number, totalDays: number): void {
78
+ if (!Number.isInteger(signal.t) || signal.t < 0 || signal.t >= totalDays) {
79
+ throw new Error(`backtest: signal.t=${signal.t} outside [0, ${totalDays})`)
80
+ }
81
+ if (signal.weights.length !== tickers) {
82
+ throw new Error(`backtest: signal at t=${signal.t} has ${signal.weights.length} weights, universe has ${tickers}`)
83
+ }
84
+ let sum = 0
85
+ for (const w of signal.weights) {
86
+ if (!Number.isFinite(w)) throw new Error(`backtest: non-finite weight at t=${signal.t}`)
87
+ if (w < -WEIGHT_EPS) throw new Error(`backtest: negative weight ${w} at t=${signal.t} — no shorting`)
88
+ sum += w
89
+ }
90
+ if (sum > 1 + 1e-6) throw new Error(`backtest: weights sum ${sum} > 1 at t=${signal.t} — no leverage`)
91
+ }
92
+
93
+ /** Map of decision day -> target weights. A rebalance is executed ONLY at the
94
+ * open following an emitted signal; between signals, positions drift with
95
+ * prices (no silent daily re-targeting, no hidden fee drag). */
96
+ export function fillSchedule(signals: Signal[], tickers: number, totalDays: number): Map<number, number[]> {
97
+ const sorted = [...signals].sort((a, b) => a.t - b.t)
98
+ for (let i = 1; i < sorted.length; i++) {
99
+ if (sorted[i]!.t === sorted[i - 1]!.t) {
100
+ throw new Error(`backtest: two signals share t=${sorted[i]!.t} — a strategy emits at most one target per day`)
101
+ }
102
+ }
103
+ const schedule = new Map<number, number[]>()
104
+ for (const s of sorted) {
105
+ assertSignal(s, tickers, totalDays)
106
+ schedule.set(s.t, s.weights)
107
+ }
108
+ return schedule
109
+ }
110
+
111
+ export function runBacktest(bars: Bar[][], signals: Signal[], config: BacktestConfig): BacktestResult {
112
+ assertAligned(bars)
113
+ const N = bars.length
114
+ const T = bars[0]!.length
115
+ const fills = fillSchedule(signals, N, T)
116
+ const feeRate = (config.costBps + config.slippageBps) / 10_000
117
+
118
+ const equity: number[] = new Array(T).fill(0)
119
+ const fillsByDay: number[] = new Array(T).fill(0)
120
+ const turnoverByDay: number[] = new Array(T).fill(0)
121
+ let cash = 1
122
+ const pos: number[] = new Array(N).fill(0)
123
+ equity[0] = 1
124
+
125
+ for (let t = 1; t < T; t++) {
126
+ // Overnight: yesterday's close -> today's open.
127
+ for (let k = 0; k < N; k++) {
128
+ const prevClose = bars[k]![t - 1]!.close
129
+ if (prevClose <= 0) throw new Error(`backtest: nonpositive close for ticker ${k} at t=${t - 1}`)
130
+ pos[k] = pos[k]! * (bars[k]![t]!.open / prevClose)
131
+ }
132
+ // Fill the target decided at yesterday's close, at today's open.
133
+ const target = fills.get(t - 1)
134
+ if (target !== undefined) {
135
+ let equityOpen = cash
136
+ for (let k = 0; k < N; k++) equityOpen += pos[k]!
137
+ if (equityOpen <= 0) throw new Error(`backtest: equity wiped out at t=${t}`)
138
+ let traded = 0
139
+ let fills = 0
140
+ for (let k = 0; k < N; k++) {
141
+ const desired = target[k]! * equityOpen
142
+ const delta = desired - pos[k]!
143
+ traded += Math.abs(delta)
144
+ if (Math.abs(delta) > WEIGHT_EPS * equityOpen) fills++
145
+ cash -= delta
146
+ pos[k] = desired
147
+ }
148
+ const fee = traded * feeRate
149
+ cash -= fee
150
+ fillsByDay[t] = fills
151
+ turnoverByDay[t] = traded / equityOpen
152
+ }
153
+ // Intraday: today's open -> today's close.
154
+ for (let k = 0; k < N; k++) {
155
+ const open = bars[k]![t]!.open
156
+ if (open <= 0) throw new Error(`backtest: nonpositive open for ticker ${k} at t=${t}`)
157
+ pos[k] = pos[k]! * (bars[k]![t]!.close / open)
158
+ }
159
+ let eq = cash
160
+ for (let k = 0; k < N; k++) eq += pos[k]!
161
+ equity[t] = eq
162
+ }
163
+
164
+ const dailyReturns: number[] = new Array(T - 1)
165
+ for (let i = 0; i < T - 1; i++) dailyReturns[i] = equity[i + 1]! / equity[i]! - 1
166
+
167
+ const result: BacktestResult = {
168
+ dates: bars[0]!.map((b) => b.date),
169
+ equity,
170
+ dailyReturns,
171
+ fillsByDay,
172
+ turnoverByDay,
173
+ stats: undefined as unknown as RangeStats,
174
+ }
175
+ result.stats = statsForRange(result, 0, T)
176
+ return result
177
+ }
178
+
179
+ /** Score a sub-range [start, end) of an existing backtest — the walk-forward
180
+ * window view. Positions carried into the window count; nothing after `end`
181
+ * leaks in. */
182
+ export function statsForRange(result: BacktestResult, start: number, end: number): RangeStats {
183
+ const T = result.equity.length
184
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > T || end - start < 2) {
185
+ throw new Error(`statsForRange: bad range [${start}, ${end}) over ${T} days`)
186
+ }
187
+ const eq = result.equity.slice(start, end)
188
+ const totalReturn = eq[eq.length - 1]! / eq[0]! - 1
189
+ let peak = eq[0]!
190
+ let maxDrawdown = 0
191
+ for (const e of eq) {
192
+ if (e > peak) peak = e
193
+ const dd = (peak - e) / peak
194
+ if (dd > maxDrawdown) maxDrawdown = dd
195
+ }
196
+ const returns = result.dailyReturns.slice(start, end - 1)
197
+ const n = returns.length
198
+ const mean = returns.reduce((s, r) => s + r, 0) / n
199
+ const variance = n > 1 ? returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (n - 1) : 0
200
+ const std = Math.sqrt(variance)
201
+ const sharpe = std > 0 ? (mean / std) * Math.sqrt(TRADING_DAYS_PER_YEAR) : 0
202
+ let tradeCount = 0
203
+ let turnover = 0
204
+ for (let t = start + 1; t < end; t++) {
205
+ tradeCount += result.fillsByDay[t]!
206
+ turnover += result.turnoverByDay[t]!
207
+ }
208
+ return {
209
+ start,
210
+ end,
211
+ days: end - start,
212
+ totalReturn,
213
+ maxDrawdown,
214
+ sharpe,
215
+ tradeCount,
216
+ turnover,
217
+ }
218
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readdir } from 'node:fs/promises'
3
+ import {
4
+ HOLDOUT_DIR,
5
+ HOLDOUT_START,
6
+ IN_SAMPLE_DIR,
7
+ INDEX_TICKER,
8
+ loadInSample,
9
+ parseStooqCsv,
10
+ } from './data.ts'
11
+
12
+ describe('parseStooqCsv', () => {
13
+ it('parses the vendored CSV format', () => {
14
+ const bars = parseStooqCsv('Date,Open,High,Low,Close,Volume\n2020-01-02,10,11,9,10.5,12345\n2020-01-03,10.5,12,10,11,999\n', 'test')
15
+ expect(bars).toHaveLength(2)
16
+ expect(bars[0]).toEqual({ date: '2020-01-02', open: 10, high: 11, low: 9, close: 10.5, volume: 12345 })
17
+ })
18
+
19
+ it('fails loud on descending dates, bad prices, and foreign headers', () => {
20
+ expect(() => parseStooqCsv('Date,Open,High,Low,Close,Volume\n2020-01-03,10,11,9,10,1\n2020-01-02,10,11,9,10,1\n', 't')).toThrow(/ascending/)
21
+ expect(() => parseStooqCsv('Date,Open,High,Low,Close,Volume\n2020-01-02,0,11,9,10,1\n', 't')).toThrow(/nonpositive/)
22
+ expect(() => parseStooqCsv('Ticker,Per,Date\nX,D,2020\n', 't')).toThrow(/header/)
23
+ })
24
+ })
25
+
26
+ describe('vendored fixture integrity (in-sample side only — the holdout is not read here)', () => {
27
+ it('in-sample and holdout are physically separate directories with the same tickers', async () => {
28
+ const inFiles = (await readdir(IN_SAMPLE_DIR)).filter((f) => f.endsWith('.csv')).sort()
29
+ const outFiles = (await readdir(HOLDOUT_DIR)).filter((f) => f.endsWith('.csv')).sort()
30
+ expect(inFiles).toEqual(outFiles)
31
+ expect(inFiles).toContain(`${INDEX_TICKER}.csv`)
32
+ expect(inFiles.length).toBe(11)
33
+ })
34
+
35
+ it('loadInSample aligns the universe, leads with the index, and never crosses into the holdout era', async () => {
36
+ const aligned = await loadInSample()
37
+ expect(aligned.tickers[0]).toBe(INDEX_TICKER)
38
+ expect(aligned.tickers).toHaveLength(11)
39
+ expect(aligned.bars).toHaveLength(11)
40
+ for (const series of aligned.bars) expect(series).toHaveLength(aligned.dates.length)
41
+ expect(aligned.dates.length).toBeGreaterThan(1800)
42
+ expect(aligned.dates[aligned.dates.length - 1]! < HOLDOUT_START).toBe(true)
43
+ })
44
+ })
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Vendored daily-bar data: Stooq-format CSVs (Date,Open,High,Low,Close,Volume)
3
+ * committed under fixtures/data/. Two physically separate directories:
4
+ *
5
+ * fixtures/data/insample/ — everything the campaign loop may read.
6
+ * fixtures/data/holdout/ — the FINAL 2 years. Loaded ONLY by the
7
+ * certification path (holdout-certify.mts).
8
+ * quant-loop.mts never imports `loadHoldout`.
9
+ *
10
+ * See fixtures/data/PROVENANCE.md for where the series come from.
11
+ */
12
+
13
+ import { readdir, readFile } from 'node:fs/promises'
14
+ import { basename, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import type { Bar } from './types.ts'
17
+
18
+ export const DATA_DIR = fileURLToPath(new URL('./fixtures/data', import.meta.url))
19
+ export const IN_SAMPLE_DIR = join(DATA_DIR, 'insample')
20
+ export const HOLDOUT_DIR = join(DATA_DIR, 'holdout')
21
+ /** First holdout date — insample bars must all be strictly before this. */
22
+ export const HOLDOUT_START = '2024-07-01'
23
+ /** The benchmark index ticker; always bars[0] in the aligned universe. */
24
+ export const INDEX_TICKER = 'IDX'
25
+
26
+ export interface AlignedBars {
27
+ /** tickers[0] === INDEX_TICKER; the rest sorted alphabetically. */
28
+ tickers: string[]
29
+ dates: string[]
30
+ /** bars[k] belongs to tickers[k]; every series shares the date axis. */
31
+ bars: Bar[][]
32
+ }
33
+
34
+ export function parseStooqCsv(text: string, source: string): Bar[] {
35
+ const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
36
+ if (lines.length < 2) throw new Error(`${source}: empty CSV`)
37
+ const header = lines[0]!.toLowerCase()
38
+ if (!header.startsWith('date,open,high,low,close')) {
39
+ throw new Error(`${source}: unexpected header '${lines[0]}'`)
40
+ }
41
+ const bars: Bar[] = []
42
+ for (const line of lines.slice(1)) {
43
+ const cells = line.split(',')
44
+ if (cells.length < 5) throw new Error(`${source}: bad row '${line}'`)
45
+ const [date, open, high, low, close, volume] = cells
46
+ const bar: Bar = {
47
+ date: date!,
48
+ open: Number(open),
49
+ high: Number(high),
50
+ low: Number(low),
51
+ close: Number(close),
52
+ volume: volume !== undefined ? Number(volume) : 0,
53
+ }
54
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(bar.date)) throw new Error(`${source}: bad date '${bar.date}'`)
55
+ for (const v of [bar.open, bar.high, bar.low, bar.close]) {
56
+ if (!Number.isFinite(v) || v <= 0) throw new Error(`${source}: nonpositive price on ${bar.date}`)
57
+ }
58
+ bars.push(bar)
59
+ }
60
+ for (let i = 1; i < bars.length; i++) {
61
+ if (bars[i]!.date <= bars[i - 1]!.date) {
62
+ throw new Error(`${source}: dates not strictly ascending at ${bars[i]!.date}`)
63
+ }
64
+ }
65
+ return bars
66
+ }
67
+
68
+ /** Load every `<TICKER>.csv` in a directory and align on the intersection of
69
+ * dates. The index ticker leads; the rest follow alphabetically. */
70
+ export async function loadBarsDir(dir: string): Promise<AlignedBars> {
71
+ const files = (await readdir(dir)).filter((f) => f.endsWith('.csv')).sort()
72
+ if (files.length === 0) throw new Error(`loadBarsDir: no CSVs in ${dir}`)
73
+ const byTicker = new Map<string, Bar[]>()
74
+ for (const file of files) {
75
+ const ticker = basename(file, '.csv')
76
+ byTicker.set(ticker, parseStooqCsv(await readFile(join(dir, file), 'utf8'), join(dir, file)))
77
+ }
78
+ if (!byTicker.has(INDEX_TICKER)) {
79
+ throw new Error(`loadBarsDir: ${dir} has no ${INDEX_TICKER}.csv — the universe needs its benchmark index`)
80
+ }
81
+ const tickers = [INDEX_TICKER, ...[...byTicker.keys()].filter((t) => t !== INDEX_TICKER).sort()]
82
+ let shared: Set<string> | null = null
83
+ for (const ticker of tickers) {
84
+ const tickerDates = new Set(byTicker.get(ticker)!.map((b) => b.date))
85
+ if (shared === null) {
86
+ shared = tickerDates
87
+ } else {
88
+ const carried: Set<string> = shared
89
+ shared = new Set([...carried].filter((d) => tickerDates.has(d)))
90
+ }
91
+ }
92
+ const dates = [...shared!].sort()
93
+ if (dates.length < 2) throw new Error(`loadBarsDir: fewer than 2 shared dates across ${dir}`)
94
+ const bars = tickers.map((ticker) => {
95
+ const wanted = new Set(dates)
96
+ return byTicker.get(ticker)!.filter((b) => wanted.has(b.date))
97
+ })
98
+ return { tickers, dates, bars }
99
+ }
100
+
101
+ /** The campaign loop's data. Fails loud if any bar strays into the holdout era. */
102
+ export async function loadInSample(): Promise<AlignedBars> {
103
+ const aligned = await loadBarsDir(IN_SAMPLE_DIR)
104
+ const last = aligned.dates[aligned.dates.length - 1]!
105
+ if (last >= HOLDOUT_START) {
106
+ throw new Error(`loadInSample: in-sample data reaches ${last}, at/past the holdout start ${HOLDOUT_START}`)
107
+ }
108
+ return aligned
109
+ }
110
+
111
+ /** CERTIFICATION PATH ONLY (holdout-certify.mts). The final 2 years. */
112
+ export async function loadHoldout(): Promise<AlignedBars> {
113
+ const aligned = await loadBarsDir(HOLDOUT_DIR)
114
+ const first = aligned.dates[0]!
115
+ if (first < HOLDOUT_START) {
116
+ throw new Error(`loadHoldout: holdout data starts ${first}, before the holdout start ${HOLDOUT_START}`)
117
+ }
118
+ return aligned
119
+ }
120
+
121
+ /** In-sample followed by holdout on one axis — what the certification run
122
+ * backtests so lookback indicators are warm when the holdout period begins.
123
+ * Returns the concatenated universe plus the index of the first holdout day. */
124
+ export function concatForCertification(insample: AlignedBars, holdout: AlignedBars): { aligned: AlignedBars; holdoutStartIndex: number } {
125
+ if (insample.tickers.join(',') !== holdout.tickers.join(',')) {
126
+ throw new Error('concatForCertification: in-sample and holdout universes differ')
127
+ }
128
+ const lastIn = insample.dates[insample.dates.length - 1]!
129
+ const firstOut = holdout.dates[0]!
130
+ if (firstOut <= lastIn) {
131
+ throw new Error(`concatForCertification: holdout starts ${firstOut}, not after in-sample end ${lastIn}`)
132
+ }
133
+ return {
134
+ aligned: {
135
+ tickers: insample.tickers,
136
+ dates: [...insample.dates, ...holdout.dates],
137
+ bars: insample.bars.map((series, k) => [...series, ...holdout.bars[k]!]),
138
+ },
139
+ holdoutStartIndex: insample.dates.length,
140
+ }
141
+ }