alpha-engine-lib 0.45.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.
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/PKG-INFO +18 -1
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/README.md +15 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/pyproject.toml +5 -1
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/__init__.py +1 -1
- alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/__init__.py +25 -0
- alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/attribution.py +183 -0
- alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/factor_risk.py +207 -0
- alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/returns.py +189 -0
- alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/risk_measures.py +170 -0
- alpha_engine_lib-0.46.0/src/alpha_engine_lib/quant/riskstats.py +93 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/PKG-INFO +18 -1
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +11 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/requires.txt +3 -0
- alpha_engine_lib-0.46.0/tests/test_quant_attribution.py +134 -0
- alpha_engine_lib-0.46.0/tests/test_quant_factor_risk.py +145 -0
- alpha_engine_lib-0.46.0/tests/test_quant_returns.py +157 -0
- alpha_engine_lib-0.46.0/tests/test_quant_risk_measures.py +77 -0
- alpha_engine_lib-0.46.0/tests/test_quant_riskstats.py +80 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/setup.cfg +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/alerts.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/arcticdb.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/collector_results.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/cost.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/dates.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/decision_capture.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/email_sender.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/locks.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/logging.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pillars.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/registry.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/preflight.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/db.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/reconcile.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/secrets.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/telegram.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/transparency.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib/universe.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_agent_schemas.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_alerts.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_anthropic_payload.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_arcticdb.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_artifact_freshness.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_collector_results.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_cost.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_dates.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_decision_capture.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_ec2_spot.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_email_sender.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_eval_artifacts.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_locks.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_logging.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_pillars.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_pipeline_status_read.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_pipeline_status_registry.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_pipeline_status_templates.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_preflight.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_rag.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_rag_rerank.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_rag_retrieval_hybrid.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_reconcile.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_secrets.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_sources_protocols.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_ssm_dispatcher.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_ssm_log_capture.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_telegram.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_trading_calendar.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_transparency.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_universe.py +0 -0
- {alpha_engine_lib-0.45.0 → alpha_engine_lib-0.46.0}/tests/test_version_bump_workflow.py +0 -0
- {alpha_engine_lib-0.45.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.
|
|
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.
|
|
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",
|
|
@@ -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
|
+
}
|