alphaengine 1.0.1__tar.gz → 1.0.2__tar.gz

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 (114) hide show
  1. {alphaengine-1.0.1 → alphaengine-1.0.2}/PKG-INFO +1 -1
  2. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/_version.py +8 -1
  3. alphaengine-1.0.2/src/alphaengine/agent/bakeoff.py +398 -0
  4. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/agent/brief.py +44 -13
  5. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/agent/chart.py +44 -24
  6. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/bakeoff.py +159 -19
  7. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/tui/dispatch.py +17 -1
  8. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_bakeoff.py +46 -3
  9. alphaengine-1.0.1/src/alphaengine/agent/bakeoff.py +0 -240
  10. {alphaengine-1.0.1 → alphaengine-1.0.2}/.github/workflows/ci.yml +0 -0
  11. {alphaengine-1.0.1 → alphaengine-1.0.2}/.github/workflows/publish.yml +0 -0
  12. {alphaengine-1.0.1 → alphaengine-1.0.2}/.gitignore +0 -0
  13. {alphaengine-1.0.1 → alphaengine-1.0.2}/AGENTS.md +0 -0
  14. {alphaengine-1.0.1 → alphaengine-1.0.2}/LICENSE +0 -0
  15. {alphaengine-1.0.1 → alphaengine-1.0.2}/README.md +0 -0
  16. {alphaengine-1.0.1 → alphaengine-1.0.2}/SECURITY.md +0 -0
  17. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/assets/banner.png +0 -0
  18. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/assets/data_boundary.png +0 -0
  19. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/assets/session.png +0 -0
  20. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/figure_contract.md +0 -0
  21. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/workflows/evaluate_process.json +0 -0
  22. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/workflows/research_week.json +0 -0
  23. {alphaengine-1.0.1 → alphaengine-1.0.2}/docs/workflows/stress_study.json +0 -0
  24. {alphaengine-1.0.1 → alphaengine-1.0.2}/pyproject.toml +0 -0
  25. {alphaengine-1.0.1 → alphaengine-1.0.2}/scripts/gen_docs.py +0 -0
  26. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/__init__.py +0 -0
  27. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/__main__.py +0 -0
  28. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/agent/__init__.py +0 -0
  29. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/agent/answer.py +0 -0
  30. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/agent/driver.py +0 -0
  31. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/agent/sim.py +0 -0
  32. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/auth.py +0 -0
  33. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/book.py +0 -0
  34. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/charts.py +0 -0
  35. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/cli.py +0 -0
  36. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/client/__init__.py +0 -0
  37. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/client/agent.py +0 -0
  38. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/client/executor.py +0 -0
  39. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/client/session.py +0 -0
  40. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/commands.py +0 -0
  41. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/complete.py +0 -0
  42. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/connectors/__init__.py +0 -0
  43. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/context.py +0 -0
  44. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/__init__.py +0 -0
  45. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/active.py +0 -0
  46. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/allocate.py +0 -0
  47. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/backtest.py +0 -0
  48. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/covariance.py +0 -0
  49. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/cross_section.py +0 -0
  50. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/factors.py +0 -0
  51. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/market.py +0 -0
  52. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/options.py +0 -0
  53. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/pairs.py +0 -0
  54. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/panel.py +0 -0
  55. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/performance.py +0 -0
  56. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/portfolio.py +0 -0
  57. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/process.py +0 -0
  58. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/profile.py +0 -0
  59. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/rates.py +0 -0
  60. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/regime.py +0 -0
  61. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/relations.py +0 -0
  62. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/risk.py +0 -0
  63. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/screen.py +0 -0
  64. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/series_shapes.py +0 -0
  65. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/signals.py +0 -0
  66. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/stress.py +0 -0
  67. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/technical.py +0 -0
  68. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/trace.py +0 -0
  69. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/validation.py +0 -0
  70. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/core/walkforward.py +0 -0
  71. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/demo.py +0 -0
  72. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/demo_book.py +0 -0
  73. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/demo_returns.py +0 -0
  74. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/demo_signal.py +0 -0
  75. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/demo_universe.py +0 -0
  76. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/events.py +0 -0
  77. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/loaders.py +0 -0
  78. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/model.py +0 -0
  79. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/py.typed +0 -0
  80. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/repl.py +0 -0
  81. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/study/__init__.py +0 -0
  82. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/study/report.py +0 -0
  83. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/study/schema.py +0 -0
  84. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/sweep/__init__.py +0 -0
  85. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/sweep/runner.py +0 -0
  86. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/tui/__init__.py +0 -0
  87. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/tui/app.py +0 -0
  88. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/tui/mark.py +0 -0
  89. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/tui/stage.py +0 -0
  90. {alphaengine-1.0.1 → alphaengine-1.0.2}/src/alphaengine/tui/typeface.py +0 -0
  91. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_agent.py +0 -0
  92. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_answer.py +0 -0
  93. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_book.py +0 -0
  94. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_cli.py +0 -0
  95. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_client.py +0 -0
  96. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_commands.py +0 -0
  97. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_context.py +0 -0
  98. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_events.py +0 -0
  99. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_executor_ops.py +0 -0
  100. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_goldens.py +0 -0
  101. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_loaders.py +0 -0
  102. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_math_audit.py +0 -0
  103. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_model.py +0 -0
  104. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_portfolio.py +0 -0
  105. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_process.py +0 -0
  106. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_quant_maths.py +0 -0
  107. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_relations.py +0 -0
  108. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_screen.py +0 -0
  109. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_signals.py +0 -0
  110. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_sim.py +0 -0
  111. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_smoke.py +0 -0
  112. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_sweep.py +0 -0
  113. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_trace.py +0 -0
  114. {alphaengine-1.0.1 → alphaengine-1.0.2}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
