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.
- {alphaengine-0.9.5 → alphaengine-1.0.0}/PKG-INFO +3 -2
- {alphaengine-0.9.5 → alphaengine-1.0.0}/README.md +2 -1
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/_version.py +40 -1
- alphaengine-1.0.0/src/alphaengine/agent/sim.py +241 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/cli.py +41 -3
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/executor.py +38 -6
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/session.py +5 -5
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/commands.py +17 -0
- alphaengine-1.0.0/src/alphaengine/context.py +130 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/__init__.py +4 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/active.py +38 -6
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/backtest.py +23 -5
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/factors.py +50 -18
- alphaengine-1.0.0/src/alphaengine/core/market.py +154 -0
- alphaengine-1.0.0/src/alphaengine/core/options.py +143 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/pairs.py +34 -4
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/performance.py +36 -13
- alphaengine-1.0.0/src/alphaengine/core/portfolio.py +97 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/process.py +19 -5
- alphaengine-1.0.0/src/alphaengine/core/rates.py +45 -0
- alphaengine-1.0.0/src/alphaengine/core/regime.py +117 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/relations.py +18 -2
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/risk.py +42 -23
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/stress.py +14 -3
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/validation.py +42 -8
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/study/schema.py +1 -1
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/sweep/runner.py +2 -2
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/tui/app.py +32 -3
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/tui/dispatch.py +322 -48
- alphaengine-1.0.0/src/alphaengine/tui/stage.py +42 -0
- alphaengine-1.0.0/tests/test_context.py +80 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_goldens.py +17 -11
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_math_audit.py +71 -18
- alphaengine-1.0.0/tests/test_portfolio.py +53 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_process.py +17 -3
- alphaengine-1.0.0/tests/test_sim.py +91 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_tui.py +64 -1
- {alphaengine-0.9.5 → alphaengine-1.0.0}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/.gitignore +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/AGENTS.md +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/LICENSE +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/SECURITY.md +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/assets/banner.png +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/assets/session.png +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/figure_contract.md +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/pyproject.toml +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/scripts/gen_docs.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/book.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/charts.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/client/agent.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/events.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/model.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_agent.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_answer.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_book.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_cli.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_client.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_commands.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_events.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_loaders.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_model.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_relations.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_screen.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_signals.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_smoke.py +0 -0
- {alphaengine-0.9.5 → alphaengine-1.0.0}/tests/test_sweep.py +0 -0
- {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.
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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(
|
|
478
|
+
peak = np.maximum.accumulate(wealth)
|
|
477
479
|
with np.errstate(divide="ignore", invalid="ignore"):
|
|
478
|
-
dd: list[float] = np.where(peak > 0,
|
|
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
|
-
|
|
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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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",
|