alphaengine 0.10.0__tar.gz → 1.0.0__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-0.10.0 → alphaengine-1.0.0}/PKG-INFO +1 -1
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/_version.py +25 -6
- alphaengine-1.0.0/src/alphaengine/agent/sim.py +241 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/executor.py +3 -1
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/__init__.py +4 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/backtest.py +9 -3
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/factors.py +26 -14
- alphaengine-1.0.0/src/alphaengine/core/market.py +154 -0
- alphaengine-1.0.0/src/alphaengine/core/options.py +143 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/pairs.py +34 -4
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/performance.py +13 -10
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/process.py +8 -4
- alphaengine-1.0.0/src/alphaengine/core/rates.py +45 -0
- alphaengine-1.0.0/src/alphaengine/core/regime.py +117 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/relations.py +18 -2
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/stress.py +6 -2
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/validation.py +17 -12
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/study/schema.py +1 -1
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/sweep/runner.py +1 -1
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/tui/app.py +23 -2
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/tui/dispatch.py +188 -45
- alphaengine-1.0.0/src/alphaengine/tui/stage.py +42 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_goldens.py +14 -10
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_math_audit.py +63 -16
- alphaengine-1.0.0/tests/test_sim.py +91 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_tui.py +11 -6
- {alphaengine-0.10.0 → alphaengine-1.0.0}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/.gitignore +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/AGENTS.md +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/LICENSE +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/README.md +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/SECURITY.md +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/assets/banner.png +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/assets/session.png +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/figure_contract.md +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/pyproject.toml +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/scripts/gen_docs.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/book.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/charts.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/cli.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/agent.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/session.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/commands.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/context.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/active.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/portfolio.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/events.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/model.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_agent.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_answer.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_book.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_cli.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_client.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_commands.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_context.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_events.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_loaders.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_model.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_portfolio.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_process.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_relations.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_screen.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_signals.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_smoke.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_sweep.py +0 -0
- {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: alphaengine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
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
|
|
@@ -113,9 +113,28 @@ is what a changed figure costs. The API may still move underneath it.
|
|
|
113
113
|
# at unit capital, so a loss on the first bar counts. Grinold alpha is empty
|
|
114
114
|
# when no volatility is supplied, instead of treating every name as vol 1.
|
|
115
115
|
# Breadth does not imply an IR until the names are declared independent or a
|
|
116
|
-
# mean correlation is supplied.
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
|
|
116
|
+
# mean correlation is supplied. The same release answers a later question
|
|
117
|
+
# from the session record, and builds a portfolio (hierarchical risk parity,
|
|
118
|
+
# or risk parity when asked) from the loaded returns. Those two add no
|
|
119
|
+
# figure to the goldens.
|
|
120
|
+
#
|
|
121
|
+
# 0.11.0 MOVES THE CASH RATE, ON PURPOSE. An omitted rate is the H.15
|
|
122
|
+
# 3-month Treasury constant maturity, investment basis, 4.21% as of
|
|
123
|
+
# 2026-10-06 (DGS3MO). It is a pinned print, not a download. Pass 0 for
|
|
124
|
+
# excess of zero. Sharpe, Sortino, the deflated Sharpe, and PSR versus a
|
|
125
|
+
# Sharpe of zero all move because the excess is no longer the raw return.
|
|
126
|
+
# The five-trial golden crosses from marginal to likely_noise (0.5090 to
|
|
127
|
+
# 0.427). sr0 moves slightly because its sampling-error floor depends on
|
|
128
|
+
# the Sharpe. Drawdown, total return, geometric CAGR, volatility, and PBO
|
|
129
|
+
# do not use the rate and stay. HAC lags are the Newey-West automatic rule,
|
|
130
|
+
# so a factor t-stat on a short sample uses fewer than five lags and reports
|
|
131
|
+
# the lag it chose. A close below $1.50 is still a price when the path is
|
|
132
|
+
# calm. The cointegration screen reports Benjamini-Hochberg survivors;
|
|
133
|
+
# the single-test flag remains on the pair as cointegrated_raw.
|
|
134
|
+
#
|
|
135
|
+
# 1.0.0 IS THE FIRST RELEASE. Everything through 0.11 was the beta: the
|
|
136
|
+
# maths, the session, the airlock. 1.0 is the simulator. A sentence is a
|
|
137
|
+
# stack of tool calls. The trace is the product, drawn while the tools
|
|
138
|
+
# run. A report file is written on this machine only when the user
|
|
139
|
+
# accepts it. The golden figures stay the 0.11 contract.
|
|
140
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""The simulator. A sentence becomes a stack of tool calls, and the stack is the product.
|
|
2
|
+
|
|
3
|
+
The model chooses the next tool. The tool is deterministic. The trace is what
|
|
4
|
+
gets shown, and what a report file is made of when the user accepts one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..events import data_dir
|
|
18
|
+
|
|
19
|
+
Invoke = Callable[[str, dict[str, Any]], dict[str, Any]]
|
|
20
|
+
Think = Callable[[str], str]
|
|
21
|
+
|
|
22
|
+
MAX_CALLS = 8
|
|
23
|
+
|
|
24
|
+
TOOLS: tuple[tuple[str, str], ...] = (
|
|
25
|
+
("profile", "what is wrong with the loaded file"),
|
|
26
|
+
("align", "shared dates across stocks and macro; gaps counted, never filled"),
|
|
27
|
+
("regime", "two-state Hamilton filter on one named series"),
|
|
28
|
+
("price", "Black-Scholes price and Greeks for one contract"),
|
|
29
|
+
("reprice", "full reprice of that contract under a spot and vol shock"),
|
|
30
|
+
("correlation", "correlation of the loaded names"),
|
|
31
|
+
("covariance", "covariance of the loaded names"),
|
|
32
|
+
("cointegration", "cointegrated pairs, FDR survivors"),
|
|
33
|
+
("portfolio", "weights; args.method is hrp or risk_parity"),
|
|
34
|
+
("overlap", "one name against the equal-weight of the others; args.symbol"),
|
|
35
|
+
("performance", "Sharpe, drawdown, and the cash rate on the loaded book"),
|
|
36
|
+
("var", "value at risk on the loaded book"),
|
|
37
|
+
("process", "fit ou, gbm, jump, or garch and stress it; args.dgp"),
|
|
38
|
+
("workflow", "a portal workflow by name, when the account offers it"),
|
|
39
|
+
("done", "stop. args.answer may cite only numbers the tools returned"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_PROMPT = """You are the simulator inside alphaengine 1.0.
|
|
43
|
+
|
|
44
|
+
The user asked a question. You answer it by calling tools, as many as the
|
|
45
|
+
scenario needs, one per reply. The math is already written. You decide the
|
|
46
|
+
order. You do not invent a figure.
|
|
47
|
+
|
|
48
|
+
Question:
|
|
49
|
+
@@QUESTION@@
|
|
50
|
+
|
|
51
|
+
What is already loaded and already known:
|
|
52
|
+
@@CONTEXT@@
|
|
53
|
+
|
|
54
|
+
Tools:
|
|
55
|
+
@@TOOLS@@
|
|
56
|
+
|
|
57
|
+
Calls already made this turn:
|
|
58
|
+
@@CALLS@@
|
|
59
|
+
|
|
60
|
+
Reply with ONE JSON object and nothing else.
|
|
61
|
+
{"tool": "<name>", "args": {}}
|
|
62
|
+
{"tool": "done", "args": {"answer": "two or three sentences citing only tool numbers"}}
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class Call:
|
|
68
|
+
tool: str
|
|
69
|
+
args: dict[str, Any]
|
|
70
|
+
figures: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
note: str = ""
|
|
72
|
+
error: str | None = None
|
|
73
|
+
|
|
74
|
+
def line(self) -> str:
|
|
75
|
+
if self.error:
|
|
76
|
+
return f"{self.tool} refused {self.error}"
|
|
77
|
+
return f"{self.tool} {self.note}".rstrip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Trace:
|
|
82
|
+
"""The run, as a stack. This is the artifact. The answer is the last line of it."""
|
|
83
|
+
|
|
84
|
+
question: str
|
|
85
|
+
calls: list[Call] = field(default_factory=list)
|
|
86
|
+
answer: str = ""
|
|
87
|
+
refused: bool = False
|
|
88
|
+
|
|
89
|
+
def render(self) -> str:
|
|
90
|
+
lines = ["alphaengine 1.0", f"question {self.question}", ""]
|
|
91
|
+
if not self.calls:
|
|
92
|
+
lines.append("no tools ran")
|
|
93
|
+
for i, call in enumerate(self.calls, start=1):
|
|
94
|
+
lines.append(f"{i:02d} {call.line()}")
|
|
95
|
+
for key, value in _public(call.figures).items():
|
|
96
|
+
lines.append(f" {key} {value}")
|
|
97
|
+
lines.append("")
|
|
98
|
+
if self.answer:
|
|
99
|
+
lines.append(self.answer)
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
def save(self, directory: Path | None = None) -> Path:
|
|
103
|
+
folder = directory or (data_dir() / "reports")
|
|
104
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
106
|
+
path = folder / f"trace-{stamp}.txt"
|
|
107
|
+
path.write_text(self.render() + "\n", encoding="utf-8")
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_tools(
|
|
112
|
+
question: str,
|
|
113
|
+
think: Think,
|
|
114
|
+
invoke: Invoke,
|
|
115
|
+
*,
|
|
116
|
+
context: str = "",
|
|
117
|
+
max_calls: int = MAX_CALLS,
|
|
118
|
+
on_call: Callable[[Call], None] | None = None,
|
|
119
|
+
) -> Trace:
|
|
120
|
+
"""Call tools until the model says done, or the stack is full."""
|
|
121
|
+
trace = Trace(question=question)
|
|
122
|
+
known: dict[str, float] = {}
|
|
123
|
+
for _ in range(max_calls):
|
|
124
|
+
raw = think(_fill(question, context, trace))
|
|
125
|
+
parsed = parse_call(raw)
|
|
126
|
+
if parsed is None:
|
|
127
|
+
trace.answer = "The model did not return a tool call."
|
|
128
|
+
trace.refused = True
|
|
129
|
+
return trace
|
|
130
|
+
name = str(parsed.get("tool") or "")
|
|
131
|
+
args = parsed.get("args") if isinstance(parsed.get("args"), dict) else {}
|
|
132
|
+
if name == "done":
|
|
133
|
+
answer = str(args.get("answer") or parsed.get("answer") or "").strip()
|
|
134
|
+
trace.answer = _guard_answer(answer, known, question)
|
|
135
|
+
trace.refused = trace.answer.startswith("Refused")
|
|
136
|
+
return trace
|
|
137
|
+
if name not in {tool for tool, _ in TOOLS}:
|
|
138
|
+
call = Call(tool=name or "?", args=args, error="that tool is not in the stack")
|
|
139
|
+
trace.calls.append(call)
|
|
140
|
+
if on_call:
|
|
141
|
+
on_call(call)
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
figures = invoke(name, args)
|
|
145
|
+
except Exception as exc: # noqa: BLE001 — the stack records the failure
|
|
146
|
+
figures = {"error": str(exc)}
|
|
147
|
+
if not isinstance(figures, dict):
|
|
148
|
+
figures = {"result": figures}
|
|
149
|
+
error = str(figures["error"]) if figures.get("error") else None
|
|
150
|
+
note = str(figures.get("note") or _note(figures))
|
|
151
|
+
call = Call(tool=name, args=args, figures=_public(figures), note=note, error=error)
|
|
152
|
+
trace.calls.append(call)
|
|
153
|
+
known.update(_numbers(call.figures))
|
|
154
|
+
if on_call:
|
|
155
|
+
on_call(call)
|
|
156
|
+
trace.answer = f"Stopped after {max_calls} tool calls. The stack above is the run."
|
|
157
|
+
return trace
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_call(text: str) -> dict[str, Any] | None:
|
|
161
|
+
start = text.find("{")
|
|
162
|
+
end = text.rfind("}")
|
|
163
|
+
if start < 0 or end <= start:
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
obj = json.loads(text[start : end + 1])
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
return None
|
|
169
|
+
if not isinstance(obj, dict) or "tool" not in obj:
|
|
170
|
+
return None
|
|
171
|
+
return obj
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fill(question: str, context: str, trace: Trace) -> str:
|
|
175
|
+
calls = "\n".join(call.line() for call in trace.calls) if trace.calls else "none yet"
|
|
176
|
+
tools = "\n".join(f"- {name}: {purpose}" for name, purpose in TOOLS)
|
|
177
|
+
return (
|
|
178
|
+
_PROMPT.replace("@@QUESTION@@", question)
|
|
179
|
+
.replace("@@CONTEXT@@", context or "nothing loaded yet")
|
|
180
|
+
.replace("@@TOOLS@@", tools)
|
|
181
|
+
.replace("@@CALLS@@", calls)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _public(figures: dict[str, Any]) -> dict[str, Any]:
|
|
186
|
+
"""Scalars and short lists. A path is not a figure."""
|
|
187
|
+
out: dict[str, Any] = {}
|
|
188
|
+
for key, value in figures.items():
|
|
189
|
+
if str(key).startswith("_"):
|
|
190
|
+
continue
|
|
191
|
+
if isinstance(value, dict):
|
|
192
|
+
continue
|
|
193
|
+
if isinstance(value, (list, tuple)):
|
|
194
|
+
if len(value) > 12:
|
|
195
|
+
continue
|
|
196
|
+
out[str(key)] = list(value)
|
|
197
|
+
continue
|
|
198
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
199
|
+
continue
|
|
200
|
+
out[str(key)] = value
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _note(figures: dict[str, Any]) -> str:
|
|
205
|
+
bits = []
|
|
206
|
+
for key, value in _public(figures).items():
|
|
207
|
+
if key in {"method", "note"}:
|
|
208
|
+
continue
|
|
209
|
+
bits.append(f"{key} {value}")
|
|
210
|
+
if len(bits) == 4:
|
|
211
|
+
break
|
|
212
|
+
return " ".join(bits)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _numbers(figures: dict[str, Any]) -> dict[str, float]:
|
|
216
|
+
found: dict[str, float] = {}
|
|
217
|
+
|
|
218
|
+
def walk(node: Any, prefix: str) -> None:
|
|
219
|
+
if isinstance(node, bool):
|
|
220
|
+
return
|
|
221
|
+
if isinstance(node, (int, float)) and math.isfinite(float(node)):
|
|
222
|
+
found[prefix or "n"] = float(node)
|
|
223
|
+
return
|
|
224
|
+
if isinstance(node, dict):
|
|
225
|
+
for key, value in node.items():
|
|
226
|
+
walk(value, f"{prefix}.{key}" if prefix else str(key))
|
|
227
|
+
|
|
228
|
+
walk(figures, "")
|
|
229
|
+
return found
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _guard_answer(answer: str, known: dict[str, float], question: str) -> str:
|
|
233
|
+
if not answer:
|
|
234
|
+
return "The stack above is the run."
|
|
235
|
+
from .answer import UncitedFigure, check_citations
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
check_citations(answer, known, question)
|
|
239
|
+
except UncitedFigure as exc:
|
|
240
|
+
return f"Refused. {exc}"
|
|
241
|
+
return answer
|
|
@@ -562,7 +562,9 @@ class StepExecutor:
|
|
|
562
562
|
"verdict": out.get("verdict"),
|
|
563
563
|
"n_trials": n_trials,
|
|
564
564
|
"n_trials_source": source,
|
|
565
|
-
"risk_free_rate": out.get("risk_free_rate"
|
|
565
|
+
"risk_free_rate": out.get("risk_free_rate"),
|
|
566
|
+
"risk_free_rate_source": out.get("risk_free_rate_source"),
|
|
567
|
+
"risk_free_rate_as_of": out.get("risk_free_rate_as_of"),
|
|
566
568
|
}
|
|
567
569
|
# Kept so `emit.study` can carry the verdict this run actually produced
|
|
568
570
|
# rather than deriving it a second time. Two derivations of one figure
|
|
@@ -47,6 +47,7 @@ from .process import (
|
|
|
47
47
|
ou_simulate,
|
|
48
48
|
)
|
|
49
49
|
from .profile import profile_data
|
|
50
|
+
from .rates import TREASURY_BILL_AS_OF, TREASURY_BILL_RATE, TREASURY_BILL_SERIES
|
|
50
51
|
from .risk import compute_var_cvar
|
|
51
52
|
from .screen import screen_universe
|
|
52
53
|
from .series_shapes import series_values
|
|
@@ -72,6 +73,9 @@ __all__ = [
|
|
|
72
73
|
"cpcv_score",
|
|
73
74
|
# performance and risk
|
|
74
75
|
"performance_report",
|
|
76
|
+
"TREASURY_BILL_RATE",
|
|
77
|
+
"TREASURY_BILL_AS_OF",
|
|
78
|
+
"TREASURY_BILL_SERIES",
|
|
75
79
|
"compute_var_cvar",
|
|
76
80
|
# simulation and scoring
|
|
77
81
|
"run_backtest",
|
|
@@ -488,7 +488,13 @@ def _regime_attribution(trades: list, regime_series: dict) -> dict:
|
|
|
488
488
|
|
|
489
489
|
|
|
490
490
|
def _attribution(
|
|
491
|
-
bt: dict,
|
|
491
|
+
bt: dict,
|
|
492
|
+
returns: list,
|
|
493
|
+
prices,
|
|
494
|
+
factor_returns,
|
|
495
|
+
regime_series,
|
|
496
|
+
risk_free_rate: float | None,
|
|
497
|
+
not_computed: dict,
|
|
492
498
|
) -> dict:
|
|
493
499
|
"""MAE/MFE + factor + regime attribution. Each leg is OPTIONAL and pure: it
|
|
494
500
|
runs only when its caller-supplied input is present, else names itself in
|
|
@@ -521,7 +527,7 @@ def _attribution(
|
|
|
521
527
|
return attribution
|
|
522
528
|
|
|
523
529
|
|
|
524
|
-
def _cost_report(bt: dict, perf, risk_free_rate: float) -> dict | None:
|
|
530
|
+
def _cost_report(bt: dict, perf, risk_free_rate: float | None) -> dict | None:
|
|
525
531
|
"""Turnover + net-of-cost vs gross Sharpe from the sim's cost bookkeeping.
|
|
526
532
|
|
|
527
533
|
The equity curve is ALREADY net of slippage + commission + impact (they are
|
|
@@ -578,7 +584,7 @@ def score_backtest(
|
|
|
578
584
|
*,
|
|
579
585
|
n_trials: int | None = None,
|
|
580
586
|
n_trials_source: str | None = None,
|
|
581
|
-
risk_free_rate: float =
|
|
587
|
+
risk_free_rate: float | None = None,
|
|
582
588
|
periods_per_year: int = 252,
|
|
583
589
|
benchmark_returns: list | None = None,
|
|
584
590
|
pnl_matrix=None,
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
Risk, factor decomposition over supplied portfolio + factor return streams.
|
|
3
3
|
|
|
4
4
|
Lifted (math-identical) from backend/quant/factors.compute_multi_factor_loadings,
|
|
5
|
-
with the FRED rfr fetch and quant.limits import removed:
|
|
6
|
-
|
|
5
|
+
with the FRED rfr fetch and quant.limits import removed: an omitted cash
|
|
6
|
+
rate is the pinned H.15 print in ``core.rates``, not a download, and the VIF
|
|
7
7
|
threshold is inlined. OLS
|
|
8
8
|
with HAC standard errors (statsmodels) gives factor betas, alpha, t-stats, R²,
|
|
9
9
|
and a multicollinearity diagnostic (VIF).
|
|
@@ -28,10 +28,10 @@ import math
|
|
|
28
28
|
import numpy as np
|
|
29
29
|
import statsmodels.api as sm
|
|
30
30
|
|
|
31
|
+
from .rates import cash_rate, rate_fields
|
|
31
32
|
from .trace import record
|
|
32
33
|
|
|
33
34
|
VIF_MAX_THRESHOLD = 10.0
|
|
34
|
-
_DEFAULT_RFR = 0.0
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
def _clean(val):
|
|
@@ -68,6 +68,15 @@ def _compute_vif(X: np.ndarray, factor_names: list[str]) -> dict[str, float]:
|
|
|
68
68
|
return vifs
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _hac_lags(n: int) -> int:
|
|
72
|
+
"""Newey-West (1994) automatic lag: floor(4 * (T/100)^(2/9)), at least 1.
|
|
73
|
+
|
|
74
|
+
A fixed 5 lags is a daily-sample habit. The same regression on a monthly
|
|
75
|
+
panel does not want five months of overlap.
|
|
76
|
+
"""
|
|
77
|
+
return max(1, int(4.0 * (n / 100.0) ** (2.0 / 9.0)))
|
|
78
|
+
|
|
79
|
+
|
|
71
80
|
def _model_label(factor_names: list[str]) -> str:
|
|
72
81
|
core_set = set(factor_names)
|
|
73
82
|
if {"market", "size", "value", "profitability", "low_vol", "momentum"}.issubset(core_set):
|
|
@@ -91,13 +100,14 @@ def decompose_factors(
|
|
|
91
100
|
(annualized %), factor betas + t-stats, R²/adj-R², residual vol, and a VIF
|
|
92
101
|
multicollinearity diagnostic.
|
|
93
102
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
``factors_are_excess`` is true for Ken
|
|
97
|
-
already above cash, so the rate is
|
|
98
|
-
Set it false when the factors are
|
|
99
|
-
times ``periods_per_year``, an
|
|
100
|
-
|
|
103
|
+
An omitted rate is the pinned H.15 3-month Treasury, the same bill as
|
|
104
|
+
``performance_report``. Pass ``0`` for excess of zero, or another annual
|
|
105
|
+
rate for a different print. ``factors_are_excess`` is true for Ken
|
|
106
|
+
French-style factors, which are already above cash, so the rate is
|
|
107
|
+
subtracted from the portfolio only. Set it false when the factors are
|
|
108
|
+
total returns. Alpha is the intercept times ``periods_per_year``, an
|
|
109
|
+
arithmetic annualization, not a CAGR. HAC lags follow the Newey-West
|
|
110
|
+
automatic rule and the chosen lag is reported.
|
|
101
111
|
"""
|
|
102
112
|
factor_names = list(factor_returns.keys())
|
|
103
113
|
if not factor_names:
|
|
@@ -107,8 +117,9 @@ def decompose_factors(
|
|
|
107
117
|
if min_len < 30:
|
|
108
118
|
return {"error": "Need 30+ observations"}
|
|
109
119
|
|
|
110
|
-
rfr =
|
|
120
|
+
rfr, _, _ = cash_rate(risk_free_rate)
|
|
111
121
|
ppy = max(int(periods_per_year), 1)
|
|
122
|
+
hac_lags = _hac_lags(min_len)
|
|
112
123
|
y = np.array(portfolio_returns[-min_len:], dtype=float)
|
|
113
124
|
rf_per = rfr / ppy
|
|
114
125
|
y_excess = y - rf_per
|
|
@@ -124,7 +135,7 @@ def decompose_factors(
|
|
|
124
135
|
high_vif = [f for f, v in vifs.items() if math.isfinite(v) and v > VIF_MAX_THRESHOLD]
|
|
125
136
|
|
|
126
137
|
X_const = sm.add_constant(X)
|
|
127
|
-
model = sm.OLS(y_excess, X_const).fit(cov_type="HAC", cov_kwds={"maxlags":
|
|
138
|
+
model = sm.OLS(y_excess, X_const).fit(cov_type="HAC", cov_kwds={"maxlags": hac_lags})
|
|
128
139
|
|
|
129
140
|
betas = {}
|
|
130
141
|
tstats = {}
|
|
@@ -158,11 +169,12 @@ def decompose_factors(
|
|
|
158
169
|
"residual_vol": _clean(round(float(np.std(model.resid, ddof=1) * np.sqrt(ppy) * 100), 2)),
|
|
159
170
|
"residual_vol_ddof": 1,
|
|
160
171
|
"n_observations": int(min_len),
|
|
161
|
-
|
|
172
|
+
**rate_fields(risk_free_rate),
|
|
162
173
|
"periods_per_year": ppy,
|
|
163
174
|
"factors_are_excess": bool(factors_are_excess),
|
|
164
175
|
"alpha_annualization": "arithmetic",
|
|
165
|
-
"hac_maxlags":
|
|
176
|
+
"hac_maxlags": hac_lags,
|
|
177
|
+
"hac_rule": "newey_west_1994",
|
|
166
178
|
"model": _model_label(factor_names),
|
|
167
179
|
"vifs": {k: _clean(round(v, 2)) if math.isfinite(v) else None for k, v in vifs.items()},
|
|
168
180
|
"high_vif_factors": high_vif,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Read a loaded book as stocks and macro series that share a calendar.
|
|
2
|
+
|
|
3
|
+
Gaps stay gaps. A missing print is counted, not carried forward, because a
|
|
4
|
+
filled macro series is a series the file did not contain.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .relations import _looks_like_prices
|
|
14
|
+
from .series_shapes import series_values
|
|
15
|
+
|
|
16
|
+
# FRED-style names a macro file actually uses. A ticker is not in this set.
|
|
17
|
+
_MACRO = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"dff",
|
|
20
|
+
"fedfunds",
|
|
21
|
+
"sofr",
|
|
22
|
+
"dgs1mo",
|
|
23
|
+
"dgs3mo",
|
|
24
|
+
"dgs6mo",
|
|
25
|
+
"dgs1",
|
|
26
|
+
"dgs2",
|
|
27
|
+
"dgs5",
|
|
28
|
+
"dgs7",
|
|
29
|
+
"dgs10",
|
|
30
|
+
"dgs20",
|
|
31
|
+
"dgs30",
|
|
32
|
+
"t10y2y",
|
|
33
|
+
"unrate",
|
|
34
|
+
"payems",
|
|
35
|
+
"cpi",
|
|
36
|
+
"cpiaucsl",
|
|
37
|
+
"pce",
|
|
38
|
+
"pcepilfe",
|
|
39
|
+
"gdp",
|
|
40
|
+
"gdpc1",
|
|
41
|
+
"indpro",
|
|
42
|
+
"vix",
|
|
43
|
+
"vixcls",
|
|
44
|
+
"mortgage30us",
|
|
45
|
+
"umcsent",
|
|
46
|
+
"walcl",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def series_kind(name: str, values: list[float]) -> str:
|
|
52
|
+
"""`macro`, `price`, or `series`. The name wins when it is a known macro print."""
|
|
53
|
+
key = "".join(ch for ch in name.lower() if ch.isalnum())
|
|
54
|
+
if key in _MACRO or key.startswith("dgs"):
|
|
55
|
+
return "macro"
|
|
56
|
+
if values and _looks_like_prices(values):
|
|
57
|
+
return "price"
|
|
58
|
+
return "series"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _dated(series: Any) -> tuple[dict[str, float] | None, list[float]]:
|
|
62
|
+
"""A `{date: value}` mapping keeps its dates. A list does not grow any."""
|
|
63
|
+
if isinstance(series, dict):
|
|
64
|
+
out: dict[str, float] = {}
|
|
65
|
+
for key, value in series.items():
|
|
66
|
+
try:
|
|
67
|
+
number = float(value)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
continue
|
|
70
|
+
if number == number: # not NaN
|
|
71
|
+
out[str(key)] = number
|
|
72
|
+
if out:
|
|
73
|
+
return out, [out[k] for k in sorted(out)]
|
|
74
|
+
values, _dates = series_values(series)
|
|
75
|
+
if values.ndim != 1:
|
|
76
|
+
return None, []
|
|
77
|
+
clean = [float(v) for v in values.tolist() if np.isfinite(v)]
|
|
78
|
+
return None, clean
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def describe_market(data: Any) -> dict:
|
|
82
|
+
"""What is loaded: how many names, which are macro, which are prices, whether dates exist."""
|
|
83
|
+
if not isinstance(data, dict) or not data:
|
|
84
|
+
n = len(data) if isinstance(data, (list, tuple)) else 0
|
|
85
|
+
return {"n_names": 1 if n else 0, "n_obs": n, "dates_supplied": False, "kind": "series"}
|
|
86
|
+
macro: list[str] = []
|
|
87
|
+
prices: list[str] = []
|
|
88
|
+
other: list[str] = []
|
|
89
|
+
dated = 0
|
|
90
|
+
shortest = None
|
|
91
|
+
for name, series in data.items():
|
|
92
|
+
if str(name) in {"returns", "pnl", "book_returns", "signal", "prices"}:
|
|
93
|
+
continue
|
|
94
|
+
mapping, values = _dated(series)
|
|
95
|
+
if not values:
|
|
96
|
+
continue
|
|
97
|
+
if mapping is not None:
|
|
98
|
+
dated += 1
|
|
99
|
+
kind = series_kind(str(name), values)
|
|
100
|
+
bucket = {"macro": macro, "price": prices}.get(kind, other)
|
|
101
|
+
bucket.append(str(name))
|
|
102
|
+
shortest = len(values) if shortest is None else min(shortest, len(values))
|
|
103
|
+
names = macro + prices + other
|
|
104
|
+
return {
|
|
105
|
+
"n_names": len(names),
|
|
106
|
+
"n_macro": len(macro),
|
|
107
|
+
"n_price": len(prices),
|
|
108
|
+
"n_other": len(other),
|
|
109
|
+
"dates_supplied": dated == len(names) and dated > 0,
|
|
110
|
+
"n_dated": dated,
|
|
111
|
+
"min_obs": shortest or 0,
|
|
112
|
+
"macro": macro[:12],
|
|
113
|
+
"prices": prices[:12],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def align_market(data: Any) -> dict:
|
|
118
|
+
"""The dates every supplied series actually shares. Missing prints are counted, not filled."""
|
|
119
|
+
if not isinstance(data, dict) or not data:
|
|
120
|
+
return {"error": "load a file of named series first", "n_common": 0}
|
|
121
|
+
dated: dict[str, dict[str, float]] = {}
|
|
122
|
+
undated: list[str] = []
|
|
123
|
+
for name, series in data.items():
|
|
124
|
+
if str(name) in {"returns", "pnl", "book_returns", "signal", "prices"}:
|
|
125
|
+
continue
|
|
126
|
+
mapping, values = _dated(series)
|
|
127
|
+
if mapping is None:
|
|
128
|
+
if values:
|
|
129
|
+
undated.append(str(name))
|
|
130
|
+
continue
|
|
131
|
+
dated[str(name)] = mapping
|
|
132
|
+
if len(dated) < 1:
|
|
133
|
+
return {
|
|
134
|
+
"error": "these series have no dates, so a calendar cannot be shared",
|
|
135
|
+
"n_undated": len(undated),
|
|
136
|
+
"dates_supplied": False,
|
|
137
|
+
}
|
|
138
|
+
common = set.intersection(*(set(series) for series in dated.values()))
|
|
139
|
+
union = set.union(*(set(series) for series in dated.values()))
|
|
140
|
+
ordered = sorted(common)
|
|
141
|
+
gaps = {name: len(union - set(series)) for name, series in dated.items()}
|
|
142
|
+
kinds = {name: series_kind(name, list(series.values())) for name, series in dated.items()}
|
|
143
|
+
return {
|
|
144
|
+
"n_names": len(dated),
|
|
145
|
+
"n_common": len(ordered),
|
|
146
|
+
"start": ordered[0] if ordered else None,
|
|
147
|
+
"end": ordered[-1] if ordered else None,
|
|
148
|
+
"n_macro": sum(1 for kind in kinds.values() if kind == "macro"),
|
|
149
|
+
"n_price": sum(1 for kind in kinds.values() if kind == "price"),
|
|
150
|
+
"gaps": {name: count for name, count in gaps.items() if count},
|
|
151
|
+
"n_undated": len(undated),
|
|
152
|
+
"dates_supplied": True,
|
|
153
|
+
"names": list(dated)[:24],
|
|
154
|
+
}
|