alpha-engine-lib 0.44.0__tar.gz → 0.46.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 (93) hide show
  1. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/PKG-INFO +18 -1
  2. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/README.md +15 -0
  3. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/pyproject.toml +5 -1
  4. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/__init__.py +1 -1
  5. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/registry.py +14 -0
  6. alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/__init__.py +25 -0
  7. alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/attribution.py +183 -0
  8. alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/factor_risk.py +207 -0
  9. alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/returns.py +189 -0
  10. alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/risk_measures.py +170 -0
  11. alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/riskstats.py +93 -0
  12. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/PKG-INFO +18 -1
  13. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +11 -0
  14. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/requires.txt +3 -0
  15. alpha_engine_lib-0.46.0/tests/test_quant_attribution.py +134 -0
  16. alpha_engine_lib-0.46.0/tests/test_quant_factor_risk.py +145 -0
  17. alpha_engine_lib-0.46.0/tests/test_quant_returns.py +157 -0
  18. alpha_engine_lib-0.46.0/tests/test_quant_risk_measures.py +77 -0
  19. alpha_engine_lib-0.46.0/tests/test_quant_riskstats.py +80 -0
  20. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/setup.cfg +0 -0
  21. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
  22. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/alerts.py +0 -0
  23. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
  24. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/arcticdb.py +0 -0
  25. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
  26. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/collector_results.py +0 -0
  27. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/cost.py +0 -0
  28. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/dates.py +0 -0
  29. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/decision_capture.py +0 -0
  30. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
  31. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/email_sender.py +0 -0
  32. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  33. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/locks.py +0 -0
  34. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/logging.py +0 -0
  35. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  36. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pillars.py +0 -0
  37. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  38. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
  39. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  40. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/preflight.py +0 -0
  41. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
  42. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/db.py +0 -0
  43. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  44. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  45. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
  46. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  47. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
  48. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/reconcile.py +0 -0
  49. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/secrets.py +0 -0
  50. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
  51. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
  52. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  53. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  54. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/telegram.py +0 -0
  55. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
  56. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/transparency.py +0 -0
  57. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  58. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/universe.py +0 -0
  59. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  60. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  61. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_agent_schemas.py +0 -0
  62. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_alerts.py +0 -0
  63. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_anthropic_payload.py +0 -0
  64. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_arcticdb.py +0 -0
  65. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_artifact_freshness.py +0 -0
  66. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_collector_results.py +0 -0
  67. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_cost.py +0 -0
  68. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_dates.py +0 -0
  69. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_decision_capture.py +0 -0
  70. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_ec2_spot.py +0 -0
  71. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_email_sender.py +0 -0
  72. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_eval_artifacts.py +0 -0
  73. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_locks.py +0 -0
  74. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_logging.py +0 -0
  75. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_pillars.py +0 -0
  76. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_pipeline_status_read.py +0 -0
  77. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_pipeline_status_registry.py +0 -0
  78. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_pipeline_status_templates.py +0 -0
  79. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_preflight.py +0 -0
  80. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_rag.py +0 -0
  81. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_rag_rerank.py +0 -0
  82. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_rag_retrieval_hybrid.py +0 -0
  83. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_reconcile.py +0 -0
  84. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_secrets.py +0 -0
  85. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_sources_protocols.py +0 -0
  86. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_ssm_dispatcher.py +0 -0
  87. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_ssm_log_capture.py +0 -0
  88. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_telegram.py +0 -0
  89. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_trading_calendar.py +0 -0
  90. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_transparency.py +0 -0
  91. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_universe.py +0 -0
  92. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_version_bump_workflow.py +0 -0
  93. {alpha_engine_lib-0.44.0 → alpha_engine_lib-0.46.0}/tests/test_version_pin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.44.0
3
+ Version: 0.46.0
4
4
  Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -14,6 +14,8 @@ Requires-Dist: eval_type_backport>=0.2.0; python_version < "3.10"
14
14
  Provides-Extra: arcticdb
15
15
  Requires-Dist: arcticdb>=6.11; extra == "arcticdb"
16
16
  Requires-Dist: pandas>=2.0; extra == "arcticdb"
17
+ Provides-Extra: quant
18
+ Requires-Dist: numpy>=1.24; extra == "quant"
17
19
  Provides-Extra: flow-doctor
18
20
  Requires-Dist: flow-doctor[diagnosis,s3]<0.5.0,>=0.4.0; extra == "flow-doctor"
