alphaengine 0.9.4__tar.gz → 0.10.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.4 → alphaengine-0.10.0}/PKG-INFO +6 -2
- {alphaengine-0.9.4 → alphaengine-0.10.0}/README.md +5 -1
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/_version.py +27 -1
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/charts.py +9 -1
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/cli.py +162 -1
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/executor.py +52 -6
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/session.py +38 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/commands.py +59 -0
- alphaengine-0.10.0/src/alphaengine/context.py +130 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/active.py +38 -6
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/backtest.py +14 -2
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/factors.py +35 -15
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/performance.py +23 -3
- alphaengine-0.10.0/src/alphaengine/core/portfolio.py +97 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/process.py +15 -5
- alphaengine-0.10.0/src/alphaengine/core/relations.py +309 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/risk.py +42 -23
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/stress.py +10 -3
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/validation.py +37 -8
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/sweep/runner.py +1 -1
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/tui/app.py +59 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/tui/dispatch.py +266 -2
- alphaengine-0.10.0/tests/test_context.py +80 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_goldens.py +3 -1
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_math_audit.py +19 -13
- alphaengine-0.10.0/tests/test_portfolio.py +53 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_process.py +17 -3
- alphaengine-0.10.0/tests/test_relations.py +150 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_tui.py +126 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/.gitignore +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/AGENTS.md +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/LICENSE +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/SECURITY.md +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/assets/banner.png +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/assets/session.png +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/figure_contract.md +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/pyproject.toml +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/scripts/gen_docs.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/book.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/agent.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/events.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/model.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_agent.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_answer.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_book.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_cli.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_client.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_commands.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_events.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_loaders.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_model.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_screen.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_signals.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_smoke.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_sweep.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.10.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: 0.10.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
|
---
|
|
@@ -334,6 +334,10 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
|
|
|
334
334
|
| `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
|
|
335
335
|
| `load <file \| module \| universe>` | a CSV, a project module, or a portal universe | session |
|
|
336
336
|
| `thesis [<name> \| clear]` | choose a portal thesis for the next run | session |
|
|
337
|
+
| `correlation` | the correlation of every loaded name with every other name | session |
|
|
338
|
+
| `covariance` | the sample covariance of every loaded name with every other name | session |
|
|
339
|
+
| `cointegration` | which loaded names are cointegrated | session |
|
|
340
|
+
| `portfolio` | weights for the names already loaded | session |
|
|
337
341
|
| `universe <name>` | same as load: a universe registered in the portal | session |
|
|
338
342
|
| `data <file>` | same as load: a local CSV or parquet | session |
|
|
339
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
|
---
|
|
@@ -289,6 +289,10 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
|
|
|
289
289
|
| `book [<name> \| status]` | show or load sleeves on the multi-strategy book | session |
|
|
290
290
|
| `load <file \| module \| universe>` | a CSV, a project module, or a portal universe | session |
|
|
291
291
|
| `thesis [<name> \| clear]` | choose a portal thesis for the next run | session |
|
|
292
|
+
| `correlation` | the correlation of every loaded name with every other name | session |
|
|
293
|
+
| `covariance` | the sample covariance of every loaded name with every other name | session |
|
|
294
|
+
| `cointegration` | which loaded names are cointegrated | session |
|
|
295
|
+
| `portfolio` | weights for the names already loaded | session |
|
|
292
296
|
| `universe <name>` | same as load: a universe registered in the portal | session |
|
|
293
297
|
| `data <file>` | same as load: a local CSV or parquet | session |
|
|
294
298
|
| `project <module>` | same as load: a module with data and backtest_fn | session |
|
|
@@ -92,4 +92,30 @@ is what a changed figure costs. The API may still move underneath it.
|
|
|
92
92
|
# 0.9.4 IS THE SESSION, NOT THE MATHS. No computed value moved. The session
|
|
93
93
|
# opens on the desk: what is loaded, the thesis on its own lines, and the
|
|
94
94
|
# next thing to type.
|
|
95
|
-
|
|
95
|
+
#
|
|
96
|
+
# 0.9.5 IS THE SESSION, NOT THE MATHS. No golden moved. Correlation, covariance,
|
|
97
|
+
# and cointegration run on the universe already loaded, and the square matrix
|
|
98
|
+
# is filed for the portal. check_overlap keeps that universe: one named symbol
|
|
99
|
+
# against the equal-weight of the others. The correlation and beta are the
|
|
100
|
+
# same numbers; the step now also names the candidate and the book.
|
|
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.
|
|
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"
|
|
@@ -12,7 +12,14 @@ KINDS
|
|
|
12
12
|
hist {edges, counts} histogram
|
|
13
13
|
cost {bps, sharpe} cost ladder
|
|
14
14
|
scatter {x, y} overlap
|
|
15
|
-
triangle {a, b, v} covariance heatmap
|
|
15
|
+
triangle {a, b, v} covariance heatmap, only when the
|
|
16
|
+
upper triangle fits in 512 cells
|
|
17
|
+
matrix names + matrix full square heatmap. `names[i]` labels
|
|
18
|
+
`matrix[i][j]`. Each row is at most 512
|
|
19
|
+
long, so a book larger than that is truncated
|
|
20
|
+
and `truncated` is true. This is the shape a
|
|
21
|
+
100-name correlation uses; `triangle` cannot
|
|
22
|
+
carry it.
|
|
16
23
|
rows bounded dict rows table
|
|
17
24
|
quantiles [float] bar
|
|
18
25
|
|
|
@@ -32,6 +39,7 @@ CHART_KINDS = frozenset(
|
|
|
32
39
|
"cost",
|
|
33
40
|
"scatter",
|
|
34
41
|
"triangle",
|
|
42
|
+
"matrix",
|
|
35
43
|
"rows",
|
|
36
44
|
"quantiles",
|
|
37
45
|
}
|
|
@@ -1131,6 +1131,22 @@ def preflight(catalogue: list[dict[str, Any]], name: str, *, data: Any, backtest
|
|
|
1131
1131
|
if not missing:
|
|
1132
1132
|
return None
|
|
1133
1133
|
|
|
1134
|
+
# check_overlap wants one candidate and a separate book. A loaded universe
|
|
1135
|
+
# already holds both: name one symbol and the other names are the book.
|
|
1136
|
+
# Telling somebody to pass --symbol and then discarding the rest of the
|
|
1137
|
+
# names was the loop. The session builds that book itself; this message is
|
|
1138
|
+
# the shell form of the same move.
|
|
1139
|
+
if name == "check_overlap" and _looks_like_universe(data) and isinstance(data, dict):
|
|
1140
|
+
example = next(iter(sorted(str(k) for k in data)), "MU")
|
|
1141
|
+
return (
|
|
1142
|
+
f"check_overlap compares ONE name with the equal-weight of the others, "
|
|
1143
|
+
f"and a universe of {len(data)} names is loaded.\n"
|
|
1144
|
+
f" run check_overlap --symbol {example}\n"
|
|
1145
|
+
" That name is the candidate. The other names are the book.\n"
|
|
1146
|
+
" For every name against every other name:\n"
|
|
1147
|
+
" run correlation"
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1134
1150
|
# THE ANSWER THE LOADED DATA MAKES POSSIBLE, before the generic doors. A
|
|
1135
1151
|
# user with a universe loaded who is told "load a universe" is in a loop
|
|
1136
1152
|
# with no exit — the real move is naming which of their names to measure.
|
|
@@ -1653,16 +1669,145 @@ def _drive(run: Any, *, quiet: bool = False) -> None:
|
|
|
1653
1669
|
return
|
|
1654
1670
|
|
|
1655
1671
|
|
|
1672
|
+
def _publish_panel(session: Any, result: dict[str, Any], *, thesis_id: str | None = None) -> None:
|
|
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
|
+
"""
|
|
1677
|
+
if session is None or not hasattr(session, "file_panel"):
|
|
1678
|
+
return
|
|
1679
|
+
from .client.executor import _guard
|
|
1680
|
+
|
|
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)
|
|
1689
|
+
try:
|
|
1690
|
+
_guard(figures)
|
|
1691
|
+
except ValueError as exc:
|
|
1692
|
+
say(yellow(str(exc)))
|
|
1693
|
+
return
|
|
1694
|
+
filed = session.file_panel(figures, thesis_id=thesis_id)
|
|
1695
|
+
if not isinstance(filed, dict):
|
|
1696
|
+
return
|
|
1697
|
+
if filed.get("filed"):
|
|
1698
|
+
say(dim(" filed to the portal"))
|
|
1699
|
+
return
|
|
1700
|
+
if filed.get("reason") == "missing":
|
|
1701
|
+
say(dim(" held on this machine; the portal has no panels route yet"))
|
|
1702
|
+
return
|
|
1703
|
+
if filed.get("reason") and filed.get("reason") != "offline":
|
|
1704
|
+
say(dim(f" held on this machine ({filed.get('reason')})"))
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _print_cross(
|
|
1708
|
+
data: Any,
|
|
1709
|
+
kind: str,
|
|
1710
|
+
*,
|
|
1711
|
+
session: Any = None,
|
|
1712
|
+
thesis_id: str | None = None,
|
|
1713
|
+
) -> int:
|
|
1714
|
+
from .core.relations import cointegration, correlation, covariance, summary_lines
|
|
1715
|
+
|
|
1716
|
+
try:
|
|
1717
|
+
if kind == "correlation":
|
|
1718
|
+
result = correlation(data)
|
|
1719
|
+
elif kind == "covariance":
|
|
1720
|
+
result = covariance(data)
|
|
1721
|
+
else:
|
|
1722
|
+
result = cointegration(data)
|
|
1723
|
+
except ValueError as exc:
|
|
1724
|
+
say(red(str(exc)))
|
|
1725
|
+
return 2
|
|
1726
|
+
for line in summary_lines(result):
|
|
1727
|
+
say(line)
|
|
1728
|
+
_publish_panel(session, result, thesis_id=thesis_id)
|
|
1729
|
+
return 0
|
|
1730
|
+
|
|
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
|
+
|
|
1754
|
+
def _overlap_payload(data: Any, symbol: str) -> dict[str, Any] | None:
|
|
1755
|
+
"""Candidate plus the equal-weight of the other loaded names, or None."""
|
|
1756
|
+
from .core.relations import overlap_against_the_rest
|
|
1757
|
+
|
|
1758
|
+
if not _looks_like_universe(data):
|
|
1759
|
+
return None
|
|
1760
|
+
try:
|
|
1761
|
+
prepared = overlap_against_the_rest(data, symbol)
|
|
1762
|
+
except ValueError as exc:
|
|
1763
|
+
say(red(str(exc)))
|
|
1764
|
+
return None
|
|
1765
|
+
others = len(prepared["book_names"])
|
|
1766
|
+
say(dim(f" {prepared['symbol']} against the equal-weight of {others} other names."))
|
|
1767
|
+
return {"returns": prepared["returns"], "book_returns": prepared["book_returns"]}
|
|
1768
|
+
|
|
1769
|
+
|
|
1656
1770
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
1657
1771
|
from .client import Offline, ServerError
|
|
1772
|
+
from .core.relations import KINDS
|
|
1658
1773
|
|
|
1659
1774
|
session, url = _session(args.url, args.key)
|
|
1775
|
+
kind = KINDS.get(str(args.workflow).lower().replace("-", "_"))
|
|
1776
|
+
if kind:
|
|
1777
|
+
try:
|
|
1778
|
+
data, _fn = resolve_data(args, session)
|
|
1779
|
+
except (ProjectError, ValueError) as exc:
|
|
1780
|
+
say(red(str(exc)))
|
|
1781
|
+
return 2
|
|
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)
|
|
1790
|
+
symbol = getattr(args, "symbol", None)
|
|
1791
|
+
resolve_args = args
|
|
1792
|
+
if args.workflow == "check_overlap" and symbol:
|
|
1793
|
+
resolve_args = argparse.Namespace(**vars(args))
|
|
1794
|
+
resolve_args.symbol = None
|
|
1660
1795
|
try:
|
|
1661
|
-
data, backtest_fn = resolve_data(
|
|
1796
|
+
data, backtest_fn = resolve_data(resolve_args, session)
|
|
1662
1797
|
workspace_id = None
|
|
1663
1798
|
except (ProjectError, ValueError) as exc:
|
|
1664
1799
|
say(red(str(exc)))
|
|
1665
1800
|
return 2
|
|
1801
|
+
if args.workflow == "check_overlap" and _looks_like_universe(data):
|
|
1802
|
+
if symbol:
|
|
1803
|
+
payload = _overlap_payload(data, str(symbol))
|
|
1804
|
+
if payload is None:
|
|
1805
|
+
return 2
|
|
1806
|
+
data = payload
|
|
1807
|
+
else:
|
|
1808
|
+
say(" check_overlap measures one name against the equal-weight of the others.")
|
|
1809
|
+
say(dim(" No name was given, so this is every name against every other name."))
|
|
1810
|
+
return _print_cross(data, "correlation", session=session)
|
|
1666
1811
|
|
|
1667
1812
|
inputs: dict[str, Any] = {}
|
|
1668
1813
|
for pair in args.input or []:
|
|
@@ -3391,6 +3536,22 @@ def _ask(
|
|
|
3391
3536
|
except Exception as exc: # noqa: BLE001 - reachable and unloadable: say so, refuse below
|
|
3392
3537
|
say(yellow(f" {wanted} is on your account and could not be loaded: {exc}"))
|
|
3393
3538
|
|
|
3539
|
+
# check_overlap on a universe is one name against the equal-weight of the
|
|
3540
|
+
# rest. Collapsing --symbol here used to throw the book away, which is why
|
|
3541
|
+
# the next line was "needs book_returns, and none was loaded."
|
|
3542
|
+
if name == "check_overlap" and _looks_like_universe(data):
|
|
3543
|
+
sym = _symbol_in(request, data)
|
|
3544
|
+
if sym:
|
|
3545
|
+
payload = _overlap_payload(data, sym)
|
|
3546
|
+
if payload is None:
|
|
3547
|
+
return None
|
|
3548
|
+
data = payload
|
|
3549
|
+
else:
|
|
3550
|
+
say(" check_overlap measures one name against the equal-weight of the others.")
|
|
3551
|
+
say(dim(" No name was given, so this is every name against every other name."))
|
|
3552
|
+
_print_cross(data, "correlation", session=session, thesis_id=thesis_id)
|
|
3553
|
+
return None
|
|
3554
|
+
|
|
3394
3555
|
# "size MU" against a loaded universe is a complete instruction: the name
|
|
3395
3556
|
# the question mentions picks the series out of the caller's own data.
|
|
3396
3557
|
# One match only — two is a real ambiguity and the refusal below names the
|
|
@@ -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,7 @@ 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", 0.0),
|
|
558
566
|
}
|
|
559
567
|
# Kept so `emit.study` can carry the verdict this run actually produced
|
|
560
568
|
# rather than deriving it a second time. Two derivations of one figure
|
|
@@ -788,6 +796,22 @@ class StepExecutor:
|
|
|
788
796
|
out["rolling_window"] = window
|
|
789
797
|
out["max_rolling_correlation"] = round(max(clean), 6)
|
|
790
798
|
out["min_rolling_correlation"] = round(min(clean), 6)
|
|
799
|
+
if isinstance(self.data, dict):
|
|
800
|
+
symbol = self.data.get("symbol")
|
|
801
|
+
if symbol:
|
|
802
|
+
out["symbol"] = str(symbol)
|
|
803
|
+
book_names = self.data.get("book_names")
|
|
804
|
+
if isinstance(book_names, list):
|
|
805
|
+
out["n_book_names"] = len(book_names)
|
|
806
|
+
if len(book_names) <= MAX_FIGURE_LIST:
|
|
807
|
+
out["book_names"] = [str(name) for name in book_names]
|
|
808
|
+
hints = []
|
|
809
|
+
if out.get("overlap_scatter"):
|
|
810
|
+
hints.append(chart("scatter", "overlap_scatter", "candidate vs the book"))
|
|
811
|
+
if out.get("rolling_correlation"):
|
|
812
|
+
hints.append(chart("curve", "rolling_correlation", "rolling correlation"))
|
|
813
|
+
if hints:
|
|
814
|
+
out["charts"] = hints
|
|
791
815
|
return out
|
|
792
816
|
|
|
793
817
|
# ── the rest of the modeling week, previously library-only ─────────────
|
|
@@ -837,11 +861,17 @@ class StepExecutor:
|
|
|
837
861
|
sweep = ws.get("sweep")
|
|
838
862
|
if sweep is not None and n_trials is None:
|
|
839
863
|
n_trials, source = int(sweep.n_trials), "derived_from_grid"
|
|
864
|
+
score_kwargs: dict[str, Any] = {}
|
|
865
|
+
if params.get("risk_free_rate") is not None:
|
|
866
|
+
score_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
|
|
867
|
+
if params.get("periods_per_year") is not None:
|
|
868
|
+
score_kwargs["periods_per_year"] = int(params["periods_per_year"])
|
|
840
869
|
scored = score_backtest(
|
|
841
870
|
bt,
|
|
842
871
|
n_trials=None if n_trials is None else int(n_trials),
|
|
843
872
|
n_trials_source=source,
|
|
844
873
|
cpcv=bool(params.get("cpcv")),
|
|
874
|
+
**score_kwargs,
|
|
845
875
|
)
|
|
846
876
|
ws["score"] = scored
|
|
847
877
|
return {
|
|
@@ -858,6 +888,11 @@ class StepExecutor:
|
|
|
858
888
|
sweep = ws.get("sweep")
|
|
859
889
|
if sweep is not None and n_trials is None:
|
|
860
890
|
n_trials = int(sweep.n_trials)
|
|
891
|
+
cpcv_kwargs: dict[str, Any] = {}
|
|
892
|
+
if params.get("risk_free_rate") is not None:
|
|
893
|
+
cpcv_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
|
|
894
|
+
if params.get("periods_per_year") is not None:
|
|
895
|
+
cpcv_kwargs["periods_per_year"] = int(params["periods_per_year"])
|
|
861
896
|
return dict(
|
|
862
897
|
cpcv_score(
|
|
863
898
|
returns,
|
|
@@ -866,6 +901,7 @@ class StepExecutor:
|
|
|
866
901
|
purge=int(params.get("purge") or 1),
|
|
867
902
|
embargo=int(params.get("embargo") or 1),
|
|
868
903
|
n_trials=int(n_trials) if n_trials is not None else 1,
|
|
904
|
+
**cpcv_kwargs,
|
|
869
905
|
)
|
|
870
906
|
)
|
|
871
907
|
|
|
@@ -882,9 +918,13 @@ class StepExecutor:
|
|
|
882
918
|
portfolio = _as_returns(data) or data.get("returns")
|
|
883
919
|
if portfolio is None:
|
|
884
920
|
raise UnsupportedOp("compute.factors found factor_returns but no portfolio returns.")
|
|
885
|
-
kwargs = {}
|
|
921
|
+
kwargs: dict[str, Any] = {}
|
|
886
922
|
if params.get("risk_free_rate") is not None:
|
|
887
923
|
kwargs["risk_free_rate"] = float(params["risk_free_rate"])
|
|
924
|
+
if params.get("periods_per_year") is not None:
|
|
925
|
+
kwargs["periods_per_year"] = int(params["periods_per_year"])
|
|
926
|
+
if params.get("factors_are_excess") is not None:
|
|
927
|
+
kwargs["factors_are_excess"] = bool(params["factors_are_excess"])
|
|
888
928
|
return dict(decompose_factors(list(portfolio), data["factor_returns"], **kwargs))
|
|
889
929
|
|
|
890
930
|
def _pairs(self, params: Figures, ws: Workspace) -> Figures:
|
|
@@ -1267,12 +1307,18 @@ class StepExecutor:
|
|
|
1267
1307
|
ideal = params.get("ideal") if isinstance(params.get("ideal"), dict) else ws.get("alpha")
|
|
1268
1308
|
if isinstance(ideal, dict) and not holdings:
|
|
1269
1309
|
ideal = None
|
|
1310
|
+
breadth_kwargs: dict[str, Any] = {}
|
|
1311
|
+
if params.get("independent") is not None:
|
|
1312
|
+
breadth_kwargs["independent"] = bool(params["independent"])
|
|
1313
|
+
if params.get("mean_correlation") is not None:
|
|
1314
|
+
breadth_kwargs["mean_correlation"] = float(params["mean_correlation"])
|
|
1270
1315
|
return dict(
|
|
1271
1316
|
breadth_ir(
|
|
1272
1317
|
ic=float(ic),
|
|
1273
1318
|
n_names=int(n_names or 0),
|
|
1274
1319
|
holdings=holdings,
|
|
1275
1320
|
ideal=ideal if isinstance(ideal, dict) else None,
|
|
1321
|
+
**breadth_kwargs,
|
|
1276
1322
|
)
|
|
1277
1323
|
)
|
|
1278
1324
|
|
|
@@ -506,6 +506,44 @@ class Session:
|
|
|
506
506
|
# to `/api/me/proposals/from-os` and `/api/me/sleeves`. Those left with the
|
|
507
507
|
# agent desk in 0.5.0. Selecting a thesis is the read above, not that writer.
|
|
508
508
|
|
|
509
|
+
def file_panel(
|
|
510
|
+
self,
|
|
511
|
+
figures: Figures,
|
|
512
|
+
*,
|
|
513
|
+
thesis_id: str | None = None,
|
|
514
|
+
label: str | None = None,
|
|
515
|
+
) -> Figures:
|
|
516
|
+
"""File a client-computed panel so the portal can draw it.
|
|
517
|
+
|
|
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
|
+
"""
|
|
524
|
+
from .._version import __version__
|
|
525
|
+
|
|
526
|
+
kind = str(figures.get("kind") or "panel")
|
|
527
|
+
n = figures.get("n_names")
|
|
528
|
+
body: Figures = {
|
|
529
|
+
"kind": kind,
|
|
530
|
+
"label": label or (f"{kind} {n} names" if n else kind),
|
|
531
|
+
"engine": f"alphaengine@{__version__}",
|
|
532
|
+
"figures": figures,
|
|
533
|
+
}
|
|
534
|
+
if thesis_id:
|
|
535
|
+
body["thesis_id"] = thesis_id
|
|
536
|
+
try:
|
|
537
|
+
out = self._post("/api/me/panels", body)
|
|
538
|
+
except ServerError as exc:
|
|
539
|
+
if exc.status in (404, 405):
|
|
540
|
+
return {"filed": False, "reason": "missing"}
|
|
541
|
+
return {"filed": False, "reason": str(exc)}
|
|
542
|
+
except Offline:
|
|
543
|
+
return {"filed": False, "reason": "offline"}
|
|
544
|
+
panel_id = out.get("id") if isinstance(out, dict) else None
|
|
545
|
+
return {"filed": True, "id": panel_id}
|
|
546
|
+
|
|
509
547
|
def post_event(self, run_id: str, event: dict[str, Any]) -> Figures:
|
|
510
548
|
"""Allowlisted model/run event. 404-tolerant at the sink, not here.
|
|
511
549
|
|
|
@@ -490,6 +490,65 @@ COMMANDS: tuple[Command, ...] = _question_commands() + (
|
|
|
490
490
|
"alphaengine run screen_universe --thesis momentum",
|
|
491
491
|
),
|
|
492
492
|
),
|
|
493
|
+
Command(
|
|
494
|
+
verb="correlation",
|
|
495
|
+
group="data",
|
|
496
|
+
args="",
|
|
497
|
+
scope="repl",
|
|
498
|
+
purpose="the correlation of every loaded name with every other name",
|
|
499
|
+
body=(
|
|
500
|
+
"Runs on the universe already loaded. Closes become returns. The full "
|
|
501
|
+
"matrix stays on this machine and opens on the right of the session.\n\n"
|
|
502
|
+
"`covariance` is the same panel as a sample covariance. "
|
|
503
|
+
"`cointegration` screens the closes, pairwise, and needs the factors extra.\n\n"
|
|
504
|
+
"`check_overlap` is a different question: one name against the equal-weight "
|
|
505
|
+
"of the others. Name it with `run check_overlap --symbol MU`. With no name, "
|
|
506
|
+
"that command shows this matrix instead of refusing."
|
|
507
|
+
),
|
|
508
|
+
examples=("correlation", "run check_overlap --symbol MU"),
|
|
509
|
+
),
|
|
510
|
+
Command(
|
|
511
|
+
verb="covariance",
|
|
512
|
+
group="data",
|
|
513
|
+
args="",
|
|
514
|
+
scope="repl",
|
|
515
|
+
purpose="the sample covariance of every loaded name with every other name",
|
|
516
|
+
body=(
|
|
517
|
+
"Same panel as `correlation`: the universe already loaded, closes "
|
|
518
|
+
"turned into returns, full matrix on the right of the session."
|
|
519
|
+
),
|
|
520
|
+
examples=("covariance",),
|
|
521
|
+
),
|
|
522
|
+
Command(
|
|
523
|
+
verb="cointegration",
|
|
524
|
+
group="data",
|
|
525
|
+
args="",
|
|
526
|
+
scope="repl",
|
|
527
|
+
purpose="which loaded names are cointegrated",
|
|
528
|
+
body=(
|
|
529
|
+
"Engle-Granger across the loaded closes, every pair. Needs the factors "
|
|
530
|
+
"extra (`pip install 'alphaengine[factors]'`). A hundred names is a few "
|
|
531
|
+
"thousand tests and takes a while. The pairs open on the right."
|
|
532
|
+
),
|
|
533
|
+
examples=("cointegration",),
|
|
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
|
+
),
|
|
493
552
|
Command(
|
|
494
553
|
verb="universe",
|
|
495
554
|
group="data",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""What this session has already done, in a form a model may quote.
|
|
2
|
+
|
|
3
|
+
A generic question should not start a new workflow when the desk already
|
|
4
|
+
holds the answer. The notes are short on purpose: counts, a few pairs, a
|
|
5
|
+
few weights. A return series never goes into a note, and an answer that
|
|
6
|
+
cites a number the notes do not contain is refused.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["remember", "loaded_note", "result_note", "consider"]
|
|
15
|
+
|
|
16
|
+
_MAX_NOTES = 12
|
|
17
|
+
|
|
18
|
+
_PROMPT = """You are answering from a research session that is already in progress.
|
|
19
|
+
|
|
20
|
+
The question:
|
|
21
|
+
{question}
|
|
22
|
+
|
|
23
|
+
What this session has already recorded. These are the only facts you may use:
|
|
24
|
+
{notes}
|
|
25
|
+
|
|
26
|
+
Reply with ONE JSON object and nothing else.
|
|
27
|
+
{"answer": "two or three sentences"} when the notes already contain the answer.
|
|
28
|
+
{"workflow": true} when the question asks for a calculation the notes do not contain.
|
|
29
|
+
|
|
30
|
+
Quote only numbers that appear in the notes. If the notes do not answer the
|
|
31
|
+
question, reply {"workflow": true}.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def remember(notes: list[str], text: str) -> list[str]:
|
|
36
|
+
"""Append one note. The oldest falls off so the window stays the recent desk."""
|
|
37
|
+
clean = " ".join(str(text).split())
|
|
38
|
+
if not clean:
|
|
39
|
+
return list(notes)
|
|
40
|
+
return [*notes, clean][-_MAX_NOTES:]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def loaded_note(label: str, data: Any) -> str:
|
|
44
|
+
"""The shape of what was loaded, never the series."""
|
|
45
|
+
from .core.relations import as_closes
|
|
46
|
+
|
|
47
|
+
closes = as_closes(data) if isinstance(data, dict) else {}
|
|
48
|
+
if len(closes) >= 1:
|
|
49
|
+
longest = max(len(values) for values in closes.values())
|
|
50
|
+
return f"loaded {label}: {len(closes)} names, about {longest} observations"
|
|
51
|
+
if isinstance(data, (list, tuple)):
|
|
52
|
+
return f"loaded {label}: {len(data)} returns"
|
|
53
|
+
return f"loaded {label}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def result_note(result: dict[str, Any]) -> str:
|
|
57
|
+
"""One line a later question can quote."""
|
|
58
|
+
kind = str(result.get("kind") or "")
|
|
59
|
+
if kind in ("correlation", "covariance"):
|
|
60
|
+
names = result.get("names") or []
|
|
61
|
+
top = ""
|
|
62
|
+
pairs = result.get("pairs") or []
|
|
63
|
+
if pairs:
|
|
64
|
+
pair = pairs[0]
|
|
65
|
+
top = f" Strongest {pair.get('a')} {pair.get('b')} {pair.get('value')}."
|
|
66
|
+
return f"{kind}: {len(names)} names, {result.get('n_obs')} observations.{top}"
|
|
67
|
+
if kind == "cointegration":
|
|
68
|
+
return (
|
|
69
|
+
f"cointegration: {result.get('n_cointegrated')} of {result.get('n_pairs')} pairs"
|
|
70
|
+
f" across {len(result.get('names') or [])} names."
|
|
71
|
+
)
|
|
72
|
+
if kind == "portfolio":
|
|
73
|
+
weights = result.get("weights") or {}
|
|
74
|
+
largest = ""
|
|
75
|
+
if weights:
|
|
76
|
+
name, weight = max(weights.items(), key=lambda item: float(item[1]))
|
|
77
|
+
largest = f" Largest weight {weight} on {name}."
|
|
78
|
+
return (
|
|
79
|
+
f"portfolio {result.get('method')}: {result.get('n_assets')} names,"
|
|
80
|
+
f" weights sum to {result.get('weight_sum')}.{largest}"
|
|
81
|
+
)
|
|
82
|
+
return kind
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def consider(question: str, notes: list[str], think: Any) -> tuple[str, str]:
|
|
86
|
+
"""("answer", text), ("refuse", reason), or ("workflow", "").
|
|
87
|
+
|
|
88
|
+
"workflow" means the notes do not answer it, so the caller should run one.
|
|
89
|
+
A missing model is "workflow" too: the catalogue path already says what is missing.
|
|
90
|
+
"""
|
|
91
|
+
if not notes or think is None:
|
|
92
|
+
return "workflow", ""
|
|
93
|
+
brief = "\n".join(f"- {note}" for note in notes)
|
|
94
|
+
raw = think(_PROMPT.replace("{question}", question).replace("{notes}", brief))
|
|
95
|
+
parsed = _parse(raw)
|
|
96
|
+
if not isinstance(parsed, dict):
|
|
97
|
+
return "workflow", ""
|
|
98
|
+
if parsed.get("workflow") is True:
|
|
99
|
+
return "workflow", ""
|
|
100
|
+
answer = str(parsed.get("answer") or "").strip()
|
|
101
|
+
if not answer:
|
|
102
|
+
return "workflow", ""
|
|
103
|
+
bad = _uncited(answer, brief)
|
|
104
|
+
if bad:
|
|
105
|
+
return "refuse", f"The answer cited {bad}, which this session has not recorded."
|
|
106
|
+
return "answer", answer
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse(raw: str) -> dict[str, Any] | None:
|
|
110
|
+
text = str(raw or "").strip()
|
|
111
|
+
start = text.find("{")
|
|
112
|
+
end = text.rfind("}")
|
|
113
|
+
if start < 0 or end <= start:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
obj = json.loads(text[start : end + 1])
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
return None
|
|
119
|
+
return obj if isinstance(obj, dict) else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _uncited(answer: str, notes: str) -> str | None:
|
|
123
|
+
from .agent.answer import _numbers_in, _quotes
|
|
124
|
+
|
|
125
|
+
recorded = _numbers_in(notes)
|
|
126
|
+
for cited, decimals in _numbers_in(answer):
|
|
127
|
+
if any(_quotes(cited, decimals, value) for value, _decimals in recorded):
|
|
128
|
+
continue
|
|
129
|
+
return str(cited)
|
|
130
|
+
return None
|