alphaengine 0.9.5__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.5 → alphaengine-0.10.0}/PKG-INFO +3 -2
  2. {alphaengine-0.9.5 → alphaengine-0.10.0}/README.md +2 -1
  3. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/_version.py +21 -1
  4. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/cli.py +41 -3
  5. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/executor.py +36 -6
  6. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/session.py +5 -5
  7. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/commands.py +17 -0
  8. alphaengine-0.10.0/src/alphaengine/context.py +130 -0
  9. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/active.py +38 -6
  10. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/backtest.py +14 -2
  11. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/factors.py +35 -15
  12. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/performance.py +23 -3
  13. alphaengine-0.10.0/src/alphaengine/core/portfolio.py +97 -0
  14. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/process.py +15 -5
  15. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/risk.py +42 -23
  16. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/stress.py +10 -3
  17. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/validation.py +37 -8
  18. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/sweep/runner.py +1 -1
  19. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/tui/app.py +9 -1
  20. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/tui/dispatch.py +150 -19
  21. alphaengine-0.10.0/tests/test_context.py +80 -0
  22. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_goldens.py +3 -1
  23. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_math_audit.py +19 -13
  24. alphaengine-0.10.0/tests/test_portfolio.py +53 -0
  25. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_process.py +17 -3
  26. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_tui.py +58 -0
  27. {alphaengine-0.9.5 → alphaengine-0.10.0}/.github/workflows/ci.yml +0 -0
  28. {alphaengine-0.9.5 → alphaengine-0.10.0}/.github/workflows/publish.yml +0 -0
  29. {alphaengine-0.9.5 → alphaengine-0.10.0}/.gitignore +0 -0
  30. {alphaengine-0.9.5 → alphaengine-0.10.0}/AGENTS.md +0 -0
  31. {alphaengine-0.9.5 → alphaengine-0.10.0}/LICENSE +0 -0
  32. {alphaengine-0.9.5 → alphaengine-0.10.0}/SECURITY.md +0 -0
  33. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/assets/banner.png +0 -0
  34. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/assets/data_boundary.png +0 -0
  35. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/assets/session.png +0 -0
  36. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/figure_contract.md +0 -0
  37. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/workflows/evaluate_process.json +0 -0
  38. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/workflows/research_week.json +0 -0
  39. {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/workflows/stress_study.json +0 -0
  40. {alphaengine-0.9.5 → alphaengine-0.10.0}/pyproject.toml +0 -0
  41. {alphaengine-0.9.5 → alphaengine-0.10.0}/scripts/gen_docs.py +0 -0
  42. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/__init__.py +0 -0
  43. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/__main__.py +0 -0
  44. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/agent/__init__.py +0 -0
  45. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/agent/answer.py +0 -0
  46. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/agent/driver.py +0 -0
  47. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/auth.py +0 -0
  48. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/book.py +0 -0
  49. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/charts.py +0 -0
  50. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/__init__.py +0 -0
  51. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/agent.py +0 -0
  52. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/complete.py +0 -0
  53. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/connectors/__init__.py +0 -0
  54. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/__init__.py +0 -0
  55. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/allocate.py +0 -0
  56. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/covariance.py +0 -0
  57. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/cross_section.py +0 -0
  58. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/pairs.py +0 -0
  59. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/panel.py +0 -0
  60. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/profile.py +0 -0
  61. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/relations.py +0 -0
  62. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/screen.py +0 -0
  63. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/series_shapes.py +0 -0
  64. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/signals.py +0 -0
  65. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/technical.py +0 -0
  66. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/trace.py +0 -0
  67. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/walkforward.py +0 -0
  68. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo.py +0 -0
  69. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_book.py +0 -0
  70. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_returns.py +0 -0
  71. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_signal.py +0 -0
  72. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_universe.py +0 -0
  73. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/events.py +0 -0
  74. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/loaders.py +0 -0
  75. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/model.py +0 -0
  76. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/py.typed +0 -0
  77. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/repl.py +0 -0
  78. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/study/__init__.py +0 -0
  79. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/study/report.py +0 -0
  80. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/study/schema.py +0 -0
  81. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/sweep/__init__.py +0 -0
  82. {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/tui/__init__.py +0 -0
  83. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_agent.py +0 -0
  84. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_answer.py +0 -0
  85. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_book.py +0 -0
  86. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_cli.py +0 -0
  87. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_client.py +0 -0
  88. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_commands.py +0 -0
  89. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_events.py +0 -0
  90. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_executor_ops.py +0 -0
  91. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_loaders.py +0 -0
  92. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_model.py +0 -0
  93. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_quant_maths.py +0 -0
  94. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_relations.py +0 -0
  95. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_screen.py +0 -0
  96. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_signals.py +0 -0
  97. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_smoke.py +0 -0
  98. {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_sweep.py +0 -0
  99. {alphaengine-0.9.5 → 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.5
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
  ---
@@ -337,6 +337,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
337
337
  | `correlation` | the correlation of every loaded name with every other name | session |
338
338
  | `covariance` | the sample covariance of every loaded name with every other name | session |
339
339
  | `cointegration` | which loaded names are cointegrated | session |
340
+ | `portfolio` | weights for the names already loaded | session |
340
341
  | `universe <name>` | same as load: a universe registered in the portal | session |
341
342
  | `data <file>` | same as load: a local CSV or parquet | session |
342
343
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -208,7 +208,7 @@ from alphaengine.core import ou_calibrate, garch_calibrate, dgp_stress, grinold_
208
208
  ou = ou_calibrate(spread) # kappa, theta, half_life; or not mean-reverting
209
209
  g = garch_calibrate(returns) # omega, alpha, beta, persistence in (0, 1)
210
210
  s = dgp_stress(close, dgp="ou") # n_trials = n_paths, source monte_carlo
211
- a = grinold_alpha(panel, ic=0.05) # 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
  ---
@@ -292,6 +292,7 @@ w = hrp_weights(cov, names=names) # no matrix inverse; weights sum to one
292
292
  | `correlation` | the correlation of every loaded name with every other name | session |
293
293
  | `covariance` | the sample covariance of every loaded name with every other name | session |
294
294
  | `cointegration` | which loaded names are cointegrated | session |
295
+ | `portfolio` | weights for the names already loaded | session |
295
296
  | `universe <name>` | same as load: a universe registered in the portal | session |
296
297
  | `data <file>` | same as load: a local CSV or parquet | session |
297
298
  | `project <module>` | same as load: a module with data and backtest_fn | session |
@@ -98,4 +98,24 @@ is what a changed figure costs. The API may still move underneath it.
98
98
  # is filed for the portal. check_overlap keeps that universe: one named symbol
99
99
  # against the equal-weight of the others. The correlation and beta are the
100
100
  # same numbers; the step now also names the candidate and the book.
101
- __version__ = "0.9.5"
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"
@@ -1670,13 +1670,22 @@ def _drive(run: Any, *, quiet: bool = False) -> None:
1670
1670
 
1671
1671
 
1672
1672
  def _publish_panel(session: Any, result: dict[str, Any], *, thesis_id: str | None = None) -> None:
1673
- """Hand the matrix or the pair table to the portal. A missing route is not a failure."""
1673
+ """Hand a matrix, a pair table, or portfolio weights to the portal.
1674
+
1675
+ A missing route is not a failure. The result is already on this machine.
1676
+ """
1674
1677
  if session is None or not hasattr(session, "file_panel"):
1675
1678
  return
1676
1679
  from .client.executor import _guard
1677
- from .core.relations import portal_figures
1678
1680
 
1679
- figures = portal_figures(result)
1681
+ if result.get("kind") == "portfolio":
1682
+ from .core.portfolio import portfolio_figures
1683
+
1684
+ figures = portfolio_figures(result)
1685
+ else:
1686
+ from .core.relations import portal_figures
1687
+
1688
+ figures = portal_figures(result)
1680
1689
  try:
1681
1690
  _guard(figures)
1682
1691
  except ValueError as exc:
@@ -1720,6 +1729,28 @@ def _print_cross(
1720
1729
  return 0
1721
1730
 
1722
1731
 
1732
+ def _print_portfolio(
1733
+ data: Any,
1734
+ *,
1735
+ session: Any = None,
1736
+ thesis_id: str | None = None,
1737
+ method: str = "hrp",
1738
+ ) -> int:
1739
+ """One-shot portfolio. The session path can also ask for risk parity."""
1740
+ from .core.portfolio import build_portfolio
1741
+
1742
+ try:
1743
+ result = build_portfolio(data, method=method)
1744
+ except ValueError as exc:
1745
+ say(red(str(exc)))
1746
+ return 2
1747
+ say(f" {result['method']} {result['n_assets']} names Ledoit-Wolf covariance")
1748
+ for row in (result.get("rows") or [])[:8]:
1749
+ say(f" {row['name']} {row['weight']}")
1750
+ _publish_panel(session, result, thesis_id=thesis_id)
1751
+ return 0
1752
+
1753
+
1723
1754
  def _overlap_payload(data: Any, symbol: str) -> dict[str, Any] | None:
1724
1755
  """Candidate plus the equal-weight of the other loaded names, or None."""
1725
1756
  from .core.relations import overlap_against_the_rest
@@ -1749,6 +1780,13 @@ def cmd_run(args: argparse.Namespace) -> int:
1749
1780
  say(red(str(exc)))
1750
1781
  return 2
1751
1782
  return _print_cross(data, kind, session=session)
1783
+ if str(args.workflow).lower().replace("-", "_") in ("portfolio", "allocate"):
1784
+ try:
1785
+ data, _fn = resolve_data(args, session)
1786
+ except (ProjectError, ValueError) as exc:
1787
+ say(red(str(exc)))
1788
+ return 2
1789
+ return _print_portfolio(data, session=session)
1752
1790
  symbol = getattr(args, "symbol", None)
1753
1791
  resolve_args = args
1754
1792
  if args.workflow == "check_overlap" and symbol:
@@ -470,12 +470,14 @@ class StepExecutor:
470
470
  # sampling across a trough understates the risk in exactly the
471
471
  # direction that flatters.
472
472
  best = _trial_column(result, result.best.index)
473
- 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
@@ -853,11 +861,17 @@ class StepExecutor:
853
861
  sweep = ws.get("sweep")
854
862
  if sweep is not None and n_trials is None:
855
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"])
856
869
  scored = score_backtest(
857
870
  bt,
858
871
  n_trials=None if n_trials is None else int(n_trials),
859
872
  n_trials_source=source,
860
873
  cpcv=bool(params.get("cpcv")),
874
+ **score_kwargs,
861
875
  )
862
876
  ws["score"] = scored
863
877
  return {
@@ -874,6 +888,11 @@ class StepExecutor:
874
888
  sweep = ws.get("sweep")
875
889
  if sweep is not None and n_trials is None:
876
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"])
877
896
  return dict(
878
897
  cpcv_score(
879
898
  returns,
@@ -882,6 +901,7 @@ class StepExecutor:
882
901
  purge=int(params.get("purge") or 1),
883
902
  embargo=int(params.get("embargo") or 1),
884
903
  n_trials=int(n_trials) if n_trials is not None else 1,
904
+ **cpcv_kwargs,
885
905
  )
886
906
  )
887
907
 
@@ -898,9 +918,13 @@ class StepExecutor:
898
918
  portfolio = _as_returns(data) or data.get("returns")
899
919
  if portfolio is None:
900
920
  raise UnsupportedOp("compute.factors found factor_returns but no portfolio returns.")
901
- kwargs = {}
921
+ kwargs: dict[str, Any] = {}
902
922
  if params.get("risk_free_rate") is not None:
903
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"])
904
928
  return dict(decompose_factors(list(portfolio), data["factor_returns"], **kwargs))
905
929
 
906
930
  def _pairs(self, params: Figures, ws: Workspace) -> Figures:
@@ -1283,12 +1307,18 @@ class StepExecutor:
1283
1307
  ideal = params.get("ideal") if isinstance(params.get("ideal"), dict) else ws.get("alpha")
1284
1308
  if isinstance(ideal, dict) and not holdings:
1285
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"])
1286
1315
  return dict(
1287
1316
  breadth_ir(
1288
1317
  ic=float(ic),
1289
1318
  n_names=int(n_names or 0),
1290
1319
  holdings=holdings,
1291
1320
  ideal=ideal if isinstance(ideal, dict) else None,
1321
+ **breadth_kwargs,
1292
1322
  )
1293
1323
  )
1294
1324
 
@@ -515,11 +515,11 @@ class Session:
515
515
  ) -> Figures:
516
516
  """File a client-computed panel so the portal can draw it.
517
517
 
518
- Correlation, covariance, and 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.
518
+ Correlation, covariance, cointegration, and a portfolio are not catalogue
519
+ workflows. A matrix travels as `figures.matrix` plus `figures.names`;
520
+ portfolio weights travel as rows. A 404 means this portal has no panels
521
+ route yet: the maths already happened on this machine, and the missing
522
+ route must not fail that.
523
523
  """
524
524
  from .._version import __version__
525
525
 
@@ -532,6 +532,23 @@ COMMANDS: tuple[Command, ...] = _question_commands() + (
532
532
  ),
533
533
  examples=("cointegration",),
534
534
  ),
535
+ Command(
536
+ verb="portfolio",
537
+ group="data",
538
+ args="",
539
+ scope="repl",
540
+ purpose="weights for the names already loaded",
541
+ body=(
542
+ "Hierarchical risk parity on a Ledoit-Wolf covariance of the loaded "
543
+ "returns. Say `portfolio risk parity` for equal risk contribution.\n\n"
544
+ "If this session already ran a correlation, covariance, or "
545
+ "cointegration, the book is the names that calculation kept, and "
546
+ "those readings stay attached so a later question can quote them. "
547
+ "The weights themselves come from the covariance, not from mixing "
548
+ "those readings into a second optimiser."
549
+ ),
550
+ examples=("portfolio", "portfolio risk parity"),
551
+ ),
535
552
  Command(
536
553
  verb="universe",
537
554
  group="data",
@@ -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
@@ -43,18 +43,27 @@ def grinold_alpha(
43
43
  vol_map[str(k)] = abs(float(v if not isinstance(v, (list, tuple)) else v[-1]))
44
44
  except (TypeError, ValueError):
45
45
  continue
46
+ missing_vol = [name for name in names if name not in vol_map]
46
47
  for name in names:
48
+ if name not in vol_map:
49
+ continue
47
50
  score = float(scores[name][-1])
48
- vol = vol_map.get(name, 1.0)
49
- alpha[name] = round(vol * ic_f * score, _RND)
51
+ alpha[name] = round(vol_map[name] * ic_f * score, _RND)
50
52
  abs_vals = [abs(v) for v in alpha.values()]
53
+ note = None
54
+ if not vol_map:
55
+ note = "no volatility was supplied, so alpha is not reported as a return"
56
+ elif missing_vol:
57
+ note = f"{len(missing_vol)} names had no volatility and were left out of alpha"
51
58
  return {
52
59
  "alpha": alpha,
53
60
  "mean_abs_alpha": round(float(np.mean(abs_vals)), _RND) if abs_vals else None,
54
61
  "ic_used": round(ic_f, _RND),
55
62
  "n_names": len(alpha),
56
- "n_skipped": len(skipped) + (len(scores) - len(names)),
63
+ "n_skipped": len(skipped) + (len(scores) - len(names)) + len(missing_vol),
57
64
  "skipped": skipped,
65
+ "n_missing_vol": len(missing_vol),
66
+ "note": note,
58
67
  "method": "grinold",
59
68
  }
60
69
 
@@ -65,8 +74,16 @@ def breadth_ir(
65
74
  n_names: int,
66
75
  holdings: dict | None = None,
67
76
  ideal: dict | None = None,
77
+ independent: bool = False,
78
+ mean_correlation: float | None = None,
68
79
  ) -> dict[str, Any]:
69
- """Implied IR = TC * IC * sqrt(breadth). TC is 1 when holdings are absent."""
80
+ """Implied IR = TC * IC * sqrt(breadth). TC is 1 when holdings are absent.
81
+
82
+ Breadth is the name count only when ``independent`` is set, or
83
+ ``N / (1 + (N - 1) * rho)`` when ``mean_correlation`` is supplied.
84
+ Omitting both leaves ``implied_ir`` empty rather than treating the names
85
+ as independent bets.
86
+ """
70
87
  ic_f = float(ic)
71
88
  br = max(int(n_names), 0)
72
89
  tc = 1.0
@@ -85,11 +102,26 @@ def breadth_ir(
85
102
  tc = 0.0
86
103
  else:
87
104
  tc = 0.0
88
- ir = tc * ic_f * math.sqrt(br) if br else 0.0
105
+ breadth_used: float | None = None
106
+ assumption = "not_assumed"
107
+ ir: float | None = None
108
+ if mean_correlation is not None and br:
109
+ rho = float(mean_correlation)
110
+ denom = 1.0 + (br - 1) * rho
111
+ breadth_used = br / denom if denom > 0 else 0.0
112
+ assumption = "mean_correlation"
113
+ ir = tc * ic_f * math.sqrt(breadth_used) if breadth_used > 0 else 0.0
114
+ elif independent:
115
+ breadth_used = float(br)
116
+ assumption = "independent"
117
+ ir = tc * ic_f * math.sqrt(br) if br else 0.0
89
118
  return {
90
119
  "ic": round(ic_f, _RND),
91
120
  "breadth": br,
121
+ "effective_breadth": None if breadth_used is None else round(breadth_used, _RND),
122
+ "breadth_assumption": assumption,
123
+ "mean_correlation": None if mean_correlation is None else round(float(mean_correlation), _RND),
92
124
  "transfer_coefficient": round(tc, _RND),
93
- "implied_ir": round(ir, _RND),
125
+ "implied_ir": None if ir is None else round(ir, _RND),
94
126
  "method": "fundamental_law",
95
127
  }
@@ -579,6 +579,7 @@ def score_backtest(
579
579
  n_trials: int | None = None,
580
580
  n_trials_source: str | None = None,
581
581
  risk_free_rate: float = 0.0,
582
+ periods_per_year: int = 252,
582
583
  benchmark_returns: list | None = None,
583
584
  pnl_matrix=None,
584
585
  trials_sharpe_std: float | None = None,
@@ -636,10 +637,19 @@ def score_backtest(
636
637
  equity_curve=bt.get("equity_curve"),
637
638
  benchmark_returns=benchmark_returns,
638
639
  risk_free_rate=risk_free_rate,
640
+ periods_per_year=periods_per_year,
641
+ )
642
+ dsr = deflated_sharpe(
643
+ returns,
644
+ n_trials=n_trials_eff,
645
+ trials_sharpe_std=trials_sharpe_std,
646
+ risk_free_rate=risk_free_rate,
647
+ periods_per_year=periods_per_year,
639
648
  )
640
- dsr = deflated_sharpe(returns, n_trials=n_trials_eff, trials_sharpe_std=trials_sharpe_std)
641
649
  pbo = pbo_cscv(pnl_matrix) if pnl_matrix is not None else None
642
- mintrl = min_track_record_length(returns)
650
+ mintrl = min_track_record_length(
651
+ returns, risk_free_rate=risk_free_rate, periods_per_year=periods_per_year
652
+ )
643
653
 
644
654
  dsr_ok = isinstance(dsr, dict) and "error" not in dsr
645
655
  pbo_ok = isinstance(pbo, dict) and "error" not in pbo
@@ -711,6 +721,8 @@ def score_backtest(
711
721
  purge=cpcv_purge,
712
722
  embargo=cpcv_embargo,
713
723
  n_trials=n_trials_eff,
724
+ risk_free_rate=risk_free_rate,
725
+ periods_per_year=periods_per_year,
714
726
  )
715
727
  if isinstance(cp, dict) and "error" not in cp:
716
728
  validation["cpcv"] = cp
@@ -3,7 +3,8 @@ Risk, factor decomposition over supplied portfolio + factor return streams.
3
3
 
4
4
  Lifted (math-identical) from backend/quant/factors.compute_multi_factor_loadings,
5
5
  with the FRED rfr fetch and quant.limits import removed: the caller supplies the
6
- risk-free rate (defaults to 0.04 annual) and the VIF threshold is inlined. OLS
6
+ risk-free rate (default 0, excess of zero, not a Treasury bill) and the VIF
7
+ threshold is inlined. OLS
7
8
  with HAC standard errors (statsmodels) gives factor betas, alpha, t-stats, R²,
8
9
  and a multicollinearity diagnostic (VIF).
9
10
 
@@ -30,7 +31,7 @@ import statsmodels.api as sm
30
31
  from .trace import record
31
32
 
32
33
  VIF_MAX_THRESHOLD = 10.0
33
- _DEFAULT_RFR = 0.04
34
+ _DEFAULT_RFR = 0.0
34
35
 
35
36
 
36
37
  def _clean(val):
@@ -81,18 +82,22 @@ def decompose_factors(
81
82
  factor_returns: dict[str, list[float]],
82
83
  *,
83
84
  risk_free_rate: float | None = None,
85
+ periods_per_year: int = 252,
86
+ factors_are_excess: bool = True,
84
87
  ) -> dict:
85
88
  """Multi-factor regression (FF5 + Momentum style) over supplied returns.
86
89
 
87
90
  `factor_returns` = {"market": [...], "size": [...]...}. Returns alpha
88
91
  (annualized %), factor betas + t-stats, R²/adj-R², residual vol, and a VIF
89
- multicollinearity diagnostic. Excess returns use the supplied rfr (annual),
90
- defaulting to 4%.
91
-
92
- That 4% is not the same default as ``performance_report``, which uses 0.
93
- A missing rate there must not invent a T-bill. This default is the rate
94
- the backend copy used when the caller did not pass one. Pass
95
- ``risk_free_rate`` explicitly when the two have to agree.
92
+ multicollinearity diagnostic.
93
+
94
+ The default rate is 0, the same as ``performance_report``: excess of zero,
95
+ not a bill. Pass the annual rate explicitly when the study uses one.
96
+ ``factors_are_excess`` is true for Ken French-style factors, which are
97
+ already above cash, so the rate is subtracted from the portfolio only.
98
+ Set it false when the factors are total returns. Alpha is the intercept
99
+ times ``periods_per_year``, an arithmetic annualization, not a CAGR.
100
+ HAC lags stay at 5, which is a daily-sample assumption paired with 252.
96
101
  """
97
102
  factor_names = list(factor_returns.keys())
98
103
  if not factor_names:
@@ -103,11 +108,14 @@ def decompose_factors(
103
108
  return {"error": "Need 30+ observations"}
104
109
 
105
110
  rfr = float(risk_free_rate) if risk_free_rate is not None else _DEFAULT_RFR
111
+ ppy = max(int(periods_per_year), 1)
106
112
  y = np.array(portfolio_returns[-min_len:], dtype=float)
107
- rf_daily = rfr / 252
108
- y_excess = y - rf_daily
113
+ rf_per = rfr / ppy
114
+ y_excess = y - rf_per
109
115
 
110
116
  X = np.column_stack([np.array(factor_returns[f][-min_len:], dtype=float) for f in factor_names])
117
+ if not factors_are_excess:
118
+ X = X - rf_per
111
119
 
112
120
  vifs: dict[str, float] = {}
113
121
  high_vif: list[str] = []
@@ -128,12 +136,18 @@ def decompose_factors(
128
136
  record(
129
137
  "factor_alpha",
130
138
  "annualised intercept of excess portfolio returns on the factor panel, HAC errors",
131
- inputs={"n_observations": int(min_len), "risk_free_rate": rfr, "n_factors": len(factor_names)},
139
+ inputs={
140
+ "n_observations": int(min_len),
141
+ "risk_free_rate": rfr,
142
+ "periods_per_year": ppy,
143
+ "factors_are_excess": bool(factors_are_excess),
144
+ "n_factors": len(factor_names),
145
+ },
132
146
  series={"portfolio_excess": y_excess},
133
- result=round(float(model.params[0] * 252 * 100), 2),
147
+ result=round(float(model.params[0] * ppy * 100), 2),
134
148
  )
135
149
  return {
136
- "alpha": _clean(round(float(model.params[0] * 252 * 100), 2)),
150
+ "alpha": _clean(round(float(model.params[0] * ppy * 100), 2)),
137
151
  "alpha_tstat": _clean(round(float(model.tvalues[0]), 2)),
138
152
  "alpha_pvalue": _clean(round(alpha_pvalue, 4)),
139
153
  "alpha_significant_at_5pct": bool(alpha_pvalue < 0.05),
@@ -141,8 +155,14 @@ def decompose_factors(
141
155
  "factor_tstats": tstats,
142
156
  "r_squared": _clean(round(float(model.rsquared), 3)),
143
157
  "adj_r_squared": _clean(round(float(model.rsquared_adj), 3)),
144
- "residual_vol": _clean(round(float(np.std(model.resid) * np.sqrt(252) * 100), 2)),
158
+ "residual_vol": _clean(round(float(np.std(model.resid, ddof=1) * np.sqrt(ppy) * 100), 2)),
159
+ "residual_vol_ddof": 1,
145
160
  "n_observations": int(min_len),
161
+ "risk_free_rate": round(rfr, 4),
162
+ "periods_per_year": ppy,
163
+ "factors_are_excess": bool(factors_are_excess),
164
+ "alpha_annualization": "arithmetic",
165
+ "hac_maxlags": 5,
146
166
  "model": _model_label(factor_names),
147
167
  "vifs": {k: _clean(round(v, 2)) if math.isfinite(v) else None for k, v in vifs.items()},
148
168
  "high_vif_factors": high_vif,