19
21
  Provides-Extra: rag
@@ -248,6 +250,21 @@ Rotates across `(instance_type × subnet)` combinations on `InsufficientInstance
248
250
 
249
251
  `universe_writer_lock(writer_id, ttl_seconds=3600)` context manager that uses `PutObject(IfNoneMatch="*")` to claim a single-writer lease on `s3://alpha-engine-research/locks/universe-writer.lock`. The first writer's conditional PUT succeeds; subsequent writers get `LockHeldByAnotherWriterError` carrying the live `LockHolder` body (writer_id + started_at + ttl_epoch + hostname + pid) for operator diagnostics. Soft-TTL self-recovery deletes-and-re-acquires when the on-disk lock's `ttl_epoch` has elapsed; the operator-side S3 lifecycle on `locks/` (expires-after-days=1) is the hard backstop. Release on context exit is best-effort and never masks an inner exception. Closes the producer-side half of the same single-writer-per-resource invariant the SF MutualExclusionGuard (DynamoDB-side) covers at the Step Function entry point; the lib lift is the chokepoint that picks up the third adopter for free (predictor weight-promote, backfill loops, etc.).
250
252
 
253
+ ### `quant` — portfolio analytics engine (factor risk, VaR/CVaR, attribution, returns)
254
+
255
+ The shared institutional-analytics engine: pure, front-end- and data-source-agnostic functions that *describe and measure* a portfolio (performance, risk, attribution) with **no advisory logic** — it sits on the "analytics, not advice" side of the line. Lifted from robodashboard's `analytics/` after the 2026-06-03 cross-repo leverage audit, so both the alpha-engine fleet and robodashboard consume one engine instead of parallel reimplementations. Import the submodule you need (the package keeps no eager imports, so the stdlib-only modules import without numpy):
256
+
257
+ - **`quant.factor_risk`** — statistical factor risk model `Σ = B·F·Bᵀ + D`: `estimate_factor_model` (time-series factor-ETF / Fama-MacBeth loadings), `portfolio_risk` (ex-ante vol + factor/idio split + per-factor variance contribution), `tracking_error`, `benchmark_exposure`, and a numpy-only `ledoit_wolf_cov` (no sklearn). The estimator-agnostic consumption core (`portfolio_risk`/`tracking_error`) consumes any `FactorRiskModel` (B, F, D). **Needs numpy** — `pip install "alpha-engine-lib[quant]"`.
258
+ - **`quant.risk_measures`** — parametric (Gaussian, Acklam inverse-normal, no scipy) + historical VaR & CVaR, as positive loss fractions at a horizon (stdlib).
259
+ - **`quant.riskstats`** — `volatility`, `sharpe_ratio`, `sortino_ratio`, `max_drawdown` (stdlib).
260
+ - **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
261
+ - **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
262
+
263
+ ```python
264
+ from alpha_engine_lib.quant.risk_measures import historical_cvar
265
+ from alpha_engine_lib.quant.factor_risk import estimate_factor_model, portfolio_risk
266
+ ```
267
+
251
268
  ## How it's used
252
269
 
253
270
  All six Nous Ergon module repos depend on this lib:
@@ -219,6 +219,21 @@ Rotates across `(instance_type × subnet)` combinations on `InsufficientInstance
219
219
 
220
220
  `universe_writer_lock(writer_id, ttl_seconds=3600)` context manager that uses `PutObject(IfNoneMatch="*")` to claim a single-writer lease on `s3://alpha-engine-research/locks/universe-writer.lock`. The first writer's conditional PUT succeeds; subsequent writers get `LockHeldByAnotherWriterError` carrying the live `LockHolder` body (writer_id + started_at + ttl_epoch + hostname + pid) for operator diagnostics. Soft-TTL self-recovery deletes-and-re-acquires when the on-disk lock's `ttl_epoch` has elapsed; the operator-side S3 lifecycle on `locks/` (expires-after-days=1) is the hard backstop. Release on context exit is best-effort and never masks an inner exception. Closes the producer-side half of the same single-writer-per-resource invariant the SF MutualExclusionGuard (DynamoDB-side) covers at the Step Function entry point; the lib lift is the chokepoint that picks up the third adopter for free (predictor weight-promote, backfill loops, etc.).
221
221
 
