permutalpha 0.9.1__py3-none-any.whl

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 (50) hide show
  1. oql/__init__.py +2 -0
  2. oql/__main__.py +4 -0
  3. oql/agent_tools.py +495 -0
  4. oql/board.py +504 -0
  5. oql/calibration.py +135 -0
  6. oql/catalog.py +259 -0
  7. oql/cli.py +299 -0
  8. oql/collect.py +273 -0
  9. oql/demo.py +45 -0
  10. oql/efficiency.py +666 -0
  11. oql/engine.py +118 -0
  12. oql/experiment.py +133 -0
  13. oql/gate.py +129 -0
  14. oql/gates.py +122 -0
  15. oql/ledger.py +102 -0
  16. oql/live.py +156 -0
  17. oql/locks.py +87 -0
  18. oql/market.py +208 -0
  19. oql/mcp_server.py +79 -0
  20. oql/paper.py +294 -0
  21. oql/permute.py +120 -0
  22. oql/projects/__init__.py +62 -0
  23. oql/projects/_util.py +156 -0
  24. oql/projects/hawkes.py +70 -0
  25. oql/projects/p01_tail_index.py +82 -0
  26. oql/projects/p02_intraday_seasonality.py +81 -0
  27. oql/projects/p03_overfitting_detector.py +84 -0
  28. oql/projects/p04_momentum_regimes.py +81 -0
  29. oql/projects/p05_factor_neutrality.py +85 -0
  30. oql/projects/p06_american_put.py +116 -0
  31. oql/projects/p07_autodiff_greeks.py +118 -0
  32. oql/projects/p08_implied_distribution.py +123 -0
  33. oql/projects/p09_hawkes_trades.py +76 -0
  34. oql/projects/p10_news_diffusion.py +170 -0
  35. oql/projects/p11_large_search.py +99 -0
  36. oql/search.py +116 -0
  37. oql/server.py +272 -0
  38. oql/sources.py +340 -0
  39. oql/stats.py +103 -0
  40. oql/strategies.py +97 -0
  41. oql/synthetic.py +39 -0
  42. oql/web/index.html +393 -0
  43. oql/web/live.js +352 -0
  44. permutalpha-0.9.1.dist-info/METADATA +187 -0
  45. permutalpha-0.9.1.dist-info/RECORD +50 -0
  46. permutalpha-0.9.1.dist-info/WHEEL +5 -0
  47. permutalpha-0.9.1.dist-info/entry_points.txt +3 -0
  48. permutalpha-0.9.1.dist-info/licenses/LICENSE +21 -0
  49. permutalpha-0.9.1.dist-info/licenses/NOTICE.md +9 -0
  50. permutalpha-0.9.1.dist-info/top_level.txt +1 -0
