alphaengine 1.0.0__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.
- {alphaengine-1.0.0 → alphaengine-1.0.2}/PKG-INFO +3 -1
- {alphaengine-1.0.0 → alphaengine-1.0.2}/pyproject.toml +5 -1
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/_version.py +14 -1
- alphaengine-1.0.2/src/alphaengine/agent/bakeoff.py +398 -0
- alphaengine-1.0.2/src/alphaengine/agent/brief.py +891 -0
- alphaengine-1.0.2/src/alphaengine/agent/chart.py +1049 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/agent/sim.py +173 -15
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/cli.py +3 -2
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/client/session.py +21 -1
- alphaengine-1.0.2/src/alphaengine/core/bakeoff.py +418 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/covariance.py +1 -1
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/regime.py +55 -13
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/model.py +42 -10
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/tui/app.py +430 -320
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/tui/dispatch.py +400 -30
- alphaengine-1.0.2/src/alphaengine/tui/mark.py +92 -0
- alphaengine-1.0.2/src/alphaengine/tui/typeface.py +48 -0
- alphaengine-1.0.2/tests/test_bakeoff.py +132 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_model.py +43 -0
- alphaengine-1.0.2/tests/test_sim.py +191 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_tui.py +98 -27
- alphaengine-1.0.0/tests/test_sim.py +0 -91
- {alphaengine-1.0.0 → alphaengine-1.0.2}/.github/workflows/ci.yml +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/.github/workflows/publish.yml +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/.gitignore +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/AGENTS.md +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/LICENSE +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/README.md +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/SECURITY.md +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/assets/banner.png +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/assets/data_boundary.png +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/assets/session.png +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/figure_contract.md +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/workflows/research_week.json +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/docs/workflows/stress_study.json +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/scripts/gen_docs.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/__main__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/auth.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/book.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/charts.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/client/agent.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/client/executor.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/commands.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/complete.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/context.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/active.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/backtest.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/market.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/options.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/portfolio.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/process.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/rates.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/relations.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/stress.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/demo.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/events.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/loaders.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/py.typed +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/repl.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/study/report.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/sweep/runner.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/src/alphaengine/tui/stage.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_agent.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_answer.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_book.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_cli.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_client.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_commands.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_context.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_events.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_executor_ops.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_goldens.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_loaders.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_math_audit.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_portfolio.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_process.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_quant_maths.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_relations.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_screen.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_signals.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_smoke.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_sweep.py +0 -0
- {alphaengine-1.0.0 → alphaengine-1.0.2}/tests/test_trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: alphaengine
|
|
3
|
-
Version: 1.0.
|
|
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
|
|
@@ -31,6 +31,7 @@ Provides-Extra: connectors
|
|
|
31
31
|
Requires-Dist: httpx>=0.27; extra == 'connectors'
|
|
32
32
|
Requires-Dist: pyarrow>=15.0; extra == 'connectors'
|
|
33
33
|
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: matplotlib>=3.8; extra == 'dev'
|
|
34
35
|
Requires-Dist: mypy==2.3.0; extra == 'dev'
|
|
35
36
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
36
37
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -40,6 +41,7 @@ Requires-Dist: textual>=1.0; extra == 'dev'
|
|
|
40
41
|
Provides-Extra: factors
|
|
41
42
|
Requires-Dist: statsmodels>=0.14; extra == 'factors'
|
|
42
43
|
Provides-Extra: tui
|
|
44
|
+
Requires-Dist: matplotlib>=3.8; extra == 'tui'
|
|
43
45
|
Requires-Dist: textual>=1.0; extra == 'tui'
|
|
44
46
|
Description-Content-Type: text/markdown
|
|
45
47
|
|
|
@@ -77,8 +77,11 @@ connectors = [
|
|
|
77
77
|
]
|
|
78
78
|
# The interactive session. Not a core dependency: a researcher who only wants
|
|
79
79
|
# a deflated Sharpe should not install a TUI framework to get one.
|
|
80
|
+
# matplotlib is the charted report only. It is imported when a trace is saved,
|
|
81
|
+
# never by `import alphaengine`.
|
|
80
82
|
tui = [
|
|
81
83
|
"textual>=1.0",
|
|
84
|
+
"matplotlib>=3.8",
|
|
82
85
|
]
|
|
83
86
|
dev = [
|
|
84
87
|
"pytest>=8.0",
|
|
@@ -94,6 +97,7 @@ dev = [
|
|
|
94
97
|
# it resolves fine on a machine that happens to have it.
|
|
95
98
|
"statsmodels>=0.14",
|
|
96
99
|
"textual>=1.0",
|
|
100
|
+
"matplotlib>=3.8",
|
|
97
101
|
]
|
|
98
102
|
|
|
99
103
|
[project.urls]
|
|
@@ -185,5 +189,5 @@ ignore_missing_imports = true
|
|
|
185
189
|
# import is an error, so this is the difference between a build that passes on a
|
|
186
190
|
# machine that happens to have them and one that passes anywhere.
|
|
187
191
|
[[tool.mypy.overrides]]
|
|
188
|
-
module = ["scipy.*", "statsmodels.*", "pyarrow.*", "httpx", "textual.*"]
|
|
192
|
+
module = ["scipy.*", "statsmodels.*", "pyarrow.*", "httpx", "textual.*", "matplotlib.*"]
|
|
189
193
|
ignore_missing_imports = true
|
|
@@ -137,4 +137,17 @@ is what a changed figure costs. The API may still move underneath it.
|
|
|
137
137
|
# stack of tool calls. The trace is the product, drawn while the tools
|
|
138
138
|
# run. A report file is written on this machine only when the user
|
|
139
139
|
# accepts it. The golden figures stay the 0.11 contract.
|
|
140
|
-
|
|
140
|
+
#
|
|
141
|
+
# 1.0.1 IS THE REPORT AND THE HARNESS TOOLS. No golden in tests/test_goldens.py
|
|
142
|
+
# moves. The session adds `monte_carlo` and `scenarios` to the tool stack, fixes
|
|
143
|
+
# the sample-count divisor in Ledoit-Wolf shrinkage so hierarchical risk parity
|
|
144
|
+
# and risk parity differentiate weights across a 50-name panel, and writes a
|
|
145
|
+
# multi-page portfolio, scenario, Monte Carlo, and bakeoff report to Downloads.
|
|
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
|
+
)
|