5
5
  Project-URL: Homepage, https://github.com/quantOSC/alphaengine
6
6
  Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
@@ -143,4 +143,11 @@ is what a changed figure costs. The API may still move underneath it.
143
143
  # the sample-count divisor in Ledoit-Wolf shrinkage so hierarchical risk parity
144
144
  # and risk parity differentiate weights across a 50-name panel, and writes a
145
145
  # multi-page portfolio, scenario, Monte Carlo, and bakeoff report to Downloads.
146
- __version__ = "1.0.1"
146
+ #
147
+ # 1.0.2 IS THE FOUR-PASS GAN BAKEOFF. No golden in tests/test_goldens.py moves.
148
+ # The bakeoff tournament runs four adversarial passes (eight candidate proposals
149
+ # across `equal`, `hrp`, `risk_parity`, `inverse_vol`, `var_sorted`, `low_vol`,
150
+ # `momentum`, and `reversion` against eight regime, tail, rate, and crash-cluster
151
+ # attacks) under a hard twelve-call, sixty-second ceiling, feeding the referee's
152
+ # post-attack standing back to the board after every pass.
153
+ __version__ = "1.0.2"
@@ -0,0 +1,398 @@
1
+ """Bakeoff mode. Two roles, one clock, and a referee that does not take votes.
2
+
3
+ Generators may submit a rule. An adversary may submit an attack. Neither may
4
+ start another bakeoff, and neither may name the survivor. The loop is four
5
+ passes of two proposals plus one attack after each pass: twelve model calls,
6
+ and then it stops even if a model asks to continue.
7
+
8
+ The clock is the other stop. The default budget is 45 seconds. Nothing can
9
+ stretch it past 60. When the budget is gone, the call in flight is abandoned
10
+ and the referee scores whatever already came back.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from ..core.bakeoff import ATTACKS, RULES, referee, trial_paths
24
+ from .sim import Call, Think, Trace
25
+
26
+ DEFAULT_SECONDS = 45.0
27
+ HARD_CEILING = 60.0
28
+ MAX_ROUNDS = 4
29
+ MAX_SEATS = 2
30
+
31
+ _DROPPED = {"winner", "survivor", "score", "sharpe", "verdict", "deflated_sharpe"}
32
+
33
+ _GENERATOR = """BAKEOFF GENERATOR
34
+ Pass @@ROUND@@ of @@ROUNDS@@ (Seat @@SEAT@@ of @@SEATS@@).
35
+ Propose one strategy that survives the adversary's active attacks and improves on
36
+ the current leader's post-attack standing. Do not repeat a rule+lookback+names
37
+ already on the board. You do not score it, you do not name a winner, and you
38
+ do not start another bakeoff.
39
+ Rules: @@RULES@@
40
+ lookback (5 to 60) sets the signal window for momentum/reversion or the calibration
41
+ window for hrp, risk_parity, inverse_vol, var_sorted, and low_vol.
42
+ Optional names: a subset of 2 to 12 symbols from [@@NAMES@@].
43
+ Active attacks: @@ACTIVE_ATTACKS@@
44
+ Question:
45
+ @@QUESTION@@
46
+ Board:
47
+ @@BOARD@@
48
+ Reply with one JSON object: {"rule": "hrp", "lookback": 30, "why": "short reason"}
49
+ """
50
+
51
+ _ADVERSARY = """BAKEOFF ADVERSARY
52
+ Pass @@ROUND@@ of @@ROUNDS@@.
53
+ Name one attack to stress the current leader's regime or tail vulnerability.
54
+ Prefer an attack from Unused attacks so each pass adds a new stress dimension.
55
+ You do not propose a rule, you do not name a winner, and you do not start another bakeoff.
56
+ Attacks: @@ATTACKS@@
57
+ Active attacks: @@ACTIVE_ATTACKS@@
58
+ Unused attacks: @@UNUSED_ATTACKS@@
59
+ Question:
60
+ @@QUESTION@@
61
+ Board:
62
+ @@BOARD@@
63
+ Reply with one JSON object: {"attack": "tail_stress", "why": "short reason"}
64
+ """
65
+
66
+
67
+ @dataclass
68
+ class Bakeoff:
69
+ """The trace the session shows, and the figures a tool result is allowed to carry."""
70
+
71
+ trace: Trace
72
+ figures: dict[str, Any]
73
+
74
+
75
+ def run_bakeoff(
76
+ question: str,
77
+ think: Think,
78
+ data: Any,
79
+ *,
80
+ rounds: int = MAX_ROUNDS,
81
+ seats: int = MAX_SEATS,
82
+ seconds: float = DEFAULT_SECONDS,
83
+ on_call: Callable[[Call], None] | None = None,
84
+ ) -> Bakeoff:
85
+ """Run one bakeoff. Returns when the rounds end or the clock does, whichever is first."""
86
+ budget = _budget(seconds)
87
+ deadline = time.monotonic() + budget
88
+ n_rounds = min(max(int(rounds), 1), MAX_ROUNDS)
89
+ n_seats = min(max(int(seats), 1), MAX_SEATS)
90
+ submissions: list[dict[str, Any]] = []
91
+ attacks: list[str] = []
92
+ passes: list[dict[str, Any]] = []
93
+ trace = Trace(question=question)
94
+ stopped = "complete"
95
+ names = _universe_names(data)
96
+
97
+ def board() -> str:
98
+ if not submissions and not attacks:
99
+ return "nothing proposed yet"
100
+ scored = referee(data, submissions, attacks)
101
+ lines = list(scored.get("trials") or [])
102
+ if scored.get("survivor"):
103
+ lines.append(
104
+ f"leader {scored.get('survivor')} "
105
+ f"sharpe {scored.get('survivor_sharpe')} "
106
+ f"standing {scored.get('attacked_sharpe')}"
107
+ )
108
+ if attacks:
109
+ lines.append("attacks " + ", ".join(dict.fromkeys(attacks)))
110
+ return "\n".join(str(line) for line in lines) or "nothing proposed yet"
111
+
112
+ def emit(call: Call) -> None:
113
+ trace.calls.append(call)
114
+ if on_call is not None:
115
+ on_call(call)
116
+
117
+ for round_idx in range(1, n_rounds + 1):
118
+ for seat_idx in range(1, n_seats + 1):
119
+ prompt = _fill(
120
+ _GENERATOR,
121
+ question,
122
+ board(),
123
+ round_idx=round_idx,
124
+ n_rounds=n_rounds,
125
+ seat_idx=seat_idx,
126
+ n_seats=n_seats,
127
+ attacks=attacks,
128
+ names=names,
129
+ )
130
+ text, in_time = _ask(think, prompt, deadline)
131
+ if not in_time:
132
+ stopped = "clock"
133
+ break
134
+ submission, why = _proposal(text)
135
+ submission["_round"] = round_idx
136
+ submission["_seat"] = seat_idx
137
+ submissions.append(submission)
138
+ emit(_submit_call(data, submissions, attacks, submission, why, round_idx, seat_idx))
139
+ if stopped == "clock":
140
+ break
141
+ prompt = _fill(
142
+ _ADVERSARY,
143
+ question,
144
+ board(),
145
+ round_idx=round_idx,
146
+ n_rounds=n_rounds,
147
+ seat_idx=0,
148
+ n_seats=n_seats,
149
+ attacks=attacks,
150
+ names=names,
151
+ )
152
+ text, in_time = _ask(think, prompt, deadline)
153
+ if not in_time:
154
+ stopped = "clock"
155
+ break
156
+ attack, why = _attack(text)
157
+ if attack is None:
158
+ emit(
159
+ Call(
160
+ tool="attack",
161
+ args={"pass": round_idx},
162
+ error="that attack is not in the menu",
163
+ )
164
+ )
165
+ continue
166
+ attacks.append(attack)
167
+ scored_pass = referee(data, submissions, attacks)
168
+ passes.append(
169
+ {
170
+ "pass": round_idx,
171
+ "attack": attack,
172
+ "why": why,
173
+ "leader": scored_pass.get("survivor"),
174
+ "leader_sharpe": scored_pass.get("survivor_sharpe"),
175
+ "leader_standing": scored_pass.get("attacked_sharpe"),
176
+ "n_trials": len(submissions),
177
+ }
178
+ )
179
+ emit(_attack_call(scored_pass, attack, why, round_idx))
180
+
181
+ figures = referee(data, submissions, attacks)
182
+ elapsed = min(budget, time.monotonic() - (deadline - budget))
183
+ figures["seconds"] = round(elapsed, 1)
184
+ figures["stopped"] = stopped
185
+ figures["budget_seconds"] = budget
186
+ figures["n_passes"] = len(passes) or n_rounds
187
+ trace.answer = _verdict(figures)
188
+ curves = trial_paths(data, submissions, attacks)
189
+ survivor_label = str(figures.get("survivor") or "")
190
+ survivor_curve = next((c for c in curves if c.get("label") == survivor_label), None)
191
+ trace.bakeoff = {
192
+ "survivor": figures.get("survivor"),
193
+ "survivor_sharpe": figures.get("survivor_sharpe"),
194
+ "attacked_sharpe": figures.get("attacked_sharpe"),
195
+ "deflated_sharpe": figures.get("deflated_sharpe"),
196
+ "verdict": figures.get("verdict"),
197
+ "n_trials": figures.get("n_trials"),
198
+ "n_failed": figures.get("n_failed"),
199
+ "n_passes": len(passes) or n_rounds,
200
+ "attacks": list(figures.get("attacks") or []),
201
+ "trials": list(figures.get("trials") or []),
202
+ "passes": passes,
203
+ "curves": curves,
204
+ "survivor_weights": (survivor_curve or {}).get("weights") or {},
205
+ }
206
+ return Bakeoff(trace=trace, figures=figures)
207
+
208
+
209
+ def _submit_call(
210
+ data: Any,
211
+ submissions: list[dict[str, Any]],
212
+ attacks: list[str],
213
+ submission: dict[str, Any],
214
+ why: str,
215
+ round_idx: int,
216
+ seat_idx: int,
217
+ ) -> Call:
218
+ clean_args = {k: v for k, v in submission.items() if not str(k).startswith("_")}
219
+ scored = referee(data, submissions, attacks)
220
+ trials = list(scored.get("trials") or [])
221
+ last_line = str(trials[-1]) if trials else ""
222
+ rule_name = str(submission.get("rule") or "")
223
+ if not rule_name or "that rule is not in the bakeoff" in last_line:
224
+ return Call(
225
+ tool="submit",
226
+ args=clean_args,
227
+ figures={"pass": round_idx, "seat": seat_idx, "rule": rule_name or "refused"},
228
+ note=f"pass {round_idx}.{seat_idx} refused",
229
+ )
230
+ parts = last_line.split()
231
+ label = parts[1] if len(parts) > 1 else rule_name
232
+ call_figs: dict[str, Any] = {
233
+ "pass": round_idx,
234
+ "seat": seat_idx,
235
+ "proposal": label,
236
+ "leader": scored.get("survivor"),
237
+ "leader_standing": scored.get("attacked_sharpe"),
238
+ }
239
+ if "lookback" in clean_args:
240
+ call_figs["lookback"] = clean_args["lookback"]
241
+ if why:
242
+ call_figs["why"] = why
243
+ note_tail = " ".join(parts[1:]) if len(parts) > 1 else rule_name
244
+ note = f"pass {round_idx}.{seat_idx} {note_tail}"
245
+ return Call(tool="submit", args=clean_args, figures=call_figs, note=note)
246
+
247
+
248
+ def _attack_call(
249
+ scored: dict[str, Any],
250
+ attack: str,
251
+ why: str,
252
+ round_idx: int,
253
+ ) -> Call:
254
+ leader = scored.get("survivor") or "none"
255
+ standing = scored.get("attacked_sharpe")
256
+ call_figs: dict[str, Any] = {
257
+ "pass": round_idx,
258
+ "attack": attack,
259
+ "leader": leader,
260
+ "leader_sharpe": scored.get("survivor_sharpe"),
261
+ "leader_standing": standing,
262
+ "n_trials": scored.get("n_trials"),
263
+ }
264
+ if why:
265
+ call_figs["why"] = why
266
+ note = f"pass {round_idx} {attack} leader {leader} standing {standing}"
267
+ return Call(tool="attack", args={"attack": attack}, figures=call_figs, note=note)
268
+
269
+
270
+ def _universe_names(data: Any) -> list[str]:
271
+ if not isinstance(data, dict):
272
+ return []
273
+ return [str(k) for k in list(data.keys())[:24]]
274
+
275
+
276
+ def _budget(seconds: float) -> float:
277
+ """A positive request, never longer than a minute. A bad request uses the default."""
278
+ try:
279
+ number = float(seconds)
280
+ except (TypeError, ValueError):
281
+ return DEFAULT_SECONDS
282
+ if not math.isfinite(number) or number <= 0:
283
+ return DEFAULT_SECONDS
284
+ return min(HARD_CEILING, number)
285
+
286
+
287
+ def _ask(think: Think, prompt: str, deadline: float) -> tuple[str, bool]:
288
+ """One model call. The second value is false when the clock won and the loop must stop.
289
+
290
+ The worker is a daemon so a model that keeps talking after the deadline
291
+ cannot hold the process open. That reply is never read.
292
+ """
293
+ remaining = deadline - time.monotonic()
294
+ if remaining <= 0:
295
+ return "", False
296
+ box: list[str] = []
297
+
298
+ def run() -> None:
299
+ try:
300
+ text = think(prompt)
301
+ except Exception: # noqa: BLE001 — a broken call is a refused turn, not a crash
302
+ return
303
+ if isinstance(text, str):
304
+ box.append(text)
305
+
306
+ worker = threading.Thread(target=run, daemon=True)
307
+ worker.start()
308
+ worker.join(remaining)
309
+ if worker.is_alive():
310
+ return "", False
311
+ return (box[0] if box else ""), True
312
+
313
+
314
+ def _fill(
315
+ template: str,
316
+ question: str,
317
+ board: str,
318
+ *,
319
+ round_idx: int = 1,
320
+ n_rounds: int = MAX_ROUNDS,
321
+ seat_idx: int = 1,
322
+ n_seats: int = MAX_SEATS,
323
+ attacks: list[str] | None = None,
324
+ names: list[str] | None = None,
325
+ ) -> str:
326
+ active = list(dict.fromkeys(attacks or []))
327
+ unused = [a for a in ATTACKS if a not in active]
328
+ return (
329
+ template.replace("@@QUESTION@@", question)
330
+ .replace("@@BOARD@@", board)
331
+ .replace("@@ROUND@@", str(round_idx))
332
+ .replace("@@ROUNDS@@", str(n_rounds))
333
+ .replace("@@SEAT@@", str(seat_idx))
334
+ .replace("@@SEATS@@", str(n_seats))
335
+ .replace("@@RULES@@", ", ".join(RULES))
336
+ .replace("@@ATTACKS@@", ", ".join(ATTACKS))
337
+ .replace("@@ACTIVE_ATTACKS@@", ", ".join(active) or "none yet")
338
+ .replace("@@UNUSED_ATTACKS@@", ", ".join(unused) or "all attacks active")
339
+ .replace("@@NAMES@@", ", ".join(names or []) or "loaded book")
340
+ )
341
+
342
+
343
+ def _proposal(text: str) -> tuple[dict[str, Any], str]:
344
+ obj = _object(text)
345
+ if obj is None:
346
+ return {"rule": ""}, ""
347
+ if "bakeoff" in {str(obj.get("tool") or ""), str(obj.get("rule") or "")}:
348
+ return {"rule": ""}, ""
349
+ rule = obj.get("rule")
350
+ if not isinstance(rule, str):
351
+ return {"rule": ""}, ""
352
+ out: dict[str, Any] = {"rule": rule}
353
+ if "lookback" in obj and obj["lookback"] is not None:
354
+ out["lookback"] = obj["lookback"]
355
+ if isinstance(obj.get("names"), list):
356
+ out["names"] = [str(name) for name in obj["names"][:12]]
357
+ why = str(obj.get("why") or "").strip()[:80]
358
+ return out, why
359
+
360
+
361
+ def _attack(text: str) -> tuple[str | None, str]:
362
+ obj = _object(text)
363
+ if obj is None:
364
+ return None, ""
365
+ attack = str(obj.get("attack") or "").strip().lower().replace("-", "_").replace(" ", "_")
366
+ if attack not in ATTACKS:
367
+ return None, ""
368
+ why = str(obj.get("why") or "").strip()[:80]
369
+ return attack, why
370
+
371
+
372
+ def _object(text: str) -> dict[str, Any] | None:
373
+ start = text.find("{")
374
+ end = text.rfind("}")
375
+ if start < 0 or end <= start:
376
+ return None
377
+ try:
378
+ obj = json.loads(text[start : end + 1])
379
+ except json.JSONDecodeError:
380
+ return None
381
+ if not isinstance(obj, dict):
382
+ return None
383
+ nested = obj.get("args")
384
+ if isinstance(nested, dict) and "rule" not in obj and "attack" not in obj:
385
+ obj = nested
386
+ return {str(key): value for key, value in obj.items() if str(key) not in _DROPPED}
387
+
388
+
389
+ def _verdict(figures: dict[str, Any]) -> str:
390
+ survivor = figures.get("survivor")
391
+ if not survivor:
392
+ return "Referee. No proposal survived."
393
+ return (
394
+ f"Referee. {figures.get('n_trials')} candidates across {figures.get('n_passes', MAX_ROUNDS)} passes, "
395
+ f"source bakeoff. Survivor {survivor} (in-sample Sharpe {figures.get('survivor_sharpe')}, "
396
+ f"post-attack standing {figures.get('attacked_sharpe')}, "
397
+ f"deflated Sharpe {figures.get('deflated_sharpe')}). Verdict {figures.get('verdict')}."
398
+ )
@@ -216,7 +216,7 @@ def _books(data: dict[str, list[float]], question: str = "") -> list[dict[str, A
216
216
  return []
217
217
  if aligned.ndim != 2 or aligned.shape[1] < 2 or aligned.shape[0] < 8:
218
218
  return []
219
- built: dict[str, np.ndarray] = {}
219
+ built: dict[str, Any] = {}
220
220
  for method in ("hrp", "risk_parity"):
221
221
  try:
222
222
  result = build_portfolio(data, method=method)
@@ -559,7 +559,7 @@ def _bakeoff(raw: Any, data: dict[str, list[float]]) -> dict[str, Any] | None:
559
559
  if not isinstance(raw, dict):
560
560
  return None
561
561
  curves = []
562
- for row in raw.get("curves") or []:
562
+ for idx, row in enumerate(raw.get("curves") or []):
563
563
  if not isinstance(row, dict):
564
564
  continue
565
565
  growth = row.get("growth")
@@ -570,10 +570,14 @@ def _bakeoff(raw: Any, data: dict[str, list[float]]) -> dict[str, Any] | None:
570
570
  stats = _stats([float(x) for x in rets])
571
571
  curves.append(
572
572
  {
573
+ "round": int(row.get("round") or (idx // 2 + 1)),
574
+ "seat": int(row.get("seat") or (idx % 2 + 1)),
573
575
  "label": str(row.get("label") or ""),
574
576
  "growth": growth,
575
577
  "sharpe": row.get("sharpe"),
576
578
  "standing": row.get("standing"),
579
+ "worst_attack": row.get("worst_attack") or "in_sample",
580
+ "attack_scores": row.get("attack_scores") or {},
577
581
  "annualized_sharpe": stats.get("sharpe_annualized"),
578
582
  "total_return_pct": stats.get("total_return_pct"),
579
583
  "max_drawdown_pct": stats.get("max_drawdown_pct"),
@@ -583,6 +587,7 @@ def _bakeoff(raw: Any, data: dict[str, list[float]]) -> dict[str, Any] | None:
583
587
  trials = [str(line) for line in (raw.get("trials") or [])]
584
588
  if not raw.get("survivor") and not trials and not curves:
585
589
  return None
590
+ passes = [p for p in (raw.get("passes") or []) if isinstance(p, dict)]
586
591
  return {
587
592
  "survivor": raw.get("survivor"),
588
593
  "survivor_sharpe": raw.get("survivor_sharpe"),
@@ -590,33 +595,56 @@ def _bakeoff(raw: Any, data: dict[str, list[float]]) -> dict[str, Any] | None:
590
595
  "deflated_sharpe": raw.get("deflated_sharpe"),
591
596
  "verdict": raw.get("verdict"),
592
597
  "n_trials": raw.get("n_trials"),
598
+ "n_passes": raw.get("n_passes") or max(len(passes), 4),
593
599
  "attacks": [str(attack) for attack in (raw.get("attacks") or [])],
594
600
  "trials": trials,
595
- "curves": curves[:6],
601
+ "passes": passes,
602
+ "curves": curves[:8],
603
+ "survivor_weights": raw.get("survivor_weights") or {},
596
604
  }
597
605
 
598
606
 
599
607
  def _default_bakeoff(data: dict[str, list[float]]) -> dict[str, Any] | None:
600
- """Run the referee across all six rules when the session did not already run a bakeoff."""
608
+ """Run the 4-pass referee tournament across all eight rules when the session did not run one."""
601
609
  try:
602
- from ..core.bakeoff import ATTACKS, referee, trial_paths
610
+ from ..core.bakeoff import referee, trial_paths
603
611
  except Exception: # noqa: BLE001
604
612
  return None
605
613
  submissions: list[dict[str, Any]] = [
606
- {"rule": "hrp"},
607
- {"rule": "risk_parity"},
608
- {"rule": "inverse_vol"},
609
- {"rule": "equal"},
610
- {"rule": "momentum", "lookback": 20},
611
- {"rule": "reversion", "lookback": 20},
614
+ {"rule": "hrp", "_round": 1, "_seat": 1},
615
+ {"rule": "risk_parity", "_round": 1, "_seat": 2},
616
+ {"rule": "inverse_vol", "_round": 2, "_seat": 1},
617
+ {"rule": "var_sorted", "_round": 2, "_seat": 2},
618
+ {"rule": "low_vol", "_round": 3, "_seat": 1},
619
+ {"rule": "equal", "_round": 3, "_seat": 2},
620
+ {"rule": "momentum", "lookback": 20, "_round": 4, "_seat": 1},
621
+ {"rule": "reversion", "lookback": 20, "_round": 4, "_seat": 2},
612
622
  ]
623
+ pass_attacks = ["last_half", "high_vol", "tail_stress", "rate_shock"]
613
624
  try:
614
- figures = referee(data, submissions, list(ATTACKS))
615
- curves = trial_paths(data, submissions)
625
+ passes: list[dict[str, Any]] = []
626
+ for p_idx, atk in enumerate(pass_attacks, start=1):
627
+ sub_slice = submissions[: p_idx * 2]
628
+ atk_slice = pass_attacks[:p_idx]
629
+ step = referee(data, sub_slice, atk_slice)
630
+ passes.append(
631
+ {
632
+ "pass": p_idx,
633
+ "attack": atk,
634
+ "leader": step.get("survivor"),
635
+ "leader_sharpe": step.get("survivor_sharpe"),
636
+ "leader_standing": step.get("attacked_sharpe"),
637
+ "n_trials": len(sub_slice),
638
+ }
639
+ )
640
+ figures = referee(data, submissions, pass_attacks)
641
+ curves = trial_paths(data, submissions, pass_attacks)
616
642
  except Exception: # noqa: BLE001
617
643
  return None
618
644
  if not figures.get("survivor") and not curves:
619
645
  return None
646
+ survivor_label = str(figures.get("survivor") or "")
647
+ survivor_curve = next((c for c in curves if c.get("label") == survivor_label), None)
620
648
  return {
621
649
  "survivor": figures.get("survivor"),
622
650
  "survivor_sharpe": figures.get("survivor_sharpe"),
@@ -624,9 +652,12 @@ def _default_bakeoff(data: dict[str, list[float]]) -> dict[str, Any] | None:
624
652
  "deflated_sharpe": figures.get("deflated_sharpe"),
625
653
  "verdict": figures.get("verdict"),
626
654
  "n_trials": figures.get("n_trials"),
655
+ "n_passes": 4,
627
656
  "attacks": list(figures.get("attacks") or []),
628
657
  "trials": list(figures.get("trials") or []),
658
+ "passes": passes,
629
659
  "curves": curves,
660
+ "survivor_weights": (survivor_curve or {}).get("weights") or {},
630
661
  }
631
662
 
632
663
 
@@ -792,7 +792,7 @@ def _bakeoff(
792
792
  fig = plt.figure(figsize=(8.5, 11))
793
793
  _chrome(
794
794
  fig,
795
- "Strategy Bakeoff & Adversarial Referee Tournament",
795
+ f"Multi-Pass GAN Strategy Bakeoff & Referee Tournament ({bakeoff.get('n_passes') or 4} Passes)",
796
796
  stamp,
797
797
  stem,
798
798
  _question(trace),
@@ -800,68 +800,88 @@ def _bakeoff(
800
800
  banner = (
801
801
  f"Survivor: {bakeoff.get('survivor')} | "
802
802
  f"In-Sample Sharpe: {bakeoff.get('survivor_sharpe')} | "
803
- f"Attacked Sharpe: {bakeoff.get('attacked_sharpe')} | "
803
+ f"Post-Attack Standing: {bakeoff.get('attacked_sharpe')} | "
804
804
  f"Deflated Sharpe: {bakeoff.get('deflated_sharpe')} ({bakeoff.get('verdict')})\n"
805
- f"Trials: {bakeoff.get('n_trials')} | "
805
+ f"Passes: {bakeoff.get('n_passes') or 4} | "
806
+ f"Candidates: {bakeoff.get('n_trials')} | "
806
807
  f"Adversarial Attacks: {', '.join(bakeoff.get('attacks') or []) or 'none'}"
807
808
  )
808
809
  fig.text(0.075, 0.895, banner, fontsize=8.0, fontweight="bold", color="#0F172A", va="top")
809
810
 
810
811
  curves = list(bakeoff.get("curves") or [])
811
812
  if curves:
812
- ax = fig.add_axes((0.11, 0.45, 0.80, 0.37))
813
- ax.set_title("Cumulative Wealth (Growth of 1) Across Candidate Rules", loc="left")
813
+ ax1 = fig.add_axes((0.09, 0.48, 0.44, 0.34))
814
+ ax1.set_title("Cumulative Wealth Across Passes", loc="left", fontsize=9.5)
814
815
  survivor = str(bakeoff.get("survivor") or "")
815
816
  for index, curve in enumerate(curves):
816
817
  label = str(curve.get("label") or "")
817
818
  is_win = label == survivor
818
- ax.plot(
819
+ ax1.plot(
819
820
  curve.get("growth") or [],
820
821
  color="#0F172A" if is_win else _COLORS[index % len(_COLORS)],
821
- lw=2.3 if is_win else 1.25,
822
- alpha=1.0 if is_win else 0.85,
823
- label=f"{label} (survivor)" if is_win else label,
822
+ lw=2.2 if is_win else 1.2,
823
+ alpha=1.0 if is_win else 0.84,
824
+ label=f"{label}*" if is_win else label,
824
825
  )
825
- ax.axhline(1.0, color="#64748B", lw=0.7, ls="--")
826
- ax.set_xlabel("trading observation")
827
- ax.set_ylabel("growth of 1")
828
- ax.legend(loc="upper left", ncol=3, fontsize=7.6)
826
+ ax1.axhline(1.0, color="#64748B", lw=0.7, ls="--")
827
+ ax1.set_xlabel("trading observation")
828
+ ax1.set_ylabel("growth of 1")
829
+ ax1.legend(loc="upper left", ncol=2, fontsize=6.8)
830
+
831
+ ax2 = fig.add_axes((0.62, 0.48, 0.30, 0.34))
832
+ labels = [str(c.get("label") or "") for c in curves][::-1]
833
+ in_sh = [float(c.get("sharpe") or 0.0) for c in curves][::-1]
834
+ st_sh = [
835
+ float(c.get("standing") if c.get("standing") is not None else (c.get("sharpe") or 0.0))
836
+ for c in curves
837
+ ][::-1]
838
+ y_pos = np.arange(len(labels))
839
+ ax2.barh(y_pos + 0.17, in_sh, height=0.32, color="#0072B2", label="In-Sample")
840
+ ax2.barh(y_pos - 0.17, st_sh, height=0.32, color="#D95F02", label="Post-Attack")
841
+ ax2.axvline(0.0, color="#475569", lw=0.8)
842
+ ax2.set_yticks(y_pos, labels, fontsize=7.2)
843
+ ax2.set_title("In-Sample vs. Attacked Sharpe", loc="left", fontsize=9.5)
844
+ ax2.set_xlabel("period Sharpe")
845
+ ax2.legend(loc="lower right", fontsize=7.0)
829
846
 
830
847
  tbl_rows: list[list[str]] = []
831
848
  survivor = str(bakeoff.get("survivor") or "")
832
849
  for idx, curve in enumerate(curves, start=1):
833
850
  label = str(curve.get("label") or "")
834
851
  status = "SURVIVOR" if label == survivor else "eliminated"
852
+ p_num = curve.get("round") or ((idx - 1) // 2 + 1)
835
853
  tbl_rows.append(
836
854
  [
837
- f"{idx:02d}",
855
+ f"P{p_num}",
838
856
  label,
839
857
  _shown(curve.get("sharpe")),
858
+ _shown(curve.get("standing")),
859
+ str(curve.get("worst_attack") or "in_sample"),
840
860
  _shown(curve.get("annualized_sharpe")),
841
861
  _fmt_pct(curve.get("total_return_pct")),
842
- _fmt_pct(curve.get("volatility_annualized_pct")),
843
862
  _fmt_pct(curve.get("max_drawdown_pct")),
844
863
  status,
845
864
  ]
846
865
  )
847
- ax_tbl = fig.add_axes((0.075, 0.08, 0.85, 0.30))
866
+ ax_tbl = fig.add_axes((0.075, 0.07, 0.85, 0.34))
848
867
  ax_tbl.axis("off")
849
- ax_tbl.set_title("Referee Candidate Scorecard", loc="left", fontsize=9.5)
868
+ ax_tbl.set_title("Multi-Pass Referee Candidate Scorecard", loc="left", fontsize=9.5)
850
869
  _styled_table(
851
870
  ax_tbl,
852
871
  [
853
- "#",
872
+ "Pass",
854
873
  "Candidate Rule",
855
- "Period Sharpe",
856
- "Ann Sharpe",
874
+ "Period SR",
875
+ "Attacked SR",
876
+ "Worst Attack",
877
+ "Ann SR",
857
878
  "Total Ret",
858
- "Ann Vol",
859
879
  "Max DD",
860
- "Referee Status",
880
+ "Status",
861
881
  ],
862
882
  tbl_rows,
863
- [0.06, 0.20, 0.12, 0.12, 0.12, 0.12, 0.12, 0.14],
864
- fontsize=7.6,
883
+ [0.06, 0.17, 0.10, 0.11, 0.14, 0.10, 0.10, 0.10, 0.12],
884
+ fontsize=7.3,
865
885
  )
866
886
  else:
867
887
  trials = list(bakeoff.get("trials") or [])