oql/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """Open Quant Lab: a fully open research pipeline for trading strategies, built around tests that cannot be gamed."""
2
+ __version__ = "0.9.1"
oql/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
oql/agent_tools.py ADDED
@@ -0,0 +1,495 @@
1
+ """What an AI assistant is allowed to do in the lab, and nothing more.
2
+
3
+ The agent works like a research assistant: it can read everything, propose ideas, draft pre-registrations and
4
+ experiments, and explore DEVELOPMENT data. It cannot freeze a card, run the gate or the holdout, deploy paper
5
+ trading, or touch a holdout's dates. Those are commitments, and they stay with a human.
6
+
7
+ Two design rules make this safe:
8
+ 1. Structured inputs only. The agent never writes Python: experiment.py is rendered from validated fields, and
9
+ the pre-registration's pass rule is fixed text the agent cannot edit.
10
+ 2. Every exploration is counted. Each strategy the agent evaluates is logged to the card and the ledger, and the
11
+ gate report shows how many exploratory evaluations happened before the freeze, so an AI's trial and error is
12
+ visible to anyone judging the result.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import time
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from . import board, catalog, ledger
24
+ from .engine import prepare
25
+ from .experiment import Experiment, load as load_exp, split_date
26
+ from .permute import permute_book
27
+ from .search import Scorer, search, sharpe
28
+ from .gates import GATES, validate as validate_gate
29
+ from .strategies import ALL_RULES, RULES, valid
30
+
31
+ EDITABLE = ("idea", "research")
32
+ MAX_PERM = 199
33
+
34
+
35
+ class AgentError(RuntimeError):
36
+ pass
37
+
38
+
39
+ def _card(cid):
40
+ return board.get(board.load(), cid)
41
+
42
+
43
+ def _log_exploration(card, entry):
44
+ d = board.card_dir(card)
45
+ with open(os.path.join(d, "explorations.jsonl"), "a") as f:
46
+ f.write(json.dumps({"utc": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), **entry}) + "\n")
47
+ check = entry.get("kind") == "permutation_check"
48
+ ledger.append(board.lab_dir(), card=card["id"], family=card["family"], kind="explore", verdict="-",
49
+ p=round(entry["p"], 4) if check else "", sharpe=round(entry.get("sharpe", float("nan")), 3), n_trials=1,
50
+ finalist=entry.get("genome", ""), data=entry.get("dataset", ""),
51
+ note=f"by {entry.get('actor', 'agent')}" + (" · single-strategy permutation check" if check else "")
52
+ + f" · {entry.get('cost_bps', 1.0):g} bp")
53
+
54
+
55
+ def explorations(card) -> list[dict]:
56
+ p = os.path.join(board.card_dir(card), "explorations.jsonl")
57
+ if not os.path.exists(p):
58
+ return []
59
+ with open(p) as f:
60
+ return [json.loads(l) for l in f if l.strip()]
61
+
62
+
63
+ def dev_cutoff(card, ds: str) -> pd.Timestamp:
64
+ """Latest date an exploration may see: the dataset's own 70% point, and never past the split date of the
65
+ card's experiment (which is set by ALL its datasets), so exploring can never reach into a holdout."""
66
+ df = catalog.read(ds)
67
+ cut = df.index[int(len(df) * 0.7)]
68
+ exp_path = os.path.join(board.card_dir(card), "experiment.py")
69
+ try:
70
+ exp = load_exp(exp_path)
71
+ if exp.datasets and all(catalog.meta(x).kind == "bars" for x in exp.datasets):
72
+ book = {x: catalog.read(x) for x in exp.datasets}
73
+ cut = min(cut, split_date(book, exp.dev_frac))
74
+ except Exception:
75
+ pass
76
+ return cut
77
+
78
+
79
+ # ---------------------------------------------------------------------------------------------- read
80
+ def lab_overview() -> dict:
81
+ bd = board.load()
82
+ rows = ledger.rows(board.lab_dir())
83
+ return {"cards": [{"id": c["id"], "title": c["title"], "stage": c["stage"],
84
+ "gate": c["results"].get("gate", {}).get("verdict"), "review": c.get("review", {}).get("summary")}
85
+ for c in bd["cards"]],
86
+ "tests_in_ledger": sum(r["kind"] in ("gate", "holdout") for r in rows),
87
+ "explorations_in_ledger": sum(int(r["n_trials"] or 1) for r in rows if r["kind"] == "explore"),
88
+ "datasets": [m.id for m in catalog.list_all()],
89
+ "rules": {k: g for k, (_, g) in ALL_RULES.items()},
90
+ "rules_note": "orb (opening-range breakout) needs intraday bars and is not in the default search; name it in rules to use it",
91
+ "gates": {"none": "always on", **{k: g for k, g in GATES.items()}},
92
+ "gates_note": ("An efficiency gate switches a strategy on only when the market is (expected to be) trending ('high') or "
93
+ "choppy ('low'). er_trailing uses the last `lookback` bars; er_forecast uses the efficiency lab's walk-forward "
94
+ "forecast. Declare gates in write_experiment to let the search use them; the gate test reruns them on shuffled data."),
95
+ "stages": board.STAGES + ["archive"],
96
+ "agent_may": ["propose_idea", "start_research", "write_preregistration", "write_experiment", "explore",
97
+ "permutation_check", "efficiency_study", "request_review"],
98
+ "human_only": ["freeze", "gate", "holdout", "paper deploy"]}
99
+
100
+
101
+ def list_datasets() -> list[dict]:
102
+ return [{"id": m.id, "kind": m.kind, "license": m.license, "rows": m.rows, "start": m.start[:10], "end": m.end[:10],
103
+ "notes": m.notes[:200]} for m in catalog.list_all()]
104
+
105
+
106
+ def dataset_summary(dataset: str) -> dict:
107
+ m = catalog.meta(dataset)
108
+ rep = catalog.check(dataset)
109
+ out = {"id": m.id, "kind": m.kind, "license": m.license, "rows": m.rows, "start": m.start, "end": m.end,
110
+ "columns": m.columns, "issues": rep["issues"], "warnings": rep.get("warnings", []), "notes": m.notes}
111
+ if m.kind == "bars":
112
+ df = catalog.read(dataset)
113
+ r = np.log(df["close"]).diff().dropna()
114
+ out.update(bar_spacing=str(pd.Series(df.index).diff().median()), daily_vol_estimate=float(r.std()))
115
+ return out
116
+
117
+
118
+ def get_card(card_id: str) -> dict:
119
+ c = _card(card_id)
120
+ d = board.card_dir(c)
121
+ out = {"card": c, "explorations": len(explorations(c))}
122
+ for name in ("PREREGISTRATION.md", "experiment.py", "gate.md"):
123
+ p = os.path.join(d, name)
124
+ if os.path.exists(p):
125
+ with open(p) as f:
126
+ out[name] = f.read()
127
+ return out
128
+
129
+
130
+ # ---------------------------------------------------------------------------------------------- write (idea/research only)
131
+ @board._locked
132
+ def propose_idea(title: str, mechanism: str, hypothesis: str) -> dict:
133
+ if len(mechanism.strip()) < 40:
134
+ raise AgentError("explain the mechanism: why should this make money, and who is on the other side? (40+ characters)")
135
+ c = board.new_card(title, mechanism)
136
+ bd = board.load()
137
+ card = board.get(bd, c["id"])
138
+ card["author"] = "agent"
139
+ board.save(bd)
140
+ write_preregistration(c["id"], hypothesis=hypothesis, mechanism=mechanism)
141
+ return {"created": c["id"], "stage": "idea", "next": "start_research, then write_experiment and explore"}
142
+
143
+
144
+ def start_research(card_id: str) -> dict:
145
+ return {"stage": board.start(card_id)["stage"]}
146
+
147
+
148
+ PREREG = """# Pre-registration: {title}
149
+
150
+ Card {id} · family {family} · drafted {utc} UTC{agent}
151
+
152
+ ## Hypothesis
153
+ {hypothesis}
154
+
155
+ ## Mechanism
156
+ {mechanism}
157
+
158
+ ## Data
159
+ {data}
160
+
161
+ ## Strategy space
162
+ {space}
163
+
164
+ ## Test and pass rule
165
+ The gate reruns the whole search on `n_perm` shuffled copies of the development data (set in experiment.py).
166
+ PASS if p < `alpha` and the cost/delay Monte Carlo median Sharpe > 0. Otherwise the card is archived.
167
+ The holdout runs once. PASS if holdout Sharpe > 0 and its permutation p < `alpha`.
168
+ Exploratory evaluations made before the freeze are counted and reported with the result.
169
+
170
+ ## What would change my mind
171
+ {falsify}
172
+ """
173
+
174
+
175
+ def write_preregistration(card_id: str, hypothesis: str = "TODO", mechanism: str | None = None, data: str = "TODO",
176
+ strategy_space: str = "TODO", what_would_change_my_mind: str = "TODO") -> dict:
177
+ c = _card(card_id)
178
+ if c["stage"] not in EDITABLE:
179
+ raise AgentError(f"card is '{c['stage']}': pre-registrations can only be edited before the freeze")
180
+ text = PREREG.format(title=c["title"], id=c["id"], family=c["family"], utc=time.strftime("%Y-%m-%d %H:%M", time.gmtime()),
181
+ agent=" · drafted by an AI assistant; frozen only by a person" if c.get("author") == "agent" else "",
182
+ hypothesis=hypothesis.strip(), mechanism=(mechanism or c["mechanism"]).strip(), data=data.strip(),
183
+ space=strategy_space.strip(), falsify=what_would_change_my_mind.strip())
184
+ with open(os.path.join(board.card_dir(c), "PREREGISTRATION.md"), "w") as f:
185
+ f.write(text)
186
+ stale = _mark_review_outdated(c["id"], "PREREGISTRATION.md")
187
+ return {"written": "PREREGISTRATION.md", "sections_still_TODO": text.count("TODO"),
188
+ **({"review": stale} if stale else {})}
189
+
190
+
191
+ @board._locked
192
+ def _mark_review_outdated(card_id: str, what: str) -> str | None:
193
+ """A review summary describes the card as it was. Editing the card afterwards marks the summary as out of date,
194
+ so the person reviewing knows to read the files, not the summary, or to ask for a fresh one."""
195
+ bd = board.load()
196
+ c = board.get(bd, card_id)
197
+ if not c.get("review"):
198
+ return None
199
+ c["review"]["edited_after"] = f"{what} changed {time.strftime('%Y-%m-%d %H:%M', time.gmtime())} UTC"
200
+ board.save(bd)
201
+ return "your earlier review summary is now out of date; call request_review again with a corrected summary"
202
+
203
+
204
+ EXPERIMENT = '''"""Experiment for card {id}: {title}. Rendered from structured fields{agent}. Frozen with PREREGISTRATION.md."""
205
+ from oql.experiment import Experiment
206
+
207
+ EXPERIMENT = Experiment(
208
+ datasets={datasets!r},
209
+ rules={rules!r},
210
+ grid={grid!r},
211
+ cost_bps={cost!r},
212
+ alpha={alpha!r},
213
+ n_perm={n_perm!r},{gates}{band}{nulls}
214
+ )
215
+ '''
216
+
217
+
218
+ def write_experiment(card_id: str, datasets: list, rules: list | None = None, grid: dict | None = None,
219
+ cost_bps: dict | None = None, alpha: float = 0.05, n_perm: int = 99, gates: list | None = None,
220
+ gate_features: list | None = None, rebalance_band: float = 0.0, nulls: list | None = None) -> dict:
221
+ c = _card(card_id)
222
+ if c["stage"] not in EDITABLE:
223
+ raise AgentError(f"card is '{c['stage']}': experiments can only be edited before the freeze")
224
+ rules = rules or list(RULES)
225
+ for ds in datasets:
226
+ if catalog.meta(ds).kind != "bars":
227
+ raise AgentError(f"{ds} is a table, not price bars")
228
+ bad = [r for r in rules if r not in ALL_RULES]
229
+ if bad:
230
+ raise AgentError(f"unknown rules {bad}; library rules: {sorted(ALL_RULES)} (custom code rules need a person)")
231
+ grid = {r: {k: [int(v) for v in vals] for k, vals in g.items()} for r, g in (grid or {}).items()}
232
+ for r, g in grid.items():
233
+ if r not in rules or set(g) != set(ALL_RULES[r][1]):
234
+ raise AgentError(f"grid for {r} must give exactly the parameters {sorted(ALL_RULES[r][1])}")
235
+ from .efficiency import GROUPS
236
+ gates = list(gates or ["none"])
237
+ if [g for g in gates if g != "none" and g not in GATES]:
238
+ raise AgentError(f"gates must come from: none, {', '.join(GATES)}")
239
+ gate_features = list(gate_features or ["price"])
240
+ if [g for g in gate_features if g not in GROUPS]:
241
+ raise AgentError(f"gate_features must come from: {', '.join(GROUPS)}")
242
+ if "price" not in gate_features:
243
+ gate_features = ["price"] + gate_features
244
+ if not 0 <= float(rebalance_band) <= 0.5:
245
+ raise AgentError("rebalance_band must be between 0 (resize every bar) and 0.5")
246
+ band_text = f"\n rebalance_band={float(rebalance_band)!r}," if rebalance_band else ""
247
+ nulls = list(nulls or ["bars"])
248
+ if not nulls or [m for m in nulls if m not in ("bars", "vol_matched", "block")]:
249
+ raise AgentError("nulls must be a list of 'bars', 'vol_matched' and/or 'block'")
250
+ nulls_text = "" if nulls == ["bars"] else f"\n nulls={nulls!r},"
251
+ gate_text = "" if gates == ["none"] else f"\n gates={gates!r},\n gate_features={gate_features!r},"
252
+ cost_bps = {k: float(v) for k, v in (cost_bps or {"default": 1.0}).items()}
253
+ if not 0.001 <= alpha <= 0.1:
254
+ raise AgentError("alpha must be between 0.001 and 0.1")
255
+ if not 39 <= int(n_perm) <= 999:
256
+ raise AgentError("n_perm must be between 39 and 999 (fewer cannot reach p < 0.05)")
257
+ text = EXPERIMENT.format(id=c["id"], title=c["title"], datasets=list(datasets), rules=list(rules), grid=grid,
258
+ cost=cost_bps, alpha=float(alpha), n_perm=int(n_perm), gates=gate_text, band=band_text, nulls=nulls_text,
259
+ agent=" by an AI assistant" if c.get("author") == "agent" else "")
260
+ path = os.path.join(board.card_dir(c), "experiment.py")
261
+ with open(path, "w") as f:
262
+ f.write(text)
263
+ exp = load_exp(path) # proves it loads
264
+ exp.rule_table()
265
+ n, k = _space_size(exp), len(exp.datasets)
266
+ stale = _mark_review_outdated(c["id"], "experiment.py")
267
+ return {"written": "experiment.py", "datasets": exp.datasets, "strategies_in_search_space": n,
268
+ "how_the_search_works": (f"Each of the {n} strategies trades all {k} datasets together as one equal-risk portfolio, "
269
+ f"and the gate picks the best portfolio. The count is {n}, not {n} x {k}.") if k > 1 else
270
+ f"Each of the {n} strategies trades {exp.datasets[0]}; the gate picks the best one.",
271
+ **({"review": stale} if stale else {})}
272
+
273
+
274
+ def _space_size(exp: Experiment) -> int:
275
+ import itertools
276
+ n = 0
277
+ for r, (_, g) in exp.rule_table().items():
278
+ for combo in itertools.product(*g.values()):
279
+ n += valid(r, dict(zip(g.keys(), combo)))
280
+ gates = exp.gate_table()
281
+ k = 1 if gates is None else sum(1 if not g else int(np.prod([len(v) for v in g.values()])) for g in gates.values())
282
+ return n * k * (1 if exp.both_sides else 3)
283
+
284
+
285
+ # ---------------------------------------------------------------------------------------------- explore (development data only)
286
+ def _card_costs(c, ds) -> tuple[float, float]:
287
+ """The cost (bps) and fill delay the card's experiment declares for this dataset, so exploration sees what the
288
+ gate will see. Before an experiment is written: 1 bp and the default delay."""
289
+ path = os.path.join(board.card_dir(c), "experiment.py")
290
+ if os.path.exists(path):
291
+ try:
292
+ exp = load_exp(path)
293
+ return float(exp.cost_bps.get(ds, exp.cost_bps.get("default", 1.0))), float(exp.delay)
294
+ except Exception: # an unfinished template: fall back to the defaults
295
+ pass
296
+ return 1.0, 1 / 23
297
+
298
+
299
+ def _dev_scorer(book, ds, warmup, cost_bps=1.0, gate_features=("price",), band=0.0):
300
+ exp = Experiment(datasets=[ds])
301
+ return Scorer(prepare(book, {"default": cost_bps}, warmup=warmup, band=band), exp.rule_table(), 0.0, gate_features), exp
302
+
303
+
304
+ def _card_band(c) -> float:
305
+ path = os.path.join(board.card_dir(c), "experiment.py")
306
+ try:
307
+ return float(load_exp(path).rebalance_band) if os.path.exists(path) else 0.0
308
+ except Exception:
309
+ return 0.0
310
+
311
+
312
+ def _card_gate_features(c) -> list:
313
+ path = os.path.join(board.card_dir(c), "experiment.py")
314
+ try:
315
+ return list(load_exp(path).gate_features) if os.path.exists(path) else ["price"]
316
+ except Exception:
317
+ return ["price"]
318
+
319
+
320
+ def _genome(rule, params, side, gate):
321
+ if rule not in ALL_RULES or not valid(rule, params) or set(params) != set(ALL_RULES[rule][1]):
322
+ raise AgentError(f"{rule} needs parameters {sorted(ALL_RULES[rule][1]) if rule in ALL_RULES else '?'} (fast < slow for ma_cross)")
323
+ g = {"rule": rule, "params": {k: int(v) for k, v in params.items()}, "side": side}
324
+ if gate and gate.get("kind", "none") != "none":
325
+ try:
326
+ g["gate"] = validate_gate(gate)
327
+ except ValueError as e:
328
+ raise AgentError(str(e))
329
+ return g
330
+
331
+
332
+ def explore(card_id: str, dataset: str, rule: str, params: dict, side: str = "both", gate: dict | None = None,
333
+ actor: str = "agent") -> dict:
334
+ """Score ONE strategy on the development part of one dataset. Logged and counted."""
335
+ c = _card(card_id)
336
+ if c["stage"] != "research":
337
+ raise AgentError(f"exploration happens in 'research'; this card is in '{c['stage']}'")
338
+ g = _genome(rule, params, side, gate)
339
+ cut = dev_cutoff(c, dataset)
340
+ dev = catalog.read(dataset)
341
+ dev = dev[dev.index < cut]
342
+ cost, delay = _card_costs(c, dataset)
343
+ sc, exp = _dev_scorer({dataset: dev}, dataset, min(250, max(20, len(dev) // 10)), cost, _card_gate_features(c), _card_band(c))
344
+ port, _, trades = sc.returns(g, delay=delay)
345
+ s = sharpe(port.to_numpy(), sc.prep.ppy)
346
+ entry = dict(actor=actor, kind="explore", dataset=dataset, genome=json.dumps(g, sort_keys=True), sharpe=s, trades=trades,
347
+ cost_bps=cost)
348
+ _log_exploration(c, entry)
349
+ n = len(explorations(c))
350
+ years = len(port) / sc.prep.ppy
351
+ on = None
352
+ if "gate" in g:
353
+ from .gates import mask
354
+ on = float(np.nanmean(mask(g["gate"], dev, dataset, sc._gate_cache, sc.gate_features)))
355
+ return {"sharpe_dev": s, "cost_bps": cost, "direction_changes": trades,
356
+ **({"gate": g["gate"], "share_of_time_gate_is_on": round(on, 3)} if on is not None else {}),
357
+ "direction_changes_per_year": round(trades / years, 1) if years > 0 else None,
358
+ "dev_data": f"{str(dev.index[0])[:10]} to {str(dev.index[-1])[:10]}",
359
+ "explorations_on_this_card": n,
360
+ "reminder": f"This is exploration #{n} on this card. Every one is reported next to the gate result; "
361
+ "the more strategies you look at before freezing, the higher the real bar.",
362
+ **({"scope": f"This scored {dataset} alone. The gate scores each strategy on all {len(others)} datasets of the "
363
+ "experiment together, as one portfolio, so a single-market result is only indirect evidence."}
364
+ if len(others := _card_datasets(c)) > 1 else {})}
365
+
366
+
367
+ def _card_datasets(c) -> list:
368
+ path = os.path.join(board.card_dir(c), "experiment.py")
369
+ try:
370
+ return list(load_exp(path).datasets) if os.path.exists(path) else []
371
+ except Exception:
372
+ return []
373
+
374
+
375
+ def permutation_check(card_id: str, dataset: str, rule: str, params: dict, side: str = "both", n: int = 99,
376
+ gate: dict | None = None, actor: str = "agent") -> dict:
377
+ """A quick permutation test of ONE strategy on development data. Exploratory: it is logged and does not
378
+ replace the gate, which tests the whole declared search."""
379
+ n = int(min(max(n, 19), MAX_PERM))
380
+ c = _card(card_id)
381
+ if c["stage"] != "research":
382
+ raise AgentError(f"exploration happens in 'research'; this card is in '{c['stage']}'")
383
+ g = _genome(rule, params, side, gate)
384
+ cut = dev_cutoff(c, dataset)
385
+ dev = catalog.read(dataset)
386
+ dev = dev[dev.index < cut]
387
+ warm = min(250, max(20, len(dev) // 10))
388
+ cost, delay = _card_costs(c, dataset)
389
+ feats, band = _card_gate_features(c), _card_band(c)
390
+
391
+ def one(book):
392
+ sc, exp = _dev_scorer(book, dataset, warm, cost, feats, band)
393
+ return sharpe(sc.returns(g, delay=delay)[0].to_numpy(), sc.prep.ppy)
394
+ real = one({dataset: dev})
395
+ null = np.array([one(permute_book({dataset: dev}, 7000 + k)) for k in range(n)])
396
+ p = float((1 + (null >= real).sum()) / (n + 1))
397
+ _log_exploration(c, dict(actor=actor, kind="permutation_check", dataset=dataset, genome=json.dumps(g, sort_keys=True),
398
+ sharpe=real, p=p, n=n, cost_bps=cost))
399
+ return {"sharpe_dev": real, "cost_bps": cost, "p_single_strategy": p, "permutations": n, "null_median": float(np.median(null)),
400
+ "explorations_on_this_card": len(explorations(c)),
401
+ "warning": "p for ONE strategy you chose after looking is optimistic; only the gate's whole-search test counts."}
402
+
403
+
404
+ def efficiency_study(dataset: str, groups: list | None = None) -> dict:
405
+ """Can the next period's efficiency be forecast? Walk-forward ridge on the dataset's development data. Logged."""
406
+ from . import efficiency as E
407
+ groups = ["price"] + [g for g in (groups or []) if g in E.GROUPS and g != "price"]
408
+ if catalog.meta(dataset).kind != "bars":
409
+ raise AgentError(f"{dataset} is a table, not price bars")
410
+ s = E.study(dataset, groups, label=None)
411
+ return {"dataset": dataset, "horizon": s["horizon"], "groups": groups, "features": s["features"], "notes": s["notes"],
412
+ "oos_r2": round(s["r2"], 4), "correlation": round(s["corr"], 3), "high_minus_low_third_er": round(s["spread"], 4),
413
+ "t_stat": round(s["t_spread"], 2), "own_past_efficiency_only": {"oos_r2": round(s["own_r2"], 4), "correlation": round(s["own_corr"], 3)},
414
+ "development_data_ends": s["dev_end"],
415
+ "context": f"An opening-range breakout needed a correlation of about {E.NEEDED_CORR}; momentum rules can gain from less. "
416
+ "Logged in the ledger as an exploration. Whether a gate helps a strategy is only settled by a card's gate test."}
417
+
418
+
419
+ @board._locked
420
+ def request_review(card_id: str, summary: str) -> dict:
421
+ """Hand the card to a person: they read it, and only they can freeze it and run the gate."""
422
+ bd = board.load()
423
+ c = board.get(bd, card_id)
424
+ if c["stage"] != "research":
425
+ raise AgentError("only a card in research can be sent for review")
426
+ pre = os.path.join(board.card_dir(c), "PREREGISTRATION.md")
427
+ with open(pre) as f:
428
+ if "TODO" in f.read():
429
+ raise AgentError("PREREGISTRATION.md still has TODO sections")
430
+ c["review"] = {"by": "agent", "utc": time.strftime("%Y-%m-%d %H:%M", time.gmtime()), "summary": summary.strip()[:2000],
431
+ "explorations": len(explorations(c))}
432
+ c["history"].append({"utc": c["review"]["utc"], "from": "research", "to": "research", "note": "AI asked for review"})
433
+ board.save(bd)
434
+ return {"status": "waiting for a person", "next_steps_for_the_person": [f"read cards/{c['id']}/PREREGISTRATION.md",
435
+ f"oql card freeze {c['id'].split('-')[0]}", f"oql card gate {c['id'].split('-')[0]}"]}
436
+
437
+
438
+ # ---------------------------------------------------------------------------------------------- registry
439
+ S = lambda props, req: {"type": "object", "properties": props, "required": req, "additionalProperties": False}
440
+ STR, INT, NUM = {"type": "string"}, {"type": "integer"}, {"type": "number"}
441
+ PARAMS = {"type": "object", "description": "parameter name -> integer, e.g. {\"lookback\": 60}", "additionalProperties": {"type": "integer"}}
442
+ SIDE = {"type": "string", "enum": ["both", "long", "short"]}
443
+ GATE = {"type": "object", "description": "optional efficiency gate, e.g. {\"kind\": \"er_forecast\", \"side\": \"high\", \"share\": 50} or "
444
+ "{\"kind\": \"er_trailing\", \"lookback\": 24, \"side\": \"high\", \"share\": 33}; share = percent of the time it may be on",
445
+ "properties": {"kind": {"type": "string", "enum": ["none", *GATES]}, "lookback": INT, "side": {"type": "string", "enum": ["high", "low"]},
446
+ "share": INT}, "required": ["kind"], "additionalProperties": False}
447
+ RULE = {"type": "string", "enum": list(ALL_RULES)}
448
+
449
+ TOOLS = {
450
+ "lab_overview": (lab_overview, "Cards by stage, ledger counts, datasets, strategy rules, and what the agent may and may not do. Call this first.", S({}, [])),
451
+ "list_datasets": (list_datasets, "Every dataset in the catalog with its licence, rows and dates.", S({}, [])),
452
+ "dataset_summary": (dataset_summary, "One dataset's metadata, quality check, bar spacing and volatility.", S({"dataset": STR}, ["dataset"])),
453
+ "get_card": (get_card, "A card with its pre-registration, experiment, gate report and exploration count.", S({"card_id": STR}, ["card_id"])),
454
+ "propose_idea": (propose_idea, "Create a new idea card. The mechanism must say why it should make money and who is on the other side.",
455
+ S({"title": STR, "mechanism": STR, "hypothesis": STR}, ["title", "mechanism", "hypothesis"])),
456
+ "start_research": (start_research, "Move an idea card to research so it can be explored.", S({"card_id": STR}, ["card_id"])),
457
+ "write_preregistration": (write_preregistration, "Fill the pre-registration sections (only before the freeze). The pass rule is fixed and cannot be edited.",
458
+ S({"card_id": STR, "hypothesis": STR, "mechanism": STR, "data": STR, "strategy_space": STR,
459
+ "what_would_change_my_mind": STR}, ["card_id", "hypothesis", "data", "strategy_space", "what_would_change_my_mind"])),
460
+ "write_experiment": (write_experiment, "Declare the experiment from structured fields: datasets, library rules, optional parameter grids, costs (bps per side), alpha, n_perm. "
461
+ "Every strategy trades ALL the listed datasets together as one equal-risk portfolio, so the search size is the number of "
462
+ "rule and parameter combinations, not that number times the datasets.",
463
+ S({"card_id": STR, "datasets": {"type": "array", "items": STR},
464
+ "rules": {"type": "array", "items": RULE},
465
+ "grid": {"type": "object", "description": "rule -> {param -> [integers]}"},
466
+ "cost_bps": {"type": "object", "additionalProperties": NUM}, "alpha": NUM, "n_perm": INT,
467
+ "gates": {"type": "array", "items": {"type": "string", "enum": ["none", *GATES]},
468
+ "description": "efficiency gates the search may add to strategies (default ['none'] = no gates). Each gate setting multiplies the search size."},
469
+ "nulls": {"type": "array", "items": {"type": "string", "enum": ["bars", "vol_matched", "block"]},
470
+ "description": "null models for the gate; PASS needs p < alpha under every one (default ['bars'])"},
471
+ "rebalance_band": {"type": "number", "description": "resize positions only in steps of (1 + band), e.g. 0.25, "
472
+ "so hourly strategies stop paying to resize every bar; 0 (default) = every bar"},
473
+ "gate_features": {"type": "array", "items": {"type": "string"},
474
+ "description": "feature groups for er_forecast: price (always), calendar, funding, basis, macro, positioning, orderbook"}},
475
+ ["card_id", "datasets"])),
476
+ "explore": (explore, "Score one strategy on ONE dataset's DEVELOPMENT data only (never the holdout), at the card's declared costs. Logged and "
477
+ "counted against the card. The gate scores strategies on all the experiment's datasets together, so this is indirect evidence when there are several.",
478
+ S({"card_id": STR, "dataset": STR, "rule": RULE, "params": PARAMS, "side": SIDE, "gate": GATE},
479
+ ["card_id", "dataset", "rule", "params"])),
480
+ "permutation_check": (permutation_check, "Exploratory permutation test of one strategy on development data (19-199 shuffles). Logged; does not replace the gate.",
481
+ S({"card_id": STR, "dataset": STR, "rule": RULE, "params": PARAMS, "side": SIDE, "n": INT, "gate": GATE},
482
+ ["card_id", "dataset", "rule", "params"])),
483
+ "efficiency_study": (efficiency_study, "Is the next period's efficiency (|net move| / distance travelled) predictable on this dataset? Walk-forward "
484
+ "forecast on development data from price features plus optional groups (calendar, funding, basis, macro, positioning, "
485
+ "orderbook). Returns out-of-sample R², correlation and the realized-efficiency spread between predicted thirds. Logged.",
486
+ S({"dataset": STR, "groups": {"type": "array", "items": {"type": "string"}}}, ["dataset"])),
487
+ "request_review": (request_review, "Hand a finished research card to a person, with a summary. Only a person can freeze it and run the gate.",
488
+ S({"card_id": STR, "summary": STR}, ["card_id", "summary"])),
489
+ }
490
+
491
+
492
+ def call(name: str, arguments: dict):
493
+ if name not in TOOLS:
494
+ raise AgentError(f"no tool {name!r}. Freezing, the gate, the holdout and paper deployment are for people only.")
495
+ return TOOLS[name][0](**(arguments or {}))