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.
Files changed (108) hide show
  1. {alpha_engine_lib-0.47.0/src/alpha_engine_lib.egg-info → alpha_engine_lib-0.49.0}/PKG-INFO +11 -2
  2. alpha_engine_lib-0.47.0/PKG-INFO → alpha_engine_lib-0.49.0/README.md +5 -35
  3. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/pyproject.toml +6 -2
  4. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/__init__.py +1 -1
  5. alpha_engine_lib-0.49.0/src/alpha_engine_lib/http_retry.py +199 -0
  6. alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/__init__.py +22 -0
  7. alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/dsr.py +278 -0
  8. alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/expectancy.py +161 -0
  9. alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/information_coefficient.py +149 -0
  10. alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/multiple_testing.py +48 -0
  11. alpha_engine_lib-0.49.0/src/alpha_engine_lib/quant/stats/risk_matched_benchmark.py +305 -0
  12. alpha_engine_lib-0.47.0/README.md → alpha_engine_lib-0.49.0/src/alpha_engine_lib.egg-info/PKG-INFO +44 -0
  13. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +13 -0
  14. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/requires.txt +5 -0
  15. alpha_engine_lib-0.49.0/tests/test_http_retry.py +199 -0
  16. alpha_engine_lib-0.49.0/tests/test_quant_stats_dsr.py +95 -0
  17. alpha_engine_lib-0.49.0/tests/test_quant_stats_expectancy.py +102 -0
  18. alpha_engine_lib-0.49.0/tests/test_quant_stats_information_coefficient.py +100 -0
  19. alpha_engine_lib-0.49.0/tests/test_quant_stats_multiple_testing.py +42 -0
  20. alpha_engine_lib-0.49.0/tests/test_quant_stats_risk_matched_benchmark.py +140 -0
  21. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/setup.cfg +0 -0
  22. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
  23. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/alerts.py +0 -0
  24. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
  25. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/arcticdb.py +0 -0
  26. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
  27. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/collector_results.py +0 -0
  28. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/cost.py +0 -0
  29. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/dates.py +0 -0
  30. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/decision_capture.py +0 -0
  31. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
  32. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/email_sender.py +0 -0
  33. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  34. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/locks.py +0 -0
  35. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/logging.py +0 -0
  36. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  37. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pillars.py +0 -0
  38. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  39. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
  40. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/registry.py +0 -0
  41. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  42. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/preflight.py +0 -0
  43. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/__init__.py +0 -0
  44. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/attribution.py +0 -0
  45. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/factor_risk.py +0 -0
  46. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/factor_risk_xs.py +0 -0
  47. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/returns.py +0 -0
  48. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/risk_measures.py +0 -0
  49. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/quant/riskstats.py +0 -0
  50. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
  51. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/db.py +0 -0
  52. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  53. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  54. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
  55. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  56. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
  57. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/reconcile.py +0 -0
  58. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/secrets.py +0 -0
  59. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
  60. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
  61. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  62. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  63. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/telegram.py +0 -0
  64. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
  65. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/transparency.py +0 -0
  66. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  67. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib/universe.py +0 -0
  68. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  69. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  70. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_agent_schemas.py +0 -0
  71. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_alerts.py +0 -0
  72. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_anthropic_payload.py +0 -0
  73. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_arcticdb.py +0 -0
  74. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_artifact_freshness.py +0 -0
  75. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_collector_results.py +0 -0
  76. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_cost.py +0 -0
  77. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_dates.py +0 -0
  78. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_decision_capture.py +0 -0
  79. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_ec2_spot.py +0 -0
  80. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_email_sender.py +0 -0
  81. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_eval_artifacts.py +0 -0
  82. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_locks.py +0 -0
  83. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_logging.py +0 -0
  84. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pillars.py +0 -0
  85. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pipeline_status_read.py +0 -0
  86. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pipeline_status_registry.py +0 -0
  87. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_pipeline_status_templates.py +0 -0
  88. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_preflight.py +0 -0
  89. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_attribution.py +0 -0
  90. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_factor_risk.py +0 -0
  91. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_factor_risk_xs.py +0 -0
  92. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_returns.py +0 -0
  93. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_risk_measures.py +0 -0
  94. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_quant_riskstats.py +0 -0
  95. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_rag.py +0 -0
  96. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_rag_rerank.py +0 -0
  97. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_rag_retrieval_hybrid.py +0 -0
  98. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_reconcile.py +0 -0
  99. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_secrets.py +0 -0
  100. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_sources_protocols.py +0 -0
  101. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_ssm_dispatcher.py +0 -0
  102. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_ssm_log_capture.py +0 -0
  103. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_telegram.py +0 -0
  104. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_trading_calendar.py +0 -0
  105. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_transparency.py +0 -0
  106. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_universe.py +0 -0
  107. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.49.0}/tests/test_version_bump_workflow.py +0 -0
  108. {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.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.
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.47.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, and S3-conditional-PUT writer locks. Full surface documented in README."
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",
@@ -1,3 +1,3 @@
1
1
  """alpha-engine-lib — shared utilities for Alpha Engine modules."""
2
2
 
3
- __version__ = "0.47.0"
3
+ __version__ = "0.49.0"
@@ -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
+ }