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 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
+ )
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.