Skip to content

Commit e1482e4

Browse files
committed
feat: fair search benchmark (P1) and bounded self-improvement (P2) (#28)
P1: chronological dev/holdout episode splits shared across arms, a uniform transaction-cost model, and five comparison arms (buy-and-hold, random search, grid search, frozen agent, frozen agent + memory) with leakage sentinels, future-dated-memory filtering, and missing outcomes kept explicit rather than coerced to zero. Reports net return, drawdown, turnover, search efficiency, and cross-seed variance, with failures counted in success-rate denominators. P2: a bounded outer loop that mutates one policy family (prompt/ generator allocation) with full mutation provenance (parent id, patch, diagnosis, expected benefit, budget, rollback ref), dev/validation selection, a single frozen final-holdout grade guarded against reuse, a fixed promotion epsilon plus protected-episode regression check, and a random-mutation baseline under an identical budget. Ties/losses keep the incumbent; persisting a config alone does not count as promotion.
1 parent 0d1a636 commit e1482e4

8 files changed

Lines changed: 1469 additions & 0 deletions

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,55 @@ python scripts/benchmark_harness_evolution.py \
297297
# Output: JSON report based on a mock fitness function (not backtests)
298298
```
299299

300+
### Fair Search Benchmark (P1) and Bounded Self-Improvement (P2)
301+
302+
```bash
303+
# P1: compare arms (fixed / random_search / grid_search / frozen_agent /
304+
# frozen_agent_memory) on identical chronological dev/holdout episode
305+
# splits, with a uniform transaction-cost model and >=3 seeds per arm.
306+
python scripts/fair_search_benchmark.py --episodes 3 --seeds 7 11 19 \
307+
--output results/fair_search_benchmark.json
308+
309+
# P2: outer-loop policy mutation (mutates prompt_template/prompt_context)
310+
# evaluated on dev episodes, selected on a validation episode, promoted
311+
# only if it clears a fixed Sharpe-improvement threshold without regressing
312+
# on a protected episode, then frozen-graded once on final holdout episodes.
313+
# Also runs a random-mutation baseline and memory-disabled/shuffled-memory
314+
# ablations under the identical budget.
315+
python scripts/bounded_self_improvement.py --episodes 6 --seeds 7 11 19 \
316+
--n-mutations 3 --output results/bounded_self_improvement.json
317+
```
318+
319+
Both scripts run offline on deterministic synthetic OHLCV data by default
320+
(no API keys needed) and are development benchmarks, not claims about live
321+
or historical trading performance.
322+
323+
- **Arms** (`src/agent/search_arms.py`): `fixed` is buy-and-hold; `random_search`
324+
and `grid_search` are non-agent baselines over the momentum parameter grid;
325+
`frozen_agent` runs the existing propose→backtest→reflect loop
326+
(`src/agent/agent_graph.py`) once per episode with a fresh, empty memory
327+
snapshot each time; `frozen_agent_memory` gives that same loop read access
328+
to memory written by strictly earlier episodes only (never future ones —
329+
see `filter_visible_memory`).
330+
- **Episode splits** (`src/agent/episode_splits.py`): chronological
331+
(dev-window, sealed-holdout-window) pairs generated once and persisted to
332+
JSON so every arm is graded on identical windows. A fixed bps-per-trade
333+
transaction cost (`apply_transaction_costs`) is applied uniformly.
334+
- **Reported per arm/episode**: held-out net return after costs, max
335+
drawdown, turnover, search efficiency (return per attempted candidate),
336+
cross-seed mean/std, and full candidate logs (including failed attempts,
337+
which count in the denominator of the success rate). Missing/failed
338+
outcomes are reported as `"missing"`, never coerced to 0.
339+
- **Promotion criterion** (`src/agent/policy_mutation.py`): a candidate
340+
policy is only promoted over the incumbent if its mean validation-episode
341+
holdout Sharpe beats the incumbent's by more than `PROMOTION_EPSILON`
342+
(0.10) AND it doesn't regress by more than `MAX_PROTECTED_REGRESSION`
343+
(0.25) on a reserved protected episode. Ties, losses, and inconclusive
344+
deltas keep the incumbent — persisting a new config is never itself
345+
treated as improvement. The final holdout episodes can only be used for
346+
one frozen grading pass per run (`FinalHoldoutGuard` errors loudly on
347+
reuse).
348+
300349
### Run Agent (Streamlit UI)
301350

302351
```bash
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env python3
2+
"""P2 -- Bounded self-improvement runner.
3+
4+
Ties the inner loop (src.agent.agent_graph.run_agent, invoked per-episode
5+
under a given policy) to the outer loop (src.agent.policy_mutation): propose
6+
N candidate policy mutations, evaluate them on development episodes, select
7+
the best on a separate validation episode slice, gate promotion on a fixed
8+
threshold + protected-episode regression check, then do ONE final frozen
9+
evaluation of the selected policy on held-out final episodes.
10+
11+
Also runs a random-mutation baseline under the identical budget, plus
12+
memory-disabled / shuffled-memory ablations, for comparison.
13+
14+
Usage:
15+
python scripts/bounded_self_improvement.py --n-mutations 3 --seeds 7 11 19
16+
"""
17+
from __future__ import annotations
18+
19+
import argparse
20+
import json
21+
import random
22+
import sys
23+
import tempfile
24+
from pathlib import Path
25+
from typing import Optional
26+
27+
ROOT = Path(__file__).resolve().parents[1]
28+
sys.path.insert(0, str(ROOT))
29+
30+
from src.agent.episode_splits import get_or_build_episodes, synthetic_ohlcv # noqa: E402
31+
from src.agent.harness_config import harness_v1_base # noqa: E402
32+
from src.agent.policy_mutation import FinalHoldoutGuard, run_bounded_self_improvement # noqa: E402
33+
from src.agent.search_arms import _run_agent_offline, slice_dev # noqa: E402
34+
35+
ASSET = "SIM"
36+
37+
38+
def make_eval_fn(ohlcv, memory_mode: str, cost_bps: float, max_iterations: int):
39+
"""memory_mode: 'normal' | 'disabled' | 'shuffled'.
40+
41+
'disabled' gives every episode a fresh empty memory db (ablation: no
42+
cross-episode evidence at all). 'shuffled' reuses one memory db but
43+
episodes are evaluated in a shuffled (non-chronological) order so any
44+
stored rows are misattributed to the wrong episode context -- isolating
45+
whether real (correctly-timed) evidence, vs. just "more context", drives
46+
any measured gain.
47+
"""
48+
shared_dirs: dict = {}
49+
50+
def eval_fn(policy, episode, seed) -> Optional[float]:
51+
dev_ohlcv = slice_dev(ohlcv, episode)
52+
if memory_mode == "disabled":
53+
with tempfile.TemporaryDirectory() as tmp:
54+
state = _run_agent_offline(dev_ohlcv, episode.asset, seed,
55+
str(Path(tmp) / "memory.db"), max_iterations=max_iterations)
56+
else:
57+
key = seed if memory_mode == "normal" else f"shuffled-{seed}"
58+
if key not in shared_dirs:
59+
shared_dirs[key] = tempfile.mkdtemp()
60+
state = _run_agent_offline(dev_ohlcv, episode.asset, seed,
61+
str(Path(shared_dirs[key]) / "memory.db"), max_iterations=max_iterations)
62+
best = state.get("best_result") or {}
63+
return best.get("sharpe")
64+
65+
return eval_fn
66+
67+
68+
def run_condition(label: str, ohlcv, episodes, args, use_random_baseline: bool, memory_mode: str) -> dict:
69+
n = len(episodes)
70+
if n < 4:
71+
raise SystemExit("Need at least 4 episodes to split dev/val/protected/final.")
72+
dev_episodes = episodes[: n // 2]
73+
val_episodes = [episodes[n // 2]]
74+
protected_episode = episodes[max(0, n // 2 - 1)]
75+
final_episodes = episodes[n // 2 + 1:]
76+
if not final_episodes:
77+
final_episodes = [episodes[-1]]
78+
79+
eval_fn = make_eval_fn(ohlcv, memory_mode, args.cost_bps, args.max_iterations)
80+
guard = FinalHoldoutGuard(path=Path(args.results_dir) / f"final_holdout_used_{label}.json")
81+
82+
result = run_bounded_self_improvement(
83+
incumbent=harness_v1_base(),
84+
dev_episodes=dev_episodes,
85+
val_episodes=val_episodes,
86+
final_episodes=final_episodes,
87+
protected_episode=protected_episode,
88+
eval_fn=eval_fn,
89+
seeds=args.seeds,
90+
n_mutations=args.n_mutations,
91+
holdout_guard=guard,
92+
use_random_baseline=use_random_baseline,
93+
rng_seed=hash(label) % (2 ** 31),
94+
)
95+
result["label"] = label
96+
print(f"[{label}] promote={result['promotion_decision']['promote']} "
97+
f"reason={result['promotion_decision']['reason']}")
98+
return result
99+
100+
101+
def main() -> None:
102+
p = argparse.ArgumentParser()
103+
p.add_argument("--episodes", type=int, default=6)
104+
p.add_argument("--seeds", type=int, nargs="+", default=[7, 11, 19])
105+
p.add_argument("--n-mutations", type=int, default=3)
106+
p.add_argument("--cost-bps", type=float, default=5.0)
107+
p.add_argument("--max-iterations", type=int, default=2)
108+
p.add_argument("--splits-path", default="experiments/bounded_improvement_splits.json")
109+
p.add_argument("--results-dir", default="experiments")
110+
p.add_argument("--output", default="results/bounded_self_improvement.json")
111+
args = p.parse_args()
112+
113+
ohlcv = synthetic_ohlcv(seed=args.seeds[0], n_days=3200, asset=ASSET)
114+
episodes = get_or_build_episodes(
115+
ohlcv, ASSET, path=Path(args.splits_path),
116+
n_episodes=args.episodes, dev_days=320, holdout_days=50,
117+
)
118+
119+
report = {
120+
"diagnosed_mutation": run_condition("diagnosed", ohlcv, episodes, args,
121+
use_random_baseline=False, memory_mode="normal"),
122+
"random_mutation_baseline": run_condition("random_baseline", ohlcv, episodes, args,
123+
use_random_baseline=True, memory_mode="normal"),
124+
"memory_disabled_ablation": run_condition("memory_disabled", ohlcv, episodes, args,
125+
use_random_baseline=False, memory_mode="disabled"),
126+
"shuffled_memory_ablation": run_condition("shuffled_memory", ohlcv, episodes, args,
127+
use_random_baseline=False, memory_mode="shuffled"),
128+
}
129+
report["promotion_epsilon_and_protected_check"] = (
130+
"See src/agent/policy_mutation.py: PROMOTION_EPSILON, MAX_PROTECTED_REGRESSION. "
131+
"Inconclusive/tied/losing candidates keep the incumbent."
132+
)
133+
134+
out_path = ROOT / args.output
135+
out_path.parent.mkdir(parents=True, exist_ok=True)
136+
out_path.write_text(json.dumps(report, indent=2, default=str))
137+
print(f"Saved {out_path}")
138+
139+
140+
if __name__ == "__main__":
141+
main()

scripts/fair_search_benchmark.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env python3
2+
"""P1 -- Fair search benchmark.
3+
4+
Runs several strategy-selection "arms" (fixed / random_search / grid_search /
5+
frozen_agent / frozen_agent_memory) over identical, frozen chronological
6+
episode splits (development window for search, sealed holdout window for
7+
final grading only), with a uniform transaction-cost model, at least 3 seeds
8+
per arm, and a full candidate log per episode for selection-bias analysis.
9+
10+
Usage:
11+
python scripts/fair_search_benchmark.py --episodes 3 --seeds 7 11 19 \\
12+
--output results/fair_search_benchmark.json
13+
14+
This is a development benchmark on deterministic synthetic fixtures, not a
15+
claim about live or historical trading performance. Runs offline (no LLM/API
16+
keys) by default via the agent's existing offline fallback path.
17+
"""
18+
from __future__ import annotations
19+
20+
import argparse
21+
import json
22+
import sys
23+
import tempfile
24+
from pathlib import Path
25+
from typing import Any, Dict, List
26+
27+
ROOT = Path(__file__).resolve().parents[1]
28+
sys.path.insert(0, str(ROOT))
29+
30+
from src.agent.episode_splits import ( # noqa: E402
31+
DEFAULT_COST_BPS, get_or_build_episodes, synthetic_ohlcv,
32+
)
33+
from src.agent.search_arms import ( # noqa: E402
34+
run_fixed_arm, run_frozen_agent_arm, run_frozen_agent_memory_arm,
35+
run_grid_search_arm, run_random_search_arm,
36+
)
37+
38+
ASSET = "SIM"
39+
40+
41+
def _summarize(results: List[dict]) -> Dict[str, Any]:
42+
ok = [r for r in results if r["status"] == "ok"]
43+
missing = [r for r in results if r["status"] != "ok"]
44+
n_total = len(results)
45+
n_candidates = sum(r["n_candidates"] for r in results)
46+
n_failed_candidates = sum(r["n_failed"] for r in results)
47+
tool_calls = sum(r["tool_calls"] for r in results)
48+
wall_time = sum(r["wall_time_s"] for r in results)
49+
50+
net_returns = [r["holdout_net_return"] for r in ok]
51+
drawdowns = [r["holdout_max_drawdown"] for r in ok]
52+
turnovers = [r["holdout_turnover"] for r in ok]
53+
54+
def mean_std(xs):
55+
if not xs:
56+
return None, None
57+
m = sum(xs) / len(xs)
58+
var = sum((x - m) ** 2 for x in xs) / len(xs)
59+
return m, var ** 0.5
60+
61+
net_mean, net_std = mean_std(net_returns)
62+
dd_mean, dd_std = mean_std(drawdowns)
63+
to_mean, to_std = mean_std(turnovers)
64+
65+
search_efficiency = None
66+
if net_mean is not None and n_candidates > 0:
67+
search_efficiency = net_mean / n_candidates
68+
69+
return {
70+
"n_episode_seed_runs": n_total,
71+
# success rate counts failed attempts in the denominator, per issue reqs
72+
"success_rate": len(ok) / n_total if n_total else None,
73+
"n_missing": len(missing),
74+
"net_return_mean": net_mean,
75+
"net_return_std": net_std,
76+
"max_drawdown_mean": dd_mean,
77+
"max_drawdown_std": dd_std,
78+
"turnover_mean": to_mean,
79+
"turnover_std": to_std,
80+
"search_efficiency_return_per_candidate": search_efficiency,
81+
"total_candidates_attempted": n_candidates,
82+
"total_candidates_failed": n_failed_candidates,
83+
"total_tool_calls": tool_calls,
84+
"total_wall_time_s": wall_time,
85+
}
86+
87+
88+
def main() -> None:
89+
p = argparse.ArgumentParser()
90+
p.add_argument("--episodes", type=int, default=3)
91+
p.add_argument("--seeds", type=int, nargs="+", default=[7, 11, 19])
92+
p.add_argument("--cost-bps", type=float, default=DEFAULT_COST_BPS)
93+
p.add_argument("--max-iterations", type=int, default=2, help="agent arms only")
94+
p.add_argument("--splits-path", default="experiments/fair_benchmark_splits.json")
95+
p.add_argument("--output", default="results/fair_search_benchmark.json")
96+
p.add_argument("--skip-agent-arms", action="store_true",
97+
help="skip frozen_agent/frozen_agent_memory (faster, no agent_graph dependency)")
98+
args = p.parse_args()
99+
100+
if len(args.seeds) < 3:
101+
raise SystemExit("Need at least 3 seeds for cross-seed variance reporting.")
102+
103+
data_seed = args.seeds[0]
104+
ohlcv = synthetic_ohlcv(seed=data_seed, n_days=1500, asset=ASSET)
105+
episodes = get_or_build_episodes(
106+
ohlcv, ASSET, path=Path(args.splits_path),
107+
n_episodes=args.episodes, dev_days=220, holdout_days=60,
108+
)
109+
if not episodes:
110+
raise SystemExit("Not enough synthetic data to build even one episode; increase n_days.")
111+
112+
report: Dict[str, Any] = {"episodes": [e.to_dict() for e in episodes], "arms": {}}
113+
114+
def run_arm(name: str, fn) -> None:
115+
rows = []
116+
for ep in episodes:
117+
for seed in args.seeds:
118+
res = fn(ohlcv, ep, seed, args.cost_bps)
119+
rows.append(res.to_dict())
120+
report["arms"][name] = {"episode_results": rows, "summary": _summarize(rows)}
121+
print(f"[{name}] {report['arms'][name]['summary']}")
122+
123+
run_arm("fixed", run_fixed_arm)
124+
run_arm("random_search", run_random_search_arm)
125+
run_arm("grid_search", run_grid_search_arm)
126+
127+
if not args.skip_agent_arms:
128+
run_arm("frozen_agent", lambda oh, ep, seed, cb: run_frozen_agent_arm(
129+
oh, ep, seed, cb, max_iterations=args.max_iterations))
130+
131+
# frozen_agent_memory: one persistent memory db shared across
132+
# episodes IN CHRONOLOGICAL ORDER, reset once per seed so results
133+
# are comparable across seeds and never see another seed's memory.
134+
for seed in args.seeds:
135+
with tempfile.TemporaryDirectory() as tmp:
136+
memory_db = str(Path(tmp) / "memory.db")
137+
for ep in episodes: # episodes list is already chronological
138+
res = run_frozen_agent_memory_arm(
139+
ohlcv, ep, seed, args.cost_bps, memory_db_path=memory_db,
140+
max_iterations=args.max_iterations,
141+
)
142+
report["arms"].setdefault("frozen_agent_memory", {"episode_results": []})
143+
report["arms"]["frozen_agent_memory"]["episode_results"].append(res.to_dict())
144+
rows = report["arms"]["frozen_agent_memory"]["episode_results"]
145+
report["arms"]["frozen_agent_memory"]["summary"] = _summarize(rows)
146+
print(f"[frozen_agent_memory] {report['arms']['frozen_agent_memory']['summary']}")
147+
148+
report["cost_model"] = {"cost_bps": args.cost_bps}
149+
report["seeds"] = args.seeds
150+
report["note"] = (
151+
"Negative or tied results for adaptive arms are reported as-is, not suppressed. "
152+
"Missing/failed outcomes are reported as status='missing' and are not coerced into "
153+
"a fallback numeric score."
154+
)
155+
156+
out_path = ROOT / args.output
157+
out_path.parent.mkdir(parents=True, exist_ok=True)
158+
out_path.write_text(json.dumps(report, indent=2, default=str))
159+
print(f"Saved {out_path}")
160+
161+
162+
if __name__ == "__main__":
163+
main()

0 commit comments

Comments
 (0)