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.
Files changed (106) hide show
  1. {alphaengine-0.10.0 → alphaengine-1.0.0}/PKG-INFO +1 -1
  2. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/_version.py +25 -6
  3. alphaengine-1.0.0/src/alphaengine/agent/sim.py +241 -0
  4. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/executor.py +3 -1
  5. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/__init__.py +4 -0
  6. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/backtest.py +9 -3
  7. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/factors.py +26 -14
  8. alphaengine-1.0.0/src/alphaengine/core/market.py +154 -0
  9. alphaengine-1.0.0/src/alphaengine/core/options.py +143 -0
  10. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/pairs.py +34 -4
  11. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/performance.py +13 -10
  12. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/process.py +8 -4
  13. alphaengine-1.0.0/src/alphaengine/core/rates.py +45 -0
  14. alphaengine-1.0.0/src/alphaengine/core/regime.py +117 -0
  15. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/relations.py +18 -2
  16. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/stress.py +6 -2
  17. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/validation.py +17 -12
  18. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/study/schema.py +1 -1
  19. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/sweep/runner.py +1 -1
  20. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/tui/app.py +23 -2
  21. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/tui/dispatch.py +188 -45
  22. alphaengine-1.0.0/src/alphaengine/tui/stage.py +42 -0
  23. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_goldens.py +14 -10
  24. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_math_audit.py +63 -16
  25. alphaengine-1.0.0/tests/test_sim.py +91 -0
  26. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_tui.py +11 -6
  27. {alphaengine-0.10.0 → alphaengine-1.0.0}/.github/workflows/ci.yml +0 -0
  28. {alphaengine-0.10.0 → alphaengine-1.0.0}/.github/workflows/publish.yml +0 -0
  29. {alphaengine-0.10.0 → alphaengine-1.0.0}/.gitignore +0 -0
  30. {alphaengine-0.10.0 → alphaengine-1.0.0}/AGENTS.md +0 -0
  31. {alphaengine-0.10.0 → alphaengine-1.0.0}/LICENSE +0 -0
  32. {alphaengine-0.10.0 → alphaengine-1.0.0}/README.md +0 -0
  33. {alphaengine-0.10.0 → alphaengine-1.0.0}/SECURITY.md +0 -0
  34. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/assets/banner.png +0 -0
  35. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/assets/data_boundary.png +0 -0
  36. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/assets/session.png +0 -0
  37. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/figure_contract.md +0 -0
  38. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/workflows/evaluate_process.json +0 -0
  39. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/workflows/research_week.json +0 -0
  40. {alphaengine-0.10.0 → alphaengine-1.0.0}/docs/workflows/stress_study.json +0 -0
  41. {alphaengine-0.10.0 → alphaengine-1.0.0}/pyproject.toml +0 -0
  42. {alphaengine-0.10.0 → alphaengine-1.0.0}/scripts/gen_docs.py +0 -0
  43. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/__init__.py +0 -0
  44. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/__main__.py +0 -0
  45. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/agent/__init__.py +0 -0
  46. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/agent/answer.py +0 -0
  47. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/agent/driver.py +0 -0
  48. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/auth.py +0 -0
  49. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/book.py +0 -0
  50. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/charts.py +0 -0
  51. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/cli.py +0 -0
  52. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/__init__.py +0 -0
  53. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/agent.py +0 -0
  54. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/client/session.py +0 -0
  55. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/commands.py +0 -0
  56. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/complete.py +0 -0
  57. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/connectors/__init__.py +0 -0
  58. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/context.py +0 -0
  59. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/active.py +0 -0
  60. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/allocate.py +0 -0
  61. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/covariance.py +0 -0
  62. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/cross_section.py +0 -0
  63. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/panel.py +0 -0
  64. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/portfolio.py +0 -0
  65. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/profile.py +0 -0
  66. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/risk.py +0 -0
  67. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/screen.py +0 -0
  68. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/series_shapes.py +0 -0
  69. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/signals.py +0 -0
  70. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/technical.py +0 -0
  71. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/trace.py +0 -0
  72. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/core/walkforward.py +0 -0
  73. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo.py +0 -0
  74. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_book.py +0 -0
  75. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_returns.py +0 -0
  76. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_signal.py +0 -0
  77. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/demo_universe.py +0 -0
  78. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/events.py +0 -0
  79. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/loaders.py +0 -0
  80. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/model.py +0 -0
  81. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/py.typed +0 -0
  82. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/repl.py +0 -0
  83. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/study/__init__.py +0 -0
  84. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/study/report.py +0 -0
  85. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/sweep/__init__.py +0 -0
  86. {alphaengine-0.10.0 → alphaengine-1.0.0}/src/alphaengine/tui/__init__.py +0 -0
  87. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_agent.py +0 -0
  88. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_answer.py +0 -0
  89. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_book.py +0 -0
  90. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_cli.py +0 -0
  91. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_client.py +0 -0
  92. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_commands.py +0 -0
  93. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_context.py +0 -0
  94. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_events.py +0 -0
  95. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_executor_ops.py +0 -0
  96. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_loaders.py +0 -0
  97. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_model.py +0 -0
  98. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_portfolio.py +0 -0
  99. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_process.py +0 -0
  100. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_quant_maths.py +0 -0
  101. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_relations.py +0 -0
  102. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_screen.py +0 -0
  103. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_signals.py +0 -0
  104. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_smoke.py +0 -0
  105. {alphaengine-0.10.0 → alphaengine-1.0.0}/tests/test_sweep.py +0 -0
  106. {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.10.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
- # The same release answers a later question from the session record, and
119
- # builds a portfolio (hierarchical risk parity, or risk parity when asked)
120
- # from the loaded returns. Those two add no figure to the goldens.
121
- __version__ = "0.10.0"
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", 0.0),
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, returns: list, prices, factor_returns, regime_series, risk_free_rate: float, not_computed: 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 = 0.0,
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: the caller supplies the
6
- risk-free rate (default 0, excess of zero, not a Treasury bill) and the VIF
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
- The default rate is 0, the same as ``performance_report``: excess of zero,
95
- not a bill. Pass the annual rate explicitly when the study uses one.
96
- ``factors_are_excess`` is true for Ken French-style factors, which are
97
- already above cash, so the rate is subtracted from the portfolio only.
98
- Set it false when the factors are total returns. Alpha is the intercept
99
- times ``periods_per_year``, an arithmetic annualization, not a CAGR.
100
- HAC lags stay at 5, which is a daily-sample assumption paired with 252.
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 = float(risk_free_rate) if risk_free_rate is not None else _DEFAULT_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": 5})
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
- "risk_free_rate": round(rfr, 4),
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": 5,
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
+ }