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.
Files changed (99) hide show
  1. {alphaengine-0.9.4 → alphaengine-0.10.0}/PKG-INFO +6 -2
  2. {alphaengine-0.9.4 → alphaengine-0.10.0}/README.md +5 -1
  3. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/_version.py +27 -1
  4. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/charts.py +9 -1
  5. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/cli.py +162 -1
  6. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/executor.py +52 -6
  7. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/session.py +38 -0
  8. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/commands.py +59 -0
  9. alphaengine-0.10.0/src/alphaengine/context.py +130 -0
  10. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/active.py +38 -6
  11. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/backtest.py +14 -2
  12. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/factors.py +35 -15
  13. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/performance.py +23 -3
  14. alphaengine-0.10.0/src/alphaengine/core/portfolio.py +97 -0
  15. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/process.py +15 -5
  16. alphaengine-0.10.0/src/alphaengine/core/relations.py +309 -0
  17. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/risk.py +42 -23
  18. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/stress.py +10 -3
  19. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/validation.py +37 -8
  20. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/sweep/runner.py +1 -1
  21. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/tui/app.py +59 -0
  22. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/tui/dispatch.py +266 -2
  23. alphaengine-0.10.0/tests/test_context.py +80 -0
  24. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_goldens.py +3 -1
  25. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_math_audit.py +19 -13
  26. alphaengine-0.10.0/tests/test_portfolio.py +53 -0
  27. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_process.py +17 -3
  28. alphaengine-0.10.0/tests/test_relations.py +150 -0
  29. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_tui.py +126 -0
  30. {alphaengine-0.9.4 → alphaengine-0.10.0}/.github/workflows/ci.yml +0 -0
  31. {alphaengine-0.9.4 → alphaengine-0.10.0}/.github/workflows/publish.yml +0 -0
  32. {alphaengine-0.9.4 → alphaengine-0.10.0}/.gitignore +0 -0
  33. {alphaengine-0.9.4 → alphaengine-0.10.0}/AGENTS.md +0 -0
  34. {alphaengine-0.9.4 → alphaengine-0.10.0}/LICENSE +0 -0
  35. {alphaengine-0.9.4 → alphaengine-0.10.0}/SECURITY.md +0 -0
  36. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/assets/banner.png +0 -0
  37. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/assets/data_boundary.png +0 -0
  38. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/assets/session.png +0 -0
  39. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/figure_contract.md +0 -0
  40. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/workflows/evaluate_process.json +0 -0
  41. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/workflows/research_week.json +0 -0
  42. {alphaengine-0.9.4 → alphaengine-0.10.0}/docs/workflows/stress_study.json +0 -0
  43. {alphaengine-0.9.4 → alphaengine-0.10.0}/pyproject.toml +0 -0
  44. {alphaengine-0.9.4 → alphaengine-0.10.0}/scripts/gen_docs.py +0 -0
  45. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/__init__.py +0 -0
  46. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/__main__.py +0 -0
  47. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/agent/__init__.py +0 -0
  48. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/agent/answer.py +0 -0
  49. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/agent/driver.py +0 -0
  50. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/auth.py +0 -0
  51. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/book.py +0 -0
  52. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/__init__.py +0 -0
  53. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/client/agent.py +0 -0
  54. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/complete.py +0 -0
  55. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/connectors/__init__.py +0 -0
  56. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/__init__.py +0 -0
  57. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/allocate.py +0 -0
  58. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/covariance.py +0 -0
  59. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/cross_section.py +0 -0
  60. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/pairs.py +0 -0
  61. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/panel.py +0 -0
  62. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/profile.py +0 -0
  63. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/screen.py +0 -0
  64. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/series_shapes.py +0 -0
  65. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/signals.py +0 -0
  66. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/technical.py +0 -0
  67. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/trace.py +0 -0
  68. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/core/walkforward.py +0 -0
  69. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo.py +0 -0
  70. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_book.py +0 -0
  71. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_returns.py +0 -0
  72. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_signal.py +0 -0
  73. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/demo_universe.py +0 -0
  74. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/events.py +0 -0
  75. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/loaders.py +0 -0
  76. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/model.py +0 -0
  77. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/py.typed +0 -0
  78. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/repl.py +0 -0
  79. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/study/__init__.py +0 -0
  80. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/study/report.py +0 -0
  81. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/study/schema.py +0 -0
  82. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/sweep/__init__.py +0 -0
  83. {alphaengine-0.9.4 → alphaengine-0.10.0}/src/alphaengine/tui/__init__.py +0 -0
  84. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_agent.py +0 -0
  85. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_answer.py +0 -0
  86. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_book.py +0 -0
  87. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_cli.py +0 -0
  88. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_client.py +0 -0
  89. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_commands.py +0 -0
  90. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_events.py +0 -0
  91. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_executor_ops.py +0 -0
  92. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_loaders.py +0 -0
  93. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_model.py +0 -0
  94. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_quant_maths.py +0 -0
  95. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_screen.py +0 -0
  96. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_signals.py +0 -0
  97. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_smoke.py +0 -0
  98. {alphaengine-0.9.4 → alphaengine-0.10.0}/tests/test_sweep.py +0 -0
  99. {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.9.4
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) # alpha vector stays here; scalars travel
256
+ a = grinold_alpha(panel, ic=0.05, vols=vols) # alpha stays here; missing vols are not treated as 1
257
257
  ```
258
258
 
259
259
  ---
@@ -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) # alpha vector stays here; scalars travel
211
+ a = grinold_alpha(panel, ic=0.05, vols=vols) # alpha stays here; missing vols are not treated as 1
212
212
  ```
213
213
 
214
214
  ---
@@ -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
- __version__ = "0.9.4"
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(args, session)
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
- equity = np.cumprod(1.0 + np.asarray(best, dtype=float))
474
- cum: list[float] = (equity - 1.0).tolist()
473
+ from ..core.performance import wealth_from_returns
474
+
475
+ wealth = wealth_from_returns(np.asarray(best, dtype=float))
476
+ cum: list[float] = (wealth[1:] - 1.0).tolist()
475
477
  figures["best_curve"] = [{"i": i, "v": round(v, 6)} for i, v in _bucketed(cum, CURVE_POINTS, _last)]
476
- peak = np.maximum.accumulate(equity)
478
+ peak = np.maximum.accumulate(wealth)
477
479
  with np.errstate(divide="ignore", invalid="ignore"):
478
- dd: list[float] = np.where(peak > 0, equity / peak - 1.0, 0.0).tolist()
480
+ dd: list[float] = np.where(peak > 0, wealth / peak - 1.0, 0.0)[1:].tolist()
479
481
  figures["drawdown_curve"] = [{"i": i, "v": round(v, 6)} for i, v in _bucketed(dd, CURVE_POINTS, min)]
480
482
  return figures
481
483
 
@@ -547,7 +549,12 @@ class StepExecutor:
547
549
  ws["verdict"] = figures
548
550
  return figures
549
551
 
550
- out = deflated_sharpe(self._returns_column(ws), n_trials=n_trials)
552
+ rf_kwargs: dict[str, Any] = {}
553
+ if params.get("risk_free_rate") is not None:
554
+ rf_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
555
+ if params.get("periods_per_year") is not None:
556
+ rf_kwargs["periods_per_year"] = int(params["periods_per_year"])
557
+ out = deflated_sharpe(self._returns_column(ws), n_trials=n_trials, **rf_kwargs)
551
558
  figures = {
552
559
  "deflated_sharpe": out.get("deflated_sharpe"),
553
560
  "psr_vs_zero": out.get("psr_vs_zero"),
@@ -555,6 +562,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