alphaengine 0.9.5__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.9.5 → alphaengine-1.0.0}/PKG-INFO +3 -2
  2. {alphaengine-0.9.5 → alphaengine-1.0.0}/README.md +2 -1
  3. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/_version.py +40 -1
  4. alphaengine-1.0.0/src/alphaengine/agent/sim.py +241 -0
  5. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/cli.py +41 -3
  6. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/executor.py +38 -6
  7. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/session.py +5 -5
  8. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/commands.py +17 -0
  9. alphaengine-1.0.0/src/alphaengine/context.py +130 -0
  10. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/__init__.py +4 -0
  11. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/active.py +38 -6
  12. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/backtest.py +23 -5
  13. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/factors.py +50 -18
  14. alphaengine-1.0.0/src/alphaengine/core/market.py +154 -0
  15. alphaengine-1.0.0/src/alphaengine/core/options.py +143 -0
  16. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/pairs.py +34 -4
  17. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/performance.py +36 -13
  18. alphaengine-1.0.0/src/alphaengine/core/portfolio.py +97 -0
  19. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/process.py +19 -5
  20. alphaengine-1.0.0/src/alphaengine/core/rates.py +45 -0
  21. alphaengine-1.0.0/src/alphaengine/core/regime.py +117 -0
  22. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/relations.py +18 -2
  23. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/risk.py +42 -23
  24. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/stress.py +14 -3
  25. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/validation.py +42 -8
  26. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/study/schema.py +1 -1
  27. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/sweep/runner.py +2 -2
  28. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/tui/app.py +32 -3
  29. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/tui/dispatch.py +322 -48
  30. alphaengine-1.0.0/src/alphaengine/tui/stage.py +42 -0
  31. alphaengine-1.0.0/tests/test_context.py +80 -0
  32. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_goldens.py +17 -11
  33. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_math_audit.py +71 -18
  34. alphaengine-1.0.0/tests/test_portfolio.py +53 -0
  35. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_process.py +17 -3
  36. alphaengine-1.0.0/tests/test_sim.py +91 -0
  37. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_tui.py +64 -1
  38. {alphaengine-0.9.5 → alphaengine-1.0.0}/.github/workflows/ci.yml +0 -0
  39. {alphaengine-0.9.5 → alphaengine-1.0.0}/.github/workflows/publish.yml +0 -0
  40. {alphaengine-0.9.5 → alphaengine-1.0.0}/.gitignore +0 -0
  41. {alphaengine-0.9.5 → alphaengine-1.0.0}/AGENTS.md +0 -0
  42. {alphaengine-0.9.5 → alphaengine-1.0.0}/LICENSE +0 -0
  43. {alphaengine-0.9.5 → alphaengine-1.0.0}/SECURITY.md +0 -0
  44. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/assets/banner.png +0 -0
  45. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/assets/data_boundary.png +0 -0
  46. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/assets/session.png +0 -0
  47. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/figure_contract.md +0 -0
  48. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/workflows/evaluate_process.json +0 -0
  49. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/workflows/research_week.json +0 -0
  50. {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/workflows/stress_study.json +0 -0
  51. {alphaengine-0.9.5 → alphaengine-1.0.0}/pyproject.toml +0 -0
  52. {alphaengine-0.9.5 → alphaengine-1.0.0}/scripts/gen_docs.py +0 -0
  53. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/__init__.py +0 -0
  54. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/__main__.py +0 -0
  55. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/agent/__init__.py +0 -0
  56. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/agent/answer.py +0 -0
  57. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/agent/driver.py +0 -0
  58. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/auth.py +0 -0
  59. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/book.py +0 -0
  60. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/charts.py +0 -0
  61. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/__init__.py +0 -0
  62. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/agent.py +0 -0
  63. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/complete.py +0 -0
  64. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/connectors/__init__.py +0 -0
  65. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/allocate.py +0 -0
  66. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/covariance.py +0 -0
  67. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/cross_section.py +0 -0
  68. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/panel.py +0 -0
  69. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/profile.py +0 -0
  70. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/screen.py +0 -0
  71. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/series_shapes.py +0 -0
  72. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/signals.py +0 -0
  73. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/technical.py +0 -0
  74. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/trace.py +0 -0
  75. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/walkforward.py +0 -0
  76. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo.py +0 -0
  77. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_book.py +0 -0
  78. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_returns.py +0 -0
  79. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_signal.py +0 -0
  80. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_universe.py +0 -0
  81. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/events.py +0 -0
  82. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/loaders.py +0 -0
  83. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/model.py +0 -0
  84. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/py.typed +0 -0
  85. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/repl.py +0 -0
  86. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/study/__init__.py +0 -0
  87. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/study/report.py +0 -0
  88. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/sweep/__init__.py +0 -0
  89. {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/tui/__init__.py +0 -0
  90. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_agent.py +0 -0
  91. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_answer.py +0 -0
  92. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_book.py +0 -0
  93. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_cli.py +0 -0
  94. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_client.py +0 -0
  95. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_commands.py +0 -0
  96. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_events.py +0 -0
  97. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_executor_ops.py +0 -0
  98. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_loaders.py +0 -0
  99. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_model.py +0 -0
  100. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_quant_maths.py +0 -0
  101. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_relations.py +0 -0
  102. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_screen.py +0 -0
  103. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_signals.py +0 -0
  104. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_smoke.py +0 -0
  105. {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_sweep.py +0 -0
  106. {alphaengine-0.9.5 → 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.9.5
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
@@ -253,7 +253,7 @@ from alphaengine.core import ou_calibrate, garch_calibrate, dgp_stress, grinold_
253
253
  ou = ou_calibrate(spread) # kappa, theta, half_life; or not mean-reverting
254
254
  g = garch_calibrate(returns) # omega, alpha, beta, persistence in (0, 1)
255
255
  s = dgp_stress(close, dgp="ou") # n_trials = n_paths, source monte_carlo
256
- a = grinold_alpha(panel, ic=0.05) # alpha vector stays here; scalars travel
256
+ a = grinold_alpha(panel, ic=0.05, vols=vols) # alpha stays here; missing vols are not treated as 1
257
257
  ```
258
258
 
259
259
  ---
@@ -337,6 +337,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
337
337
  | `correlation` | the correlation of every loaded name with every other name | session |
338
338
  | `covariance` | the sample covariance of every loaded name with every other name | session |
339
339
  | `cointegration` | which loaded names are cointegrated | session |
340
+ | `portfolio` | weights for the names already loaded | session |
340
341
  | `universe <name>` | same as load: a universe registered in the portal | session |
341
342
  | `data <file>` | same as load: a local CSV or parquet | session |
342
343
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -208,7 +208,7 @@ from alphaengine.core import ou_calibrate, garch_calibrate, dgp_stress, grinold_
208
208
  ou = ou_calibrate(spread) # kappa, theta, half_life; or not mean-reverting
209
209
  g = garch_calibrate(returns) # omega, alpha, beta, persistence in (0, 1)
210
210
  s = dgp_stress(close, dgp="ou") # n_trials = n_paths, source monte_carlo
211
- a = grinold_alpha(panel, ic=0.05) # alpha vector stays here; scalars travel
211
+ a = grinold_alpha(panel, ic=0.05, vols=vols) # alpha stays here; missing vols are not treated as 1
212
212
  ```
213
213
 
214
214
  ---
@@ -292,6 +292,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
292
292
  | `correlation` | the correlation of every loaded name with every other name | session |
293
293
  | `covariance` | the sample covariance of every loaded name with every other name | session |
294
294
  | `cointegration` | which loaded names are cointegrated | session |
295
+ | `portfolio` | weights for the names already loaded | session |
295
296
  | `universe <name>` | same as load: a universe registered in the portal | session |
296
297
  | `data <file>` | same as load: a local CSV or parquet | session |
297
298
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -98,4 +98,43 @@ is what a changed figure costs. The API may still move underneath it.
98
98
  # is filed for the portal. check_overlap keeps that universe: one named symbol
99
99
  # against the equal-weight of the others. The correlation and beta are the
100
100
  # same numbers; the step now also names the candidate and the book.
101
- __version__ = "0.9.5"
101
+ #
102
+ # 0.10.0 MOVES ONE GOLDEN, ON PURPOSE. Parametric VaR is
103
+ # max(0, -mean*h + z*std*sqrt(h)). The 0.9 figure was z*std*sqrt(h), so the
104
+ # pinned seed goes 1.91 -> 1.84. Historical VaR and CVaR scale the same way:
105
+ # the mean over the horizon, the shock over its square root. At one day a
106
+ # negative percentile is still its absolute value. Daily vol is unchanged.
107
+ # Deflated Sharpe, PBO, and the performance golden stay: the default cash
108
+ # rate is 0, excess of zero, not a bill. The factor regression used to assume
109
+ # 4% when the caller passed nothing; it now uses 0, and reports that, plus
110
+ # whether the factors were already excess and that alpha is an arithmetic
111
+ # annualization. A supplied dollar equity curve is no longer read as a return
112
+ # by subtracting 1 from its last mark. A drawdown built from returns starts
113
+ # at unit capital, so a loss on the first bar counts. Grinold alpha is empty
114
+ # when no volatility is supplied, instead of treating every name as vol 1.
115
+ # Breadth does not imply an IR until the names are declared independent or a
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
@@ -1670,13 +1670,22 @@ def _drive(run: Any, *, quiet: bool = False) -> None:
1670
1670
 
1671
1671
 
1672
1672
  def _publish_panel(session: Any, result: dict[str, Any], *, thesis_id: str | None = None) -> None:
1673
- """Hand the matrix or the pair table to the portal. A missing route is not a failure."""
1673
+ """Hand a matrix, a pair table, or portfolio weights to the portal.
1674
+
1675
+ A missing route is not a failure. The result is already on this machine.
1676
+ """
1674
1677
  if session is None or not hasattr(session, "file_panel"):
1675
1678
  return
1676
1679
  from .client.executor import _guard
1677
- from .core.relations import portal_figures
1678
1680
 
1679
- figures = portal_figures(result)
1681
+ if result.get("kind") == "portfolio":
1682
+ from .core.portfolio import portfolio_figures
1683
+
1684
+ figures = portfolio_figures(result)
1685
+ else:
1686
+ from .core.relations import portal_figures
1687
+
1688
+ figures = portal_figures(result)
1680
1689
  try:
1681
1690
  _guard(figures)
1682
1691
  except ValueError as exc:
@@ -1720,6 +1729,28 @@ def _print_cross(
1720
1729
  return 0
1721
1730
 
1722
1731
 
1732
+ def _print_portfolio(
1733
+ data: Any,
1734
+ *,
1735
+ session: Any = None,
1736
+ thesis_id: str | None = None,
1737
+ method: str = "hrp",
1738
+ ) -> int:
1739
+ """One-shot portfolio. The session path can also ask for risk parity."""
1740
+ from .core.portfolio import build_portfolio
1741
+
1742
+ try:
1743
+ result = build_portfolio(data, method=method)
1744
+ except ValueError as exc:
1745
+ say(red(str(exc)))
1746
+ return 2
1747
+ say(f" {result['method']} {result['n_assets']} names Ledoit-Wolf covariance")
1748
+ for row in (result.get("rows") or [])[:8]:
1749
+ say(f" {row['name']} {row['weight']}")
1750
+ _publish_panel(session, result, thesis_id=thesis_id)
1751
+ return 0
1752
+
1753
+
1723
1754
  def _overlap_payload(data: Any, symbol: str) -> dict[str, Any] | None:
1724
1755
  """Candidate plus the equal-weight of the other loaded names, or None."""
1725
1756
  from .core.relations import overlap_against_the_rest
@@ -1749,6 +1780,13 @@ def cmd_run(args: argparse.Namespace) -> int:
1749
1780
  say(red(str(exc)))
1750
1781
  return 2
1751
1782
  return _print_cross(data, kind, session=session)
1783
+ if str(args.workflow).lower().replace("-", "_") in ("portfolio", "allocate"):
1784
+ try:
1785
+ data, _fn = resolve_data(args, session)
1786
+ except (ProjectError, ValueError) as exc:
1787
+ say(red(str(exc)))
1788
+ return 2
1789
+ return _print_portfolio(data, session=session)
1752
1790
  symbol = getattr(args, "symbol", None)
1753
1791
  resolve_args = args
1754
1792
  if args.workflow == "check_overlap" and symbol:
@@ -470,12 +470,14 @@ class StepExecutor:
470
470
  # sampling across a trough understates the risk in exactly the
471
471
  # direction that flatters.
472
472
  best = _trial_column(result, result.best.index)
473
- equity = np.cumprod(1.0 + np.asarray(best, dtype=float))
474
- cum: list[float] = (equity - 1.0).tolist()
473
+ from ..core.performance import wealth_from_returns
474
+
475
+ wealth = wealth_from_returns(np.asarray(best, dtype=float))
476
+ cum: list[float] = (wealth[1:] - 1.0).tolist()
475
477
  figures["best_curve"] = [{"i": i, "v": round(v, 6)} for i, v in _bucketed(cum, CURVE_POINTS, _last)]
476
- peak = np.maximum.accumulate(equity)
478
+ peak = np.maximum.accumulate(wealth)
477
479
  with np.errstate(divide="ignore", invalid="ignore"):
478
- dd: list[float] = np.where(peak > 0, equity / peak - 1.0, 0.0).tolist()
480
+ dd: list[float] = np.where(peak > 0, wealth / peak - 1.0, 0.0)[1:].tolist()
479
481
  figures["drawdown_curve"] = [{"i": i, "v": round(v, 6)} for i, v in _bucketed(dd, CURVE_POINTS, min)]
480
482
  return figures
481
483
 
@@ -547,7 +549,12 @@ class StepExecutor:
547
549
  ws["verdict"] = figures
548
550
  return figures
549
551
 
550
- out = deflated_sharpe(self._returns_column(ws), n_trials=n_trials)
552
+ rf_kwargs: dict[str, Any] = {}
553
+ if params.get("risk_free_rate") is not None:
554
+ rf_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
555
+ if params.get("periods_per_year") is not None:
556
+ rf_kwargs["periods_per_year"] = int(params["periods_per_year"])
557
+ out = deflated_sharpe(self._returns_column(ws), n_trials=n_trials, **rf_kwargs)
551
558
  figures = {
552
559
  "deflated_sharpe": out.get("deflated_sharpe"),
553
560
  "psr_vs_zero": out.get("psr_vs_zero"),
@@ -555,6 +562,9 @@ class StepExecutor:
555
562
  "verdict": out.get("verdict"),
556
563
  "n_trials": n_trials,
557
564
  "n_trials_source": source,
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"),
558
568
  }
559
569
  # Kept so `emit.study` can carry the verdict this run actually produced
560
570
  # rather than deriving it a second time. Two derivations of one figure
@@ -853,11 +863,17 @@ class StepExecutor:
853
863
  sweep = ws.get("sweep")
854
864
  if sweep is not None and n_trials is None:
855
865
  n_trials, source = int(sweep.n_trials), "derived_from_grid"
866
+ score_kwargs: dict[str, Any] = {}
867
+ if params.get("risk_free_rate") is not None:
868
+ score_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
869
+ if params.get("periods_per_year") is not None:
870
+ score_kwargs["periods_per_year"] = int(params["periods_per_year"])
856
871
  scored = score_backtest(
857
872
  bt,
858
873
  n_trials=None if n_trials is None else int(n_trials),
859
874
  n_trials_source=source,
860
875
  cpcv=bool(params.get("cpcv")),
876
+ **score_kwargs,
861
877
  )
862
878
  ws["score"] = scored
863
879
  return {
@@ -874,6 +890,11 @@ class StepExecutor:
874
890
  sweep = ws.get("sweep")
875
891
  if sweep is not None and n_trials is None:
876
892
  n_trials = int(sweep.n_trials)
893
+ cpcv_kwargs: dict[str, Any] = {}
894
+ if params.get("risk_free_rate") is not None:
895
+ cpcv_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
896
+ if params.get("periods_per_year") is not None:
897
+ cpcv_kwargs["periods_per_year"] = int(params["periods_per_year"])
877
898
  return dict(
878
899
  cpcv_score(
879
900
  returns,
@@ -882,6 +903,7 @@ class StepExecutor:
882
903
  purge=int(params.get("purge") or 1),
883
904
  embargo=int(params.get("embargo") or 1),
884
905
  n_trials=int(n_trials) if n_trials is not None else 1,
906
+ **cpcv_kwargs,
885
907
  )
886
908
  )
887
909
 
@@ -898,9 +920,13 @@ class StepExecutor:
898
920
  portfolio = _as_returns(data) or data.get("returns")
899
921
  if portfolio is None:
900
922
  raise UnsupportedOp("compute.factors found factor_returns but no portfolio returns.")
901
- kwargs = {}
923
+ kwargs: dict[str, Any] = {}
902
924
  if params.get("risk_free_rate") is not None:
903
925
  kwargs["risk_free_rate"] = float(params["risk_free_rate"])
926
+ if params.get("periods_per_year") is not None:
927
+ kwargs["periods_per_year"] = int(params["periods_per_year"])
928
+ if params.get("factors_are_excess") is not None:
929
+ kwargs["factors_are_excess"] = bool(params["factors_are_excess"])
904
930
  return dict(decompose_factors(list(portfolio), data["factor_returns"], **kwargs))
905
931
 
906
932
  def _pairs(self, params: Figures, ws: Workspace) -> Figures:
@@ -1283,12 +1309,18 @@ class StepExecutor:
1283
1309
  ideal = params.get("ideal") if isinstance(params.get("ideal"), dict) else ws.get("alpha")
1284
1310
  if isinstance(ideal, dict) and not holdings:
1285
1311
  ideal = None
1312
+ breadth_kwargs: dict[str, Any] = {}
1313
+ if params.get("independent") is not None:
1314
+ breadth_kwargs["independent"] = bool(params["independent"])
1315
+ if params.get("mean_correlation") is not None:
1316
+ breadth_kwargs["mean_correlation"] = float(params["mean_correlation"])
1286
1317
  return dict(
1287
1318
  breadth_ir(
1288
1319
  ic=float(ic),
1289
1320
  n_names=int(n_names or 0),
1290
1321
  holdings=holdings,
1291
1322
  ideal=ideal if isinstance(ideal, dict) else None,
1323
+ **breadth_kwargs,
1292
1324
  )
1293
1325
  )
1294
1326
 
@@ -515,11 +515,11 @@ class Session:
515
515
  ) -> Figures:
516
516
  """File a client-computed panel so the portal can draw it.
517
517
 
518
- Correlation, covariance, and cointegration are not catalogue workflows.
519
- Their matrix does not fit a `triangle` figure, so it travels here as
520
- `figures.matrix` plus `figures.names`. A 404 means this portal has no
521
- panels route yet: the maths already happened on this machine, and the
522
- missing route must not fail that.
518
+ Correlation, covariance, cointegration, and a portfolio are not catalogue
519
+ workflows. A matrix travels as `figures.matrix` plus `figures.names`;
520
+ portfolio weights travel as rows. A 404 means this portal has no panels
521
+ route yet: the maths already happened on this machine, and the missing
522
+ route must not fail that.
523
523
  """
524
524
  from .._version import __version__
525
525
 
@@ -532,6 +532,23 @@ COMMANDS: tuple[Command, ...] = _question_commands() + (
532
532
  ),
533
533
  examples=("cointegration",),
534
534
  ),
535
+ Command(
536
+ verb="portfolio",
537
+ group="data",
538
+ args="",
539
+ scope="repl",
540
+ purpose="weights for the names already loaded",
541
+ body=(
542
+ "Hierarchical risk parity on a Ledoit-Wolf covariance of the loaded "
543
+ "returns. Say `portfolio risk parity` for equal risk contribution.\n\n"
544
+ "If this session already ran a correlation, covariance, or "
545
+ "cointegration, the book is the names that calculation kept, and "
546
+ "those readings stay attached so a later question can quote them. "
547
+ "The weights themselves come from the covariance, not from mixing "
548
+ "those readings into a second optimiser."
549
+ ),
550
+ examples=("portfolio", "portfolio risk parity"),
551
+ ),
535
552
  Command(
536
553
  verb="universe",
537
554
  group="data",