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.
- openfactsheet/__init__.py +47 -0
- openfactsheet/api.py +304 -0
- openfactsheet/compare/__init__.py +1 -0
- openfactsheet/compare/alignment.py +240 -0
- openfactsheet/compare/comparison.py +297 -0
- openfactsheet/diagnostics/__init__.py +1 -0
- openfactsheet/diagnostics/autocorrelation.py +70 -0
- openfactsheet/diagnostics/data_quality.py +188 -0
- openfactsheet/diagnostics/missingness.py +46 -0
- openfactsheet/diagnostics/outliers.py +23 -0
- openfactsheet/diagnostics/smoothing.py +41 -0
- openfactsheet/diagnostics/stale_returns.py +23 -0
- openfactsheet/documents/__init__.py +8 -0
- openfactsheet/documents/identifiers.py +136 -0
- openfactsheet/documents/labels.py +91 -0
- openfactsheet/documents/layout.py +72 -0
- openfactsheet/documents/ocr/__init__.py +32 -0
- openfactsheet/documents/ocr/base.py +93 -0
- openfactsheet/documents/ocr/rapid.py +89 -0
- openfactsheet/documents/ocr/tesseract.py +75 -0
- openfactsheet/documents/profile.py +401 -0
- openfactsheet/documents/raster.py +48 -0
- openfactsheet/documents/share_classes.py +226 -0
- openfactsheet/documents/tables.py +632 -0
- openfactsheet/documents/text_pdf.py +66 -0
- openfactsheet/exceptions.py +69 -0
- openfactsheet/factors/__init__.py +9 -0
- openfactsheet/factors/data.py +133 -0
- openfactsheet/factors/models.py +260 -0
- openfactsheet/factors/regressions.py +353 -0
- openfactsheet/input/__init__.py +1 -0
- openfactsheet/input/arrays.py +36 -0
- openfactsheet/input/clipboard.py +52 -0
- openfactsheet/input/csv.py +21 -0
- openfactsheet/input/dispatcher.py +88 -0
- openfactsheet/input/excel.py +30 -0
- openfactsheet/input/image.py +73 -0
- openfactsheet/input/limits.py +112 -0
- openfactsheet/input/monthly_table.py +104 -0
- openfactsheet/input/pandas_input.py +116 -0
- openfactsheet/input/pdf.py +113 -0
- openfactsheet/input/values.py +66 -0
- openfactsheet/interpretation/__init__.py +1 -0
- openfactsheet/interpretation/engine.py +40 -0
- openfactsheet/interpretation/glossary.py +36 -0
- openfactsheet/interpretation/templates.py +178 -0
- openfactsheet/manual/__init__.py +53 -0
- openfactsheet/manual/build.py +251 -0
- openfactsheet/manual/export.py +520 -0
- openfactsheet/manual/links.py +138 -0
- openfactsheet/metrics/__init__.py +1 -0
- openfactsheet/metrics/attribution.py +538 -0
- openfactsheet/metrics/benchmark.py +1057 -0
- openfactsheet/metrics/catalog_docs.py +687 -0
- openfactsheet/metrics/consistency.py +446 -0
- openfactsheet/metrics/context.py +336 -0
- openfactsheet/metrics/dependence.py +248 -0
- openfactsheet/metrics/distribution.py +347 -0
- openfactsheet/metrics/downside.py +125 -0
- openfactsheet/metrics/drawdown.py +742 -0
- openfactsheet/metrics/engine.py +114 -0
- openfactsheet/metrics/factors.py +278 -0
- openfactsheet/metrics/path.py +120 -0
- openfactsheet/metrics/references.py +231 -0
- openfactsheet/metrics/registry.py +149 -0
- openfactsheet/metrics/returns.py +441 -0
- openfactsheet/metrics/risk_adjusted.py +639 -0
- openfactsheet/metrics/statistical.py +343 -0
- openfactsheet/metrics/tail.py +400 -0
- openfactsheet/metrics/volatility.py +261 -0
- openfactsheet/models/__init__.py +1 -0
- openfactsheet/models/assumptions.py +81 -0
- openfactsheet/models/diagnostics.py +118 -0
- openfactsheet/models/input.py +234 -0
- openfactsheet/models/metric.py +346 -0
- openfactsheet/models/report.py +288 -0
- openfactsheet/normalization/__init__.py +1 -0
- openfactsheet/normalization/dates.py +46 -0
- openfactsheet/normalization/frequency.py +201 -0
- openfactsheet/normalization/returns.py +209 -0
- openfactsheet/normalization/scaling.py +120 -0
- openfactsheet/normalization/validation.py +36 -0
- openfactsheet/policy/__init__.py +1 -0
- openfactsheet/policy/annualization.py +43 -0
- openfactsheet/policy/applicability.py +77 -0
- openfactsheet/policy/confidence.py +251 -0
- openfactsheet/policy/defaults.py +289 -0
- openfactsheet/policy/methodology_version.py +9 -0
- openfactsheet/policy/risk_free.py +22 -0
- openfactsheet/policy/thresholds.py +223 -0
- openfactsheet/py.typed +0 -0
- openfactsheet/report/__init__.py +1 -0
- openfactsheet/report/builder.py +134 -0
- openfactsheet/report/charts.py +889 -0
- openfactsheet/report/html.py +432 -0
- openfactsheet/report/json.py +84 -0
- openfactsheet/report/markdown.py +207 -0
- openfactsheet/report/tables.py +284 -0
- openfactsheet/report/terminal.py +248 -0
- openfactsheet/utils/__init__.py +1 -0
- openfactsheet/utils/math.py +47 -0
- openfactsheet/version.py +3 -0
- openfactsheet-0.1.0a1.dist-info/METADATA +454 -0
- openfactsheet-0.1.0a1.dist-info/RECORD +106 -0
- openfactsheet-0.1.0a1.dist-info/WHEEL +4 -0
- 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
|
+
)
|