openfactsheet 0.1.0a1__py3-none-any.whl

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 (106) hide show
  1. openfactsheet/__init__.py +47 -0
  2. openfactsheet/api.py +304 -0
  3. openfactsheet/compare/__init__.py +1 -0
  4. openfactsheet/compare/alignment.py +240 -0
  5. openfactsheet/compare/comparison.py +297 -0
  6. openfactsheet/diagnostics/__init__.py +1 -0
  7. openfactsheet/diagnostics/autocorrelation.py +70 -0
  8. openfactsheet/diagnostics/data_quality.py +188 -0
  9. openfactsheet/diagnostics/missingness.py +46 -0
  10. openfactsheet/diagnostics/outliers.py +23 -0
  11. openfactsheet/diagnostics/smoothing.py +41 -0
  12. openfactsheet/diagnostics/stale_returns.py +23 -0
  13. openfactsheet/documents/__init__.py +8 -0
  14. openfactsheet/documents/identifiers.py +136 -0
  15. openfactsheet/documents/labels.py +91 -0
  16. openfactsheet/documents/layout.py +72 -0
  17. openfactsheet/documents/ocr/__init__.py +32 -0
  18. openfactsheet/documents/ocr/base.py +93 -0
  19. openfactsheet/documents/ocr/rapid.py +89 -0
  20. openfactsheet/documents/ocr/tesseract.py +75 -0
  21. openfactsheet/documents/profile.py +401 -0
  22. openfactsheet/documents/raster.py +48 -0
  23. openfactsheet/documents/share_classes.py +226 -0
  24. openfactsheet/documents/tables.py +632 -0
  25. openfactsheet/documents/text_pdf.py +66 -0
  26. openfactsheet/exceptions.py +69 -0
  27. openfactsheet/factors/__init__.py +9 -0
  28. openfactsheet/factors/data.py +133 -0
  29. openfactsheet/factors/models.py +260 -0
  30. openfactsheet/factors/regressions.py +353 -0
  31. openfactsheet/input/__init__.py +1 -0
  32. openfactsheet/input/arrays.py +36 -0
  33. openfactsheet/input/clipboard.py +52 -0
  34. openfactsheet/input/csv.py +21 -0
  35. openfactsheet/input/dispatcher.py +88 -0
  36. openfactsheet/input/excel.py +30 -0
  37. openfactsheet/input/image.py +73 -0
  38. openfactsheet/input/limits.py +112 -0
  39. openfactsheet/input/monthly_table.py +104 -0
  40. openfactsheet/input/pandas_input.py +116 -0
  41. openfactsheet/input/pdf.py +113 -0
  42. openfactsheet/input/values.py +66 -0
  43. openfactsheet/interpretation/__init__.py +1 -0
  44. openfactsheet/interpretation/engine.py +40 -0
  45. openfactsheet/interpretation/glossary.py +36 -0
  46. openfactsheet/interpretation/templates.py +178 -0
  47. openfactsheet/manual/__init__.py +53 -0
  48. openfactsheet/manual/build.py +251 -0
  49. openfactsheet/manual/export.py +520 -0
  50. openfactsheet/manual/links.py +138 -0
  51. openfactsheet/metrics/__init__.py +1 -0
  52. openfactsheet/metrics/attribution.py +538 -0
  53. openfactsheet/metrics/benchmark.py +1057 -0
  54. openfactsheet/metrics/catalog_docs.py +687 -0
  55. openfactsheet/metrics/consistency.py +446 -0
  56. openfactsheet/metrics/context.py +336 -0
  57. openfactsheet/metrics/dependence.py +248 -0
  58. openfactsheet/metrics/distribution.py +347 -0
  59. openfactsheet/metrics/downside.py +125 -0
  60. openfactsheet/metrics/drawdown.py +742 -0
  61. openfactsheet/metrics/engine.py +114 -0
  62. openfactsheet/metrics/factors.py +278 -0
  63. openfactsheet/metrics/path.py +120 -0
  64. openfactsheet/metrics/references.py +231 -0
  65. openfactsheet/metrics/registry.py +149 -0
  66. openfactsheet/metrics/returns.py +441 -0
  67. openfactsheet/metrics/risk_adjusted.py +639 -0
  68. openfactsheet/metrics/statistical.py +343 -0
  69. openfactsheet/metrics/tail.py +400 -0
  70. openfactsheet/metrics/volatility.py +261 -0
  71. openfactsheet/models/__init__.py +1 -0
  72. openfactsheet/models/assumptions.py +81 -0
  73. openfactsheet/models/diagnostics.py +118 -0
  74. openfactsheet/models/input.py +234 -0
  75. openfactsheet/models/metric.py +346 -0
  76. openfactsheet/models/report.py +288 -0
  77. openfactsheet/normalization/__init__.py +1 -0
  78. openfactsheet/normalization/dates.py +46 -0
  79. openfactsheet/normalization/frequency.py +201 -0
  80. openfactsheet/normalization/returns.py +209 -0
  81. openfactsheet/normalization/scaling.py +120 -0
  82. openfactsheet/normalization/validation.py +36 -0
  83. openfactsheet/policy/__init__.py +1 -0
  84. openfactsheet/policy/annualization.py +43 -0
  85. openfactsheet/policy/applicability.py +77 -0
  86. openfactsheet/policy/confidence.py +251 -0
  87. openfactsheet/policy/defaults.py +289 -0
  88. openfactsheet/policy/methodology_version.py +9 -0
  89. openfactsheet/policy/risk_free.py +22 -0
  90. openfactsheet/policy/thresholds.py +223 -0
  91. openfactsheet/py.typed +0 -0
  92. openfactsheet/report/__init__.py +1 -0
  93. openfactsheet/report/builder.py +134 -0
  94. openfactsheet/report/charts.py +889 -0
  95. openfactsheet/report/html.py +432 -0
  96. openfactsheet/report/json.py +84 -0
  97. openfactsheet/report/markdown.py +207 -0
  98. openfactsheet/report/tables.py +284 -0
  99. openfactsheet/report/terminal.py +248 -0
  100. openfactsheet/utils/__init__.py +1 -0
  101. openfactsheet/utils/math.py +47 -0
  102. openfactsheet/version.py +3 -0
  103. openfactsheet-0.1.0a1.dist-info/METADATA +454 -0
  104. openfactsheet-0.1.0a1.dist-info/RECORD +106 -0
  105. openfactsheet-0.1.0a1.dist-info/WHEEL +4 -0
  106. openfactsheet-0.1.0a1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,47 @@