222
+ ### `quant` — portfolio analytics engine (factor risk, VaR/CVaR, attribution, returns)
223
+
224
+ The shared institutional-analytics engine: pure, front-end- and data-source-agnostic functions that *describe and measure* a portfolio (performance, risk, attribution) with **no advisory logic** — it sits on the "analytics, not advice" side of the line. Lifted from robodashboard's `analytics/` after the 2026-06-03 cross-repo leverage audit, so both the alpha-engine fleet and robodashboard consume one engine instead of parallel reimplementations. Import the submodule you need (the package keeps no eager imports, so the stdlib-only modules import without numpy):
225
+
226
+ - **`quant.factor_risk`** — statistical factor risk model `Σ = B·F·Bᵀ + D`: `estimate_factor_model` (time-series factor-ETF / Fama-MacBeth loadings), `portfolio_risk` (ex-ante vol + factor/idio split + per-factor variance contribution), `tracking_error`, `benchmark_exposure`, and a numpy-only `ledoit_wolf_cov` (no sklearn). The estimator-agnostic consumption core (`portfolio_risk`/`tracking_error`) consumes any `FactorRiskModel` (B, F, D). **Needs numpy** — `pip install "alpha-engine-lib[quant]"`.
227
+ - **`quant.risk_measures`** — parametric (Gaussian, Acklam inverse-normal, no scipy) + historical VaR & CVaR, as positive loss fractions at a horizon (stdlib).
228
+ - **`quant.riskstats`** — `volatility`, `sharpe_ratio`, `sortino_ratio`, `max_drawdown` (stdlib).
229
+ - **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
230
+ - **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
231
+
232
+ ```python
233
+ from alpha_engine_lib.quant.risk_measures import historical_cvar
234
+ from alpha_engine_lib.quant.factor_risk import estimate_factor_model, portfolio_risk
235
+ ```
236
+
222
237
  ## How it's used
223
238
 
224
239
  All six Nous Ergon module repos depend on this lib:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpha-engine-lib"
7
- version = "0.44.0"
7
+ version = "0.46.0"
8
8
  description = "Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README."
9
9
  readme = "README.md"
10
10
  # EC2 still runs Python 3.9 on the always-on micro instance (boto3 drops
