alphaengine 0.9.3__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.
Files changed (95) hide show
  1. {alphaengine-0.9.3 → alphaengine-0.9.5}/PKG-INFO +4 -1
  2. {alphaengine-0.9.3 → alphaengine-0.9.5}/README.md +3 -0
  3. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/_version.py +11 -1
  4. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/charts.py +9 -1
  5. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/cli.py +124 -1
  6. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/client/executor.py +16 -0
  7. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/client/session.py +38 -0
  8. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/commands.py +42 -0
  9. alphaengine-0.9.5/src/alphaengine/core/relations.py +309 -0
  10. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/tui/app.py +187 -41
  11. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/tui/dispatch.py +134 -1
  12. alphaengine-0.9.5/tests/test_relations.py +150 -0
  13. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_tui.py +68 -0
  14. {alphaengine-0.9.3 → alphaengine-0.9.5}/.github/workflows/ci.yml +0 -0
  15. {alphaengine-0.9.3 → alphaengine-0.9.5}/.github/workflows/publish.yml +0 -0
  16. {alphaengine-0.9.3 → alphaengine-0.9.5}/.gitignore +0 -0
  17. {alphaengine-0.9.3 → alphaengine-0.9.5}/AGENTS.md +0 -0
  18. {alphaengine-0.9.3 → alphaengine-0.9.5}/LICENSE +0 -0
  19. {alphaengine-0.9.3 → alphaengine-0.9.5}/SECURITY.md +0 -0
  20. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/assets/banner.png +0 -0
  21. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/assets/data_boundary.png +0 -0
  22. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/assets/session.png +0 -0
  23. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/figure_contract.md +0 -0
  24. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/workflows/evaluate_process.json +0 -0
  25. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/workflows/research_week.json +0 -0
  26. {alphaengine-0.9.3 → alphaengine-0.9.5}/docs/workflows/stress_study.json +0 -0
  27. {alphaengine-0.9.3 → alphaengine-0.9.5}/pyproject.toml +0 -0
  28. {alphaengine-0.9.3 → alphaengine-0.9.5}/scripts/gen_docs.py +0 -0
  29. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/__init__.py +0 -0
  30. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/__main__.py +0 -0
  31. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/agent/__init__.py +0 -0
  32. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/agent/answer.py +0 -0
  33. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/agent/driver.py +0 -0
  34. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/auth.py +0 -0
  35. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/book.py +0 -0
  36. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/client/__init__.py +0 -0
  37. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/client/agent.py +0 -0
  38. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/complete.py +0 -0
  39. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/connectors/__init__.py +0 -0
  40. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/__init__.py +0 -0
  41. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/active.py +0 -0
  42. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/allocate.py +0 -0
  43. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/backtest.py +0 -0
  44. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/covariance.py +0 -0
  45. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/cross_section.py +0 -0
  46. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/factors.py +0 -0
  47. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/pairs.py +0 -0
  48. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/panel.py +0 -0
  49. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/performance.py +0 -0
  50. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/process.py +0 -0
  51. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/profile.py +0 -0
  52. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/risk.py +0 -0
  53. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/screen.py +0 -0
  54. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/series_shapes.py +0 -0
  55. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/signals.py +0 -0
  56. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/stress.py +0 -0
  57. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/technical.py +0 -0
  58. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/trace.py +0 -0
  59. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/validation.py +0 -0
  60. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/core/walkforward.py +0 -0
  61. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/demo.py +0 -0
  62. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/demo_book.py +0 -0
  63. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/demo_returns.py +0 -0
  64. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/demo_signal.py +0 -0
  65. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/demo_universe.py +0 -0
  66. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/events.py +0 -0
  67. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/loaders.py +0 -0
  68. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/model.py +0 -0
  69. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/py.typed +0 -0
  70. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/repl.py +0 -0
  71. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/study/__init__.py +0 -0
  72. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/study/report.py +0 -0
  73. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/study/schema.py +0 -0
  74. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/sweep/__init__.py +0 -0
  75. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/sweep/runner.py +0 -0
  76. {alphaengine-0.9.3 → alphaengine-0.9.5}/src/alphaengine/tui/__init__.py +0 -0
  77. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_agent.py +0 -0
  78. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_answer.py +0 -0
  79. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_book.py +0 -0
  80. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_cli.py +0 -0
  81. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_client.py +0 -0
  82. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_commands.py +0 -0
  83. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_events.py +0 -0
  84. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_executor_ops.py +0 -0
  85. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_goldens.py +0 -0
  86. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_loaders.py +0 -0
  87. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_math_audit.py +0 -0
  88. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_model.py +0 -0
  89. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_process.py +0 -0
  90. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_quant_maths.py +0 -0
  91. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_screen.py +0 -0
  92. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_signals.py +0 -0
  93. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_smoke.py +0 -0
  94. {alphaengine-0.9.3 → alphaengine-0.9.5}/tests/test_sweep.py +0 -0
  95. {alphaengine-0.9.3 → 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
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 |
@@ -88,4 +88,14 @@ is what a changed figure costs. The API may still move underneath it.
88
88
  # 0.9.3 IS THE SESSION, NOT THE MATHS. No computed value moved. The math pane
89
89
  # is narrower. A sentence is shown the workflow it is choosing and what a step
90
90
  # already measured, and a finished step is not run again.
91
- __version__ = "0.9.3"
91
+ #
92
+ # 0.9.4 IS THE SESSION, NOT THE MATHS. No computed value moved. The session
93
+ # opens on the desk: what is loaded, the thesis on its own lines, and the
94
+ # next thing to type.
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(args, session)
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
+ }