alpha-engine-lib 0.47.0__tar.gz → 0.49.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.47.0/src/alpha_engine_lib.egg-info → alpha_engine_lib-0.49.0}/PKG-INFO +11 -2
- alpha_engine_lib-0.47.0/PKG-INFO → alpha_engine_lib-0.49.0/README.md +5 -35
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/pyproject.toml +6 -2
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/__init__.py +1 -1
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/http_retry.py +199 -0
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/__init__.py +22 -0
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/dsr.py +278 -0
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/expectancy.py +161 -0
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/information_coefficient.py +149 -0
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/multiple_testing.py +48 -0
- alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/risk_matched_benchmark.py +305 -0
- alpha_engine_lib-0.47.0/README.md → alpha_engine_lib-0.49.0/src/alpha_engine_lib.egg-info/PKG-INFO +44 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +13 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/requires.txt +5 -0
- alpha_engine_lib-0.49.0/tests/test_http_retry.py +199 -0
- alpha_engine_lib-0.49.0/tests/test_quant_stats_dsr.py +95 -0
- alpha_engine_lib-0.49.0/tests/test_quant_stats_expectancy.py +102 -0
- alpha_engine_lib-0.49.0/tests/test_quant_stats_information_coefficient.py +100 -0
- alpha_engine_lib-0.49.0/tests/test_quant_stats_multiple_testing.py +42 -0
- alpha_engine_lib-0.49.0/tests/test_quant_stats_risk_matched_benchmark.py +140 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/setup.cfg +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/alerts.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/arcticdb.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/collector_results.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/cost.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/dates.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/decision_capture.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/email_sender.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/locks.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/logging.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pillars.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/registry.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/preflight.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/__init__.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/attribution.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/factor_risk.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/factor_risk_xs.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/returns.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/risk_measures.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/riskstats.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/db.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/reconcile.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/secrets.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/telegram.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/transparency.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/universe.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_agent_schemas.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_alerts.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_anthropic_payload.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_arcticdb.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_artifact_freshness.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_collector_results.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_cost.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_dates.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_decision_capture.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_ec2_spot.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_email_sender.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_eval_artifacts.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_locks.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_logging.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pillars.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pipeline_status_read.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pipeline_status_registry.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pipeline_status_templates.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_preflight.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_attribution.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_factor_risk.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_factor_risk_xs.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_returns.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_risk_measures.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_riskstats.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_rag.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_rag_rerank.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_rag_retrieval_hybrid.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_reconcile.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_secrets.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_sources_protocols.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_ssm_dispatcher.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_ssm_log_capture.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_telegram.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_trading_calendar.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_transparency.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_universe.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_version_bump_workflow.py +0 -0
- {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_version_pin.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alpha-engine-lib
|
|
3
|
-
Version: 0.
|
|
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,
|
|
3
|
+
Version: 0.49.0
|
|
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, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. Full surface documented in README.
|
|
5
5
|
Author: Brian McMahon
|
|
6
6
|
License: Proprietary
|
|
7
7
|
Requires-Python: >=3.9
|
|
@@ -20,6 +20,10 @@ Provides-Extra: quant-xs
|
|
|
20
20
|
Requires-Dist: numpy>=1.24; extra == "quant-xs"
|
|
21
21
|
Requires-Dist: pandas>=2.0; extra == "quant-xs"
|
|
22
22
|
Requires-Dist: scikit-learn>=1.0; extra == "quant-xs"
|
|
23
|
+
Provides-Extra: quant-stats
|
|
24
|
+
Requires-Dist: numpy>=1.24; extra == "quant-stats"
|
|
25
|
+
Requires-Dist: pandas>=2.0; extra == "quant-stats"
|
|
26
|
+
Requires-Dist: scipy>=1.7; extra == "quant-stats"
|
|
23
27
|
Provides-Extra: flow-doctor
|
|
24
28
|
Requires-Dist: flow-doctor[diagnosis,s3]<0.5.0,>=0.4.0; extra == "flow-doctor"
|
|
25
29
|
Provides-Extra: rag
|
|
@@ -264,6 +268,11 @@ The shared institutional-analytics engine: pure, front-end- and data-source-agno
|
|
|
264
268
|
- **`quant.riskstats`** — `volatility`, `sharpe_ratio`, `sortino_ratio`, `max_drawdown` (stdlib).
|
|
265
269
|
- **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
|
|
266
270
|
- **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
|
|
271
|
+
- **`quant.stats`** — strategy/signal-quality evaluation metrics (lifted from the backtester's `analysis/`): `dsr` (Probabilistic + Deflated Sharpe, López de Prado), `information_coefficient` (Spearman rank IC), `expectancy` (hit-rate × win/loss decomposition), `multiple_testing` (Benjamini-Hochberg FDR), `risk_matched_benchmark` (EW-high-vol + beta-matched-SPY baselines + Information Ratio). **Needs pandas + scipy** — `pip install "alpha-engine-lib[quant-stats]"` (scipy is only the IC p-value; numpy fallback otherwise).
|
|
272
|
+
|
|
273
|
+
### `http_retry` — bounded-backoff transient-API retry chokepoint
|
|
274
|
+
|
|
275
|
+
`request_with_retry(url, *, params, session, transient_status, ...)` returns the final `requests.Response` after retrying the transient class — 429 + 5xx responses (honoring `Retry-After`) and `Timeout`/`ConnectionError` network errors — with exponential backoff + full jitter; an exhausted network error raises `HttpRetryError` (api-key-scrubbed), while a persistent transient-status response is returned for the caller to interpret (so a 403, not in the transient set, is handed back for e.g. polygon's `PolygonForbiddenError` conversion). Also exposes the low-level `backoff_delay(attempt, *, base, cap, retry_after)` and `scrub_api_keys(msg)` (masks `api_key=`/`apiKey=` querystring values) for consumers with bespoke loops (the rate-limited `polygon_client` keeps its own loop + 403 + JSON parse and reuses just the delay math + scrubber). Consolidates the four mirrored alpha-engine-data retry sites (FRED fetch, polygon client, preflight reachability, FRED repair) into one policy so they stop drifting (L4499). Stdlib + `requests` only.
|
|
267
276
|
|
|
268
277
|
```python
|
|
269
278
|
from alpha_engine_lib.quant.risk_measures import historical_cvar
|
|
@@ -1,38 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: alpha-engine-lib
|
|
3
|
-
Version: 0.47.0
|
|
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
|
-
Author: Brian McMahon
|
|
6
|
-
License: Proprietary
|
|
7
|
-
Requires-Python: >=3.9
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
Requires-Dist: boto3>=1.34
|
|
10
|
-
Requires-Dist: pydantic>=2.0
|
|
11
|
-
Requires-Dist: pyyaml>=6.0
|
|
12
|
-
Requires-Dist: requests>=2.31
|
|
13
|
-
Requires-Dist: eval_type_backport>=0.2.0; python_version < "3.10"
|
|
14
|
-
Provides-Extra: arcticdb
|
|
15
|
-
Requires-Dist: arcticdb>=6.11; extra == "arcticdb"
|
|
16
|
-
Requires-Dist: pandas>=2.0; extra == "arcticdb"
|
|
17
|
-
Provides-Extra: quant
|
|
18
|
-
Requires-Dist: numpy>=1.24; extra == "quant"
|
|
19
|
-
Provides-Extra: quant-xs
|
|
20
|
-
Requires-Dist: numpy>=1.24; extra == "quant-xs"
|
|
21
|
-
Requires-Dist: pandas>=2.0; extra == "quant-xs"
|
|
22
|
-
Requires-Dist: scikit-learn>=1.0; extra == "quant-xs"
|
|
23
|
-
Provides-Extra: flow-doctor
|
|
24
|
-
Requires-Dist: flow-doctor[diagnosis,s3]<0.5.0,>=0.4.0; extra == "flow-doctor"
|
|
25
|
-
Provides-Extra: rag
|
|
26
|
-
Requires-Dist: psycopg2-binary>=2.9; extra == "rag"
|
|
27
|
-
Requires-Dist: pgvector>=0.2; extra == "rag"
|
|
28
|
-
Requires-Dist: numpy>=1.24; extra == "rag"
|
|
29
|
-
Provides-Extra: rerank
|
|
30
|
-
Requires-Dist: sentence-transformers>=3.0; extra == "rerank"
|
|
31
|
-
Provides-Extra: dev
|
|
32
|
-
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
33
|
-
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
34
|
-
Requires-Dist: moto>=5.0; extra == "dev"
|
|
35
|
-
|
|
36
1
|
# alpha-engine-lib
|
|
37
2
|
|
|
38
3
|
> Part of [**Nous Ergon**](https://nousergon.ai) — Autonomous Multi-Agent Trading System. Repo and S3 names use the underlying project name `alpha-engine`.
|
|
@@ -264,6 +229,11 @@ The shared institutional-analytics engine: pure, front-end- and data-source-agno
|
|
|
264
229
|
- **`quant.riskstats`** — `volatility`, `sharpe_ratio`, `sortino_ratio`, `max_drawdown` (stdlib).
|
|
265
230
|
- **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
|
|
266
231
|
- **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
|
|
232
|
+
- **`quant.stats`** — strategy/signal-quality evaluation metrics (lifted from the backtester's `analysis/`): `dsr` (Probabilistic + Deflated Sharpe, López de Prado), `information_coefficient` (Spearman rank IC), `expectancy` (hit-rate × win/loss decomposition), `multiple_testing` (Benjamini-Hochberg FDR), `risk_matched_benchmark` (EW-high-vol + beta-matched-SPY baselines + Information Ratio). **Needs pandas + scipy** — `pip install "alpha-engine-lib[quant-stats]"` (scipy is only the IC p-value; numpy fallback otherwise).
|
|
233
|
+
|
|
234
|
+
### `http_retry` — bounded-backoff transient-API retry chokepoint
|
|
235
|
+
|
|
236
|
+
`request_with_retry(url, *, params, session, transient_status, ...)` returns the final `requests.Response` after retrying the transient class — 429 + 5xx responses (honoring `Retry-After`) and `Timeout`/`ConnectionError` network errors — with exponential backoff + full jitter; an exhausted network error raises `HttpRetryError` (api-key-scrubbed), while a persistent transient-status response is returned for the caller to interpret (so a 403, not in the transient set, is handed back for e.g. polygon's `PolygonForbiddenError` conversion). Also exposes the low-level `backoff_delay(attempt, *, base, cap, retry_after)` and `scrub_api_keys(msg)` (masks `api_key=`/`apiKey=` querystring values) for consumers with bespoke loops (the rate-limited `polygon_client` keeps its own loop + 403 + JSON parse and reuses just the delay math + scrubber). Consolidates the four mirrored alpha-engine-data retry sites (FRED fetch, polygon client, preflight reachability, FRED repair) into one policy so they stop drifting (L4499). Stdlib + `requests` only.
|
|
267
237
|
|
|
268
238
|
```python
|
|
269
239
|
from alpha_engine_lib.quant.risk_measures import historical_cvar
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "alpha-engine-lib"
|
|
7
|
-
version = "0.
|
|
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,
|
|
7
|
+
version = "0.49.0"
|
|
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, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. 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
|
|
11
11
|
# 3.9 support 2026-04-29, so upgrade is on the near-term roadmap). All
|
|
@@ -39,6 +39,10 @@ quant = ["numpy>=1.24"]
|
|
|
39
39
|
# separate from [quant] so the numpy-only consumers (e.g. robodashboard)
|
|
40
40
|
# don't pull pandas+sklearn.
|
|
41
41
|
quant-xs = ["numpy>=1.24", "pandas>=2.0", "scikit-learn>=1.0"]
|
|
42
|
+
# Statistical evaluation utilities (alpha_engine_lib.quant.stats — PSR/DSR, IC,
|
|
43
|
+
# expectancy, BH-FDR, risk-matched benchmarks). numpy + pandas always; scipy is
|
|
44
|
+
# used by information_coefficient for the p-value (numpy fallback otherwise).
|
|
45
|
+
quant-stats = ["numpy>=1.24", "pandas>=2.0", "scipy>=1.7"]
|
|
42
46
|
flow_doctor = ["flow-doctor[diagnosis,s3]>=0.4.0,<0.5.0"]
|
|
43
47
|
rag = [
|
|
44
48
|
"psycopg2-binary>=2.9",
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Bounded-backoff HTTP retry primitive — the transient external-API
|
|
2
|
+
resilience chokepoint (L4499).
|
|
3
|
+
|
|
4
|
+
Consolidates the backoff + full-jitter + ``Retry-After`` + api-key-scrub
|
|
5
|
+
retry idiom that was mirrored across four alpha-engine-data sites:
|
|
6
|
+
|
|
7
|
+
* ``collectors/daily_closes.py::_fred_get_with_retry`` (L4480)
|
|
8
|
+
* ``polygon_client.py::_get`` / ``_backoff`` (L4496)
|
|
9
|
+
* ``preflight.py::_reachability_get`` (L4494)
|
|
10
|
+
* ``collectors/daily_closes_fred_repair.py::_fetch_fred_range``
|
|
11
|
+
|
|
12
|
+
Each had its own copy of "exponential backoff + full jitter, honor
|
|
13
|
+
``Retry-After``, retry the transient class, scrub the api-key from the
|
|
14
|
+
error before logging/raising, then fail loud." This module is the single
|
|
15
|
+
source of truth for that policy so the four callsites stop drifting.
|
|
16
|
+
|
|
17
|
+
Two layers are exported:
|
|
18
|
+
|
|
19
|
+
* :func:`request_with_retry` — the full GET-with-retry for the plain
|
|
20
|
+
callsites (FRED fetch, preflight probe, FRED repair). Returns the final
|
|
21
|
+
``requests.Response``; the caller still owns status interpretation
|
|
22
|
+
(``raise_for_status`` / special-casing a 403), so genuinely different
|
|
23
|
+
consumers compose it without a leaky mega-config.
|
|
24
|
+
* :func:`backoff_delay` + :func:`scrub_api_keys` — the low-level pieces for
|
|
25
|
+
a consumer with bespoke control flow (the rate-limited ``polygon_client``
|
|
26
|
+
keeps its own loop + 403 handling + JSON parse + rate limiter, but shares
|
|
27
|
+
the delay math and the scrubber).
|
|
28
|
+
|
|
29
|
+
Design note (anti-over-engineering): this is deliberately NOT a
|
|
30
|
+
pluggable-everything HTTP framework. It captures the one invariant the four
|
|
31
|
+
sites share; consumers whose semantics diverge (polygon's 403 + rate limiter)
|
|
32
|
+
reuse the primitives rather than being forced through a generic loop.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging as _logging
|
|
38
|
+
import random as _random
|
|
39
|
+
import re
|
|
40
|
+
import time as _time
|
|
41
|
+
from typing import Callable, Iterable
|
|
42
|
+
|
|
43
|
+
import requests
|
|
44
|
+
|
|
45
|
+
_DEFAULT_LOGGER = _logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Transient HTTP status class: 429 (rate limit) + the retryable 5xx. A 4xx
|
|
48
|
+
# other than 429 is a deterministic client error — retrying it is pointless,
|
|
49
|
+
# so it is NOT in the default set and is returned to the caller as-is.
|
|
50
|
+
DEFAULT_TRANSIENT_STATUS: "frozenset[int]" = frozenset({429, 500, 502, 503, 504})
|
|
51
|
+
|
|
52
|
+
# Mask FRED ``api_key=`` (snake) and polygon ``apiKey=`` (camel) querystring
|
|
53
|
+
# VALUES — both leak via ``requests`` exception ``str()`` (the effective URL)
|
|
54
|
+
# and via hand-built error strings. Mirrors the per-repo scrubbers this module
|
|
55
|
+
# replaces; complements ``alpha_engine_lib.logging.SecretsRedactingFilter``
|
|
56
|
+
# (which catches token-shaped secrets, not query-param api keys).
|
|
57
|
+
_API_KEY_RE = re.compile(r"(?:api_key|apiKey)=[^&\s]+")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scrub_api_keys(msg: object) -> str:
|
|
61
|
+
"""Mask ``api_key=...`` / ``apiKey=...`` querystring values in a string.
|
|
62
|
+
|
|
63
|
+
Preserves the key NAME (so logs still show *which* param) and the value
|
|
64
|
+
delimiter, replacing only the secret value with ``***``. Idempotent.
|
|
65
|
+
"""
|
|
66
|
+
return _API_KEY_RE.sub(lambda m: m.group(0).split("=", 1)[0] + "=***", str(msg))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class HttpRetryError(RuntimeError):
|
|
70
|
+
"""Raised when all attempts are exhausted on a transient NETWORK error
|
|
71
|
+
(``requests.Timeout`` / ``requests.ConnectionError``) or a non-transient
|
|
72
|
+
``RequestException``.
|
|
73
|
+
|
|
74
|
+
The message is api-key-scrubbed. The originating exception is preserved
|
|
75
|
+
as ``__cause__`` (and on ``.last_exc``); ``.label`` / ``.attempts`` carry
|
|
76
|
+
context for callers that want to re-wrap (e.g. preflight's
|
|
77
|
+
``RuntimeError(... unreachable ...)``).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, label: str, attempts: int, last_exc: BaseException) -> None:
|
|
81
|
+
self.label = label
|
|
82
|
+
self.attempts = attempts
|
|
83
|
+
self.last_exc = last_exc
|
|
84
|
+
super().__init__(
|
|
85
|
+
scrub_api_keys(
|
|
86
|
+
f"{label or 'request'} failed after {attempts} attempt(s): {last_exc}"
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def backoff_delay(
|
|
92
|
+
attempt: int,
|
|
93
|
+
*,
|
|
94
|
+
base: float = 1.0,
|
|
95
|
+
cap: float = 30.0,
|
|
96
|
+
retry_after: "str | float | None" = None,
|
|
97
|
+
rng: "_random.Random | None" = None,
|
|
98
|
+
) -> float:
|
|
99
|
+
"""Full-jitter exponential backoff: ``min(base*2**attempt + U(0, base), cap)``.
|
|
100
|
+
|
|
101
|
+
``attempt`` is 0-indexed. Honors a server ``Retry-After`` (seconds, str or
|
|
102
|
+
float) when supplied — a numeric value replaces the exponential term (still
|
|
103
|
+
+ jitter, still capped); a non-numeric ``Retry-After`` (HTTP-date form)
|
|
104
|
+
falls back to the exponential term. ``rng`` is injectable for deterministic
|
|
105
|
+
tests.
|
|
106
|
+
"""
|
|
107
|
+
wait: "float | None" = None
|
|
108
|
+
if retry_after is not None:
|
|
109
|
+
try:
|
|
110
|
+
wait = float(retry_after)
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
wait = None
|
|
113
|
+
if wait is None:
|
|
114
|
+
wait = base * (2 ** attempt)
|
|
115
|
+
jitter = (rng or _random).uniform(0, base)
|
|
116
|
+
return min(wait + jitter, cap)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def request_with_retry(
|
|
120
|
+
url: str,
|
|
121
|
+
*,
|
|
122
|
+
method: str = "GET",
|
|
123
|
+
params: "dict | None" = None,
|
|
124
|
+
session: "requests.Session | None" = None,
|
|
125
|
+
timeout: float = 15.0,
|
|
126
|
+
max_attempts: int = 3,
|
|
127
|
+
backoff_base: float = 1.0,
|
|
128
|
+
backoff_cap: float = 30.0,
|
|
129
|
+
transient_status: Iterable[int] = DEFAULT_TRANSIENT_STATUS,
|
|
130
|
+
retry_network: bool = True,
|
|
131
|
+
honor_retry_after: bool = True,
|
|
132
|
+
scrub: Callable[[object], str] = scrub_api_keys,
|
|
133
|
+
logger: "_logging.Logger | None" = None,
|
|
134
|
+
label: str = "",
|
|
135
|
+
sleep: Callable[[float], None] = _time.sleep,
|
|
136
|
+
) -> requests.Response:
|
|
137
|
+
"""``method`` ``url`` with bounded backoff + full jitter on the transient
|
|
138
|
+
class, returning the final :class:`requests.Response`.
|
|
139
|
+
|
|
140
|
+
Retries:
|
|
141
|
+
* responses whose status is in ``transient_status`` (default 429 + 5xx),
|
|
142
|
+
honoring ``Retry-After`` when ``honor_retry_after``; and
|
|
143
|
+
* (when ``retry_network``) ``requests.Timeout`` / ``ConnectionError``.
|
|
144
|
+
|
|
145
|
+
Terminal behavior:
|
|
146
|
+
* a transient-status response that survives ``max_attempts`` is
|
|
147
|
+
**returned** — the caller decides whether to ``raise_for_status`` or
|
|
148
|
+
special-case it (e.g. a 403, which is NOT in the transient set, is
|
|
149
|
+
returned immediately for the caller to convert); and
|
|
150
|
+
* an exhausted NETWORK error (or a non-transient ``RequestException``
|
|
151
|
+
such as a bad URL) raises :class:`HttpRetryError` (scrubbed).
|
|
152
|
+
|
|
153
|
+
``scrub`` is applied to every error string logged or raised. ``session``
|
|
154
|
+
lets a caller reuse a session (e.g. one carrying auth query params).
|
|
155
|
+
``sleep`` is injectable for tests. ``max_attempts`` must be >= 1.
|
|
156
|
+
"""
|
|
157
|
+
if max_attempts < 1:
|
|
158
|
+
raise ValueError(f"max_attempts must be >= 1, got {max_attempts}")
|
|
159
|
+
log = logger or _DEFAULT_LOGGER
|
|
160
|
+
transient = frozenset(transient_status)
|
|
161
|
+
requester = (session or requests).request
|
|
162
|
+
resp: "requests.Response | None" = None
|
|
163
|
+
for attempt in range(max_attempts):
|
|
164
|
+
last = attempt == max_attempts - 1
|
|
165
|
+
try:
|
|
166
|
+
resp = requester(method, url, params=params or {}, timeout=timeout)
|
|
167
|
+
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
168
|
+
if not retry_network or last:
|
|
169
|
+
raise HttpRetryError(label, attempt + 1, exc) from exc
|
|
170
|
+
delay = backoff_delay(attempt, base=backoff_base, cap=backoff_cap)
|
|
171
|
+
log.warning(
|
|
172
|
+
"%s transient %s — backing off %.1fs (attempt %d/%d)",
|
|
173
|
+
label or url, type(exc).__name__, delay, attempt + 1, max_attempts,
|
|
174
|
+
)
|
|
175
|
+
sleep(delay)
|
|
176
|
+
continue
|
|
177
|
+
except requests.RequestException as exc:
|
|
178
|
+
# Non-transient (bad URL / too many redirects / invalid schema) —
|
|
179
|
+
# retrying a deterministic error is pointless; fail loud now.
|
|
180
|
+
raise HttpRetryError(label, attempt + 1, exc) from exc
|
|
181
|
+
|
|
182
|
+
if resp.status_code in transient and not last:
|
|
183
|
+
retry_after = resp.headers.get("Retry-After") if honor_retry_after else None
|
|
184
|
+
delay = backoff_delay(
|
|
185
|
+
attempt, base=backoff_base, cap=backoff_cap, retry_after=retry_after,
|
|
186
|
+
)
|
|
187
|
+
log.warning(
|
|
188
|
+
"%s HTTP %d — backing off %.1fs (attempt %d/%d)",
|
|
189
|
+
label or url, resp.status_code, delay, attempt + 1, max_attempts,
|
|
190
|
+
)
|
|
191
|
+
sleep(delay)
|
|
192
|
+
continue
|
|
193
|
+
return resp
|
|
194
|
+
|
|
195
|
+
# Loop exhausted on transient-status responses: return the last one for the
|
|
196
|
+
# caller to interpret (network exhaustion already raised above). resp is
|
|
197
|
+
# non-None because max_attempts >= 1 guarantees at least one assignment.
|
|
198
|
+
assert resp is not None
|
|
199
|
+
return resp
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Statistical evaluation utilities for signal/strategy quality assessment.
|
|
2
|
+
|
|
3
|
+
Pure-compute metrics consumed across the fleet (backtester, robodashboard) for
|
|
4
|
+
judging signal quality, strategy skill, and selection bias — no I/O. Import the
|
|
5
|
+
submodule you need (the package keeps no eager imports). Most need numpy+pandas;
|
|
6
|
+
``information_coefficient`` additionally uses scipy when present (with a numpy
|
|
7
|
+
fallback). Install ``alpha-engine-lib[quant-stats]``.
|
|
8
|
+
|
|
9
|
+
Modules:
|
|
10
|
+
- ``dsr`` — Probabilistic + Deflated Sharpe (López de Prado)
|
|
11
|
+
- ``information_coefficient`` — Spearman rank IC of conviction vs forward return
|
|
12
|
+
- ``expectancy`` — hit-rate × win/loss decomposition
|
|
13
|
+
- ``multiple_testing`` — Benjamini-Hochberg FDR correction
|
|
14
|
+
- ``risk_matched_benchmark`` — EW-high-vol + beta-matched-SPY baselines + IR
|
|
15
|
+
|
|
16
|
+
Example::
|
|
17
|
+
|
|
18
|
+
from alpha_engine_lib.quant.stats.dsr import compute_dsr
|
|
19
|
+
from alpha_engine_lib.quant.stats.multiple_testing import benjamini_hochberg
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""dsr — Probabilistic Sharpe Ratio (PSR) and Deflated Sharpe Ratio (DSR).
|
|
2
|
+
|
|
3
|
+
Confidence-adjusted Sharpe per López de Prado:
|
|
4
|
+
- PSR (Bailey & López de Prado 2012): probability that the *true* Sharpe
|
|
5
|
+
is above a benchmark, given the observed sample size + skew + kurtosis.
|
|
6
|
+
Answers "is this Sharpe distinguishable from the benchmark, given how
|
|
7
|
+
little data we have?"
|
|
8
|
+
- DSR (Bailey & López de Prado 2014): PSR with a multiple-testing
|
|
9
|
+
correction. The benchmark is set to the expected maximum Sharpe under
|
|
10
|
+
N independent trials, so DSR > 0.95 means "even after accounting for
|
|
11
|
+
cherry-picking from N candidates, this Sharpe is significant."
|
|
12
|
+
|
|
13
|
+
The promotion gate for any multiple-testing factory (param sweeps that
|
|
14
|
+
auto-promote the top-Sharpe combo): point-estimate Sharpe on a short sample
|
|
15
|
+
has a wide CI; DSR is what prevents promoting noise winners.
|
|
16
|
+
|
|
17
|
+
Mathematical reference:
|
|
18
|
+
Bailey & López de Prado (2012) "The Sharpe Ratio Efficient Frontier"
|
|
19
|
+
Bailey & López de Prado (2014) "The Deflated Sharpe Ratio: Correcting
|
|
20
|
+
for Selection Bias, Backtest Overfitting, and Non-Normality"
|
|
21
|
+
|
|
22
|
+
Pure-compute. Operates on a daily return series + sample-size metadata;
|
|
23
|
+
no I/O.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import math
|
|
30
|
+
from typing import TypedDict
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import pandas as pd
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_TRADING_DAYS_PER_YEAR = 252
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PSRResult(TypedDict, total=False):
|
|
41
|
+
status: str
|
|
42
|
+
n: int
|
|
43
|
+
sharpe: float # observed annualized Sharpe
|
|
44
|
+
sharpe_benchmark: float # benchmark Sharpe being tested against
|
|
45
|
+
psr: float # probability in [0, 1] that true SR > benchmark
|
|
46
|
+
skew: float
|
|
47
|
+
kurtosis: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DSRResult(TypedDict, total=False):
|
|
51
|
+
status: str
|
|
52
|
+
n: int
|
|
53
|
+
sharpe: float
|
|
54
|
+
n_trials: int # number of candidates considered (multiple-testing N)
|
|
55
|
+
sharpe_benchmark: float # implied benchmark from N_trials under H0: SR=0
|
|
56
|
+
dsr: float # probability that the true Sharpe survives selection bias
|
|
57
|
+
skew: float
|
|
58
|
+
kurtosis: float
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normal_cdf(x: float) -> float:
|
|
62
|
+
"""Standard normal CDF — pure-Python, no scipy dependency."""
|
|
63
|
+
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _annualized_sharpe(returns: np.ndarray) -> float:
|
|
67
|
+
"""Annualized Sharpe (risk-free = 0), sample-std (ddof=1)."""
|
|
68
|
+
if returns.size < 2:
|
|
69
|
+
return 0.0
|
|
70
|
+
mean = float(returns.mean())
|
|
71
|
+
std = float(returns.std(ddof=1))
|
|
72
|
+
if std == 0.0:
|
|
73
|
+
return 0.0
|
|
74
|
+
return mean / std * math.sqrt(_TRADING_DAYS_PER_YEAR)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sample_skew_kurtosis(returns: np.ndarray) -> tuple[float, float]:
|
|
78
|
+
"""Sample skewness and excess kurtosis. Pearson-style; scipy-equivalent.
|
|
79
|
+
|
|
80
|
+
Excess kurtosis = K - 3 (so a normal has 0 excess kurtosis).
|
|
81
|
+
Returns (0, 0) on insufficient sample.
|
|
82
|
+
"""
|
|
83
|
+
n = returns.size
|
|
84
|
+
if n < 4:
|
|
85
|
+
return 0.0, 0.0
|
|
86
|
+
mean = returns.mean()
|
|
87
|
+
centered = returns - mean
|
|
88
|
+
var = float((centered * centered).mean())
|
|
89
|
+
if var == 0.0:
|
|
90
|
+
return 0.0, 0.0
|
|
91
|
+
std = math.sqrt(var)
|
|
92
|
+
skew = float((centered ** 3).mean() / (std ** 3))
|
|
93
|
+
kurt_excess = float((centered ** 4).mean() / (var * var)) - 3.0
|
|
94
|
+
return skew, kurt_excess
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def compute_psr(
|
|
98
|
+
daily_returns: pd.Series | np.ndarray,
|
|
99
|
+
sharpe_benchmark: float = 0.0,
|
|
100
|
+
) -> PSRResult:
|
|
101
|
+
"""Probabilistic Sharpe Ratio.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
daily_returns : array-like
|
|
106
|
+
Daily simple returns. NaN dropped.
|
|
107
|
+
sharpe_benchmark : float
|
|
108
|
+
Annualized Sharpe to test against (default 0.0, i.e. "is the
|
|
109
|
+
true SR positive?").
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
PSRResult dict with:
|
|
114
|
+
status: "ok" | "insufficient_data"
|
|
115
|
+
n: sample size
|
|
116
|
+
sharpe: observed annualized SR
|
|
117
|
+
sharpe_benchmark: as input
|
|
118
|
+
psr: probability that true SR > benchmark
|
|
119
|
+
skew, kurtosis: moments of the return series
|
|
120
|
+
|
|
121
|
+
Formula (Bailey & López de Prado 2012):
|
|
122
|
+
PSR(SR*) = Phi( (SR_hat - SR*) * sqrt(n - 1)
|
|
123
|
+
/ sqrt(1 - skew * SR_hat + (kurtosis - 1)/4 * SR_hat^2) )
|
|
124
|
+
|
|
125
|
+
where SR_hat is the *non-annualized* observed Sharpe and SR* is the
|
|
126
|
+
benchmark on the same scale. We compute on daily Sharpe internally
|
|
127
|
+
and convert benchmarks accordingly.
|
|
128
|
+
"""
|
|
129
|
+
r = np.asarray(daily_returns, dtype=np.float64)
|
|
130
|
+
r = r[np.isfinite(r)]
|
|
131
|
+
n = r.size
|
|
132
|
+
if n < 30: # PSR is asymptotic; small samples produce nonsense
|
|
133
|
+
return {"status": "insufficient_data", "n": n}
|
|
134
|
+
|
|
135
|
+
sr_annualized = _annualized_sharpe(r)
|
|
136
|
+
# PSR formula uses the daily SR. Convert annualized benchmark back to daily.
|
|
137
|
+
sr_daily = sr_annualized / math.sqrt(_TRADING_DAYS_PER_YEAR)
|
|
138
|
+
sr_bench_daily = sharpe_benchmark / math.sqrt(_TRADING_DAYS_PER_YEAR)
|
|
139
|
+
|
|
140
|
+
skew, kurt_excess = _sample_skew_kurtosis(r)
|
|
141
|
+
# The "kurtosis" term in López de Prado's formula is the raw 4th
|
|
142
|
+
# moment / variance^2 (so 3.0 for a normal); we have excess kurtosis.
|
|
143
|
+
kurt_raw = kurt_excess + 3.0
|
|
144
|
+
|
|
145
|
+
denom_sq = 1.0 - skew * sr_daily + (kurt_raw - 1.0) / 4.0 * sr_daily ** 2
|
|
146
|
+
if denom_sq <= 0.0:
|
|
147
|
+
# Pathological skew/kurtosis combo; PSR formula breaks down.
|
|
148
|
+
return {
|
|
149
|
+
"status": "ok",
|
|
150
|
+
"n": n,
|
|
151
|
+
"sharpe": sr_annualized,
|
|
152
|
+
"sharpe_benchmark": sharpe_benchmark,
|
|
153
|
+
"psr": 0.5, # max-uncertainty fallback
|
|
154
|
+
"skew": skew,
|
|
155
|
+
"kurtosis": kurt_excess,
|
|
156
|
+
}
|
|
157
|
+
z = (sr_daily - sr_bench_daily) * math.sqrt(n - 1) / math.sqrt(denom_sq)
|
|
158
|
+
psr = _normal_cdf(z)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"status": "ok",
|
|
162
|
+
"n": n,
|
|
163
|
+
"sharpe": sr_annualized,
|
|
164
|
+
"sharpe_benchmark": sharpe_benchmark,
|
|
165
|
+
"psr": float(psr),
|
|
166
|
+
"skew": skew,
|
|
167
|
+
"kurtosis": kurt_excess,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_EULER_MASCHERONI = 0.5772156649015329
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def compute_dsr(
|
|
175
|
+
daily_returns: pd.Series | np.ndarray,
|
|
176
|
+
n_trials: int,
|
|
177
|
+
) -> DSRResult:
|
|
178
|
+
"""Deflated Sharpe Ratio.
|
|
179
|
+
|
|
180
|
+
Corrects PSR for the selection bias of choosing the maximum Sharpe
|
|
181
|
+
from ``n_trials`` candidates. The benchmark Sharpe is set to the
|
|
182
|
+
expected maximum SR under the null hypothesis (true SR = 0 for all
|
|
183
|
+
candidates), accounting for sample size + sample moments.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
daily_returns : array-like
|
|
188
|
+
Daily returns of the *winner* (the candidate selected as best).
|
|
189
|
+
n_trials : int
|
|
190
|
+
Number of candidates considered when selecting this winner. For
|
|
191
|
+
a 60-combo param sweep, n_trials = 60. Must be >= 1.
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
DSRResult dict with:
|
|
196
|
+
status, n, sharpe, n_trials, sharpe_benchmark, dsr, skew, kurtosis
|
|
197
|
+
|
|
198
|
+
Formula (Bailey & López de Prado 2014, Theorem 1):
|
|
199
|
+
E[max(SR)] ≈ V * (sqrt(2 ln N) - (gamma + ln ln N) / (2 sqrt(2 ln N)))
|
|
200
|
+
where V is the standard deviation of estimated SRs across trials and
|
|
201
|
+
gamma is Euler-Mascheroni. We approximate V with the sampling std of
|
|
202
|
+
SR_hat = sqrt((1 - skew*SR + (k-1)/4 * SR^2) / (n - 1)) on the winner.
|
|
203
|
+
|
|
204
|
+
DSR = PSR(SR_hat | benchmark = E[max(SR_null)]).
|
|
205
|
+
|
|
206
|
+
Notes
|
|
207
|
+
-----
|
|
208
|
+
- n_trials = 1 reduces to PSR(0) — no selection correction needed.
|
|
209
|
+
- For very high n_trials (>1000) the asymptotic expansion above is
|
|
210
|
+
adequate; for small n (< 5) it overstates the threshold slightly,
|
|
211
|
+
which is the conservative direction (harder to clear) — fine for
|
|
212
|
+
a promotion gate.
|
|
213
|
+
"""
|
|
214
|
+
if n_trials < 1:
|
|
215
|
+
raise ValueError(f"n_trials must be >= 1, got {n_trials}")
|
|
216
|
+
|
|
217
|
+
r = np.asarray(daily_returns, dtype=np.float64)
|
|
218
|
+
r = r[np.isfinite(r)]
|
|
219
|
+
n = r.size
|
|
220
|
+
if n < 30:
|
|
221
|
+
return {"status": "insufficient_data", "n": n, "n_trials": n_trials}
|
|
222
|
+
|
|
223
|
+
if n_trials == 1:
|
|
224
|
+
# No selection bias correction needed; reduce to PSR(0).
|
|
225
|
+
psr_result = compute_psr(r, sharpe_benchmark=0.0)
|
|
226
|
+
return {
|
|
227
|
+
"status": psr_result["status"],
|
|
228
|
+
"n": n,
|
|
229
|
+
"sharpe": psr_result.get("sharpe", 0.0),
|
|
230
|
+
"n_trials": 1,
|
|
231
|
+
"sharpe_benchmark": 0.0,
|
|
232
|
+
"dsr": psr_result.get("psr", 0.5),
|
|
233
|
+
"skew": psr_result.get("skew", 0.0),
|
|
234
|
+
"kurtosis": psr_result.get("kurtosis", 0.0),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
sr_annualized = _annualized_sharpe(r)
|
|
238
|
+
sr_daily = sr_annualized / math.sqrt(_TRADING_DAYS_PER_YEAR)
|
|
239
|
+
skew, kurt_excess = _sample_skew_kurtosis(r)
|
|
240
|
+
kurt_raw = kurt_excess + 3.0
|
|
241
|
+
|
|
242
|
+
# Sampling std of SR_hat (per López de Prado eq. 5).
|
|
243
|
+
var_sr_sq = (1.0 - skew * sr_daily + (kurt_raw - 1.0) / 4.0 * sr_daily ** 2) / (n - 1)
|
|
244
|
+
if var_sr_sq <= 0.0:
|
|
245
|
+
return {
|
|
246
|
+
"status": "ok",
|
|
247
|
+
"n": n,
|
|
248
|
+
"sharpe": sr_annualized,
|
|
249
|
+
"n_trials": n_trials,
|
|
250
|
+
"sharpe_benchmark": 0.0,
|
|
251
|
+
"dsr": 0.5,
|
|
252
|
+
"skew": skew,
|
|
253
|
+
"kurtosis": kurt_excess,
|
|
254
|
+
}
|
|
255
|
+
v = math.sqrt(var_sr_sq)
|
|
256
|
+
|
|
257
|
+
# Expected max SR under the null, in daily SR units.
|
|
258
|
+
ln_n = math.log(n_trials)
|
|
259
|
+
sqrt_2_ln_n = math.sqrt(2.0 * ln_n)
|
|
260
|
+
if n_trials > 1:
|
|
261
|
+
ln_ln_n = math.log(ln_n) if ln_n > 0 else 0.0
|
|
262
|
+
else:
|
|
263
|
+
ln_ln_n = 0.0
|
|
264
|
+
expected_max_sr_daily = v * (sqrt_2_ln_n - (_EULER_MASCHERONI + ln_ln_n) / (2.0 * sqrt_2_ln_n))
|
|
265
|
+
expected_max_sr_annualized = expected_max_sr_daily * math.sqrt(_TRADING_DAYS_PER_YEAR)
|
|
266
|
+
|
|
267
|
+
psr_result = compute_psr(r, sharpe_benchmark=expected_max_sr_annualized)
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"status": psr_result["status"],
|
|
271
|
+
"n": n,
|
|
272
|
+
"sharpe": sr_annualized,
|
|
273
|
+
"n_trials": n_trials,
|
|
274
|
+
"sharpe_benchmark": expected_max_sr_annualized,
|
|
275
|
+
"dsr": psr_result.get("psr", 0.5),
|
|
276
|
+
"skew": skew,
|
|
277
|
+
"kurtosis": kurt_excess,
|
|
278
|
+
}
|