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.
- {alphaengine-0.9.5 → alphaengine-0.10.0}/PKG-INFO +3 -2
- {alphaengine-0.9.5 → alphaengine-0.10.0}/README.md +2 -1
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/_version.py +21 -1
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/cli.py +41 -3
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/executor.py +36 -6
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/session.py +5 -5
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/commands.py +17 -0
- alphaengine-0.10.0/src/alphaengine/context.py +130 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/active.py +38 -6
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/backtest.py +14 -2
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/factors.py +35 -15
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/performance.py +23 -3
- alphaengine-0.10.0/src/alphaengine/core/portfolio.py +97 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/process.py +15 -5
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/risk.py +42 -23
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/stress.py +10 -3
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/validation.py +37 -8
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/sweep/runner.py +1 -1
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/tui/app.py +9 -1
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/tui/dispatch.py +150 -19
- alphaengine-0.10.0/tests/test_context.py +80 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_goldens.py +3 -1
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_math_audit.py +19 -13
- alphaengine-0.10.0/tests/test_portfolio.py +53 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_process.py +17 -3
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_tui.py +58 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/.github/workflows/ci.yml +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/.github/workflows/publish.yml +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/.gitignore +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/AGENTS.md +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/LICENSE +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/SECURITY.md +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/assets/banner.png +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/assets/data_boundary.png +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/assets/session.png +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/figure_contract.md +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/workflows/evaluate_process.json +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/workflows/research_week.json +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/docs/workflows/stress_study.json +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/pyproject.toml +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/scripts/gen_docs.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/__main__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/agent/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/agent/answer.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/agent/driver.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/auth.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/book.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/charts.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/client/agent.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/complete.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/connectors/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/allocate.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/covariance.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/cross_section.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/pairs.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/panel.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/profile.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/relations.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/screen.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/series_shapes.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/signals.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/technical.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/trace.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/core/walkforward.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_book.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_returns.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_signal.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/demo_universe.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/events.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/loaders.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/model.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/py.typed +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/repl.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/study/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/study/report.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/study/schema.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/sweep/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/src/alphaengine/tui/__init__.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_agent.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_answer.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_book.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_cli.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_client.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_commands.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_events.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_executor_ops.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_loaders.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_model.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_quant_maths.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_relations.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_screen.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_signals.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_smoke.py +0 -0
- {alphaengine-0.9.5 → alphaengine-0.10.0}/tests/test_sweep.py +0 -0
- {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.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
|
|
5
5
|
Project-URL: Homepage, https://github.com/quantOSC/alphaengine
|
|
6
6
|
Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
|
|
@@ -253,7 +253,7 @@ from alphaengine.core import ou_calibrate, garch_calibrate, dgp_stress, grinold_
|
|
|
253
253
|
ou = ou_calibrate(spread) # kappa, theta, half_life; or not mean-reverting
|
|
254
254
|
g = garch_calibrate(returns) # omega, alpha, beta, persistence in (0, 1)
|
|
255
255
|
s = dgp_stress(close, dgp="ou") # n_trials = n_paths, source monte_carlo
|
|
256
|
-
a = grinold_alpha(panel, ic=0.05)
|
|
256
|
+
a = grinold_alpha(panel, ic=0.05, vols=vols) # alpha stays here; missing vols are not treated as 1
|
|
257
257
|
```
|
|
258
258
|
|
|
259
259
|
---
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
473
|
+
from ..core.performance import wealth_from_returns
|
|
474
|
+
|
|
475
|
+
wealth = wealth_from_returns(np.asarray(best, dtype=float))
|
|
476
|
+
cum: list[float] = (wealth[1:] - 1.0).tolist()
|
|
475
477
|
figures["best_curve"] = [{"i": i, "v": round(v, 6)} for i, v in _bucketed(cum, CURVE_POINTS, _last)]
|
|
476
|
-
peak = np.maximum.accumulate(
|
|
478
|
+
peak = np.maximum.accumulate(wealth)
|
|
477
479
|
with np.errstate(divide="ignore", invalid="ignore"):
|
|
478
|
-
dd: list[float] = np.where(peak > 0,
|
|
480
|
+
dd: list[float] = np.where(peak > 0, wealth / peak - 1.0, 0.0)[1:].tolist()
|
|
479
481
|
figures["drawdown_curve"] = [{"i": i, "v": round(v, 6)} for i, v in _bucketed(dd, CURVE_POINTS, min)]
|
|
480
482
|
return figures
|
|
481
483
|
|
|
@@ -547,7 +549,12 @@ class StepExecutor:
|
|
|
547
549
|
ws["verdict"] = figures
|
|
548
550
|
return figures
|
|
549
551
|
|
|
550
|
-
|
|
552
|
+
rf_kwargs: dict[str, Any] = {}
|
|
553
|
+
if params.get("risk_free_rate") is not None:
|
|
554
|
+
rf_kwargs["risk_free_rate"] = float(params["risk_free_rate"])
|
|
555
|
+
if params.get("periods_per_year") is not None:
|
|
556
|
+
rf_kwargs["periods_per_year"] = int(params["periods_per_year"])
|
|
557
|
+
out = deflated_sharpe(self._returns_column(ws), n_trials=n_trials, **rf_kwargs)
|
|
551
558
|
figures = {
|
|
552
559
|
"deflated_sharpe": out.get("deflated_sharpe"),
|
|
553
560
|
"psr_vs_zero": out.get("psr_vs_zero"),
|
|
@@ -555,6 +562,7 @@ class StepExecutor:
|
|
|
555
562
|
"verdict": out.get("verdict"),
|
|
556
563
|
"n_trials": n_trials,
|
|
557
564
|
"n_trials_source": source,
|
|
565
|
+
"risk_free_rate": out.get("risk_free_rate", 0.0),
|
|
558
566
|
}
|
|
559
567
|
# Kept so `emit.study` can carry the verdict this run actually produced
|
|
560
568
|
# rather than deriving it a second time. Two derivations of one figure
|
|
@@ -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
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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.
|
|
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.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
108
|
-
y_excess = y -
|
|
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={
|
|
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] *
|
|
147
|
+
result=round(float(model.params[0] * ppy * 100), 2),
|
|
134
148
|
)
|
|
135
149
|
return {
|
|
136
|
-
"alpha": _clean(round(float(model.params[0] *
|
|
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(
|
|
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,
|