alpha-engine-lib 0.50.0__tar.gz → 0.52.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 (114) hide show
  1. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/PKG-INFO +1 -1
  2. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/pyproject.toml +1 -1
  3. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/__init__.py +1 -1
  4. alpha_engine_lib-0.52.0/src/alpha_engine_lib/metrics.py +202 -0
  5. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/factor_risk_xs.py +9 -2
  6. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/__init__.py +2 -0
  7. alpha_engine_lib-0.52.0/src/alpha_engine_lib/quant/stats/intervals.py +220 -0
  8. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib.egg-info/PKG-INFO +1 -1
  9. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +4 -0
  10. alpha_engine_lib-0.52.0/tests/test_metrics.py +122 -0
  11. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_factor_risk_xs.py +12 -0
  12. alpha_engine_lib-0.52.0/tests/test_quant_stats_intervals.py +89 -0
  13. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/README.md +0 -0
  14. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/setup.cfg +0 -0
  15. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
  16. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/alerts.py +0 -0
  17. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
  18. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/arcticdb.py +0 -0
  19. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
  20. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/collector_results.py +0 -0
  21. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/cost.py +0 -0
  22. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/dates.py +0 -0
  23. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/decision_capture.py +0 -0
  24. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
  25. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/email_sender.py +0 -0
  26. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  27. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/http_retry.py +0 -0
  28. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/locks.py +0 -0
  29. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/logging.py +0 -0
  30. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  31. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/pillars.py +0 -0
  32. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  33. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
  34. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/pipeline_status/registry.py +0 -0
  35. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  36. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/preflight.py +0 -0
  37. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/__init__.py +0 -0
  38. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/attribution.py +0 -0
  39. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/factor_risk.py +0 -0
  40. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/returns.py +0 -0
  41. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/risk_measures.py +0 -0
  42. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/riskstats.py +0 -0
  43. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/dsr.py +0 -0
  44. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/expectancy.py +0 -0
  45. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/information_coefficient.py +0 -0
  46. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/multiple_testing.py +0 -0
  47. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/regime_sortino.py +0 -0
  48. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/quant/stats/risk_matched_benchmark.py +0 -0
  49. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
  50. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/db.py +0 -0
  51. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  52. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  53. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
  54. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  55. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
  56. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/reconcile.py +0 -0
  57. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/secrets.py +0 -0
  58. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
  59. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
  60. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  61. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  62. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/telegram.py +0 -0
  63. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
  64. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/transparency.py +0 -0
  65. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  66. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib/universe.py +0 -0
  67. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  68. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib.egg-info/requires.txt +0 -0
  69. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  70. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_agent_schemas.py +0 -0
  71. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_alerts.py +0 -0
  72. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_anthropic_payload.py +0 -0
  73. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_arcticdb.py +0 -0
  74. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_artifact_freshness.py +0 -0
  75. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_collector_results.py +0 -0
  76. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_cost.py +0 -0
  77. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_dates.py +0 -0
  78. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_decision_capture.py +0 -0
  79. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_ec2_spot.py +0 -0
  80. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_email_sender.py +0 -0
  81. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_eval_artifacts.py +0 -0
  82. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_http_retry.py +0 -0
  83. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_locks.py +0 -0
  84. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_logging.py +0 -0
  85. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_pillars.py +0 -0
  86. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_pipeline_status_read.py +0 -0
  87. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_pipeline_status_registry.py +0 -0
  88. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_pipeline_status_templates.py +0 -0
  89. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_preflight.py +0 -0
  90. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_attribution.py +0 -0
  91. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_factor_risk.py +0 -0
  92. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_returns.py +0 -0
  93. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_risk_measures.py +0 -0
  94. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_riskstats.py +0 -0
  95. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_stats_dsr.py +0 -0
  96. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_stats_expectancy.py +0 -0
  97. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_stats_information_coefficient.py +0 -0
  98. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_stats_multiple_testing.py +0 -0
  99. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_stats_regime_sortino.py +0 -0
  100. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_quant_stats_risk_matched_benchmark.py +0 -0
  101. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_rag.py +0 -0
  102. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_rag_rerank.py +0 -0
  103. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_rag_retrieval_hybrid.py +0 -0
  104. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_reconcile.py +0 -0
  105. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_secrets.py +0 -0
  106. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_sources_protocols.py +0 -0
  107. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_ssm_dispatcher.py +0 -0
  108. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_ssm_log_capture.py +0 -0
  109. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_telegram.py +0 -0
  110. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_trading_calendar.py +0 -0
  111. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_transparency.py +0 -0
  112. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_universe.py +0 -0
  113. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.0}/tests/test_version_bump_workflow.py +0 -0
  114. {alpha_engine_lib-0.50.0 → alpha_engine_lib-0.52.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.50.0
3
+ Version: 0.52.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, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpha-engine-lib"
7
- version = "0.50.0"
7
+ version = "0.52.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, 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
@@ -1,3 +1,3 @@
1
1
  """alpha-engine-lib — shared utilities for Alpha Engine modules."""
2
2
 
3
- __version__ = "0.50.0"
3
+ __version__ = "0.52.0"
@@ -0,0 +1,202 @@
1
+ """metrics — the System Report Card v2 ``MetricRecord`` contract + status derivation.
2
+
3
+ A ``MetricRecord`` is the unit of the v2 report card: every graded component
4
+ (research / predictor / executor / backtester / substrate / agent / portfolio)
5
+ emits one, carrying not just a value but its statistical context — CI, sample
6
+ size vs floor, target, red-line, trend, and a derived status/letter. The letter
7
+ is *derived* from the status, never the source of truth (RC v2 Principle 2).
8
+
9
+ This module is the shared chokepoint: the producer (the evaluator's grading
10
+ layer) and every consumer (dashboard console, public site) agree on the schema
11
+ AND on the status semantics via the pure ``derive_*`` helpers here — so the same
12
+ ``(value, CI, N)`` maps to the same GREEN/WATCH/RED everywhere.
13
+
14
+ The N/A taxonomy distinguishes the four engineering states that the legacy
15
+ "insufficient data" string conflated:
16
+ - ``N/A-NOT-IMPL`` grader exists, producer analysis not yet wired
17
+ - ``N/A-NOT-RUN`` producer implemented but did not run this cycle
18
+ - ``N/A-LOW-N`` ran, but N below half the floor — CI too wide to read
19
+ - ``N/A-MISSING-INPUT``ran, but a required upstream artifact was absent
20
+
21
+ Authoritative design: ``alpha-engine-docs/private/system-report-card-revamp-260522.md``.
22
+ Module-level aggregation (critical-gate module roll-up, BH-FDR over a tile's
23
+ component family) lives in the evaluator, not here — this is the per-component
24
+ contract only.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from datetime import datetime
30
+ from typing import Literal
31
+
32
+ from pydantic import BaseModel, ConfigDict, Field
33
+
34
+ StatusLiteral = Literal[
35
+ "GREEN",
36
+ "WATCH",
37
+ "RED",
38
+ "N/A-NOT-IMPL",
39
+ "N/A-NOT-RUN",
40
+ "N/A-LOW-N",
41
+ "N/A-MISSING-INPUT",
42
+ ]
43
+ CriticalityLiteral = Literal["critical", "supporting", "diagnostic"]
44
+ MetricTypeLiteral = Literal[
45
+ "ic", "lift", "ratio", "pct", "count", "duration",
46
+ "sharpe", "calibration", "p_value", "zscore", "log_return",
47
+ ]
48
+ TrendDecorationLiteral = Literal["↑↑", "↑", "→", "↓", "↓↓"]
49
+
50
+ _NA_PREFIX = "N/A"
51
+
52
+
53
+ class MetricRecord(BaseModel):
54
+ """One graded component of the System Report Card v2.
55
+
56
+ ``extra="allow"`` for forward-compat: a newer producer may add fields a
57
+ older consumer hasn't learned yet without breaking the read.
58
+ """
59
+
60
+ model_config = ConfigDict(extra="allow")
61
+
62
+ name: str = Field(description="snake_case stable key, e.g. 'predictor_meta_l2_ic'.")
63
+ module: str = Field(
64
+ description="Owning tile: portfolio | research | predictor | executor | "
65
+ "backtester | substrate | agent."
66
+ )
67
+ metric_type: MetricTypeLiteral
68
+ value: float | None = Field(default=None, description="Measured value (None when N/A-*).")
69
+ ci_low: float | None = Field(default=None)
70
+ ci_high: float | None = Field(default=None)
71
+ ci_method: str | None = Field(
72
+ default=None, description="e.g. 'bootstrap', 'newey-west', 'wilson'."
73
+ )
74
+ n_samples: int | None = Field(default=None, description="Observations behind the value.")
75
+ n_floor: int = Field(description="Documented minimum N for a confident reading.")
76
+ target: float | None = Field(default=None, description="At/beyond here = good.")
77
+ red_line: float | None = Field(default=None, description="At/beyond here = system-breaking.")
78
+ trend_4w: list[float] | None = Field(default=None)
79
+ trend_13w: list[float] | None = Field(default=None)
80
+ trend_decoration: TrendDecorationLiteral = Field(default="→")
81
+ status: StatusLiteral
82
+ status_reason: str = Field(description="One operator-readable sentence; never generic.")
83
+ criticality: CriticalityLiteral = Field(default="supporting")
84
+ source_path: str = Field(description="S3 URI / SQLite path / artifact this was read from.")
85
+ bh_fdr_adjusted_p: float | None = Field(default=None)
86
+ last_updated_utc: datetime
87
+ derived_letter: str = Field(default="N/A", description="Summary letter; derived from status+value.")
88
+
89
+ @property
90
+ def is_na(self) -> bool:
91
+ return self.status.startswith(_NA_PREFIX)
92
+
93
+
94
+ def derive_trend_decoration(
95
+ values: list[float] | None,
96
+ *,
97
+ higher_is_better: bool = True,
98
+ ) -> TrendDecorationLiteral:
99
+ """Map a rolling value window to a trend glyph (RC v2 Principle 5).
100
+
101
+ Looks at the last four points: ``↑↑`` if all 3 steps improved, ``↑`` if the
102
+ most recent 2 of those steps improved, ``↓↓``/``↓`` mirrored, else ``→``.
103
+ Improvement is "increase" when ``higher_is_better`` else "decrease".
104
+ """
105
+ if not values or len(values) < 2:
106
+ return "→"
107
+ window = values[-4:]
108
+ steps = [b - a for a, b in zip(window, window[1:])]
109
+ if not higher_is_better:
110
+ steps = [-s for s in steps]
111
+
112
+ eps = 1e-12
113
+ ups = sum(1 for s in steps if s > eps)
114
+ downs = sum(1 for s in steps if s < -eps)
115
+ recent2 = steps[-2:]
116
+
117
+ if len(steps) >= 3 and ups == len(steps):
118
+ return "↑↑"
119
+ if len(steps) >= 3 and downs == len(steps):
120
+ return "↓↓"
121
+ if sum(1 for s in recent2 if s > eps) == len(recent2) and ups >= downs:
122
+ return "↑"
123
+ if sum(1 for s in recent2 if s < -eps) == len(recent2) and downs >= ups:
124
+ return "↓"
125
+ return "→"
126
+
127
+
128
+ def derive_letter(status: StatusLiteral) -> str:
129
+ """Project a status onto the summary letter band (RC v2 Principle 2).
130
+
131
+ The letter is a display convenience only — ``status`` + ``value`` are the
132
+ source of truth. Any ``N/A-*`` projects to ``"N/A"``.
133
+ """
134
+ if status.startswith(_NA_PREFIX):
135
+ return "N/A"
136
+ return {"GREEN": "A", "WATCH": "C", "RED": "F"}[status]
137
+
138
+
139
+ def derive_status(
140
+ *,
141
+ value: float | None,
142
+ n_samples: int | None,
143
+ n_floor: int,
144
+ target: float | None = None,
145
+ red_line: float | None = None,
146
+ ci_low: float | None = None,
147
+ ci_high: float | None = None,
148
+ implemented: bool = True,
149
+ ran: bool = True,
150
+ input_present: bool = True,
151
+ ) -> StatusLiteral:
152
+ """Derive the GREEN/WATCH/RED/``N/A-*`` status for one component.
153
+
154
+ Encodes RC v2 Principles 2 (status taxonomy) and 6 (sample-size discipline)
155
+ so producer and consumers agree. Direction is inferred from the
156
+ ``target``/``red_line`` ordering: ``target >= red_line`` ⇒ higher-is-better,
157
+ else lower-is-better (e.g. max-drawdown, ECE).
158
+
159
+ The four N/A conditions take precedence in order: not-implemented →
160
+ not-run → missing-input → low-N. Above the floor, status follows the value
161
+ and (when provided) the confidence interval relative to target/red-line.
162
+ """
163
+ if not implemented:
164
+ return "N/A-NOT-IMPL"
165
+ if not ran:
166
+ return "N/A-NOT-RUN"
167
+ if not input_present:
168
+ return "N/A-MISSING-INPUT"
169
+ if value is None or n_samples is None or n_samples < 0.5 * n_floor:
170
+ return "N/A-LOW-N"
171
+
172
+ higher_is_better = target is None or red_line is None or target >= red_line
173
+
174
+ def _at_or_better(a: float, b: float) -> bool:
175
+ return a >= b if higher_is_better else a <= b
176
+
177
+ def _at_or_worse(a: float, b: float) -> bool:
178
+ return a <= b if higher_is_better else a >= b
179
+
180
+ # RED: value at/below the red-line, or the CI sits entirely on the bad side.
181
+ if red_line is not None:
182
+ if _at_or_worse(value, red_line):
183
+ return "RED"
184
+ bad_bound = ci_low if higher_is_better else ci_high
185
+ if bad_bound is not None and _at_or_worse(bad_bound, red_line):
186
+ return "RED"
187
+
188
+ # Between half-floor and floor: CI too wide to claim GREEN regardless.
189
+ if n_samples < n_floor:
190
+ return "WATCH"
191
+
192
+ if target is not None:
193
+ if _at_or_better(value, target):
194
+ # GREEN needs the whole CI clear of the red-line (when both known).
195
+ good_bound = ci_low if higher_is_better else ci_high
196
+ if red_line is not None and good_bound is not None and _at_or_worse(good_bound, red_line):
197
+ return "WATCH"
198
+ return "GREEN"
199
+ return "WATCH"
200
+
201
+ # No target given: above red-line with adequate N ⇒ GREEN.
202
+ return "GREEN"
@@ -235,8 +235,15 @@ def estimate_factor_covariance(
235
235
  return pd.DataFrame(np.full((K, K), np.nan), index=cols, columns=cols)
236
236
 
237
237
  if shrinkage == "ledoit_wolf":
238
- from sklearn.covariance import LedoitWolf
239
- F = LedoitWolf().fit(clean.to_numpy()).covariance_
238
+ # Single shared Ledoit-Wolf estimator (LV1-AE.a, 2026-06-03). The numpy
239
+ # ``quant.factor_risk.ledoit_wolf_cov`` is numerically identical to
240
+ # sklearn's ``LedoitWolf`` (max abs diff ~1e-21, validated across
241
+ # n∈[35,1000]) — both center the data and estimate the same shrinkage
242
+ # intensity toward a scaled-identity target. Consolidating onto the numpy
243
+ # impl kills the duplicate reimplementation; sklearn stays a lazy import
244
+ # for OAS only.
245
+ from .factor_risk import ledoit_wolf_cov
246
+ F = ledoit_wolf_cov(clean.to_numpy(), shrinkage="ledoit_wolf")
240
247
  elif shrinkage == "oas":
241
248
  from sklearn.covariance import OAS
242
249
  F = OAS().fit(clean.to_numpy()).covariance_
@@ -11,6 +11,7 @@ Modules:
11
11
  - ``information_coefficient`` — Spearman rank IC of conviction vs forward return
12
12
  - ``expectancy`` — hit-rate × win/loss decomposition
13
13
  - ``multiple_testing`` — Benjamini-Hochberg FDR correction
14
+ - ``intervals`` — bootstrap CI, Newey-West SE, Wilson score interval
14
15
  - ``risk_matched_benchmark`` — EW-high-vol + beta-matched-SPY baselines + IR
15
16
  - ``regime_sortino`` — regime-stratified cross-sectional pick-alpha Sortino
16
17
 
@@ -18,6 +19,7 @@ Example::
18
19
 
19
20
  from alpha_engine_lib.quant.stats.dsr import compute_dsr
20
21
  from alpha_engine_lib.quant.stats.multiple_testing import benjamini_hochberg
22
+ from alpha_engine_lib.quant.stats.intervals import bootstrap_ci, wilson_score_interval
21
23
  """
22
24
 
23
25
  from __future__ import annotations
@@ -0,0 +1,220 @@
1
+ """intervals — Bootstrap confidence intervals, Newey-West SE, Wilson score intervals.
2
+
3
+ The three inference primitives the System Report Card v2 metric records require
4
+ (``MetricRecord.ci_method`` ∈ {``bootstrap``, ``newey-west``, ``wilson``}):
5
+
6
+ - ``bootstrap_ci`` — percentile bootstrap CI for any statistic of a
7
+ sample (default the mean). The general-purpose
8
+ CI for ICs, lifts, hit-rates, Sharpe point
9
+ estimates where no closed form is convenient.
10
+ - ``newey_west_se`` — heteroskedasticity-and-autocorrelation-consistent
11
+ (HAC) standard error of a series mean, for
12
+ autocorrelated daily P&L where the iid SE
13
+ understates uncertainty.
14
+ - ``wilson_score_interval`` — Wilson score binomial interval for rates with
15
+ small N (veto-gate precision/recall, hit-rate),
16
+ which the normal-approximation interval handles
17
+ badly near 0/1.
18
+
19
+ Pure-compute; no I/O. bootstrap + Newey-West need numpy (install
20
+ ``alpha-engine-lib[quant]``); Wilson is stdlib-only (``statistics.NormalDist``).
21
+
22
+ Reference: López de Prado, *Advances in Financial Machine Learning* (bootstrap
23
+ + HAC); Wilson (1927) "Probable Inference, the Law of Succession, and
24
+ Statistical Inference".
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from statistics import NormalDist
31
+ from typing import Callable, Sequence, TypedDict
32
+
33
+ import numpy as np
34
+
35
+ _DEFAULT_N_RESAMPLES = 1000
36
+
37
+
38
+ class BootstrapCIResult(TypedDict, total=False):
39
+ status: str # "ok" | "insufficient_data"
40
+ n: int # observations after NaN drop
41
+ estimate: float # statistic on the full sample
42
+ ci_low: float
43
+ ci_high: float
44
+ ci_level: float # e.g. 0.95
45
+ method: str # "bootstrap"
46
+ n_resamples: int
47
+
48
+
49
+ class NeweyWestResult(TypedDict, total=False):
50
+ status: str # "ok" | "insufficient_data"
51
+ n: int
52
+ estimate: float # sample mean
53
+ se: float # HAC standard error of the mean
54
+ lags: int # Bartlett-kernel lags used
55
+ method: str # "newey-west"
56
+
57
+
58
+ class WilsonScoreResult(TypedDict, total=False):
59
+ status: str # "ok" | "insufficient_data"
60
+ n: int # trials
61
+ successes: int
62
+ rate: float # successes / trials (point estimate)
63
+ estimate: float # alias of rate (uniform with the other results)
64
+ ci_low: float
65
+ ci_high: float
66
+ ci_level: float
67
+ method: str # "wilson"
68
+
69
+
70
+ def _clean(data: Sequence[float] | np.ndarray) -> np.ndarray:
71
+ """Coerce to a 1-D float array with NaN/inf dropped."""
72
+ arr = np.asarray(data, dtype=float).ravel()
73
+ return arr[np.isfinite(arr)]
74
+
75
+
76
+ def bootstrap_ci(
77
+ data: Sequence[float] | np.ndarray,
78
+ statistic: Callable[[np.ndarray], float] = np.mean,
79
+ *,
80
+ ci_level: float = 0.95,
81
+ n_resamples: int = _DEFAULT_N_RESAMPLES,
82
+ seed: int = 0,
83
+ ) -> BootstrapCIResult:
84
+ """Percentile bootstrap confidence interval for ``statistic`` of ``data``.
85
+
86
+ Args:
87
+ data: 1-D sample of observations (NaN/inf dropped).
88
+ statistic: Callable ``(np.ndarray) -> float`` to bootstrap. Defaults to
89
+ the mean.
90
+ ci_level: Confidence level in (0, 1) (default 0.95).
91
+ n_resamples: Number of bootstrap resamples (default 1000).
92
+ seed: RNG seed for reproducibility (the report card must be stable
93
+ across re-renders of the same cycle).
94
+
95
+ Returns:
96
+ A :class:`BootstrapCIResult`. ``status == "insufficient_data"`` when
97
+ fewer than 2 finite observations remain.
98
+ """
99
+ arr = _clean(data)
100
+ n = int(arr.size)
101
+ if n < 2:
102
+ return {"status": "insufficient_data", "n": n}
103
+
104
+ estimate = float(statistic(arr))
105
+ rng = np.random.default_rng(seed)
106
+ idx = rng.integers(0, n, size=(n_resamples, n))
107
+ boot = np.fromiter(
108
+ (statistic(arr[row]) for row in idx), dtype=float, count=n_resamples
109
+ )
110
+ boot = boot[np.isfinite(boot)]
111
+ if boot.size == 0:
112
+ return {"status": "insufficient_data", "n": n}
113
+
114
+ tail = (1.0 - ci_level) / 2.0
115
+ ci_low = float(np.percentile(boot, 100.0 * tail))
116
+ ci_high = float(np.percentile(boot, 100.0 * (1.0 - tail)))
117
+ return {
118
+ "status": "ok",
119
+ "n": n,
120
+ "estimate": estimate,
121
+ "ci_low": ci_low,
122
+ "ci_high": ci_high,
123
+ "ci_level": ci_level,
124
+ "method": "bootstrap",
125
+ "n_resamples": int(boot.size),
126
+ }
127
+
128
+
129
+ def _auto_lags(n: int) -> int:
130
+ """Newey-West (1994) automatic lag selection: floor(4·(n/100)^(2/9))."""
131
+ return int(math.floor(4.0 * (n / 100.0) ** (2.0 / 9.0)))
132
+
133
+
134
+ def newey_west_se(
135
+ series: Sequence[float] | np.ndarray,
136
+ *,
137
+ max_lags: int | None = None,
138
+ ) -> NeweyWestResult:
139
+ """HAC (Newey-West, Bartlett kernel) standard error of the series mean.
140
+
141
+ For autocorrelated series (daily P&L), the iid SE ``s/√n`` understates
142
+ uncertainty. The HAC estimator inflates the long-run variance by the
143
+ Bartlett-weighted autocovariances up to ``max_lags``.
144
+
145
+ Args:
146
+ series: 1-D series (NaN/inf dropped).
147
+ max_lags: Bartlett-kernel lag truncation. ``None`` ⇒ the Newey-West
148
+ (1994) rule ``floor(4·(n/100)^(2/9))``. Clamped to ``[0, n-1]``.
149
+
150
+ Returns:
151
+ A :class:`NeweyWestResult`. ``status == "insufficient_data"`` for n < 2.
152
+ """
153
+ x = _clean(series)
154
+ n = int(x.size)
155
+ if n < 2:
156
+ return {"status": "insufficient_data", "n": n}
157
+
158
+ lags = _auto_lags(n) if max_lags is None else int(max_lags)
159
+ lags = max(0, min(lags, n - 1))
160
+
161
+ e = x - x.mean()
162
+ gamma0 = float(np.dot(e, e) / n)
163
+ lrv = gamma0
164
+ for j in range(1, lags + 1):
165
+ weight = 1.0 - j / (lags + 1.0)
166
+ gamma_j = float(np.dot(e[j:], e[:-j]) / n)
167
+ lrv += 2.0 * weight * gamma_j
168
+ lrv = max(lrv, 0.0) # Bartlett kernel guarantees PSD; clamp float error.
169
+ se = math.sqrt(lrv / n)
170
+ return {
171
+ "status": "ok",
172
+ "n": n,
173
+ "estimate": float(x.mean()),
174
+ "se": se,
175
+ "lags": lags,
176
+ "method": "newey-west",
177
+ }
178
+
179
+
180
+ def wilson_score_interval(
181
+ successes: int,
182
+ trials: int,
183
+ *,
184
+ ci_level: float = 0.95,
185
+ ) -> WilsonScoreResult:
186
+ """Wilson score interval for a binomial proportion.
187
+
188
+ Preferred over the normal-approximation (Wald) interval for small ``trials``
189
+ or rates near 0/1, where Wald produces bounds outside [0, 1] and undercovers.
190
+
191
+ Args:
192
+ successes: Count of successes (0 ≤ successes ≤ trials).
193
+ trials: Total trials (> 0).
194
+ ci_level: Confidence level in (0, 1) (default 0.95).
195
+
196
+ Returns:
197
+ A :class:`WilsonScoreResult`. ``status == "insufficient_data"`` for
198
+ ``trials <= 0``. Bounds are clamped to [0, 1].
199
+ """
200
+ if trials <= 0:
201
+ return {"status": "insufficient_data", "n": int(max(trials, 0))}
202
+ successes = max(0, min(int(successes), int(trials)))
203
+
204
+ z = NormalDist().inv_cdf(1.0 - (1.0 - ci_level) / 2.0)
205
+ p = successes / trials
206
+ z2 = z * z
207
+ denom = 1.0 + z2 / trials
208
+ center = (p + z2 / (2.0 * trials)) / denom
209
+ margin = (z / denom) * math.sqrt(p * (1.0 - p) / trials + z2 / (4.0 * trials * trials))
210
+ return {
211
+ "status": "ok",
212
+ "n": int(trials),
213
+ "successes": int(successes),
214
+ "rate": p,
215
+ "estimate": p,
216
+ "ci_low": max(0.0, center - margin),
217
+ "ci_high": min(1.0, center + margin),
218
+ "ci_level": ci_level,
219
+ "method": "wilson",
220
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.50.0
3
+ Version: 0.52.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, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
@@ -16,6 +16,7 @@ src/alpha_engine_lib/eval_artifacts.py
16
16
  src/alpha_engine_lib/http_retry.py
17
17
  src/alpha_engine_lib/locks.py
18
18
  src/alpha_engine_lib/logging.py
19
+ src/alpha_engine_lib/metrics.py
19
20
  src/alpha_engine_lib/model_pricing.yaml
20
21
  src/alpha_engine_lib/pillars.py
21
22
  src/alpha_engine_lib/preflight.py
@@ -48,6 +49,7 @@ src/alpha_engine_lib/quant/stats/__init__.py
48
49
  src/alpha_engine_lib/quant/stats/dsr.py
49
50
  src/alpha_engine_lib/quant/stats/expectancy.py
50
51
  src/alpha_engine_lib/quant/stats/information_coefficient.py
52
+ src/alpha_engine_lib/quant/stats/intervals.py
51
53
  src/alpha_engine_lib/quant/stats/multiple_testing.py
52
54
  src/alpha_engine_lib/quant/stats/regime_sortino.py
53
55
  src/alpha_engine_lib/quant/stats/risk_matched_benchmark.py
@@ -75,6 +77,7 @@ tests/test_eval_artifacts.py
75
77
  tests/test_http_retry.py
76
78
  tests/test_locks.py
77
79
  tests/test_logging.py
80
+ tests/test_metrics.py
78
81
  tests/test_pillars.py
79
82
  tests/test_pipeline_status_read.py
80
83
  tests/test_pipeline_status_registry.py
@@ -89,6 +92,7 @@ tests/test_quant_riskstats.py
89
92
  tests/test_quant_stats_dsr.py
90
93
  tests/test_quant_stats_expectancy.py
91
94
  tests/test_quant_stats_information_coefficient.py
95
+ tests/test_quant_stats_intervals.py
92
96
  tests/test_quant_stats_multiple_testing.py
93
97
  tests/test_quant_stats_regime_sortino.py
94
98
  tests/test_quant_stats_risk_matched_benchmark.py
@@ -0,0 +1,122 @@
1
+ """Tests for alpha_engine_lib.metrics — MetricRecord contract + status derivation."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+
8
+ from alpha_engine_lib.metrics import (
9
+ MetricRecord,
10
+ derive_letter,
11
+ derive_status,
12
+ derive_trend_decoration,
13
+ )
14
+
15
+
16
+ def _record(**overrides):
17
+ base = dict(
18
+ name="predictor_meta_l2_ic",
19
+ module="predictor",
20
+ metric_type="ic",
21
+ n_floor=100,
22
+ status="GREEN",
23
+ status_reason="L2 IC 0.48 (CI [0.21,0.74], N=626) above target 0.05.",
24
+ source_path="s3://alpha-engine-research/predictor/metrics/latest.json#l2_ic",
25
+ last_updated_utc=datetime(2026, 6, 4, tzinfo=timezone.utc),
26
+ )
27
+ base.update(overrides)
28
+ return MetricRecord(**base)
29
+
30
+
31
+ class TestMetricRecord:
32
+ def test_minimal_valid_record(self):
33
+ r = _record(value=0.48, n_samples=626)
34
+ assert r.module == "predictor" and r.value == 0.48
35
+ assert r.trend_decoration == "→" # default
36
+ assert r.is_na is False
37
+
38
+ def test_na_status_flags_is_na(self):
39
+ assert _record(status="N/A-LOW-N").is_na is True
40
+
41
+ def test_extra_fields_allowed(self):
42
+ r = _record(value=0.1, n_samples=200, future_field="ok")
43
+ assert r.value == 0.1
44
+
45
+ def test_bad_status_rejected(self):
46
+ with pytest.raises(ValidationError):
47
+ _record(status="MAYBE")
48
+
49
+ def test_bad_metric_type_rejected(self):
50
+ with pytest.raises(ValidationError):
51
+ _record(metric_type="vibes")
52
+
53
+
54
+ class TestDeriveTrendDecoration:
55
+ def test_sustained_up_and_down(self):
56
+ assert derive_trend_decoration([1, 2, 3, 4]) == "↑↑"
57
+ assert derive_trend_decoration([4, 3, 2, 1]) == "↓↓"
58
+
59
+ def test_flat_and_too_short(self):
60
+ assert derive_trend_decoration([2, 2, 2, 2]) == "→"
61
+ assert derive_trend_decoration([1]) == "→"
62
+ assert derive_trend_decoration(None) == "→"
63
+
64
+ def test_recent_improvement(self):
65
+ # down then up-up: not sustained, but recent 2 improved.
66
+ assert derive_trend_decoration([5, 1, 2, 3]) == "↑"
67
+
68
+ def test_higher_is_better_false_flips(self):
69
+ # decreasing values are an improvement when lower is better (e.g. drawdown).
70
+ assert derive_trend_decoration([4, 3, 2, 1], higher_is_better=False) == "↑↑"
71
+
72
+
73
+ class TestDeriveLetter:
74
+ def test_status_to_letter(self):
75
+ assert derive_letter("GREEN") == "A"
76
+ assert derive_letter("WATCH") == "C"
77
+ assert derive_letter("RED") == "F"
78
+ assert derive_letter("N/A-NOT-IMPL") == "N/A"
79
+
80
+
81
+ class TestDeriveStatus:
82
+ def test_na_precedence(self):
83
+ assert derive_status(value=0.5, n_samples=200, n_floor=100, implemented=False) == "N/A-NOT-IMPL"
84
+ assert derive_status(value=0.5, n_samples=200, n_floor=100, ran=False) == "N/A-NOT-RUN"
85
+ assert derive_status(value=0.5, n_samples=200, n_floor=100, input_present=False) == "N/A-MISSING-INPUT"
86
+
87
+ def test_low_n(self):
88
+ assert derive_status(value=0.5, n_samples=40, n_floor=100) == "N/A-LOW-N"
89
+ assert derive_status(value=None, n_samples=200, n_floor=100) == "N/A-LOW-N"
90
+
91
+ def test_red_when_at_or_below_red_line(self):
92
+ s = derive_status(value=-0.01, n_samples=200, n_floor=100, target=0.05, red_line=0.0)
93
+ assert s == "RED"
94
+
95
+ def test_red_when_ci_entirely_below_red_line(self):
96
+ s = derive_status(
97
+ value=0.02, n_samples=200, n_floor=100, target=0.05, red_line=0.0,
98
+ ci_low=-0.03, ci_high=-0.01,
99
+ )
100
+ assert s == "RED"
101
+
102
+ def test_green_when_above_target_with_clear_ci(self):
103
+ s = derive_status(
104
+ value=0.48, n_samples=626, n_floor=100, target=0.05, red_line=0.0,
105
+ ci_low=0.21, ci_high=0.74,
106
+ )
107
+ assert s == "GREEN"
108
+
109
+ def test_watch_between_half_floor_and_floor(self):
110
+ # N=70 is above 0.5*floor (50) but below floor (100) → WATCH regardless of value.
111
+ s = derive_status(value=0.9, n_samples=70, n_floor=100, target=0.05, red_line=0.0)
112
+ assert s == "WATCH"
113
+
114
+ def test_watch_below_target_above_red_line(self):
115
+ s = derive_status(value=0.02, n_samples=200, n_floor=100, target=0.05, red_line=0.0)
116
+ assert s == "WATCH"
117
+
118
+ def test_lower_is_better_direction(self):
119
+ # max_drawdown: target 0.15, red_line 0.25 (lower is better).
120
+ assert derive_status(value=0.08, n_samples=200, n_floor=60, target=0.15, red_line=0.25) == "GREEN"
121
+ assert derive_status(value=0.30, n_samples=200, n_floor=60, target=0.15, red_line=0.25) == "RED"
122
+ assert derive_status(value=0.20, n_samples=200, n_floor=60, target=0.15, red_line=0.25) == "WATCH"
@@ -29,6 +29,18 @@ from alpha_engine_lib.quant.factor_risk_xs import ( # noqa: E402 (after import
29
29
  )
30
30
 
31
31
 
32
+ def test_ledoit_wolf_branch_uses_shared_numpy_impl():
33
+ """LV1-AE.a consolidation contract: estimate_factor_covariance's ledoit_wolf
34
+ path is the shared numpy quant.factor_risk.ledoit_wolf_cov (one LW impl)."""
35
+ from alpha_engine_lib.quant.factor_risk import ledoit_wolf_cov
36
+
37
+ rng = np.random.RandomState(0)
38
+ panel = pd.DataFrame(rng.normal(0, 0.01, (200, 4)), columns=["market", "MOM", "VAL", "QUAL"])
39
+ F = estimate_factor_covariance(panel, shrinkage="ledoit_wolf", min_obs=30)
40
+ expected = ledoit_wolf_cov(panel.to_numpy(), shrinkage="ledoit_wolf")
41
+ assert np.allclose(F.to_numpy(), expected, atol=1e-15)
42
+
43
+
32
44
  # ─── Helpers ────────────────────────────────────────────────────────────────
33
45
 
34
46
 
@@ -0,0 +1,89 @@
1
+ """Tests for alpha_engine_lib.quant.stats.intervals — bootstrap CI, Newey-West SE, Wilson."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from alpha_engine_lib.quant.stats.intervals import (
9
+ bootstrap_ci,
10
+ newey_west_se,
11
+ wilson_score_interval,
12
+ )
13
+
14
+
15
+ class TestBootstrapCI:
16
+ def test_insufficient_data(self):
17
+ assert bootstrap_ci([]) == {"status": "insufficient_data", "n": 0}
18
+ assert bootstrap_ci([4.2])["status"] == "insufficient_data"
19
+
20
+ def test_constant_sample_has_zero_width(self):
21
+ out = bootstrap_ci([5.0, 5.0, 5.0, 5.0])
22
+ assert out["status"] == "ok"
23
+ assert out["estimate"] == 5.0
24
+ assert out["ci_low"] == 5.0 and out["ci_high"] == 5.0
25
+
26
+ def test_ci_brackets_estimate_and_is_deterministic(self):
27
+ data = list(range(100))
28
+ a = bootstrap_ci(data, seed=7)
29
+ b = bootstrap_ci(data, seed=7)
30
+ assert a == b # seeded → reproducible (report card must be stable)
31
+ assert a["ci_low"] <= a["estimate"] <= a["ci_high"]
32
+ assert a["estimate"] == float(np.mean(data))
33
+ assert a["ci_level"] == 0.95 and a["method"] == "bootstrap"
34
+
35
+ def test_nan_dropped(self):
36
+ out = bootstrap_ci([1.0, 2.0, float("nan"), 3.0])
37
+ assert out["n"] == 3
38
+
39
+ def test_custom_statistic(self):
40
+ out = bootstrap_ci([1.0, 2.0, 3.0, 4.0, 5.0], statistic=np.median, seed=1)
41
+ assert out["status"] == "ok"
42
+ assert out["estimate"] == 3.0
43
+
44
+
45
+ class TestNeweyWestSE:
46
+ def test_insufficient_data(self):
47
+ assert newey_west_se([2.0])["status"] == "insufficient_data"
48
+
49
+ def test_zero_lag_matches_iid_se(self):
50
+ # [1..5]: mean 3, gamma0 = 10/5 = 2, se = sqrt(2/5).
51
+ out = newey_west_se([1.0, 2.0, 3.0, 4.0, 5.0], max_lags=0)
52
+ assert out["estimate"] == 3.0
53
+ assert out["lags"] == 0
54
+ assert out["se"] == math.sqrt(0.4)
55
+
56
+ def test_lags_clamped_to_n_minus_1(self):
57
+ out = newey_west_se([1.0, 2.0, 3.0], max_lags=99)
58
+ assert out["lags"] == 2
59
+
60
+ def test_auto_lags_nonnegative(self):
61
+ out = newey_west_se([float(x) for x in range(200)])
62
+ assert out["status"] == "ok"
63
+ assert out["lags"] >= 0
64
+ assert out["se"] >= 0.0
65
+
66
+
67
+ class TestWilsonScoreInterval:
68
+ def test_insufficient_data(self):
69
+ assert wilson_score_interval(0, 0)["status"] == "insufficient_data"
70
+
71
+ def test_known_50_of_100(self):
72
+ # Textbook Wilson 95% interval for 50/100 is [0.4038, 0.5962].
73
+ out = wilson_score_interval(50, 100)
74
+ assert out["rate"] == 0.5
75
+ assert abs(out["ci_low"] - 0.4038) < 1e-3
76
+ assert abs(out["ci_high"] - 0.5962) < 1e-3
77
+
78
+ def test_bounds_clamped_to_unit_interval(self):
79
+ # successes=0 → true lower bound is 0 (residual float noise ≈ 1e-17).
80
+ lo = wilson_score_interval(0, 10)
81
+ assert lo["ci_low"] == pytest.approx(0.0, abs=1e-9)
82
+ assert 0.0 < lo["ci_high"] < 1.0
83
+ hi = wilson_score_interval(10, 10)
84
+ assert hi["ci_high"] == pytest.approx(1.0, abs=1e-9)
85
+ assert 0.0 < hi["ci_low"] < 1.0
86
+
87
+ def test_successes_clamped_to_trials(self):
88
+ out = wilson_score_interval(15, 10)
89
+ assert out["successes"] == 10 and out["rate"] == 1.0