alphaengine 0.9.4__tar.gz → 0.9.5__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.9.5}/PKG-INFO +4 -1
- {alphaengine-0.9.4 → alphaengine-0.9.5}/README.md +3 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/_version.py +7 -1
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/charts.py +9 -1
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/cli.py +124 -1
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/client/executor.py +16 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/client/session.py +38 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/commands.py +42 -0
- alphaengine-0.9.5/src/alphaengine/core/relations.py +309 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/tui/app.py +51 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/tui/dispatch.py +134 -1
- alphaengine-0.9.5/tests/test_relations.py +150 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_tui.py +68 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/.gitignore +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/AGENTS.md +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/LICENSE +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/SECURITY.md +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/assets/banner.png +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/assets/session.png +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/figure_contract.md +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/pyproject.toml +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/scripts/gen_docs.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/book.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/client/agent.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/active.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/backtest.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/factors.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/performance.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/process.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/risk.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/stress.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/validation.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/events.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/model.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/sweep/runner.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_agent.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_answer.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_book.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_cli.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_client.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_commands.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_events.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_goldens.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_loaders.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_math_audit.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_model.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_process.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_screen.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_signals.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_smoke.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_sweep.py +0 -0
- {alphaengine-0.9.4 → alphaengine-0.9.5}/tests/test_trace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: alphaengine
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.5
|
|
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
|
|
@@ -334,6 +334,9 @@ 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 |
|
|
337
340
|
| `universe <name>` | same as load: a universe registered in the portal | session |
|
|
338
341
|
| `data <file>` | same as load: a local CSV or parquet | session |
|
|
339
342
|
| `project <module>` | same as load: a module with data and backtest_fn | session |
|
|
@@ -289,6 +289,9 @@ 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 |
|
|
292
295
|
| `universe <name>` | same as load: a universe registered in the portal | session |
|
|
293
296
|
| `data <file>` | same as load: a local CSV or parquet | session |
|
|
294
297
|
| `project <module>` | same as load: a module with data and backtest_fn | session |
|
|
@@ -92,4 +92,10 @@ 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
|
+
__version__ = "0.9.5"
|
|
@@ -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,107 @@ 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 the matrix or the pair table to the portal. A missing route is not a failure."""
|
|
1674
|
+
if session is None or not hasattr(session, "file_panel"):
|
|
1675
|
+
return
|
|
1676
|
+
from .client.executor import _guard
|
|
1677
|
+
from .core.relations import portal_figures
|
|
1678
|
+
|
|
1679
|
+
figures = portal_figures(result)
|
|
1680
|
+
try:
|
|
1681
|
+
_guard(figures)
|
|
1682
|
+
except ValueError as exc:
|
|
1683
|
+
say(yellow(str(exc)))
|
|
1684
|
+
return
|
|
1685
|
+
filed = session.file_panel(figures, thesis_id=thesis_id)
|
|
1686
|
+
if not isinstance(filed, dict):
|
|
1687
|
+
return
|
|
1688
|
+
if filed.get("filed"):
|
|
1689
|
+
say(dim(" filed to the portal"))
|
|
1690
|
+
return
|
|
1691
|
+
if filed.get("reason") == "missing":
|
|
1692
|
+
say(dim(" held on this machine; the portal has no panels route yet"))
|
|
1693
|
+
return
|
|
1694
|
+
if filed.get("reason") and filed.get("reason") != "offline":
|
|
1695
|
+
say(dim(f" held on this machine ({filed.get('reason')})"))
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def _print_cross(
|
|
1699
|
+
data: Any,
|
|
1700
|
+
kind: str,
|
|
1701
|
+
*,
|
|
1702
|
+
session: Any = None,
|
|
1703
|
+
thesis_id: str | None = None,
|
|
1704
|
+
) -> int:
|
|
1705
|
+
from .core.relations import cointegration, correlation, covariance, summary_lines
|
|
1706
|
+
|
|
1707
|
+
try:
|
|
1708
|
+
if kind == "correlation":
|
|
1709
|
+
result = correlation(data)
|
|
1710
|
+
elif kind == "covariance":
|
|
1711
|
+
result = covariance(data)
|
|
1712
|
+
else:
|
|
1713
|
+
result = cointegration(data)
|
|
1714
|
+
except ValueError as exc:
|
|
1715
|
+
say(red(str(exc)))
|
|
1716
|
+
return 2
|
|
1717
|
+
for line in summary_lines(result):
|
|
1718
|
+
say(line)
|
|
1719
|
+
_publish_panel(session, result, thesis_id=thesis_id)
|
|
1720
|
+
return 0
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
def _overlap_payload(data: Any, symbol: str) -> dict[str, Any] | None:
|
|
1724
|
+
"""Candidate plus the equal-weight of the other loaded names, or None."""
|
|
1725
|
+
from .core.relations import overlap_against_the_rest
|
|
1726
|
+
|
|
1727
|
+
if not _looks_like_universe(data):
|
|
1728
|
+
return None
|
|
1729
|
+
try:
|
|
1730
|
+
prepared = overlap_against_the_rest(data, symbol)
|
|
1731
|
+
except ValueError as exc:
|
|
1732
|
+
say(red(str(exc)))
|
|
1733
|
+
return None
|
|
1734
|
+
others = len(prepared["book_names"])
|
|
1735
|
+
say(dim(f" {prepared['symbol']} against the equal-weight of {others} other names."))
|
|
1736
|
+
return {"returns": prepared["returns"], "book_returns": prepared["book_returns"]}
|
|
1737
|
+
|
|
1738
|
+
|
|
1656
1739
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
1657
1740
|
from .client import Offline, ServerError
|
|
1741
|
+
from .core.relations import KINDS
|
|
1658
1742
|
|
|
1659
1743
|
session, url = _session(args.url, args.key)
|
|
1744
|
+
kind = KINDS.get(str(args.workflow).lower().replace("-", "_"))
|
|
1745
|
+
if kind:
|
|
1746
|
+
try:
|
|
1747
|
+
data, _fn = resolve_data(args, session)
|
|
1748
|
+
except (ProjectError, ValueError) as exc:
|
|
1749
|
+
say(red(str(exc)))
|
|
1750
|
+
return 2
|
|
1751
|
+
return _print_cross(data, kind, session=session)
|
|
1752
|
+
symbol = getattr(args, "symbol", None)
|
|
1753
|
+
resolve_args = args
|
|
1754
|
+
if args.workflow == "check_overlap" and symbol:
|
|
1755
|
+
resolve_args = argparse.Namespace(**vars(args))
|
|
1756
|
+
resolve_args.symbol = None
|
|
1660
1757
|
try:
|
|
1661
|
-
data, backtest_fn = resolve_data(
|
|
1758
|
+
data, backtest_fn = resolve_data(resolve_args, session)
|
|
1662
1759
|
workspace_id = None
|
|
1663
1760
|
except (ProjectError, ValueError) as exc:
|
|
1664
1761
|
say(red(str(exc)))
|
|
1665
1762
|
return 2
|
|
1763
|
+
if args.workflow == "check_overlap" and _looks_like_universe(data):
|
|
1764
|
+
if symbol:
|
|
1765
|
+
payload = _overlap_payload(data, str(symbol))
|
|
1766
|
+
if payload is None:
|
|
1767
|
+
return 2
|
|
1768
|
+
data = payload
|
|
1769
|
+
else:
|
|
1770
|
+
say(" check_overlap measures one name against the equal-weight of the others.")
|
|
1771
|
+
say(dim(" No name was given, so this is every name against every other name."))
|
|
1772
|
+
return _print_cross(data, "correlation", session=session)
|
|
1666
1773
|
|
|
1667
1774
|
inputs: dict[str, Any] = {}
|
|
1668
1775
|
for pair in args.input or []:
|
|
@@ -3391,6 +3498,22 @@ def _ask(
|
|
|
3391
3498
|
except Exception as exc: # noqa: BLE001 - reachable and unloadable: say so, refuse below
|
|
3392
3499
|
say(yellow(f" {wanted} is on your account and could not be loaded: {exc}"))
|
|
3393
3500
|
|
|
3501
|
+
# check_overlap on a universe is one name against the equal-weight of the
|
|
3502
|
+
# rest. Collapsing --symbol here used to throw the book away, which is why
|
|
3503
|
+
# the next line was "needs book_returns, and none was loaded."
|
|
3504
|
+
if name == "check_overlap" and _looks_like_universe(data):
|
|
3505
|
+
sym = _symbol_in(request, data)
|
|
3506
|
+
if sym:
|
|
3507
|
+
payload = _overlap_payload(data, sym)
|
|
3508
|
+
if payload is None:
|
|
3509
|
+
return None
|
|
3510
|
+
data = payload
|
|
3511
|
+
else:
|
|
3512
|
+
say(" check_overlap measures one name against the equal-weight of the others.")
|
|
3513
|
+
say(dim(" No name was given, so this is every name against every other name."))
|
|
3514
|
+
_print_cross(data, "correlation", session=session, thesis_id=thesis_id)
|
|
3515
|
+
return None
|
|
3516
|
+
|
|
3394
3517
|
# "size MU" against a loaded universe is a complete instruction: the name
|
|
3395
3518
|
# the question mentions picks the series out of the caller's own data.
|
|
3396
3519
|
# One match only — two is a real ambiguity and the refusal below names the
|
|
@@ -788,6 +788,22 @@ class StepExecutor:
|
|
|
788
788
|
out["rolling_window"] = window
|
|
789
789
|
out["max_rolling_correlation"] = round(max(clean), 6)
|
|
790
790
|
out["min_rolling_correlation"] = round(min(clean), 6)
|
|
791
|
+
if isinstance(self.data, dict):
|
|
792
|
+
symbol = self.data.get("symbol")
|
|
793
|
+
if symbol:
|
|
794
|
+
out["symbol"] = str(symbol)
|
|
795
|
+
book_names = self.data.get("book_names")
|
|
796
|
+
if isinstance(book_names, list):
|
|
797
|
+
out["n_book_names"] = len(book_names)
|
|
798
|
+
if len(book_names) <= MAX_FIGURE_LIST:
|
|
799
|
+
out["book_names"] = [str(name) for name in book_names]
|
|
800
|
+
hints = []
|
|
801
|
+
if out.get("overlap_scatter"):
|
|
802
|
+
hints.append(chart("scatter", "overlap_scatter", "candidate vs the book"))
|
|
803
|
+
if out.get("rolling_correlation"):
|
|
804
|
+
hints.append(chart("curve", "rolling_correlation", "rolling correlation"))
|
|
805
|
+
if hints:
|
|
806
|
+
out["charts"] = hints
|
|
791
807
|
return out
|
|
792
808
|
|
|
793
809
|
# ── the rest of the modeling week, previously library-only ─────────────
|
|
@@ -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, 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.
|
|
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,48 @@ 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
|
+
),
|
|
493
535
|
Command(
|
|
494
536
|
verb="universe",
|
|
495
537
|
group="data",
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Correlation, covariance, and cointegration across a loaded universe.
|
|
2
|
+
|
|
3
|
+
A universe is already {name: closes}. These read that panel on this machine.
|
|
4
|
+
Nothing here asks for a separate book series, and the matrix stays local: what
|
|
5
|
+
is said out loud is the counts and the strongest pairs, not every cell.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ..charts import chart
|
|
15
|
+
from .panel import align_panel
|
|
16
|
+
from .series_shapes import series_values
|
|
17
|
+
|
|
18
|
+
_FIGURE_CAP = 512
|
|
19
|
+
_STRONG = 32
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"KINDS",
|
|
23
|
+
"as_closes",
|
|
24
|
+
"as_returns",
|
|
25
|
+
"correlation",
|
|
26
|
+
"covariance",
|
|
27
|
+
"cointegration",
|
|
28
|
+
"overlap_against_the_rest",
|
|
29
|
+
"portal_figures",
|
|
30
|
+
"summary_lines",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
KINDS = {
|
|
34
|
+
"correlation": "correlation",
|
|
35
|
+
"corr": "correlation",
|
|
36
|
+
"covariance": "covariance",
|
|
37
|
+
"cov": "covariance",
|
|
38
|
+
"cointegration": "cointegration",
|
|
39
|
+
"cointegrate": "cointegration",
|
|
40
|
+
"coint": "cointegration",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_SKIP = frozenset({"returns", "pnl", "book_returns", "signal", "prices"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _values(series: Any) -> list[float] | None:
|
|
47
|
+
arr, _dates = series_values(series)
|
|
48
|
+
if arr.ndim != 1 or arr.size < 2:
|
|
49
|
+
return None
|
|
50
|
+
out = [float(v) for v in arr.tolist() if np.isfinite(v)]
|
|
51
|
+
return out if len(out) >= 2 else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _looks_like_prices(values: list[float]) -> bool:
|
|
55
|
+
"""Closes sit well above 1. A return series does not."""
|
|
56
|
+
return any(abs(v) > 1.5 for v in values)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_returns(values: list[float]) -> list[float] | None:
|
|
60
|
+
if not _looks_like_prices(values):
|
|
61
|
+
return [round(v, 8) for v in values]
|
|
62
|
+
if any(v == 0 for v in values[:-1]):
|
|
63
|
+
return None
|
|
64
|
+
return [round(values[i] / values[i - 1] - 1.0, 8) for i in range(1, len(values))]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def as_closes(data: Any) -> dict[str, list[float]]:
|
|
68
|
+
"""{name: levels}. A return series is left as-is; cointegration wants levels."""
|
|
69
|
+
if not isinstance(data, dict):
|
|
70
|
+
return {}
|
|
71
|
+
out: dict[str, list[float]] = {}
|
|
72
|
+
for name, series in data.items():
|
|
73
|
+
if str(name) in _SKIP:
|
|
74
|
+
continue
|
|
75
|
+
values = _values(series)
|
|
76
|
+
if values:
|
|
77
|
+
out[str(name)] = values
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def as_returns(data: Any) -> dict[str, list[float]]:
|
|
82
|
+
"""{name: per-period returns}, derived from closes when that is what was loaded."""
|
|
83
|
+
out: dict[str, list[float]] = {}
|
|
84
|
+
for name, values in as_closes(data).items():
|
|
85
|
+
returns = _to_returns(values)
|
|
86
|
+
if returns and len(returns) >= 2:
|
|
87
|
+
out[name] = returns
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _matrix(panel: dict[str, list[float]], *, kind: str) -> dict[str, Any]:
|
|
92
|
+
aligned, names, skipped = align_panel(panel, need=2)
|
|
93
|
+
if len(names) < 2:
|
|
94
|
+
raise ValueError("need at least two names with a shared history.")
|
|
95
|
+
if kind == "correlation":
|
|
96
|
+
grid = np.corrcoef(aligned, rowvar=False)
|
|
97
|
+
else:
|
|
98
|
+
grid = np.cov(aligned, rowvar=False, ddof=1)
|
|
99
|
+
grid = np.atleast_2d(np.asarray(grid, dtype=float))
|
|
100
|
+
rows: list[list[float | None]] = []
|
|
101
|
+
for i in range(grid.shape[0]):
|
|
102
|
+
row: list[float | None] = []
|
|
103
|
+
for j in range(grid.shape[1]):
|
|
104
|
+
value = float(grid[i, j])
|
|
105
|
+
row.append(round(value, 4) if np.isfinite(value) else None)
|
|
106
|
+
rows.append(row)
|
|
107
|
+
pairs: list[dict[str, Any]] = []
|
|
108
|
+
for i, a in enumerate(names):
|
|
109
|
+
for j in range(i + 1, len(names)):
|
|
110
|
+
value = rows[i][j]
|
|
111
|
+
if value is None:
|
|
112
|
+
continue
|
|
113
|
+
pairs.append({"a": a, "b": names[j], "value": value})
|
|
114
|
+
pairs.sort(key=lambda item: abs(float(item["value"])), reverse=True)
|
|
115
|
+
return {
|
|
116
|
+
"kind": kind,
|
|
117
|
+
"names": names,
|
|
118
|
+
"values": rows,
|
|
119
|
+
"n_obs": int(aligned.shape[0]),
|
|
120
|
+
"skipped": skipped,
|
|
121
|
+
"pairs": pairs,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def correlation(data: Any) -> dict[str, Any]:
|
|
126
|
+
"""Pearson correlation of every name with every other name."""
|
|
127
|
+
return _matrix(as_returns(data), kind="correlation")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def covariance(data: Any) -> dict[str, Any]:
|
|
131
|
+
"""Sample covariance of every name with every other name."""
|
|
132
|
+
return _matrix(as_returns(data), kind="covariance")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cointegration(data: Any) -> dict[str, Any]:
|
|
136
|
+
"""Engle-Granger screen across the loaded closes. Levels, not returns."""
|
|
137
|
+
try:
|
|
138
|
+
from .pairs import find_cointegrated_pairs
|
|
139
|
+
except ModuleNotFoundError as exc:
|
|
140
|
+
raise ValueError(str(exc)) from exc
|
|
141
|
+
closes = as_closes(data)
|
|
142
|
+
prices = {name: values for name, values in closes.items() if _looks_like_prices(values)}
|
|
143
|
+
if len(prices) < 2:
|
|
144
|
+
raise ValueError("cointegration needs at least two names of closes, not a return series.")
|
|
145
|
+
found = find_cointegrated_pairs(prices, cointegrated_only=False)
|
|
146
|
+
rows = []
|
|
147
|
+
for pair in found.get("pairs") or []:
|
|
148
|
+
if not isinstance(pair, dict):
|
|
149
|
+
continue
|
|
150
|
+
coint = pair.get("cointegration") or {}
|
|
151
|
+
rows.append(
|
|
152
|
+
{
|
|
153
|
+
"a": pair.get("ticker_a"),
|
|
154
|
+
"b": pair.get("ticker_b"),
|
|
155
|
+
"p": coint.get("p_value"),
|
|
156
|
+
"half_life": pair.get("half_life_days"),
|
|
157
|
+
"cointegrated": bool(pair.get("cointegrated")),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
rows.sort(key=lambda row: (not row["cointegrated"], row["p"] if row["p"] is not None else 1.0))
|
|
161
|
+
return {
|
|
162
|
+
"kind": "cointegration",
|
|
163
|
+
"names": list(prices),
|
|
164
|
+
"rows": rows,
|
|
165
|
+
"n_pairs": int(found.get("n_evaluated") or len(rows)),
|
|
166
|
+
"n_cointegrated": int(found.get("n_cointegrated") or sum(1 for row in rows if row["cointegrated"])),
|
|
167
|
+
"expected_false_positives": found.get("expected_false_positives"),
|
|
168
|
+
"multiple_testing_note": found.get("multiple_testing_note"),
|
|
169
|
+
"skipped": [name for name in closes if name not in prices],
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def overlap_against_the_rest(data: Any, symbol: str) -> dict[str, Any]:
|
|
174
|
+
"""One name's returns, and the equal-weight of every other loaded name.
|
|
175
|
+
|
|
176
|
+
That is the book `check_overlap` is asking for. It does not have to be
|
|
177
|
+
loaded as a second series when the universe already holds the names.
|
|
178
|
+
"""
|
|
179
|
+
panel = as_returns(data)
|
|
180
|
+
key = next((name for name in panel if name.upper() == symbol.upper()), None)
|
|
181
|
+
if key is None:
|
|
182
|
+
known = ", ".join(list(panel)[:8]) or "none"
|
|
183
|
+
raise ValueError(f"{symbol} is not in the loaded names. Loaded: {known}")
|
|
184
|
+
candidate = panel.pop(key)
|
|
185
|
+
if len(panel) < 1:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"{key} is the only loaded name, so there is no rest of the book to compare it with."
|
|
188
|
+
)
|
|
189
|
+
aligned, names, _skipped = align_panel(panel, need=2)
|
|
190
|
+
if len(names) < 1:
|
|
191
|
+
raise ValueError("the other names do not share enough history to form a book.")
|
|
192
|
+
depth = min(len(candidate), int(aligned.shape[0]))
|
|
193
|
+
book = aligned[-depth:].mean(axis=1)
|
|
194
|
+
series = np.asarray(candidate, dtype=float)[-depth:]
|
|
195
|
+
return {
|
|
196
|
+
"returns": [round(float(v), 8) for v in series.tolist()],
|
|
197
|
+
"book_returns": [round(float(v), 8) for v in book.tolist()],
|
|
198
|
+
"symbol": key,
|
|
199
|
+
"book_names": names,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def summary_lines(result: dict[str, Any]) -> list[str]:
|
|
204
|
+
"""What to say. The full matrix stays in the result, for the inspector."""
|
|
205
|
+
kind = str(result.get("kind") or "")
|
|
206
|
+
if kind == "cointegration":
|
|
207
|
+
lines = [
|
|
208
|
+
f" cointegration {result.get('n_cointegrated')} of {result.get('n_pairs')} pairs"
|
|
209
|
+
" across the loaded closes",
|
|
210
|
+
" check_overlap is a different question: one name against the rest of the book.",
|
|
211
|
+
]
|
|
212
|
+
shown = 0
|
|
213
|
+
for row in (result.get("rows") or [])[:8]:
|
|
214
|
+
if row.get("p") is None and not row.get("cointegrated"):
|
|
215
|
+
continue
|
|
216
|
+
flag = "yes" if row.get("cointegrated") else "no"
|
|
217
|
+
p = row.get("p")
|
|
218
|
+
p_text = f"{float(p):.3f}" if isinstance(p, (int, float)) else "-"
|
|
219
|
+
lines.append(f" {row.get('a')} {row.get('b')} p {p_text} cointegrated {flag}")
|
|
220
|
+
shown += 1
|
|
221
|
+
if not result.get("n_cointegrated") and shown:
|
|
222
|
+
lines.append(" None cleared the cointegration bar. The pairs above are the closest.")
|
|
223
|
+
elif not shown:
|
|
224
|
+
lines.append(" The loaded closes are too short to test cointegration.")
|
|
225
|
+
return lines
|
|
226
|
+
names = result.get("names") or []
|
|
227
|
+
lines = [f" {kind} {len(names)} names {result.get('n_obs')} observations full matrix on the right"]
|
|
228
|
+
skipped = result.get("skipped") or []
|
|
229
|
+
if skipped:
|
|
230
|
+
lines.append(f" {len(skipped)} names skipped (short history)")
|
|
231
|
+
lines.append(" strongest pairs")
|
|
232
|
+
for pair in (result.get("pairs") or [])[:8]:
|
|
233
|
+
lines.append(f" {pair['a']} {pair['b']} {pair['value']}")
|
|
234
|
+
return lines
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def portal_figures(result: dict[str, Any]) -> dict[str, Any]:
|
|
238
|
+
"""The payload the portal draws. Derived numbers only, each list at most 512 long.
|
|
239
|
+
|
|
240
|
+
A 100-name correlation is a square of rows, not a triangle of pairs: the
|
|
241
|
+
triangle does not fit the figure cap, and the rows do. Closes and returns
|
|
242
|
+
never appear here.
|
|
243
|
+
"""
|
|
244
|
+
kind = str(result.get("kind") or "")
|
|
245
|
+
if kind == "cointegration":
|
|
246
|
+
rows = []
|
|
247
|
+
for row in result.get("rows") or []:
|
|
248
|
+
if row.get("p") is None and not row.get("cointegrated"):
|
|
249
|
+
continue
|
|
250
|
+
rows.append(
|
|
251
|
+
{
|
|
252
|
+
"a": row.get("a"),
|
|
253
|
+
"b": row.get("b"),
|
|
254
|
+
"p": row.get("p"),
|
|
255
|
+
"half_life": row.get("half_life"),
|
|
256
|
+
"cointegrated": bool(row.get("cointegrated")),
|
|
257
|
+
}
|
|
258
|
+
)
|
|
259
|
+
truncated = len(rows) > _FIGURE_CAP
|
|
260
|
+
names = [str(name) for name in (result.get("names") or [])]
|
|
261
|
+
if len(names) > _FIGURE_CAP:
|
|
262
|
+
names = names[:_FIGURE_CAP]
|
|
263
|
+
truncated = True
|
|
264
|
+
figures: dict[str, Any] = {
|
|
265
|
+
"kind": "cointegration",
|
|
266
|
+
"n_names": len(result.get("names") or []),
|
|
267
|
+
"n_pairs": result.get("n_pairs"),
|
|
268
|
+
"n_cointegrated": result.get("n_cointegrated"),
|
|
269
|
+
"names": names,
|
|
270
|
+
"pairs": rows[:_FIGURE_CAP],
|
|
271
|
+
"truncated": truncated,
|
|
272
|
+
"charts": [chart("rows", "pairs", "cointegrated pairs")],
|
|
273
|
+
}
|
|
274
|
+
if result.get("expected_false_positives") is not None:
|
|
275
|
+
figures["expected_false_positives"] = result["expected_false_positives"]
|
|
276
|
+
note = result.get("multiple_testing_note")
|
|
277
|
+
if isinstance(note, str) and note:
|
|
278
|
+
figures["multiple_testing_note"] = note
|
|
279
|
+
return figures
|
|
280
|
+
|
|
281
|
+
names = [str(name) for name in (result.get("names") or [])]
|
|
282
|
+
values = [list(row) for row in (result.get("values") or [])]
|
|
283
|
+
truncated = len(names) > _FIGURE_CAP
|
|
284
|
+
if truncated:
|
|
285
|
+
names = names[:_FIGURE_CAP]
|
|
286
|
+
values = [list(row[:_FIGURE_CAP]) for row in values[:_FIGURE_CAP]]
|
|
287
|
+
strongest: list[dict[str, Any]] = []
|
|
288
|
+
kept = set(names)
|
|
289
|
+
for pair in result.get("pairs") or []:
|
|
290
|
+
if pair.get("a") not in kept or pair.get("b") not in kept:
|
|
291
|
+
continue
|
|
292
|
+
strongest.append({"a": pair["a"], "b": pair["b"], "v": pair["value"]})
|
|
293
|
+
if len(strongest) >= _STRONG:
|
|
294
|
+
break
|
|
295
|
+
title = "correlation" if kind == "correlation" else "covariance"
|
|
296
|
+
return {
|
|
297
|
+
"kind": kind,
|
|
298
|
+
"n_names": len(result.get("names") or []),
|
|
299
|
+
"n_obs": result.get("n_obs"),
|
|
300
|
+
"n_skipped": len(result.get("skipped") or []),
|
|
301
|
+
"names": names,
|
|
302
|
+
"matrix": values,
|
|
303
|
+
"strongest": strongest,
|
|
304
|
+
"truncated": truncated,
|
|
305
|
+
"charts": [
|
|
306
|
+
chart("matrix", "matrix", title),
|
|
307
|
+
chart("rows", "strongest", "strongest pairs"),
|
|
308
|
+
],
|
|
309
|
+
}
|