1
+ """OpenFactsheet — standardized, transparent investment performance analytics
2
+ without metric cherry-picking.
3
+
4
+ >>> import openfactsheet as ofs
5
+ >>> report = ofs.analyze(returns) # doctest: +SKIP
6
+ >>> print(report.summary()) # doctest: +SKIP
7
+ """
8
+
9
+ from openfactsheet.api import analyze, compare, load, methodology, metric_catalog
10
+ from openfactsheet.exceptions import (
11
+ AmbiguousReturnScaleError,
12
+ BenchmarkAlignmentError,
13
+ FactorAlignmentError,
14
+ FrequencyInferenceError,
15
+ InputError,
16
+ MetricCalculationError,
17
+ OCRExtractionError,
18
+ OpenFactsheetError,
19
+ )
20
+ from openfactsheet.models.input import ObservationFrequency, ReturnSeries, ReturnTable
21
+ from openfactsheet.models.metric import MetricResult
22
+ from openfactsheet.models.report import FactsheetReport
23
+ from openfactsheet.policy.methodology_version import METHODOLOGY_VERSION
24
+ from openfactsheet.version import __version__
25
+
26
+ __all__ = [
27
+ "METHODOLOGY_VERSION",
28
+ "AmbiguousReturnScaleError",
29
+ "BenchmarkAlignmentError",
30
+ "FactorAlignmentError",
31
+ "FactsheetReport",
32
+ "FrequencyInferenceError",
33
+ "InputError",
34
+ "MetricCalculationError",
35
+ "MetricResult",
36
+ "OCRExtractionError",
37
+ "ObservationFrequency",
38
+ "OpenFactsheetError",
39
+ "ReturnSeries",
40
+ "ReturnTable",
41
+ "__version__",
42
+ "analyze",
43
+ "compare",
44
+ "load",
45
+ "methodology",
46
+ "metric_catalog",
47
+ ]
openfactsheet/api.py ADDED
@@ -0,0 +1,304 @@
1
+ """Public API: ``analyze``, ``compare``, ``load``, ``metric_catalog``, ``methodology``.
2
+
3
+ The API is intentionally small. There are no knobs for metric windows,
4
+ thresholds, confidence levels or metric subsets: the canonical methodology
5
+ decides. The only optional arguments *describe the input* when it cannot be
6
+ established from the data itself (percent vs decimal scale, frequency of
7
+ undated values); such declarations are recorded in the report.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import replace
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import pandas as pd
17
+
18
+ from openfactsheet.exceptions import InputError
19
+ from openfactsheet.factors.data import load_factor_set
20
+ from openfactsheet.input.dispatcher import to_raw
21
+ from openfactsheet.metrics.registry import all_metrics
22
+ from openfactsheet.models.assumptions import Methodology
23
+ from openfactsheet.models.input import (
24
+ InputDiagnostics,
25
+ ObservationFrequency,
26
+ RawReturns,
27
+ ReturnSeries,
28
+ ReturnTable,
29
+ )
30
+ from openfactsheet.models.report import FactsheetReport
31
+ from openfactsheet.normalization.returns import normalize_returns
32
+ from openfactsheet.normalization.scaling import DeclaredScale
33
+ from openfactsheet.policy.defaults import canonical_methodology
34
+ from openfactsheet.policy.thresholds import get_policy
35
+ from openfactsheet.report.builder import build_report
36
+
37
+ if TYPE_CHECKING:
38
+ from openfactsheet.compare.comparison import Comparison
39
+
40
+
41
+ def _normalize_one(
42
+ source: Any,
43
+ *,
44
+ name: str | None,
45
+ return_scale: DeclaredScale | None,
46
+ frequency: str | ObservationFrequency | None,
47
+ role: str,
48
+ ) -> tuple[ReturnSeries, InputDiagnostics]:
49
+ if isinstance(source, RawReturns) and name is not None:
50
+ source = replace(source, name=name)
51
+ if isinstance(source, ReturnSeries):
52
+ if source.input_diagnostics is not None and return_scale is None and frequency is None:
53
+ if name is not None and name != source.name:
54
+ diag = replace(source.input_diagnostics, name=name)
55
+ return replace(source, name=name, input_diagnostics=diag), diag
56
+ return source, source.input_diagnostics
57
+ source = RawReturns(
58
+ values=source.values,
59
+ dates=source.dates,
60
+ name=name or source.name,
61
+ source_type=source.source_type,
62
+ )
63
+ return_scale = return_scale or "decimal"
64
+ raws = to_raw(source, name=name)
65
+ if role == "returns":
66
+ raws = _select_document_fund(raws) or raws
67
+ if len(raws) != 1:
68
+ names = ", ".join(r.name for r in raws)
69
+ raise InputError(
70
+ f"The {role} input contains {len(raws)} return series ({names}); analyze() expects "
71
+ "exactly one. Select a column, or use ofs.compare() for several series."
72
+ )
73
+ return normalize_returns(raws[0], return_scale=return_scale, frequency=frequency)
74
+
75
+
76
+ def _role(raw: RawReturns) -> str | None:
77
+ extraction = raw.metadata.get("extraction")
78
+ return extraction.get("role") if isinstance(extraction, Mapping) else None
79
+
80
+
81
+ def _select_document_fund(raws: list[RawReturns]) -> list[RawReturns] | None:
82
+ """For documents with fund and benchmark tables, the single fund series (decision 0033).
83
+
84
+ Benchmark returns found in a document are never used as benchmark returns; the
85
+ fund series records that they exist. Returns ``None`` when no document roles
86
+ apply (the caller then applies the usual one-series rule).
87
+ """
88
+ roles = [_role(r) for r in raws]
89
+ if not any(roles):
90
+ return None
91
+ funds = [r for r, role in zip(raws, roles, strict=True) if role != "benchmark"]
92
+ benchmarks = [r.name for r, role in zip(raws, roles, strict=True) if role == "benchmark"]
93
+ if not funds:
94
+ raise InputError(
95
+ "The document's only return table is labelled as benchmark returns ("
96
+ + ", ".join(benchmarks)
97
+ + "); it is not analyzed as the fund. Load it with ofs.load() to use it explicitly."
98
+ )
99
+ if len(funds) != 1:
100
+ return funds
101
+ if not benchmarks:
102
+ return funds
103
+ note = (
104
+ "The document also shows benchmark returns ("
105
+ + ", ".join(benchmarks)
106
+ + "); they were not used. Pass them with benchmark=... (e.g. from ofs.load()) to "
107
+ "compute benchmark-relative metrics."
108
+ )
109
+ return [replace(funds[0], warnings=(*funds[0].warnings, note))]
110
+
111
+
112
+ def normalize_benchmark(
113
+ benchmark: Any,
114
+ *,
115
+ return_scale: DeclaredScale | None = None,
116
+ frequency: str | ObservationFrequency | None = None,
117
+ ) -> tuple[ReturnSeries, InputDiagnostics]:
118
+ """Normalize benchmark input as :func:`analyze` does (named ``Benchmark`` by default)."""
119
+ bench_name = (
120
+ str(benchmark.name)
121
+ if isinstance(benchmark, pd.Series) and benchmark.name is not None
122
+ else None
123
+ )
124
+ return _normalize_one(
125
+ benchmark,
126
+ name=bench_name or (None if isinstance(benchmark, ReturnSeries) else "Benchmark"),
127
+ return_scale=return_scale,
128
+ frequency=frequency,
129
+ role="benchmark",
130
+ )
131
+
132
+
133
+ def load(
134
+ source: Any,
135
+ *,
136
+ name: str | None = None,
137
+ return_scale: DeclaredScale | None = None,
138
+ frequency: str | ObservationFrequency | None = None,
139
+ ocr_engine: str | None = None,
140
+ ) -> ReturnSeries | ReturnTable:
141
+ """Parse and normalize input without computing metrics.
142
+
143
+ Inspect ``series.input_diagnostics`` to see exactly what OpenFactsheet
144
+ believes it received before analyzing it.
145
+
146
+ Args:
147
+ source: Returns in any form :func:`analyze` accepts, including CSV, Excel,
148
+ pasted monthly tables, PDF factsheets and screenshots.
149
+ name: Display name for a single series.
150
+ return_scale: Declare ``"decimal"`` or ``"percent"`` only when automatic
151
+ detection reports ambiguity.
152
+ frequency: Declare the observation frequency of *undated* values.
153
+ ocr_engine: ``"rapidocr"`` (default) or ``"tesseract"`` for images and
154
+ scanned PDF pages. It never changes an accepted value, because
155
+ recognized tables must reconcile with their printed totals.
156
+
157
+ Returns:
158
+ A :class:`ReturnSeries` for a single series, or a :class:`ReturnTable`
159
+ when the source contains several return columns (for example a fund and
160
+ the benchmark printed in a factsheet).
161
+
162
+ Examples:
163
+ >>> series = ofs.load("monthly_returns.csv")
164
+ >>> print(series.input_diagnostics.to_text())
165
+ """
166
+ raws = to_raw(source, name=name, ocr_engine=ocr_engine)
167
+ normalized = [
168
+ normalize_returns(r, return_scale=return_scale, frequency=frequency)[0] for r in raws
169
+ ]
170
+ if len(normalized) == 1:
171
+ return normalized[0]
172
+ return ReturnTable({s.name: s for s in normalized})
173
+
174
+
175
+ def analyze(
176
+ returns: Any,
177
+ benchmark: Any = None,
178
+ *,
179
+ factors: Any = None,
180
+ name: str | None = None,
181
+ return_scale: DeclaredScale | None = None,
182
+ frequency: str | ObservationFrequency | None = None,
183
+ ) -> FactsheetReport:
184
+ """Produce the canonical OpenFactsheet report for one return history.
185
+
186
+ Args:
187
+ returns: A list/tuple/array of returns, a pandas Series (ideally with a
188
+ DatetimeIndex), a one-column DataFrame, a CSV/Excel path, pasted table
189
+ text, or a :class:`ReturnSeries` from :func:`load`.
190
+ benchmark: Optional benchmark returns in any of the same forms. Benchmark-
191
+ relative metrics are computed only when a benchmark is supplied.
192
+ factors: Optional factor returns, one column per factor (a DataFrame, a
193
+ CSV/Excel path or table text, e.g. Fama-French factors). A column named
194
+ ``RF`` is the risk-free rate and is subtracted from fund returns. Factor-
195
+ model metrics are computed only when factors are supplied; OpenFactsheet
196
+ never downloads factor data.
197
+ name: Display name for the report.
198
+ return_scale: Declare ``"decimal"`` or ``"percent"`` only when automatic
199
+ detection reports ambiguity. Applies to fund, benchmark and factors.
200
+ frequency: Declare the observation frequency of *undated* values. It is
201
+ checked against dates when dates exist. Applies to fund, benchmark and
202
+ factors.
203
+
204
+ Returns:
205
+ A :class:`FactsheetReport` containing every canonical metric with its
206
+ status, confidence, warnings and assumptions.
207
+
208
+ Examples:
209
+ >>> report = ofs.analyze(returns)
210
+ >>> print(report.summary())
211
+ >>> report.metrics["sharpe_ratio"].value
212
+ >>> report = ofs.analyze(returns, benchmark=benchmark_returns)
213
+ >>> report.to_html("factsheet.html")
214
+ """
215
+ series, diag = _normalize_one(
216
+ returns, name=name, return_scale=return_scale, frequency=frequency, role="returns"
217
+ )
218
+ bench_series = bench_diag = None
219
+ if benchmark is not None:
220
+ bench_series, bench_diag = normalize_benchmark(
221
+ benchmark, return_scale=return_scale, frequency=frequency
222
+ )
223
+ factor_set = (
224
+ load_factor_set(factors, return_scale=return_scale, frequency=frequency)
225
+ if factors is not None
226
+ else None
227
+ )
228
+ return build_report(series, diag, bench_series, bench_diag, factor_set)
229
+
230
+
231
+ def compare(
232
+ funds: Mapping[str, Any] | pd.DataFrame | Sequence[Any] | ReturnTable,
233
+ benchmark: Any = None,
234
+ *,
235
+ factors: Any = None,
236
+ return_scale: DeclaredScale | None = None,
237
+ frequency: str | ObservationFrequency | None = None,
238
+ ) -> Comparison:
239
+ """Analyze several return histories under one identical methodology.
240
+
241
+ Metrics are shown side by side; nothing is ranked. When the histories cover
242
+ different periods, an additional common-period analysis (the intersection of
243
+ all histories) is provided so that track-record differences are visible. The
244
+ same benchmark and factor returns, if supplied, are used for every investment.
245
+
246
+ Args:
247
+ funds: A mapping of name to returns, a DataFrame with one column per
248
+ investment, a sequence of return series, or a :class:`ReturnTable`.
249
+ benchmark: Optional benchmark returns, used for every investment.
250
+ factors: Optional factor returns, used for every investment.
251
+ return_scale: Declare ``"decimal"`` or ``"percent"`` only when automatic
252
+ detection reports ambiguity.
253
+ frequency: Declare the observation frequency of *undated* values.
254
+
255
+ Returns:
256
+ A :class:`Comparison` holding the full report of every investment.
257
+
258
+ Examples:
259
+ >>> comparison = ofs.compare({"Fund A": fund_a, "Fund B": fund_b, "ETF": etf})
260
+ >>> print(comparison.summary())
261
+ >>> comparison.table(common_period=True)
262
+ """
263
+ from openfactsheet.compare.comparison import build_comparison
264
+
265
+ return build_comparison(
266
+ funds,
267
+ benchmark=benchmark,
268
+ factors=factors,
269
+ return_scale=return_scale,
270
+ frequency=frequency,
271
+ )
272
+
273
+
274
+ def metric_catalog() -> pd.DataFrame:
275
+ """Every canonical metric with its definition, conventions and data requirements.
276
+
277
+ Returns:
278
+ A DataFrame indexed by metric id, one row per canonical metric.
279
+
280
+ Examples:
281
+ >>> catalog = ofs.metric_catalog()
282
+ >>> catalog.loc["sharpe_ratio", "formula"]
283
+ """
284
+ rows = []
285
+ for d in all_metrics():
286
+ row = d.to_dict()
287
+ row["minimum_data"] = get_policy(d.min_data_policy).description
288
+ row["references"] = "; ".join(r.citation for r in d.docs.references)
289
+ rows.append(row)
290
+ return pd.DataFrame(rows).set_index("id")
291
+
292
+
293
+ def methodology() -> Methodology:
294
+ """The canonical, versioned methodology used by every report.
295
+
296
+ Returns:
297
+ The methodology object; ``.version`` is its version and ``.to_json()``
298
+ the complete set of canonical conventions embedded in every report.
299
+
300
+ Examples:
301
+ >>> ofs.methodology().get("tail.var_confidence")
302
+ 0.95
303
+ """
304
+ return canonical_methodology()
@@ -0,0 +1 @@
1
+ """Benchmark alignment and multi-investment comparison."""
@@ -0,0 +1,240 @@
1
+ """Fund/benchmark alignment (decision 0012). Missing returns are never filled.
2
+
3
+ * Both dated, same regular frequency: monthly/quarterly/yearly observations are
4
+ matched by calendar period (so month-start and month-end conventions align);
5
+ daily/weekly by exact date. Only the intersection is used and the loss of
6
+ observations is disclosed.
7
+ * Both dated, different frequencies: :class:`BenchmarkAlignmentError`
8
+ (no resampling in methodology 0.1.0).
9
+ * Neither dated: aligned by position only when lengths match.
10
+ * Exactly one dated: when lengths match, the undated series is assumed to share
11
+ the dated series' timestamps (warned); otherwise an error.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+ from functools import cached_property
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+ import pandas as pd
23
+
24
+ from openfactsheet.exceptions import BenchmarkAlignmentError, UndefinedMetric
25
+ from openfactsheet.models.input import ObservationFrequency, ReturnSeries
26
+ from openfactsheet.models.report import BenchmarkDiagnostics
27
+
28
+ PERIOD_CODES = {
29
+ ObservationFrequency.MONTHLY: "M",
30
+ ObservationFrequency.QUARTERLY: "Q",
31
+ ObservationFrequency.YEARLY: "Y",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class OLSResult:
37
+ """CAPM-style regression ``fund = alpha + beta * benchmark + e`` (statsmodels OLS)."""
38
+
39
+ alpha: float
40
+ beta: float
41
+ alpha_se: float | None
42
+ beta_se: float | None
43
+ alpha_t: float | None
44
+ beta_t: float | None
45
+ alpha_pvalue: float | None
46
+ beta_pvalue: float | None
47
+ r_squared: float
48
+ residual_std: float | None
49
+ n: int
50
+
51
+
52
+ def _finite_or_none(x: float) -> float | None:
53
+ return float(x) if math.isfinite(float(x)) else None
54
+
55
+
56
+ def single_index_ols(
57
+ fund: npt.NDArray[np.float64], bench: npt.NDArray[np.float64], what: str = "aligned periods"
58
+ ) -> OLSResult:
59
+ """OLS of ``fund`` on a constant and ``bench`` (statsmodels), with i.i.d. standard errors.
60
+
61
+ Raises :class:`UndefinedMetric` with fewer than 3 observations or a constant
62
+ regressor; ``what`` names the sample in those reasons.
63
+ """
64
+ n = int(fund.shape[0])
65
+ if n < 3:
66
+ raise UndefinedMetric(
67
+ f"At least 3 observations are required for regression; the {what} contain {n}."
68
+ )
69
+ if float(np.ptp(bench)) == 0.0:
70
+ raise UndefinedMetric(f"Benchmark returns are constant over the {what}; beta is undefined.")
71
+ if float(np.ptp(fund)) == 0.0:
72
+ # Exact solution: a constant fund has slope 0, intercept equal to its return and
73
+ # zero residuals. Solving numerically would leave rounding noise (e.g. beta = 1e-17)
74
+ # that downstream ratios (Treynor, appraisal) would divide by. t-statistics are 0/0.
75
+ return OLSResult(
76
+ alpha=float(fund[0]),
77
+ beta=0.0,
78
+ alpha_se=0.0,
79
+ beta_se=0.0,
80
+ alpha_t=None,
81
+ beta_t=None,
82
+ alpha_pvalue=None,
83
+ beta_pvalue=None,
84
+ r_squared=math.nan, # undefined (no variance to explain); guarded by r_squared
85
+ residual_std=0.0,
86
+ n=n,
87
+ )
88
+ import statsmodels.api as sm
89
+
90
+ x = sm.add_constant(bench, has_constant="add")
91
+ fit = sm.OLS(fund, x).fit()
92
+ params = np.asarray(fit.params, dtype=np.float64)
93
+ dof = n - 2
94
+ bse = np.asarray(fit.bse, dtype=np.float64)
95
+ tvals = np.asarray(fit.tvalues, dtype=np.float64)
96
+ pvals = np.asarray(fit.pvalues, dtype=np.float64)
97
+ resid_std = math.sqrt(float(fit.ssr) / dof)
98
+ return OLSResult(
99
+ alpha=float(params[0]),
100
+ beta=float(params[1]),
101
+ alpha_se=_finite_or_none(bse[0]),
102
+ beta_se=_finite_or_none(bse[1]),
103
+ alpha_t=_finite_or_none(tvals[0]),
104
+ beta_t=_finite_or_none(tvals[1]),
105
+ alpha_pvalue=_finite_or_none(pvals[0]),
106
+ beta_pvalue=_finite_or_none(pvals[1]),
107
+ r_squared=float(fit.rsquared),
108
+ residual_std=_finite_or_none(resid_std),
109
+ n=n,
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True, eq=False)
114
+ class AlignedBenchmark:
115
+ fund: npt.NDArray[np.float64]
116
+ bench: npt.NDArray[np.float64]
117
+ dates: pd.DatetimeIndex | None
118
+ diagnostics: BenchmarkDiagnostics
119
+ periods_per_year: float | None
120
+
121
+ @property
122
+ def n(self) -> int:
123
+ return int(self.fund.shape[0])
124
+
125
+ @cached_property
126
+ def active(self) -> npt.NDArray[np.float64]:
127
+ return self.fund - self.bench
128
+
129
+ @cached_property
130
+ def ols(self) -> OLSResult:
131
+ return single_index_ols(self.fund, self.bench)
132
+
133
+ @cached_property
134
+ def bull_ols(self) -> OLSResult:
135
+ """Single-index regression on the periods with a strictly positive benchmark return."""
136
+ up = self.bench > 0.0
137
+ return single_index_ols(self.fund[up], self.bench[up], "benchmark up periods")
138
+
139
+ @cached_property
140
+ def bear_ols(self) -> OLSResult:
141
+ """Single-index regression on the periods with a strictly negative benchmark return."""
142
+ down = self.bench < 0.0
143
+ return single_index_ols(self.fund[down], self.bench[down], "benchmark down periods")
144
+
145
+
146
+ def period_keys(series: ReturnSeries, code: str | None) -> pd.Index:
147
+ assert series.dates is not None
148
+ if code is not None:
149
+ return pd.Index(series.dates.to_period(code))
150
+ return pd.Index(series.dates.normalize())
151
+
152
+
153
+ def _fmt(ts: pd.Timestamp) -> str:
154
+ return ts.strftime("%Y-%m-%d")
155
+
156
+
157
+ def align_benchmark(fund: ReturnSeries, bench: ReturnSeries) -> AlignedBenchmark:
158
+ warnings: list[str] = []
159
+ fr, br = fund.values, bench.values
160
+
161
+ if fund.dates is not None and bench.dates is not None:
162
+ f_freq, b_freq = fund.frequency, bench.frequency
163
+ if f_freq.is_regular and b_freq.is_regular and f_freq != b_freq:
164
+ raise BenchmarkAlignmentError(
165
+ f"Fund is {f_freq.value} but benchmark is {b_freq.value}. OpenFactsheet does not "
166
+ "resample returns in methodology 0.1.0; supply a benchmark at the fund's frequency."
167
+ )
168
+ # One key type for both series: the calendar period of whichever series has a
169
+ # period-based frequency (the other may be irregular because of gaps).
170
+ period_freq = f_freq if f_freq in PERIOD_CODES else b_freq
171
+ code = PERIOD_CODES.get(period_freq)
172
+ fk, bk = period_keys(fund, code), period_keys(bench, code)
173
+ if fk.has_duplicates or bk.has_duplicates:
174
+ raise BenchmarkAlignmentError(
175
+ "Several observations fall in the same calendar period; they cannot be aligned "
176
+ "by period without choosing between them."
177
+ )
178
+ common = fk.intersection(bk)
179
+ f_pos = fk.get_indexer(common)
180
+ b_pos = bk.get_indexer(common)
181
+ order = np.argsort(f_pos)
182
+ f_pos, b_pos = f_pos[order], b_pos[order]
183
+ dates = pd.DatetimeIndex(fund.dates[f_pos])
184
+ method = f"calendar {period_freq.value} period" if code is not None else "exact date"
185
+ aligned_f, aligned_b = fr[f_pos], br[b_pos]
186
+ elif fund.dates is None and bench.dates is None:
187
+ if fund.n != bench.n:
188
+ raise BenchmarkAlignmentError(
189
+ f"Undated fund ({fund.n} observations) and benchmark ({bench.n}) have different "
190
+ "lengths; supply dates so they can be aligned explicitly."
191
+ )
192
+ dates = None
193
+ method = "by position (both series undated)"
194
+ aligned_f, aligned_b = fr, br
195
+ warnings.append(
196
+ "Neither series has dates; observations were paired by position. Verify that both "
197
+ "cover exactly the same periods."
198
+ )
199
+ else:
200
+ if fund.n != bench.n:
201
+ raise BenchmarkAlignmentError(
202
+ "Only one of fund and benchmark has dates and their lengths differ; supply dates "
203
+ "for both."
204
+ )
205
+ dates = fund.dates if fund.dates is not None else bench.dates
206
+ undated = "benchmark" if bench.dates is None else "fund"
207
+ method = f"by position ({undated} undated; assumed to share the other series' dates)"
208
+ aligned_f, aligned_b = fr, br
209
+ warnings.append(
210
+ f"The {undated} series has no dates; it was assumed to cover the same periods."
211
+ )
212
+
213
+ n_aligned = int(aligned_f.shape[0])
214
+ if n_aligned < fund.n:
215
+ warnings.append(
216
+ f"{fund.n - n_aligned} fund observation(s) have no matching benchmark return and are "
217
+ "excluded from benchmark-relative metrics (never filled)."
218
+ )
219
+ if n_aligned == 0:
220
+ warnings.append("Fund and benchmark have no periods in common.")
221
+
222
+ diag = BenchmarkDiagnostics(
223
+ name=bench.name,
224
+ fund_observations=fund.n,
225
+ benchmark_observations=bench.n,
226
+ aligned_observations=n_aligned,
227
+ fund_only_periods=fund.n - n_aligned,
228
+ benchmark_only_periods=bench.n - n_aligned,
229
+ overlap_start=_fmt(dates[0]) if dates is not None and n_aligned else None,
230
+ overlap_end=_fmt(dates[-1]) if dates is not None and n_aligned else None,
231
+ alignment_method=method,
232
+ warnings=tuple(warnings),
233
+ )
234
+ return AlignedBenchmark(
235
+ fund=np.array(aligned_f, dtype=np.float64),
236
+ bench=np.array(aligned_b, dtype=np.float64),
237
+ dates=dates,
238
+ diagnostics=diag,
239
+ periods_per_year=fund.periods_per_year,
240
+ )