@@ -30,6 +30,10 @@ dependencies = [
30
30
 
31
31
  [project.optional-dependencies]
32
32
  arcticdb = ["arcticdb>=6.11", "pandas>=2.0"]
33
+ # Quantitative portfolio analytics (alpha_engine_lib.quant). Only the
34
+ # factor-risk module needs numpy; the VaR/CVaR, riskstats, returns, and
35
+ # attribution modules are pure stdlib and import without this extra.
36
+ quant = ["numpy>=1.24"]
33
37
  flow_doctor = ["flow-doctor[diagnosis,s3]>=0.4.0,<0.5.0"]
34
38
  rag = [
35
39
  "psycopg2-binary>=2.9",
@@ -1,3 +1,3 @@
1
1
  """alpha-engine-lib — shared utilities for Alpha Engine modules."""
2
2
 
3
- __version__ = "0.44.0"
3
+ __version__ = "0.46.0"
@@ -69,6 +69,8 @@ WAIT_GROUPING: Final[dict[str, str]] = {
69
69
  "WaitForRAGIngestion": "RAGIngestion",
70
70
  "WaitForPredictorTraining": "PredictorTraining",
71
71
  "WaitForBacktester": "Backtester",
72
+ "WaitForPredictorBacktest": "PredictorBacktest",
73
+ "WaitForPortfolioOptimizerBacktest": "PortfolioOptimizerBacktest",
72
74
  "WaitForParity": "Parity",
73
75
  "WaitForEvaluator": "Evaluator",
74
76
  "WaitForSaturdayHealthCheck": "SaturdayHealthCheck",
@@ -254,6 +256,18 @@ STATE_TO_ARCHIVE_PAGE: Final[dict[str, Union[ArchivePageRef, ArtifactReason]]] =
254
256
  page="21_Backtester_Evaluator_Archive",
255
257
  artifact_label="Backtester consolidated report",
256
258
  ),
259
+ # L4472 phase-split (2026-05-31): the monolithic Backtester state was
260
+ # decomposed into Backtester (simulate) → PredictorBacktest → Portfolio-
261
+ # OptimizerBacktest. All three write into the same backtest/{date}/ prefix
262
+ # surfaced on the consolidated evaluator archive page.
263
+ "PredictorBacktest": ArchivePageRef(
264
+ page="21_Backtester_Evaluator_Archive",
265
+ artifact_label="Predictor backtest + Phase 4 report",
266
+ ),
267
+ "PortfolioOptimizerBacktest": ArchivePageRef(
268
+ page="21_Backtester_Evaluator_Archive",
269
+ artifact_label="Portfolio-optimizer / cov / gamma sweep report",
270
+ ),
257
271
  "Parity": ArchivePageRef(
258
272
  page="3_Analysis",
259
273
  artifact_label="Parity replay diff",
@@ -0,0 +1,25 @@
1
+ """Quantitative portfolio analytics — pure, front-end- and data-source-agnostic.
2
+
3
+ The shared institutional-analytics engine consumed across the fleet (predictor,
4
+ backtester, robodashboard). Every module is dependency-light and unit-testable in
5
+ isolation, and *describes/measures* a portfolio (performance, risk, attribution)
6
+ with **no advisory logic** — it sits on the "analytics, not advice" side of the
7
+ line.
8
+
9
+ Modules (import the submodule you need — the package keeps no eager imports so the
10
+ stdlib-only modules stay importable without numpy):
11
+
12
+ - ``factor_risk`` — Σ=B·F·Bᵀ+D ex-ante risk + tracking error (**needs numpy**;
13
+ install ``alpha-engine-lib[quant]``)
14
+ - ``risk_measures`` — parametric + historical VaR / CVaR (stdlib)
15
+ - ``riskstats`` — volatility, Sharpe, Sortino, max drawdown (stdlib)
16
+ - ``returns`` — XIRR (money-weighted) + time-weighted return (stdlib)
17
+ - ``attribution`` — Brinson-Fachler decomposition + Cariño linking (stdlib)
18
+
19
+ Example::
20
+
21
+ from alpha_engine_lib.quant.risk_measures import historical_cvar
22
+ from alpha_engine_lib.quant.factor_risk import estimate_factor_model, portfolio_risk
23
+ """
24
+
25
+ from __future__ import annotations
@@ -0,0 +1,183 @@
1
+ """Performance attribution — Brinson-Fachler decomposition + Cariño linking.
2
+
3
+ Pure stdlib, data-source-agnostic (takes plain group weight/return dicts, not a
4
+ broker client), so it's unit-testable in isolation and reusable unchanged across
5
+ front ends. This *explains* a portfolio's active return vs a benchmark — where
6
+ the over/under-performance came from — without ever prescribing a trade.
7
+
8
+ **Single period (Brinson-Fachler).** For each group *i* (typically a sector),
9
+ decompose the active return ``R_p − R_b`` into:
10
+
11
+ - **Allocation** ``(w_p,i − w_b,i) · (r_b,i − R_b)`` — did over/under-weighting a
12
+ group (vs its benchmark weight) help, given how that group did vs the whole
13
+ benchmark? (The Fachler refinement subtracts the total benchmark return
14
+ ``R_b`` so allocation rewards over-weighting *out-performing* groups.)
15
+ - **Selection** ``w_b,i · (r_p,i − r_b,i)`` — did picks within a group beat the
16
+ group's benchmark, at benchmark weight?
17
+ - **Interaction** ``(w_p,i − w_b,i) · (r_p,i − r_b,i)`` — the cross term.
18
+
19
+ The three effects summed over all groups equal the arithmetic active return.
20
+
21
+ **Multi-period (Cariño linking).** Arithmetic single-period effects don't simply
22
+ add across periods because returns compound geometrically. Cariño (1999) scales
23
+ each period's effects by ``k_t / k`` so the linked effects sum *exactly* to the
24
+ geometric cumulative active return — the institutional standard for chaining a
25
+ Brinson attribution through time.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import math
31
+ from dataclasses import dataclass, field
32
+
33
+ # Returns within ~1e-12 of each other are treated as equal for the linking-limit
34
+ # branches (where the divided-difference coefficient hits its L'Hôpital limit).
35
+ _EPS = 1e-12
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class GroupAttribution:
40
+ """Per-group Brinson-Fachler effects (all in return-fraction units)."""
41
+
42
+ group: str
43
+ allocation: float
44
+ selection: float
45
+ interaction: float
46
+
47
+ @property
48
+ def total(self) -> float:
49
+ return self.allocation + self.selection + self.interaction
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class BrinsonResult:
54
+ """A single- or linked-period attribution: per-group effects + totals.
55
+
56
+ ``portfolio_return`` / ``benchmark_return`` are the (cumulative, for a linked
57
+ result) totals; ``active_return`` is their difference and equals
58
+ ``total_effect`` up to floating-point error.
59
+ """
60
+
61
+ groups: list[GroupAttribution] = field(default_factory=list)
62
+ allocation: float = 0.0
63
+ selection: float = 0.0
64
+ interaction: float = 0.0
65
+ portfolio_return: float = 0.0
66
+ benchmark_return: float = 0.0
67
+
68
+ @property
69
+ def active_return(self) -> float:
70
+ return self.portfolio_return - self.benchmark_return
71
+
72
+ @property
73
+ def total_effect(self) -> float:
74
+ return self.allocation + self.selection + self.interaction
75
+
76
+
77
+ def brinson_fachler(
78
+ weights_p: dict[str, float],
79
+ returns_p: dict[str, float],
80
+ weights_b: dict[str, float],
81
+ returns_b: dict[str, float],
82
+ ) -> BrinsonResult:
83
+ """Single-period Brinson-Fachler attribution over the union of groups.
84
+
85
+ Args are group → weight / group → return maps for the portfolio (``_p``) and
86
+ benchmark (``_b``). Weights are fractions of their respective totals. The
87
+ group sets need not match: a group the benchmark doesn't hold defaults its
88
+ benchmark return to the overall benchmark return ``R_b`` (neutral allocation
89
+ baseline); a group the portfolio doesn't hold defaults its portfolio return
90
+ to that group's benchmark return (zero selection). Missing weights default to
91
+ 0.
92
+
93
+ Totals: ``R_p = Σ w_p,i·r_p,i``, ``R_b = Σ w_b,i·r_b,i`` over the groups given
94
+ — so for the decomposition to tie to the true portfolio/benchmark returns,
95
+ the weights should each sum to ~1 across the groups passed in.
96
+ """
97
+ groups = sorted(set(weights_p) | set(returns_p) | set(weights_b) | set(returns_b))
98
+
99
+ r_b_total = sum(weights_b.get(g, 0.0) * returns_b.get(g, 0.0) for g in groups)
100
+ r_p_total = sum(weights_p.get(g, 0.0) * returns_p.get(g, 0.0) for g in groups)
101
+
102
+ per_group: list[GroupAttribution] = []
103
+ for g in groups:
104
+ wp = weights_p.get(g, 0.0)
105
+ wb = weights_b.get(g, 0.0)
106
+ # Benchmark return for a group the benchmark doesn't hold → the overall
107
+ # benchmark return (so its allocation baseline is neutral). Portfolio
108
+ # return for a group the portfolio doesn't hold → that group's benchmark
109
+ # return (so selection is zero — you can't pick within what you don't own).
110
+ rb = returns_b.get(g, r_b_total)
111
+ rp = returns_p.get(g, rb)
112
+ allocation = (wp - wb) * (rb - r_b_total)
113
+ selection = wb * (rp - rb)
114
+ interaction = (wp - wb) * (rp - rb)
115
+ per_group.append(GroupAttribution(g, allocation, selection, interaction))
116
+
117
+ return BrinsonResult(
118
+ groups=per_group,
119
+ allocation=sum(a.allocation for a in per_group),
120
+ selection=sum(a.selection for a in per_group),
121
+ interaction=sum(a.interaction for a in per_group),
122
+ portfolio_return=r_p_total,
123
+ benchmark_return=r_b_total,
124
+ )
125
+
126
+
127
+ def _carino_coefficient(r_p: float, r_b: float) -> float:
128
+ """Cariño linking coefficient ``(ln(1+r_p) − ln(1+r_b)) / (r_p − r_b)``.
129
+
130
+ At ``r_p == r_b`` this is the L'Hôpital limit ``1 / (1 + r)``. Requires
131
+ ``1 + r > 0`` for both (a ≤ −100% period return has no log).
132
+ """
133
+ if 1.0 + r_p <= 0.0 or 1.0 + r_b <= 0.0:
134
+ raise ValueError("Cariño linking requires period returns > -100%")
135
+ if abs(r_p - r_b) < _EPS:
136
+ return 1.0 / (1.0 + r_b)
137
+ return (math.log(1.0 + r_p) - math.log(1.0 + r_b)) / (r_p - r_b)
138
+
139
+
140
+ def link_periods(periods: list[BrinsonResult]) -> BrinsonResult:
141
+ """Cariño-link a sequence of single-period attributions into one result.
142
+
143
+ Each period's effects are scaled by ``k_t / k`` so the linked per-group and
144
+ total effects sum exactly to the **geometric** cumulative active return
145
+ ``(∏(1+r_p,t) − 1) − (∏(1+r_b,t) − 1)``. Group identities are matched by name
146
+ across periods (a group absent in a period contributes 0 that period).
147
+
148
+ Single period → returned unchanged. Empty → an all-zero result. Raises
149
+ ``ValueError`` if any period return is ≤ −100% (no log).
150
+ """
151
+ if not periods:
152
+ return BrinsonResult()
153
+ if len(periods) == 1:
154
+ return periods[0]
155
+
156
+ cum_p = math.prod(1.0 + p.portfolio_return for p in periods) - 1.0
157
+ cum_b = math.prod(1.0 + p.benchmark_return for p in periods) - 1.0
158
+ k_overall = _carino_coefficient(cum_p, cum_b)
159
+
160
+ # Accumulate scaled effects per group and for the totals.
161
+ alloc: dict[str, float] = {}
162
+ select: dict[str, float] = {}
163
+ interact: dict[str, float] = {}
164
+ for p in periods:
165
+ k_t = _carino_coefficient(p.portfolio_return, p.benchmark_return)
166
+ scale = k_t / k_overall
167
+ for ga in p.groups:
168
+ alloc[ga.group] = alloc.get(ga.group, 0.0) + ga.allocation * scale
169
+ select[ga.group] = select.get(ga.group, 0.0) + ga.selection * scale
170
+ interact[ga.group] = interact.get(ga.group, 0.0) + ga.interaction * scale
171
+
172
+ per_group = [
173
+ GroupAttribution(g, alloc.get(g, 0.0), select.get(g, 0.0), interact.get(g, 0.0))
174
+ for g in sorted(alloc.keys() | select.keys() | interact.keys())
175
+ ]
176
+ return BrinsonResult(
177
+ groups=per_group,
178
+ allocation=sum(a.allocation for a in per_group),
179
+ selection=sum(a.selection for a in per_group),
180
+ interaction=sum(a.interaction for a in per_group),
181
+ portfolio_return=cum_p,
182
+ benchmark_return=cum_b,
183
+ )
@@ -0,0 +1,207 @@
1
+ """Statistical factor risk model — ex-ante portfolio risk + tracking error.
2
+
3
+ Pure numpy (no sklearn/scipy), data-source-agnostic. Regresses each holding's
4
+ return series on a set of **factor return series** (e.g. market + style-ETF
5
+ spreads, or universe-wide Fama-MacBeth factor returns) to recover loadings ``B``
6
+ and idiosyncratic variance ``D``; ``F`` is the (Ledoit-Wolf-shrunk) factor-return
7
+ covariance. The structural covariance
8
+
9
+ Σ = B · F · Bᵀ + D
10
+
11
+ then gives the portfolio's **ex-ante volatility**, its split into **factor vs
12
+ idiosyncratic** risk, **per-factor risk contributions**, and **tracking error**
13
+ vs a benchmark. Descriptive risk analytics — no advice, no trade.
14
+
15
+ The consumption layer (``portfolio_risk`` / ``tracking_error`` / decomposition)
16
+ is estimator-agnostic: it consumes any ``FactorRiskModel`` (B, F, D). Two
17
+ estimators are supported as factor *sources*: the time-series factor-ETF
18
+ regression here (``estimate_factor_model``), and — once wired — a universe-wide
19
+ cross-sectional Fama-MacBeth build (the alpha-engine predictor's ``risk_model``).
20
+ Both feed the same Σ=BFBᵀ+D core. See alpha-engine-lib OVERVIEW for the leverage
21
+ rationale.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Sequence
27
+ from dataclasses import dataclass
28
+
29
+ import numpy as np
30
+
31
+ _TRADING_DAYS = 252
32
+
33
+
34
+ def ledoit_wolf_cov(observations: np.ndarray, *, shrinkage: str = "ledoit_wolf") -> np.ndarray:
35
+ """Covariance of an ``(n_obs, k)`` matrix, optionally Ledoit-Wolf shrunk.
36
+
37
+ ``"ledoit_wolf"`` (default) shrinks the MLE sample covariance toward a scaled
38
+ identity (Ledoit & Wolf 2004), which keeps a small-/noisy-sample factor
39
+ covariance well-conditioned. ``"sample"`` returns the plain (n−1) sample
40
+ covariance. Shrinkage intensity is estimated from the data, so for a large,
41
+ clean sample it ≈ the sample covariance.
42
+ """
43
+ X = np.asarray(observations, dtype=float)
44
+ n, p = X.shape
45
+ Xc = X - X.mean(axis=0, keepdims=True)
46
+ if shrinkage == "sample" or n < 2 or p < 2:
47
+ return (Xc.T @ Xc) / max(n - 1, 1)
48
+
49
+ sample = (Xc.T @ Xc) / n # MLE divisor (Ledoit-Wolf convention)
50
+ mu = np.trace(sample) / p
51
+ target = mu * np.eye(p)
52
+ d2 = float(np.sum((sample - target) ** 2) / p)
53
+ if d2 == 0.0:
54
+ return sample
55
+ # b̄² — mean squared error of the per-observation outer products vs the sample.
56
+ b_sum = 0.0
57
+ for t in range(n):
58
+ xt = Xc[t][:, None]
59
+ b_sum += float(np.sum((xt @ xt.T - sample) ** 2))
60
+ b2 = min(b_sum / (n * n) / p, d2)
61
+ shrink = b2 / d2 # → 0 for a clean sample, → 1 when the sample is all noise
62
+ return shrink * target + (1.0 - shrink) * sample
63
+
64
+
65
+ def _ols(y: np.ndarray, factors: np.ndarray) -> tuple[np.ndarray, float]:
66
+ """OLS of ``y`` on ``factors`` (+ intercept). Returns (betas, residual variance)."""
67
+ n = len(y)
68
+ design = np.column_stack([np.ones(n), factors])
69
+ coef, *_ = np.linalg.lstsq(design, y, rcond=None)
70
+ resid = y - design @ coef
71
+ dof = max(n - design.shape[1], 1)
72
+ return coef[1:], float(resid @ resid / dof)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class FactorRiskModel:
77
+ """Estimated factor risk model: loadings B, idio variance D, factor cov F.
78
+
79
+ ``betas`` is ``(N, K)`` over ``tickers`` × ``factors``; ``idio_var`` is the
80
+ ``(N,)`` daily residual variance; ``factor_cov`` is the ``(K, K)`` daily
81
+ factor covariance. All variances are per-period (daily); annualization happens
82
+ in the risk functions via ``periods_per_year``.
83
+ """
84
+
85
+ factors: list[str]
86
+ tickers: list[str]
87
+ betas: np.ndarray
88
+ idio_var: np.ndarray
89
+ factor_cov: np.ndarray
90
+ periods_per_year: int = _TRADING_DAYS
91
+
92
+
93
+ def _factor_matrix(factor_returns: dict[str, Sequence[float]]) -> tuple[list[str], np.ndarray]:
94
+ factors = list(factor_returns)
95
+ mat = np.column_stack([np.asarray(factor_returns[f], dtype=float) for f in factors])
96
+ return factors, mat
97
+
98
+
99
+ def estimate_factor_model(
100
+ holding_returns: dict[str, Sequence[float]],
101
+ factor_returns: dict[str, Sequence[float]],
102
+ *,
103
+ shrinkage: str = "ledoit_wolf",
104
+ periods_per_year: int = _TRADING_DAYS,
105
+ ) -> FactorRiskModel:
106
+ """Fit loadings + idio variance per holding and the factor covariance.
107
+
108
+ ``holding_returns`` and ``factor_returns`` are ``{name: return_series}`` maps,
109
+ all aligned to the same ``n`` dates. Each holding is OLS-regressed on the
110
+ factor matrix (with intercept). Raises ``ValueError`` if the series lengths
111
+ disagree or there aren't enough observations to identify the factors.
112
+ """
113
+ factors, fmat = _factor_matrix(factor_returns)
114
+ n, k = fmat.shape
115
+ if n < k + 2:
116
+ raise ValueError(f"need ≥ {k + 2} observations for {k} factors, got {n}")
117
+
118
+ tickers: list[str] = []
119
+ betas: list[np.ndarray] = []
120
+ idio: list[float] = []
121
+ for ticker, series in holding_returns.items():
122
+ y = np.asarray(series, dtype=float)
123
+ if len(y) != n:
124
+ raise ValueError(f"{ticker} has {len(y)} returns, expected {n}")
125
+ b, rv = _ols(y, fmat)
126
+ tickers.append(ticker)
127
+ betas.append(b)
128
+ idio.append(rv)
129
+
130
+ return FactorRiskModel(
131
+ factors=factors,
132
+ tickers=tickers,
133
+ betas=np.array(betas).reshape(len(tickers), k),
134
+ idio_var=np.array(idio, dtype=float),
135
+ factor_cov=ledoit_wolf_cov(fmat, shrinkage=shrinkage),
136
+ periods_per_year=periods_per_year,
137
+ )
138
+
139
+
140
+ def benchmark_exposure(benchmark_returns: Sequence[float], factor_returns: dict[str, Sequence[float]]) -> np.ndarray:
141
+ """Benchmark's factor exposure — OLS betas of its return series on the factors.
142
+
143
+ A diversified benchmark's idiosyncratic risk is ≈ 0, so only its factor
144
+ exposure is needed for tracking error.
145
+ """
146
+ _, fmat = _factor_matrix(factor_returns)
147
+ beta, _ = _ols(np.asarray(benchmark_returns, dtype=float), fmat)
148
+ return beta
149
+
150
+
151
+ def _weight_vector(model: FactorRiskModel, weights: dict[str, float]) -> np.ndarray:
152
+ """Weights aligned to ``model.tickers``, renormalized to sum 1 over the covered set."""
153
+ w = np.array([float(weights.get(t, 0.0)) for t in model.tickers])
154
+ total = w.sum()
155
+ return w / total if total > 0 else w
156
+
157
+
158
+ def portfolio_risk(model: FactorRiskModel, weights: dict[str, float]) -> dict:
159
+ """Ex-ante annualized risk of the weighted portfolio, decomposed.
160
+
161
+ Returns total / factor / idiosyncratic volatility (annualized), the
162
+ portfolio's net factor exposures (``Bᵀw``), and each factor's share of total
163
+ **variance** (``factor_pct_contrib`` sums to the factor share; ``idio_pct``
164
+ is the rest).
165
+ """
166
+ w = _weight_vector(model, weights)
167
+ ann = model.periods_per_year
168
+ x = model.betas.T @ w # (K,) portfolio factor exposure
169
+ fx = model.factor_cov @ x
170
+ factor_var = float(x @ fx)
171
+ idio_var = float(np.sum(w**2 * model.idio_var))
172
+ total_var = factor_var + idio_var
173
+
174
+ contrib = x * fx # per-factor variance contribution; Σ = factor_var
175
+ # NB: zip() without strict= (added 3.10) — the lib targets 3.9; the iterables
176
+ # are equal-length by construction (factors, contrib, x, active are all length K).
177
+ pct = {f: (float(c) / total_var if total_var > 0 else 0.0) for f, c in zip(model.factors, contrib)}
178
+ return {
179
+ "total_vol": float(np.sqrt(max(total_var, 0.0) * ann)),
180
+ "factor_vol": float(np.sqrt(max(factor_var, 0.0) * ann)),
181
+ "idio_vol": float(np.sqrt(max(idio_var, 0.0) * ann)),
182
+ "factor_exposures": {f: float(v) for f, v in zip(model.factors, x)},
183
+ "factor_pct_contrib": pct,
184
+ "idio_pct": (idio_var / total_var if total_var > 0 else 0.0),
185
+ }
186
+
187
+
188
+ def tracking_error(model: FactorRiskModel, weights: dict[str, float], benchmark_exposures: np.ndarray) -> dict:
189
+ """Annualized tracking error (active risk) vs a benchmark.
190
+
191
+ Active factor exposure is ``Bᵀw − x_b``; the portfolio's idiosyncratic risk is
192
+ treated as fully active (a diversified benchmark contributes ≈ 0 idio). Also
193
+ returns the per-factor active exposures so you can see which tilts drive the
194
+ deviation from the benchmark.
195
+ """
196
+ w = _weight_vector(model, weights)
197
+ ann = model.periods_per_year
198
+ active = model.betas.T @ w - np.asarray(benchmark_exposures, dtype=float)
199
+ active_factor_var = float(active @ model.factor_cov @ active)
200
+ active_idio_var = float(np.sum(w**2 * model.idio_var))
201
+ te_var = active_factor_var + active_idio_var
202
+ return {
203
+ "tracking_error": float(np.sqrt(max(te_var, 0.0) * ann)),
204
+ "active_factor_vol": float(np.sqrt(max(active_factor_var, 0.0) * ann)),
205
+ "active_idio_vol": float(np.sqrt(max(active_idio_var, 0.0) * ann)),
206
+ "active_exposures": {f: float(v) for f, v in zip(model.factors, active)},
207
+ }