perfattr 0.1.0__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.
- perfattr/__init__.py +16 -0
- perfattr/_linking.py +37 -0
- perfattr/_schemas.py +104 -0
- perfattr/attribution.py +945 -0
- perfattr/py.typed +1 -0
- perfattr-0.1.0.dist-info/METADATA +89 -0
- perfattr-0.1.0.dist-info/RECORD +9 -0
- perfattr-0.1.0.dist-info/WHEEL +4 -0
- perfattr-0.1.0.dist-info/licenses/LICENSE +21 -0
perfattr/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Portable portfolio performance-attribution calculations."""
|
|
2
|
+
|
|
3
|
+
from perfattr.attribution import (
|
|
4
|
+
AttributionError,
|
|
5
|
+
AttributionResult,
|
|
6
|
+
calculate_attribution,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AttributionError",
|
|
13
|
+
"AttributionResult",
|
|
14
|
+
"__version__",
|
|
15
|
+
"calculate_attribution",
|
|
16
|
+
]
|
perfattr/_linking.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Numerically stable helpers for multi-period attribution linking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _compound_returns(returns: npt.NDArray[np.float64]) -> float:
|
|
10
|
+
"""Compound period returns with stable logarithmic arithmetic."""
|
|
11
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
12
|
+
return float(np.expm1(np.log1p(returns).sum(dtype=np.float64)))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _smoothing(returns: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
16
|
+
"""Evaluate logarithmic smoothing with its exact zero-return limit."""
|
|
17
|
+
coefficients = np.ones_like(returns, dtype=np.float64)
|
|
18
|
+
nonzero = returns != 0.0
|
|
19
|
+
np.divide(
|
|
20
|
+
np.log1p(returns),
|
|
21
|
+
returns,
|
|
22
|
+
out=coefficients,
|
|
23
|
+
where=nonzero,
|
|
24
|
+
)
|
|
25
|
+
return coefficients
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _carino(
|
|
29
|
+
portfolio_returns: npt.NDArray[np.float64],
|
|
30
|
+
benchmark_returns: npt.NDArray[np.float64],
|
|
31
|
+
) -> npt.NDArray[np.float64]:
|
|
32
|
+
"""Evaluate Carino coefficients stably for equal and near-equal returns."""
|
|
33
|
+
with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
|
|
34
|
+
relative_difference = (
|
|
35
|
+
portfolio_returns - benchmark_returns
|
|
36
|
+
) / (1.0 + benchmark_returns)
|
|
37
|
+
return _smoothing(relative_difference) / (1.0 + benchmark_returns)
|
perfattr/_schemas.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Stable column and reconciliation ordering for portable result frames."""
|
|
2
|
+
|
|
3
|
+
PERIOD_DETAIL_COLUMNS = (
|
|
4
|
+
"from_date",
|
|
5
|
+
"thru_date",
|
|
6
|
+
"quantity_of_days",
|
|
7
|
+
"identifier",
|
|
8
|
+
"portfolio_weight",
|
|
9
|
+
"portfolio_return",
|
|
10
|
+
"portfolio_contribution",
|
|
11
|
+
"benchmark_weight",
|
|
12
|
+
"benchmark_return",
|
|
13
|
+
"benchmark_contribution",
|
|
14
|
+
"active_weight",
|
|
15
|
+
"active_return",
|
|
16
|
+
"active_contribution",
|
|
17
|
+
"allocation_effect",
|
|
18
|
+
"selection_effect",
|
|
19
|
+
"total_effect",
|
|
20
|
+
"linked_portfolio_contribution",
|
|
21
|
+
"linked_benchmark_contribution",
|
|
22
|
+
"linked_active_contribution",
|
|
23
|
+
"linked_allocation_effect",
|
|
24
|
+
"linked_selection_effect",
|
|
25
|
+
"linked_total_effect",
|
|
26
|
+
)
|
|
27
|
+
PERIOD_SUMMARY_COLUMNS = (
|
|
28
|
+
"from_date",
|
|
29
|
+
"thru_date",
|
|
30
|
+
"quantity_of_days",
|
|
31
|
+
"portfolio_return",
|
|
32
|
+
"benchmark_return",
|
|
33
|
+
"active_return",
|
|
34
|
+
"portfolio_contribution",
|
|
35
|
+
"benchmark_contribution",
|
|
36
|
+
"active_contribution",
|
|
37
|
+
"allocation_effect",
|
|
38
|
+
"selection_effect",
|
|
39
|
+
"total_effect",
|
|
40
|
+
"linked_portfolio_contribution",
|
|
41
|
+
"linked_benchmark_contribution",
|
|
42
|
+
"linked_active_contribution",
|
|
43
|
+
"linked_allocation_effect",
|
|
44
|
+
"linked_selection_effect",
|
|
45
|
+
"linked_total_effect",
|
|
46
|
+
)
|
|
47
|
+
OVERALL_DETAIL_COLUMNS = (
|
|
48
|
+
"from_date",
|
|
49
|
+
"thru_date",
|
|
50
|
+
"identifier",
|
|
51
|
+
"portfolio_weight",
|
|
52
|
+
"portfolio_return",
|
|
53
|
+
"linked_portfolio_contribution",
|
|
54
|
+
"benchmark_weight",
|
|
55
|
+
"benchmark_return",
|
|
56
|
+
"linked_benchmark_contribution",
|
|
57
|
+
"active_weight",
|
|
58
|
+
"active_return",
|
|
59
|
+
"linked_active_contribution",
|
|
60
|
+
"linked_allocation_effect",
|
|
61
|
+
"linked_selection_effect",
|
|
62
|
+
"linked_total_effect",
|
|
63
|
+
)
|
|
64
|
+
CUMULATIVE_COLUMNS = (
|
|
65
|
+
"from_date",
|
|
66
|
+
"thru_date",
|
|
67
|
+
"portfolio_return",
|
|
68
|
+
"benchmark_return",
|
|
69
|
+
"active_return",
|
|
70
|
+
"cumulative_portfolio_return",
|
|
71
|
+
"cumulative_benchmark_return",
|
|
72
|
+
"cumulative_active_return",
|
|
73
|
+
"linked_portfolio_contribution",
|
|
74
|
+
"linked_benchmark_contribution",
|
|
75
|
+
"linked_active_contribution",
|
|
76
|
+
"cumulative_portfolio_contribution",
|
|
77
|
+
"cumulative_benchmark_contribution",
|
|
78
|
+
"cumulative_active_contribution",
|
|
79
|
+
"linked_allocation_effect",
|
|
80
|
+
"linked_selection_effect",
|
|
81
|
+
"linked_total_effect",
|
|
82
|
+
"cumulative_allocation_effect",
|
|
83
|
+
"cumulative_selection_effect",
|
|
84
|
+
"cumulative_total_effect",
|
|
85
|
+
)
|
|
86
|
+
RECONCILIATION_COLUMNS = (
|
|
87
|
+
"scope",
|
|
88
|
+
"from_date",
|
|
89
|
+
"thru_date",
|
|
90
|
+
"check",
|
|
91
|
+
"actual",
|
|
92
|
+
"expected",
|
|
93
|
+
"residual",
|
|
94
|
+
"tolerance",
|
|
95
|
+
"passed",
|
|
96
|
+
)
|
|
97
|
+
PERIOD_RECONCILIATION_CHECKS = tuple(
|
|
98
|
+
"""portfolio_weight benchmark_weight portfolio_contribution
|
|
99
|
+
benchmark_contribution active_contribution effect_components total_effect""".split()
|
|
100
|
+
)
|
|
101
|
+
OVERALL_RECONCILIATION_CHECKS = tuple(
|
|
102
|
+
"""linked_portfolio_contribution linked_benchmark_contribution
|
|
103
|
+
linked_active_contribution linked_effect_components linked_total_effect""".split()
|
|
104
|
+
)
|
perfattr/attribution.py
ADDED
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
"""Portable Brinson-Fachler performance-attribution calculations.
|
|
2
|
+
|
|
3
|
+
Input normalization, financial validation, calculation, linking, and reconciliation
|
|
4
|
+
remain in one explicit path so the financial behavior is straightforward to audit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import numpy.typing as npt
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from pandas.api.types import is_bool_dtype, is_datetime64_dtype, is_numeric_dtype
|
|
16
|
+
|
|
17
|
+
from perfattr._linking import _carino, _compound_returns, _smoothing
|
|
18
|
+
from perfattr._schemas import (
|
|
19
|
+
CUMULATIVE_COLUMNS,
|
|
20
|
+
OVERALL_DETAIL_COLUMNS,
|
|
21
|
+
OVERALL_RECONCILIATION_CHECKS,
|
|
22
|
+
PERIOD_DETAIL_COLUMNS,
|
|
23
|
+
PERIOD_RECONCILIATION_CHECKS,
|
|
24
|
+
PERIOD_SUMMARY_COLUMNS,
|
|
25
|
+
RECONCILIATION_COLUMNS,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_TOLERANCE = 1e-12
|
|
29
|
+
_REQUIRED_COLUMNS = (
|
|
30
|
+
"from_date",
|
|
31
|
+
"thru_date",
|
|
32
|
+
"identifier",
|
|
33
|
+
"weight",
|
|
34
|
+
"return",
|
|
35
|
+
"quantity_of_days",
|
|
36
|
+
)
|
|
37
|
+
class AttributionError(ValueError):
|
|
38
|
+
"""Report invalid financial input or a failed calculation invariant."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AttributionResult:
|
|
43
|
+
"""Hold portable attribution result frames.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
period_detail: Attribution values for each period and identifier.
|
|
47
|
+
period_summary: Attribution totals for each period.
|
|
48
|
+
overall_detail: Full-horizon values for each identifier.
|
|
49
|
+
cumulative: Chronological period and cumulative totals.
|
|
50
|
+
reconciliation: Passing financial reconciliation evidence.
|
|
51
|
+
|
|
52
|
+
Notes:
|
|
53
|
+
The calculator does not mutate caller-supplied frames. Returned frames belong
|
|
54
|
+
to the caller and are independently mutable.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
period_detail: pd.DataFrame
|
|
58
|
+
period_summary: pd.DataFrame
|
|
59
|
+
overall_detail: pd.DataFrame
|
|
60
|
+
cumulative: pd.DataFrame
|
|
61
|
+
reconciliation: pd.DataFrame
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _float_array(frame: pd.DataFrame, column: str) -> npt.NDArray[np.float64]:
|
|
65
|
+
"""Return a DataFrame column as a float64 NumPy array."""
|
|
66
|
+
return np.asarray(frame[column], dtype=np.float64)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_close(
|
|
70
|
+
actual: npt.NDArray[np.float64],
|
|
71
|
+
expected: npt.NDArray[np.float64],
|
|
72
|
+
tolerance: float,
|
|
73
|
+
) -> npt.NDArray[np.bool_]:
|
|
74
|
+
"""Vectorize the project's symmetric relative and absolute tolerance."""
|
|
75
|
+
difference = np.abs(actual - expected)
|
|
76
|
+
scale = np.maximum(np.abs(actual), np.abs(expected))
|
|
77
|
+
return difference <= np.maximum(tolerance * scale, tolerance)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_reconciliation_tolerance(value: float) -> float:
|
|
81
|
+
"""Require a finite, positive, non-boolean reconciliation tolerance."""
|
|
82
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
83
|
+
raise TypeError("reconciliation_tolerance must be a real number")
|
|
84
|
+
tolerance = float(value)
|
|
85
|
+
if not np.isfinite(tolerance) or tolerance <= 0.0:
|
|
86
|
+
raise AttributionError(
|
|
87
|
+
"reconciliation_tolerance must be finite and greater than zero"
|
|
88
|
+
)
|
|
89
|
+
return tolerance
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _has_true(values: pd.Series) -> bool:
|
|
93
|
+
"""Return whether a boolean Series contains a true value."""
|
|
94
|
+
return bool(np.asarray(values, dtype=np.bool_).any())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _raise_invalid(side: str, message: str) -> None:
|
|
98
|
+
"""Raise a consistently formatted input error."""
|
|
99
|
+
raise AttributionError(f"{side} input {message}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _normalize_dates(frame: pd.DataFrame, column: str, side: str) -> pd.Series:
|
|
103
|
+
"""Normalize a required date column to timezone-naive midnight values."""
|
|
104
|
+
values = cast(pd.Series, frame[column])
|
|
105
|
+
if _has_true(values.isna()):
|
|
106
|
+
_raise_invalid(side, f"column {column!r} contains null values")
|
|
107
|
+
if is_numeric_dtype(values.dtype) or is_bool_dtype(values.dtype):
|
|
108
|
+
_raise_invalid(side, f"column {column!r} must contain dates")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
normalized = pd.to_datetime(values, errors="raise", format="mixed")
|
|
112
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
113
|
+
raise AttributionError(
|
|
114
|
+
f"{side} input column {column!r} contains an invalid date"
|
|
115
|
+
) from error
|
|
116
|
+
|
|
117
|
+
if isinstance(normalized.dtype, pd.DatetimeTZDtype):
|
|
118
|
+
_raise_invalid(side, f"column {column!r} must be timezone-naive")
|
|
119
|
+
if not is_datetime64_dtype(normalized.dtype):
|
|
120
|
+
_raise_invalid(side, f"column {column!r} must be timezone-naive")
|
|
121
|
+
return cast(
|
|
122
|
+
pd.Series,
|
|
123
|
+
normalized.dt.normalize().astype("datetime64[ns]"),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _normalize_numeric(
|
|
128
|
+
frame: pd.DataFrame,
|
|
129
|
+
column: str,
|
|
130
|
+
side: str,
|
|
131
|
+
*,
|
|
132
|
+
nullable: bool,
|
|
133
|
+
) -> pd.Series:
|
|
134
|
+
"""Validate and normalize one financial numeric column."""
|
|
135
|
+
values = cast(pd.Series, frame[column])
|
|
136
|
+
if is_bool_dtype(values.dtype) or not is_numeric_dtype(values.dtype):
|
|
137
|
+
_raise_invalid(side, f"column {column!r} must contain numbers, not strings or booleans")
|
|
138
|
+
if not nullable and _has_true(values.isna()):
|
|
139
|
+
_raise_invalid(side, f"column {column!r} contains null values")
|
|
140
|
+
|
|
141
|
+
normalized = cast(pd.Series, values.astype("float64"))
|
|
142
|
+
finite_values = np.asarray(normalized.dropna(), dtype=np.float64)
|
|
143
|
+
if not np.isfinite(finite_values).all():
|
|
144
|
+
_raise_invalid(side, f"column {column!r} must contain only finite values")
|
|
145
|
+
return normalized
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _normalize_identifiers(frame: pd.DataFrame, side: str) -> pd.Series:
|
|
149
|
+
"""Validate identifiers without coercing their values."""
|
|
150
|
+
identifiers = cast(pd.Series, frame["identifier"])
|
|
151
|
+
identifier_types = cast(
|
|
152
|
+
pd.Series, identifiers.map(lambda value: isinstance(value, str))
|
|
153
|
+
)
|
|
154
|
+
if _has_true(identifiers.isna()) or not bool(
|
|
155
|
+
np.asarray(identifier_types, dtype=np.bool_).all()
|
|
156
|
+
):
|
|
157
|
+
_raise_invalid(side, "column 'identifier' must contain non-null strings")
|
|
158
|
+
normalized = cast(pd.Series, identifiers.astype("string[python]").str.strip())
|
|
159
|
+
if _has_true(normalized.eq("")):
|
|
160
|
+
_raise_invalid(side, "column 'identifier' contains an empty string")
|
|
161
|
+
return normalized
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_period_structure(frame: pd.DataFrame, side: str) -> None:
|
|
165
|
+
"""Validate dates, unique keys, non-overlap, and constant day counts."""
|
|
166
|
+
key_columns = ["from_date", "thru_date", "identifier"]
|
|
167
|
+
if frame.duplicated(key_columns).any():
|
|
168
|
+
_raise_invalid(side, "contains a duplicate period and identifier key")
|
|
169
|
+
if (frame["from_date"] > frame["thru_date"]).any():
|
|
170
|
+
_raise_invalid(side, "contains a from_date after its thru_date")
|
|
171
|
+
|
|
172
|
+
periods = cast(
|
|
173
|
+
pd.DataFrame,
|
|
174
|
+
frame[["from_date", "thru_date", "quantity_of_days"]].drop_duplicates(),
|
|
175
|
+
).sort_values(["thru_date", "from_date"], kind="stable")
|
|
176
|
+
thru_dates = cast(pd.Series, periods["thru_date"])
|
|
177
|
+
if _has_true(thru_dates.duplicated()):
|
|
178
|
+
_raise_invalid(side, "maps one thru_date to more than one reporting period")
|
|
179
|
+
day_counts = cast(
|
|
180
|
+
pd.Series,
|
|
181
|
+
frame.groupby(["from_date", "thru_date"])["quantity_of_days"].nunique(),
|
|
182
|
+
)
|
|
183
|
+
if _has_true(day_counts.gt(1)):
|
|
184
|
+
_raise_invalid(side, "has inconsistent quantity_of_days within a period")
|
|
185
|
+
if len(periods) > 1:
|
|
186
|
+
starts = np.asarray(periods["from_date"], dtype="datetime64[ns]")
|
|
187
|
+
ends = np.asarray(periods["thru_date"], dtype="datetime64[ns]")
|
|
188
|
+
if np.any(starts[1:] <= ends[:-1]):
|
|
189
|
+
_raise_invalid(side, "contains overlapping reporting periods")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _normalize_input(frame: pd.DataFrame, side: str) -> pd.DataFrame:
|
|
193
|
+
"""Validate and copy one caller-owned prepared attribution frame."""
|
|
194
|
+
if frame.columns.has_duplicates:
|
|
195
|
+
_raise_invalid(side, "contains duplicate column labels")
|
|
196
|
+
missing = [column for column in _REQUIRED_COLUMNS if column not in frame.columns]
|
|
197
|
+
if missing:
|
|
198
|
+
_raise_invalid(side, f"is missing required columns: {', '.join(missing)}")
|
|
199
|
+
if frame.empty:
|
|
200
|
+
_raise_invalid(side, "must not be empty")
|
|
201
|
+
|
|
202
|
+
selected_columns = [*_REQUIRED_COLUMNS]
|
|
203
|
+
if "contribution" in frame.columns:
|
|
204
|
+
selected_columns.append("contribution")
|
|
205
|
+
normalized = cast(pd.DataFrame, frame.loc[:, selected_columns].copy(deep=True))
|
|
206
|
+
normalized["from_date"] = _normalize_dates(normalized, "from_date", side)
|
|
207
|
+
normalized["thru_date"] = _normalize_dates(normalized, "thru_date", side)
|
|
208
|
+
normalized["identifier"] = _normalize_identifiers(normalized, side)
|
|
209
|
+
normalized["weight"] = _normalize_numeric(
|
|
210
|
+
normalized, "weight", side, nullable=False
|
|
211
|
+
)
|
|
212
|
+
normalized["return"] = _normalize_numeric(
|
|
213
|
+
normalized, "return", side, nullable=True
|
|
214
|
+
)
|
|
215
|
+
day_values = _normalize_numeric(
|
|
216
|
+
normalized, "quantity_of_days", side, nullable=False
|
|
217
|
+
)
|
|
218
|
+
day_array = np.asarray(day_values, dtype=np.float64)
|
|
219
|
+
if np.any(day_array <= 0.0) or np.any(day_array != np.floor(day_array)):
|
|
220
|
+
_raise_invalid(side, "column 'quantity_of_days' must contain positive integers")
|
|
221
|
+
normalized["quantity_of_days"] = day_values.astype("int64")
|
|
222
|
+
|
|
223
|
+
input_returns = _float_array(normalized, "return")
|
|
224
|
+
weights = _float_array(normalized, "weight")
|
|
225
|
+
present_returns = ~np.isnan(input_returns)
|
|
226
|
+
if np.any(input_returns[present_returns] <= -1.0):
|
|
227
|
+
_raise_invalid(side, "column 'return' must be greater than -1.0 when present")
|
|
228
|
+
if np.any((weights != 0.0) & ~present_returns):
|
|
229
|
+
_raise_invalid(side, "contains a nonzero weight with a null return")
|
|
230
|
+
|
|
231
|
+
if "contribution" in normalized.columns:
|
|
232
|
+
normalized["contribution"] = _normalize_numeric(
|
|
233
|
+
normalized, "contribution", side, nullable=False
|
|
234
|
+
)
|
|
235
|
+
contributions = _float_array(normalized, "contribution")
|
|
236
|
+
invalid_undefined_returns = (
|
|
237
|
+
(weights == 0.0) & (contributions != 0.0) & present_returns
|
|
238
|
+
)
|
|
239
|
+
if np.any(invalid_undefined_returns):
|
|
240
|
+
_raise_invalid(
|
|
241
|
+
side,
|
|
242
|
+
"requires a null return when weight is zero and contribution is nonzero",
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
contributions = np.zeros(len(normalized), dtype=np.float64)
|
|
246
|
+
np.multiply(weights, input_returns, out=contributions, where=present_returns)
|
|
247
|
+
normalized["contribution"] = contributions
|
|
248
|
+
|
|
249
|
+
effective_returns = np.zeros(len(normalized), dtype=np.float64)
|
|
250
|
+
nonzero_weights = weights != 0.0
|
|
251
|
+
np.divide(
|
|
252
|
+
contributions,
|
|
253
|
+
weights,
|
|
254
|
+
out=effective_returns,
|
|
255
|
+
where=nonzero_weights,
|
|
256
|
+
)
|
|
257
|
+
effective_returns[(weights == 0.0) & (contributions != 0.0)] = np.nan
|
|
258
|
+
if not np.isfinite(effective_returns[~np.isnan(effective_returns)]).all():
|
|
259
|
+
_raise_invalid(side, "produces a non-finite effective return")
|
|
260
|
+
|
|
261
|
+
normalized["input_return"] = input_returns
|
|
262
|
+
normalized["effective_return"] = effective_returns
|
|
263
|
+
_validate_period_structure(normalized, side)
|
|
264
|
+
return normalized.reset_index(drop=True)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _period_keys(frame: pd.DataFrame) -> pd.MultiIndex:
|
|
268
|
+
"""Return the distinct normalized reporting-period keys."""
|
|
269
|
+
periods = cast(
|
|
270
|
+
pd.DataFrame, frame[["from_date", "thru_date"]].drop_duplicates()
|
|
271
|
+
).sort_values(["thru_date", "from_date"], kind="stable")
|
|
272
|
+
return pd.MultiIndex.from_frame(periods)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _period_totals(frame: pd.DataFrame) -> pd.DataFrame:
|
|
276
|
+
"""Return sorted day, weight, and contribution totals for each period."""
|
|
277
|
+
totals = cast(
|
|
278
|
+
pd.DataFrame,
|
|
279
|
+
frame.groupby(
|
|
280
|
+
["from_date", "thru_date"],
|
|
281
|
+
as_index=False,
|
|
282
|
+
sort=False,
|
|
283
|
+
observed=True,
|
|
284
|
+
).agg(
|
|
285
|
+
quantity_of_days=("quantity_of_days", "first"),
|
|
286
|
+
weight=("weight", "sum"),
|
|
287
|
+
period_return=("contribution", "sum"),
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
return cast(
|
|
291
|
+
pd.DataFrame,
|
|
292
|
+
totals.sort_values(["thru_date", "from_date"], kind="stable"),
|
|
293
|
+
).reset_index(drop=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _validate_matched_periods(
|
|
297
|
+
portfolio: pd.DataFrame,
|
|
298
|
+
benchmark: pd.DataFrame,
|
|
299
|
+
reconciliation_tolerance: float,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Validate cross-side period, day-count, weight, and return contracts."""
|
|
302
|
+
portfolio_periods = _period_keys(portfolio)
|
|
303
|
+
benchmark_periods = _period_keys(benchmark)
|
|
304
|
+
if not portfolio_periods.equals(benchmark_periods):
|
|
305
|
+
raise AttributionError("portfolio and benchmark reporting periods must match exactly")
|
|
306
|
+
|
|
307
|
+
portfolio_totals = _period_totals(portfolio)
|
|
308
|
+
benchmark_totals = _period_totals(benchmark)
|
|
309
|
+
portfolio_days = np.asarray(portfolio_totals["quantity_of_days"], dtype=np.int64)
|
|
310
|
+
benchmark_days = np.asarray(benchmark_totals["quantity_of_days"], dtype=np.int64)
|
|
311
|
+
if not np.array_equal(portfolio_days, benchmark_days):
|
|
312
|
+
raise AttributionError(
|
|
313
|
+
"portfolio and benchmark quantity_of_days must match for each period"
|
|
314
|
+
)
|
|
315
|
+
for side, totals in (
|
|
316
|
+
("portfolio", portfolio_totals),
|
|
317
|
+
("benchmark", benchmark_totals),
|
|
318
|
+
):
|
|
319
|
+
weight_sums = _float_array(totals, "weight")
|
|
320
|
+
expected_weights = np.ones_like(weight_sums)
|
|
321
|
+
if not _is_close(
|
|
322
|
+
weight_sums,
|
|
323
|
+
expected_weights,
|
|
324
|
+
reconciliation_tolerance,
|
|
325
|
+
).all():
|
|
326
|
+
_raise_invalid(side, "weights must sum to 1.0 within tolerance")
|
|
327
|
+
period_returns = _float_array(totals, "period_return")
|
|
328
|
+
if not np.isfinite(period_returns).all():
|
|
329
|
+
_raise_invalid(side, "period returns must be finite")
|
|
330
|
+
if np.any(period_returns <= -1.0):
|
|
331
|
+
_raise_invalid(side, "period return must be greater than -1.0")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _equalize_universe(portfolio: pd.DataFrame, benchmark: pd.DataFrame) -> pd.DataFrame:
|
|
335
|
+
"""Outer-join the two sides and synthesize neutral missing rows."""
|
|
336
|
+
key_columns = ["from_date", "thru_date", "identifier"]
|
|
337
|
+
side_columns = [
|
|
338
|
+
*key_columns,
|
|
339
|
+
"quantity_of_days",
|
|
340
|
+
"weight",
|
|
341
|
+
"input_return",
|
|
342
|
+
"effective_return",
|
|
343
|
+
"contribution",
|
|
344
|
+
]
|
|
345
|
+
portfolio_side = cast(pd.DataFrame, portfolio.loc[:, side_columns]).assign(
|
|
346
|
+
portfolio_present=True
|
|
347
|
+
)
|
|
348
|
+
benchmark_side = cast(pd.DataFrame, benchmark.loc[:, side_columns]).assign(
|
|
349
|
+
benchmark_present=True
|
|
350
|
+
)
|
|
351
|
+
equalized = portfolio_side.merge(
|
|
352
|
+
benchmark_side,
|
|
353
|
+
on=key_columns,
|
|
354
|
+
how="outer",
|
|
355
|
+
suffixes=("_portfolio", "_benchmark"),
|
|
356
|
+
validate="one_to_one",
|
|
357
|
+
sort=False,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
for side in ("portfolio", "benchmark"):
|
|
361
|
+
missing = equalized[f"{side}_present"].isna()
|
|
362
|
+
for column in ("weight", "input_return", "effective_return", "contribution"):
|
|
363
|
+
equalized.loc[missing, f"{column}_{side}"] = 0.0
|
|
364
|
+
equalized["quantity_of_days"] = equalized[
|
|
365
|
+
"quantity_of_days_portfolio"
|
|
366
|
+
].fillna(equalized["quantity_of_days_benchmark"])
|
|
367
|
+
|
|
368
|
+
equalized = cast(
|
|
369
|
+
pd.DataFrame,
|
|
370
|
+
equalized.sort_values(["thru_date", "identifier"], kind="stable"),
|
|
371
|
+
).reset_index(drop=True)
|
|
372
|
+
return equalized
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _build_period_detail(equalized: pd.DataFrame) -> pd.DataFrame:
|
|
376
|
+
"""Calculate equalized period contributions and unlinked effects."""
|
|
377
|
+
values = {
|
|
378
|
+
"portfolio_weight": _float_array(equalized, "weight_portfolio"),
|
|
379
|
+
"benchmark_weight": _float_array(equalized, "weight_benchmark"),
|
|
380
|
+
"portfolio_return": _float_array(equalized, "effective_return_portfolio"),
|
|
381
|
+
"benchmark_return": _float_array(equalized, "effective_return_benchmark"),
|
|
382
|
+
"portfolio_contribution": _float_array(equalized, "contribution_portfolio"),
|
|
383
|
+
"benchmark_contribution": _float_array(equalized, "contribution_benchmark"),
|
|
384
|
+
}
|
|
385
|
+
benchmark_total_return = np.asarray(
|
|
386
|
+
equalized.groupby(
|
|
387
|
+
["from_date", "thru_date"], sort=False, observed=True
|
|
388
|
+
)["contribution_benchmark"].transform("sum"),
|
|
389
|
+
dtype=np.float64,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
active_weight = values["portfolio_weight"] - values["benchmark_weight"]
|
|
393
|
+
active_return = np.full(len(equalized), np.nan, dtype=np.float64)
|
|
394
|
+
defined_active_return = ~np.isnan(values["portfolio_return"]) & ~np.isnan(
|
|
395
|
+
values["benchmark_return"]
|
|
396
|
+
)
|
|
397
|
+
np.subtract(
|
|
398
|
+
values["portfolio_return"],
|
|
399
|
+
values["benchmark_return"],
|
|
400
|
+
out=active_return,
|
|
401
|
+
where=defined_active_return,
|
|
402
|
+
)
|
|
403
|
+
active_contribution = (
|
|
404
|
+
values["portfolio_contribution"] - values["benchmark_contribution"]
|
|
405
|
+
)
|
|
406
|
+
allocation_effect = np.where(
|
|
407
|
+
np.isnan(values["benchmark_return"]),
|
|
408
|
+
0.0,
|
|
409
|
+
active_weight * (values["benchmark_return"] - benchmark_total_return),
|
|
410
|
+
)
|
|
411
|
+
total_effect = active_contribution - active_weight * benchmark_total_return
|
|
412
|
+
selection_effect = total_effect - allocation_effect
|
|
413
|
+
|
|
414
|
+
detail = pd.DataFrame(
|
|
415
|
+
{
|
|
416
|
+
"from_date": equalized["from_date"],
|
|
417
|
+
"thru_date": equalized["thru_date"],
|
|
418
|
+
"quantity_of_days": equalized["quantity_of_days"].astype("int64"),
|
|
419
|
+
"identifier": equalized["identifier"].astype("string[python]"),
|
|
420
|
+
"portfolio_weight": values["portfolio_weight"],
|
|
421
|
+
"portfolio_return": values["portfolio_return"],
|
|
422
|
+
"portfolio_contribution": values["portfolio_contribution"],
|
|
423
|
+
"benchmark_weight": values["benchmark_weight"],
|
|
424
|
+
"benchmark_return": values["benchmark_return"],
|
|
425
|
+
"benchmark_contribution": values["benchmark_contribution"],
|
|
426
|
+
"active_weight": active_weight,
|
|
427
|
+
"active_return": active_return,
|
|
428
|
+
"active_contribution": active_contribution,
|
|
429
|
+
"allocation_effect": allocation_effect,
|
|
430
|
+
"selection_effect": selection_effect,
|
|
431
|
+
"total_effect": total_effect,
|
|
432
|
+
"linked_portfolio_contribution": values[
|
|
433
|
+
"portfolio_contribution"
|
|
434
|
+
].copy(),
|
|
435
|
+
"linked_benchmark_contribution": values[
|
|
436
|
+
"benchmark_contribution"
|
|
437
|
+
].copy(),
|
|
438
|
+
"linked_active_contribution": active_contribution.copy(),
|
|
439
|
+
"linked_allocation_effect": allocation_effect.copy(),
|
|
440
|
+
"linked_selection_effect": selection_effect.copy(),
|
|
441
|
+
"linked_total_effect": total_effect.copy(),
|
|
442
|
+
},
|
|
443
|
+
columns=PERIOD_DETAIL_COLUMNS,
|
|
444
|
+
)
|
|
445
|
+
return detail.reset_index(drop=True)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _link_period_detail(detail: pd.DataFrame) -> pd.DataFrame:
|
|
449
|
+
"""Apply full-horizon logarithmic and Carino linking coefficients."""
|
|
450
|
+
period_keys = ["from_date", "thru_date"]
|
|
451
|
+
grouped = detail.groupby(period_keys, sort=False, observed=True)
|
|
452
|
+
portfolio_period_returns = np.asarray(
|
|
453
|
+
grouped["portfolio_contribution"].sum(), dtype=np.float64
|
|
454
|
+
)
|
|
455
|
+
benchmark_period_returns = np.asarray(
|
|
456
|
+
grouped["benchmark_contribution"].sum(), dtype=np.float64
|
|
457
|
+
)
|
|
458
|
+
portfolio_horizon_return = _compound_returns(portfolio_period_returns)
|
|
459
|
+
benchmark_horizon_return = _compound_returns(benchmark_period_returns)
|
|
460
|
+
horizon_returns = np.asarray(
|
|
461
|
+
[portfolio_horizon_return, benchmark_horizon_return], dtype=np.float64
|
|
462
|
+
)
|
|
463
|
+
if np.any(horizon_returns <= -1.0) or not np.isfinite(horizon_returns).all():
|
|
464
|
+
raise AttributionError("compounded horizon returns must be finite and greater than -1.0")
|
|
465
|
+
|
|
466
|
+
with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
|
|
467
|
+
portfolio_coefficients = _smoothing(portfolio_period_returns) / _smoothing(
|
|
468
|
+
np.asarray([portfolio_horizon_return], dtype=np.float64)
|
|
469
|
+
)[0]
|
|
470
|
+
benchmark_coefficients = _smoothing(benchmark_period_returns) / _smoothing(
|
|
471
|
+
np.asarray([benchmark_horizon_return], dtype=np.float64)
|
|
472
|
+
)[0]
|
|
473
|
+
active_coefficients = _carino(
|
|
474
|
+
portfolio_period_returns, benchmark_period_returns
|
|
475
|
+
) / _carino(
|
|
476
|
+
np.asarray([portfolio_horizon_return], dtype=np.float64),
|
|
477
|
+
np.asarray([benchmark_horizon_return], dtype=np.float64),
|
|
478
|
+
)[0]
|
|
479
|
+
if not all(
|
|
480
|
+
np.isfinite(coefficients).all()
|
|
481
|
+
for coefficients in (
|
|
482
|
+
portfolio_coefficients,
|
|
483
|
+
benchmark_coefficients,
|
|
484
|
+
active_coefficients,
|
|
485
|
+
)
|
|
486
|
+
):
|
|
487
|
+
raise AttributionError("linking coefficients must be finite")
|
|
488
|
+
period_codes = np.asarray(grouped.ngroup(), dtype=np.int64)
|
|
489
|
+
linked = detail.copy(deep=True)
|
|
490
|
+
linked["linked_portfolio_contribution"] = (
|
|
491
|
+
_float_array(detail, "portfolio_contribution")
|
|
492
|
+
* portfolio_coefficients[period_codes]
|
|
493
|
+
)
|
|
494
|
+
linked["linked_benchmark_contribution"] = (
|
|
495
|
+
_float_array(detail, "benchmark_contribution")
|
|
496
|
+
* benchmark_coefficients[period_codes]
|
|
497
|
+
)
|
|
498
|
+
linked["linked_active_contribution"] = (
|
|
499
|
+
_float_array(linked, "linked_portfolio_contribution")
|
|
500
|
+
- _float_array(linked, "linked_benchmark_contribution")
|
|
501
|
+
)
|
|
502
|
+
for linked_column, simple_column in (
|
|
503
|
+
("linked_allocation_effect", "allocation_effect"),
|
|
504
|
+
("linked_selection_effect", "selection_effect"),
|
|
505
|
+
("linked_total_effect", "total_effect"),
|
|
506
|
+
):
|
|
507
|
+
linked[linked_column] = (
|
|
508
|
+
_float_array(detail, simple_column) * active_coefficients[period_codes]
|
|
509
|
+
)
|
|
510
|
+
return linked.reset_index(drop=True)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _column_sum(frame: pd.DataFrame, column: str) -> float:
|
|
514
|
+
"""Return a numeric result column's sum as an ordinary float."""
|
|
515
|
+
return float(_float_array(frame, column).sum())
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _build_period_summary(detail: pd.DataFrame) -> pd.DataFrame:
|
|
519
|
+
"""Summarize calculated detail rows for every reporting period."""
|
|
520
|
+
value_columns = """portfolio_contribution benchmark_contribution
|
|
521
|
+
active_contribution allocation_effect selection_effect total_effect
|
|
522
|
+
linked_portfolio_contribution linked_benchmark_contribution
|
|
523
|
+
linked_active_contribution linked_allocation_effect linked_selection_effect
|
|
524
|
+
linked_total_effect""".split()
|
|
525
|
+
summary = cast(
|
|
526
|
+
pd.DataFrame,
|
|
527
|
+
detail.groupby(
|
|
528
|
+
["from_date", "thru_date", "quantity_of_days"],
|
|
529
|
+
as_index=False,
|
|
530
|
+
sort=False,
|
|
531
|
+
observed=True,
|
|
532
|
+
)[value_columns].sum(),
|
|
533
|
+
)
|
|
534
|
+
summary["portfolio_return"] = summary["portfolio_contribution"]
|
|
535
|
+
summary["benchmark_return"] = summary["benchmark_contribution"]
|
|
536
|
+
summary["active_return"] = (
|
|
537
|
+
summary["portfolio_return"] - summary["benchmark_return"]
|
|
538
|
+
)
|
|
539
|
+
return cast(pd.DataFrame, summary.loc[:, PERIOD_SUMMARY_COLUMNS]).reset_index(
|
|
540
|
+
drop=True
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _build_overall_detail(
|
|
545
|
+
equalized: pd.DataFrame, detail: pd.DataFrame
|
|
546
|
+
) -> pd.DataFrame:
|
|
547
|
+
"""Build full-horizon identifier rows from supplied returns and linked values."""
|
|
548
|
+
period_days = cast(
|
|
549
|
+
pd.Series,
|
|
550
|
+
equalized.groupby(
|
|
551
|
+
["from_date", "thru_date"], sort=False, observed=True
|
|
552
|
+
)["quantity_of_days"].first(),
|
|
553
|
+
)
|
|
554
|
+
total_days = float(np.asarray(period_days, dtype=np.float64).sum())
|
|
555
|
+
if not np.isfinite(total_days):
|
|
556
|
+
raise AttributionError("the complete horizon quantity_of_days must be finite")
|
|
557
|
+
portfolio = _build_overall_side(equalized, "portfolio", total_days)
|
|
558
|
+
benchmark = _build_overall_side(equalized, "benchmark", total_days)
|
|
559
|
+
|
|
560
|
+
linked_columns = """linked_portfolio_contribution
|
|
561
|
+
linked_benchmark_contribution linked_active_contribution
|
|
562
|
+
linked_allocation_effect linked_selection_effect linked_total_effect""".split()
|
|
563
|
+
linked = cast(
|
|
564
|
+
pd.DataFrame,
|
|
565
|
+
detail.groupby(
|
|
566
|
+
"identifier", as_index=False, sort=True, observed=True
|
|
567
|
+
)[linked_columns].sum(),
|
|
568
|
+
)
|
|
569
|
+
overall = portfolio.merge(
|
|
570
|
+
benchmark,
|
|
571
|
+
on="identifier",
|
|
572
|
+
how="inner",
|
|
573
|
+
validate="one_to_one",
|
|
574
|
+
).merge(linked, on="identifier", how="inner", validate="one_to_one")
|
|
575
|
+
portfolio_return = _float_array(overall, "portfolio_return")
|
|
576
|
+
benchmark_return = _float_array(overall, "benchmark_return")
|
|
577
|
+
active_return = np.full(len(overall), np.nan, dtype=np.float64)
|
|
578
|
+
defined_active_return = ~np.isnan(portfolio_return) & ~np.isnan(benchmark_return)
|
|
579
|
+
np.subtract(
|
|
580
|
+
portfolio_return,
|
|
581
|
+
benchmark_return,
|
|
582
|
+
out=active_return,
|
|
583
|
+
where=defined_active_return,
|
|
584
|
+
)
|
|
585
|
+
horizon = pd.DataFrame(
|
|
586
|
+
{
|
|
587
|
+
"from_date": detail.at[0, "from_date"],
|
|
588
|
+
"thru_date": detail.at[len(detail) - 1, "thru_date"],
|
|
589
|
+
"identifier": overall["identifier"],
|
|
590
|
+
"portfolio_weight": overall["portfolio_weight"],
|
|
591
|
+
"portfolio_return": portfolio_return,
|
|
592
|
+
"linked_portfolio_contribution": overall[
|
|
593
|
+
"linked_portfolio_contribution"
|
|
594
|
+
],
|
|
595
|
+
"benchmark_weight": overall["benchmark_weight"],
|
|
596
|
+
"benchmark_return": benchmark_return,
|
|
597
|
+
"linked_benchmark_contribution": overall[
|
|
598
|
+
"linked_benchmark_contribution"
|
|
599
|
+
],
|
|
600
|
+
"active_weight": (
|
|
601
|
+
_float_array(overall, "portfolio_weight")
|
|
602
|
+
- _float_array(overall, "benchmark_weight")
|
|
603
|
+
),
|
|
604
|
+
"active_return": active_return,
|
|
605
|
+
"linked_active_contribution": overall["linked_active_contribution"],
|
|
606
|
+
"linked_allocation_effect": overall["linked_allocation_effect"],
|
|
607
|
+
"linked_selection_effect": overall["linked_selection_effect"],
|
|
608
|
+
"linked_total_effect": overall["linked_total_effect"],
|
|
609
|
+
},
|
|
610
|
+
columns=OVERALL_DETAIL_COLUMNS,
|
|
611
|
+
)
|
|
612
|
+
return horizon.reset_index(drop=True)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _build_overall_side(
|
|
616
|
+
equalized: pd.DataFrame, side: str, total_days: float
|
|
617
|
+
) -> pd.DataFrame:
|
|
618
|
+
"""Aggregate one side's day-weighted exposure and supplied horizon return."""
|
|
619
|
+
input_returns = _float_array(equalized, f"input_return_{side}")
|
|
620
|
+
defined_returns = ~np.isnan(input_returns)
|
|
621
|
+
log_returns = np.zeros(len(equalized), dtype=np.float64)
|
|
622
|
+
np.log1p(input_returns, out=log_returns, where=defined_returns)
|
|
623
|
+
working = pd.DataFrame(
|
|
624
|
+
{
|
|
625
|
+
"identifier": equalized["identifier"],
|
|
626
|
+
"weighted_weight": (
|
|
627
|
+
_float_array(equalized, f"weight_{side}")
|
|
628
|
+
* _float_array(equalized, "quantity_of_days")
|
|
629
|
+
),
|
|
630
|
+
"log_return": log_returns,
|
|
631
|
+
"undefined_return": ~defined_returns,
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
aggregated = cast(
|
|
635
|
+
pd.DataFrame,
|
|
636
|
+
working.groupby(
|
|
637
|
+
"identifier", as_index=False, sort=True, observed=True
|
|
638
|
+
).agg(
|
|
639
|
+
weighted_weight=("weighted_weight", "sum"),
|
|
640
|
+
log_return=("log_return", "sum"),
|
|
641
|
+
undefined_return=("undefined_return", "max"),
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
645
|
+
compounded_returns = np.expm1(_float_array(aggregated, "log_return"))
|
|
646
|
+
undefined_returns = np.asarray(aggregated["undefined_return"], dtype=np.bool_)
|
|
647
|
+
if not np.isfinite(compounded_returns[~undefined_returns]).all():
|
|
648
|
+
raise AttributionError(f"{side} overall returns must be finite when defined")
|
|
649
|
+
compounded_returns[undefined_returns] = np.nan
|
|
650
|
+
return pd.DataFrame(
|
|
651
|
+
{
|
|
652
|
+
"identifier": aggregated["identifier"].astype("string[python]"),
|
|
653
|
+
f"{side}_weight": (
|
|
654
|
+
_float_array(aggregated, "weighted_weight") / total_days
|
|
655
|
+
),
|
|
656
|
+
f"{side}_return": compounded_returns,
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _build_cumulative(summary: pd.DataFrame) -> pd.DataFrame:
|
|
662
|
+
"""Build chronological period values and their cumulative counterparts."""
|
|
663
|
+
portfolio_returns = _float_array(summary, "portfolio_return")
|
|
664
|
+
benchmark_returns = _float_array(summary, "benchmark_return")
|
|
665
|
+
cumulative_portfolio_returns = np.expm1(np.cumsum(np.log1p(portfolio_returns)))
|
|
666
|
+
cumulative_benchmark_returns = np.expm1(np.cumsum(np.log1p(benchmark_returns)))
|
|
667
|
+
cumulative = pd.DataFrame(
|
|
668
|
+
{
|
|
669
|
+
"from_date": summary["from_date"],
|
|
670
|
+
"thru_date": summary["thru_date"],
|
|
671
|
+
"portfolio_return": portfolio_returns,
|
|
672
|
+
"benchmark_return": benchmark_returns,
|
|
673
|
+
"active_return": _float_array(summary, "active_return"),
|
|
674
|
+
"cumulative_portfolio_return": cumulative_portfolio_returns,
|
|
675
|
+
"cumulative_benchmark_return": cumulative_benchmark_returns,
|
|
676
|
+
"cumulative_active_return": (
|
|
677
|
+
cumulative_portfolio_returns - cumulative_benchmark_returns
|
|
678
|
+
),
|
|
679
|
+
"linked_portfolio_contribution": summary[
|
|
680
|
+
"linked_portfolio_contribution"
|
|
681
|
+
],
|
|
682
|
+
"linked_benchmark_contribution": summary[
|
|
683
|
+
"linked_benchmark_contribution"
|
|
684
|
+
],
|
|
685
|
+
"linked_active_contribution": summary["linked_active_contribution"],
|
|
686
|
+
"cumulative_portfolio_contribution": np.cumsum(
|
|
687
|
+
_float_array(summary, "linked_portfolio_contribution")
|
|
688
|
+
),
|
|
689
|
+
"cumulative_benchmark_contribution": np.cumsum(
|
|
690
|
+
_float_array(summary, "linked_benchmark_contribution")
|
|
691
|
+
),
|
|
692
|
+
"cumulative_active_contribution": np.cumsum(
|
|
693
|
+
_float_array(summary, "linked_active_contribution")
|
|
694
|
+
),
|
|
695
|
+
"linked_allocation_effect": summary["linked_allocation_effect"],
|
|
696
|
+
"linked_selection_effect": summary["linked_selection_effect"],
|
|
697
|
+
"linked_total_effect": summary["linked_total_effect"],
|
|
698
|
+
"cumulative_allocation_effect": np.cumsum(
|
|
699
|
+
_float_array(summary, "linked_allocation_effect")
|
|
700
|
+
),
|
|
701
|
+
"cumulative_selection_effect": np.cumsum(
|
|
702
|
+
_float_array(summary, "linked_selection_effect")
|
|
703
|
+
),
|
|
704
|
+
"cumulative_total_effect": np.cumsum(
|
|
705
|
+
_float_array(summary, "linked_total_effect")
|
|
706
|
+
),
|
|
707
|
+
},
|
|
708
|
+
columns=CUMULATIVE_COLUMNS,
|
|
709
|
+
)
|
|
710
|
+
return cumulative.reset_index(drop=True)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _build_period_reconciliation(
|
|
714
|
+
detail: pd.DataFrame, summary: pd.DataFrame
|
|
715
|
+
) -> pd.DataFrame:
|
|
716
|
+
"""Build reconciliation inputs for every reporting period."""
|
|
717
|
+
aggregate_columns = """portfolio_weight benchmark_weight
|
|
718
|
+
portfolio_contribution benchmark_contribution active_contribution
|
|
719
|
+
allocation_effect selection_effect total_effect""".split()
|
|
720
|
+
period_totals = cast(
|
|
721
|
+
pd.DataFrame,
|
|
722
|
+
detail.groupby(
|
|
723
|
+
["from_date", "thru_date"],
|
|
724
|
+
as_index=False,
|
|
725
|
+
sort=False,
|
|
726
|
+
observed=True,
|
|
727
|
+
)[aggregate_columns].sum(),
|
|
728
|
+
)
|
|
729
|
+
period_actual = np.column_stack(
|
|
730
|
+
(
|
|
731
|
+
_float_array(period_totals, "portfolio_weight"),
|
|
732
|
+
_float_array(period_totals, "benchmark_weight"),
|
|
733
|
+
_float_array(period_totals, "portfolio_contribution"),
|
|
734
|
+
_float_array(period_totals, "benchmark_contribution"),
|
|
735
|
+
_float_array(period_totals, "active_contribution"),
|
|
736
|
+
_float_array(period_totals, "allocation_effect")
|
|
737
|
+
+ _float_array(period_totals, "selection_effect"),
|
|
738
|
+
_float_array(period_totals, "total_effect"),
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
period_expected = np.column_stack(
|
|
742
|
+
(
|
|
743
|
+
np.ones(len(summary), dtype=np.float64),
|
|
744
|
+
np.ones(len(summary), dtype=np.float64),
|
|
745
|
+
_float_array(summary, "portfolio_return"),
|
|
746
|
+
_float_array(summary, "benchmark_return"),
|
|
747
|
+
_float_array(summary, "active_return"),
|
|
748
|
+
_float_array(summary, "total_effect"),
|
|
749
|
+
_float_array(summary, "active_return"),
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
check_count = len(PERIOD_RECONCILIATION_CHECKS)
|
|
753
|
+
return pd.DataFrame(
|
|
754
|
+
{
|
|
755
|
+
"scope": "period",
|
|
756
|
+
"from_date": np.repeat(
|
|
757
|
+
np.asarray(summary["from_date"], dtype="datetime64[ns]"),
|
|
758
|
+
check_count,
|
|
759
|
+
),
|
|
760
|
+
"thru_date": np.repeat(
|
|
761
|
+
np.asarray(summary["thru_date"], dtype="datetime64[ns]"),
|
|
762
|
+
check_count,
|
|
763
|
+
),
|
|
764
|
+
"check": np.tile(PERIOD_RECONCILIATION_CHECKS, len(summary)),
|
|
765
|
+
"actual": period_actual.ravel(),
|
|
766
|
+
"expected": period_expected.ravel(),
|
|
767
|
+
}
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _build_overall_reconciliation(
|
|
772
|
+
detail: pd.DataFrame, summary: pd.DataFrame
|
|
773
|
+
) -> pd.DataFrame:
|
|
774
|
+
"""Build reconciliation inputs for the complete requested horizon."""
|
|
775
|
+
portfolio_return = _compound_returns(_float_array(summary, "portfolio_return"))
|
|
776
|
+
benchmark_return = _compound_returns(_float_array(summary, "benchmark_return"))
|
|
777
|
+
active_return = portfolio_return - benchmark_return
|
|
778
|
+
overall_actual = np.asarray(
|
|
779
|
+
[
|
|
780
|
+
_column_sum(detail, "linked_portfolio_contribution"),
|
|
781
|
+
_column_sum(detail, "linked_benchmark_contribution"),
|
|
782
|
+
_column_sum(detail, "linked_active_contribution"),
|
|
783
|
+
_column_sum(detail, "linked_allocation_effect")
|
|
784
|
+
+ _column_sum(detail, "linked_selection_effect"),
|
|
785
|
+
_column_sum(detail, "linked_total_effect"),
|
|
786
|
+
],
|
|
787
|
+
dtype=np.float64,
|
|
788
|
+
)
|
|
789
|
+
overall_expected = np.asarray(
|
|
790
|
+
[
|
|
791
|
+
portfolio_return,
|
|
792
|
+
benchmark_return,
|
|
793
|
+
active_return,
|
|
794
|
+
overall_actual[4],
|
|
795
|
+
active_return,
|
|
796
|
+
],
|
|
797
|
+
dtype=np.float64,
|
|
798
|
+
)
|
|
799
|
+
return pd.DataFrame(
|
|
800
|
+
{
|
|
801
|
+
"scope": "overall",
|
|
802
|
+
"from_date": detail.at[0, "from_date"],
|
|
803
|
+
"thru_date": detail.at[len(detail) - 1, "thru_date"],
|
|
804
|
+
"check": OVERALL_RECONCILIATION_CHECKS,
|
|
805
|
+
"actual": overall_actual,
|
|
806
|
+
"expected": overall_expected,
|
|
807
|
+
}
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _build_reconciliation(
|
|
812
|
+
detail: pd.DataFrame,
|
|
813
|
+
summary: pd.DataFrame,
|
|
814
|
+
reconciliation_tolerance: float,
|
|
815
|
+
) -> pd.DataFrame:
|
|
816
|
+
"""Build positive period and overall financial reconciliation evidence."""
|
|
817
|
+
reconciliation = pd.concat(
|
|
818
|
+
[
|
|
819
|
+
_build_period_reconciliation(detail, summary),
|
|
820
|
+
_build_overall_reconciliation(detail, summary),
|
|
821
|
+
],
|
|
822
|
+
ignore_index=True,
|
|
823
|
+
)
|
|
824
|
+
reconciliation["residual"] = (
|
|
825
|
+
reconciliation["actual"] - reconciliation["expected"]
|
|
826
|
+
)
|
|
827
|
+
reconciliation["tolerance"] = reconciliation_tolerance
|
|
828
|
+
reconciliation["passed"] = _is_close(
|
|
829
|
+
_float_array(reconciliation, "actual"),
|
|
830
|
+
_float_array(reconciliation, "expected"),
|
|
831
|
+
reconciliation_tolerance,
|
|
832
|
+
)
|
|
833
|
+
reconciliation = reconciliation.loc[:, RECONCILIATION_COLUMNS]
|
|
834
|
+
reconciliation["scope"] = reconciliation["scope"].astype("string[python]")
|
|
835
|
+
reconciliation["check"] = reconciliation["check"].astype("string[python]")
|
|
836
|
+
reconciliation["passed"] = reconciliation["passed"].astype("bool")
|
|
837
|
+
passed = cast(pd.Series, reconciliation["passed"])
|
|
838
|
+
if not bool(np.asarray(passed, dtype=np.bool_).all()):
|
|
839
|
+
failed = cast(
|
|
840
|
+
pd.Series, reconciliation.loc[~reconciliation["passed"], "check"]
|
|
841
|
+
)
|
|
842
|
+
failed_checks = ", ".join(
|
|
843
|
+
cast(pd.Series, failed.astype(str))
|
|
844
|
+
)
|
|
845
|
+
raise AttributionError(f"calculation reconciliation failed: {failed_checks}")
|
|
846
|
+
return cast(pd.DataFrame, reconciliation.reset_index(drop=True))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _validate_result_values(
|
|
850
|
+
name: str,
|
|
851
|
+
frame: pd.DataFrame,
|
|
852
|
+
nullable_columns: tuple[str, ...] = (),
|
|
853
|
+
) -> None:
|
|
854
|
+
"""Require finite result numbers except in specified undefined-return fields."""
|
|
855
|
+
numeric = cast(pd.DataFrame, frame.select_dtypes(include="number"))
|
|
856
|
+
for column in numeric.columns:
|
|
857
|
+
values = _float_array(numeric, column)
|
|
858
|
+
valid = np.isfinite(values)
|
|
859
|
+
if column in nullable_columns:
|
|
860
|
+
valid |= np.isnan(values)
|
|
861
|
+
if not valid.all():
|
|
862
|
+
raise AttributionError(f"{name} column {column!r} contains a non-finite value")
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def calculate_attribution(
|
|
866
|
+
portfolio: pd.DataFrame,
|
|
867
|
+
benchmark: pd.DataFrame,
|
|
868
|
+
*,
|
|
869
|
+
reconciliation_tolerance: float = _TOLERANCE,
|
|
870
|
+
) -> AttributionResult:
|
|
871
|
+
"""Calculate portable multi-period Brinson-Fachler attribution.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
portfolio: Prepared portfolio rows satisfying the portable input contract.
|
|
875
|
+
benchmark: Prepared benchmark rows for the same reporting periods.
|
|
876
|
+
reconciliation_tolerance: Positive finite relative and absolute tolerance
|
|
877
|
+
used for input weight totals and returned reconciliation evidence. The
|
|
878
|
+
standalone default is ``1e-12``; a host may explicitly request a wider
|
|
879
|
+
compatibility tolerance without changing any calculation formula.
|
|
880
|
+
|
|
881
|
+
Returns:
|
|
882
|
+
Five new, caller-owned result frames containing period, overall, cumulative,
|
|
883
|
+
and reconciliation values.
|
|
884
|
+
|
|
885
|
+
Raises:
|
|
886
|
+
TypeError: If either input is not a pandas DataFrame.
|
|
887
|
+
AttributionError: If financial input is invalid or a calculation invariant
|
|
888
|
+
fails.
|
|
889
|
+
|
|
890
|
+
Notes:
|
|
891
|
+
Selection is portfolio-weighted and absorbs interaction. Contributions use
|
|
892
|
+
logarithmic linking; active effects use Carino linking. Supplied contribution
|
|
893
|
+
is authoritative; otherwise contribution is derived as weight multiplied by
|
|
894
|
+
return.
|
|
895
|
+
"""
|
|
896
|
+
if not isinstance(portfolio, pd.DataFrame):
|
|
897
|
+
raise TypeError("portfolio must be a pandas DataFrame")
|
|
898
|
+
if not isinstance(benchmark, pd.DataFrame):
|
|
899
|
+
raise TypeError("benchmark must be a pandas DataFrame")
|
|
900
|
+
tolerance = _normalize_reconciliation_tolerance(reconciliation_tolerance)
|
|
901
|
+
|
|
902
|
+
with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
|
|
903
|
+
normalized_portfolio = _normalize_input(portfolio, "portfolio")
|
|
904
|
+
normalized_benchmark = _normalize_input(benchmark, "benchmark")
|
|
905
|
+
_validate_matched_periods(
|
|
906
|
+
normalized_portfolio,
|
|
907
|
+
normalized_benchmark,
|
|
908
|
+
tolerance,
|
|
909
|
+
)
|
|
910
|
+
equalized = _equalize_universe(normalized_portfolio, normalized_benchmark)
|
|
911
|
+
period_detail = _link_period_detail(_build_period_detail(equalized))
|
|
912
|
+
period_summary = _build_period_summary(period_detail)
|
|
913
|
+
overall_detail = _build_overall_detail(equalized, period_detail)
|
|
914
|
+
cumulative = _build_cumulative(period_summary)
|
|
915
|
+
reconciliation = _build_reconciliation(
|
|
916
|
+
period_detail,
|
|
917
|
+
period_summary,
|
|
918
|
+
tolerance,
|
|
919
|
+
)
|
|
920
|
+
_validate_result_values(
|
|
921
|
+
"period_detail",
|
|
922
|
+
period_detail,
|
|
923
|
+
("portfolio_return", "benchmark_return", "active_return"),
|
|
924
|
+
)
|
|
925
|
+
_validate_result_values(
|
|
926
|
+
"overall_detail",
|
|
927
|
+
overall_detail,
|
|
928
|
+
("portfolio_return", "benchmark_return", "active_return"),
|
|
929
|
+
)
|
|
930
|
+
for name, frame in (
|
|
931
|
+
("period_summary", period_summary),
|
|
932
|
+
("cumulative", cumulative),
|
|
933
|
+
("reconciliation", reconciliation),
|
|
934
|
+
):
|
|
935
|
+
_validate_result_values(name, frame)
|
|
936
|
+
return AttributionResult(
|
|
937
|
+
period_detail=period_detail,
|
|
938
|
+
period_summary=period_summary,
|
|
939
|
+
overall_detail=overall_detail,
|
|
940
|
+
cumulative=cumulative,
|
|
941
|
+
reconciliation=reconciliation,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
__all__ = ["AttributionError", "AttributionResult", "calculate_attribution"]
|
perfattr/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: perfattr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auditable portfolio performance attribution using pandas and NumPy.
|
|
5
|
+
Project-URL: Repository, https://github.com/JohnDReynolds/perfattr
|
|
6
|
+
Author: John Reynolds
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: attribution,brinson,finance,performance,portfolio
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: numpy>=1.26
|
|
20
|
+
Requires-Dist: pandas>=2.2
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: pylint>=4.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pyright>=1.1.409; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: twine>=6.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# perfattr
|
|
30
|
+
|
|
31
|
+
`perfattr` is a small, auditable portfolio performance-attribution calculation
|
|
32
|
+
library built with pandas and NumPy.
|
|
33
|
+
|
|
34
|
+
The 0.1.0 release provides a reusable Brinson-Fachler calculation core for
|
|
35
|
+
prepared reporting-period data. Source loading, portfolio accounting, vendor schemas,
|
|
36
|
+
calendar logic, and presentation are intentionally outside the package boundary.
|
|
37
|
+
|
|
38
|
+
The calculation core accepts one or more prepared reporting periods and provides
|
|
39
|
+
input validation, universe equalization, Brinson-Fachler allocation and selection,
|
|
40
|
+
logarithmic contribution linking, Carino active-effect linking, cumulative and
|
|
41
|
+
full-horizon results, and financial reconciliation. The governing roadmap is available
|
|
42
|
+
in [`_extras/perfattr_roadmap.md`](_extras/perfattr_roadmap.md), and the complete
|
|
43
|
+
portable calculation contract is defined in
|
|
44
|
+
[`docs/specification.md`](docs/specification.md).
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from perfattr import calculate_attribution
|
|
48
|
+
|
|
49
|
+
result = calculate_attribution(portfolio, benchmark)
|
|
50
|
+
print(result.period_detail)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
Create and activate a virtual environment:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python3 -m venv .venv
|
|
59
|
+
source .venv/bin/activate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Install the package and development dependencies:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python -m pip install --editable ".[dev]"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run the initial checks:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python -m pytest
|
|
72
|
+
python -m pylint src/perfattr tests scripts
|
|
73
|
+
python -m pyright
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run the four roadmap performance workloads:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python scripts/benchmark_core.py --samples 5
|
|
80
|
+
python scripts/benchmark_core.py --samples 5 --input-form authoritative
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Add `--workload monthly_121260 --profile` to inspect one workload's cumulative
|
|
84
|
+
call profile. The benchmark methodology and initial observations are recorded in
|
|
85
|
+
[`docs/performance.md`](docs/performance.md).
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
`perfattr` is distributed under the MIT License.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
perfattr/__init__.py,sha256=7np-78_e5dgEJ2hUmb_ntXoY7bkGjvifBRMPzRxIlPg,308
|
|
2
|
+
perfattr/_linking.py,sha256=O7GQeCvFF4HtEh5nKiIcSQ5093Wg-vLeTStYOEP6V0M,1287
|
|
3
|
+
perfattr/_schemas.py,sha256=3b_GZlPfMQeHcCEhkSJ5O86oCELb2U1Fvj1brZOPzbU,2804
|
|
4
|
+
perfattr/attribution.py,sha256=uxyq-_mID1txURfc4r_x3Zp0yontaPnn_3WpEUTj1eQ,38284
|
|
5
|
+
perfattr/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
perfattr-0.1.0.dist-info/METADATA,sha256=6JPaNgKL0_0s2KzkLZj_G3UEkb8y__8rcW_P8IhmBhI,2877
|
|
7
|
+
perfattr-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
perfattr-0.1.0.dist-info/licenses/LICENSE,sha256=gupE58P8MWMDwzrvVztNkjwqWueqJQpuz1EnroX0wCE,1070
|
|
9
|
+
perfattr-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Reynolds
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|