pybrinson 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ dist/
9
+ wheels/
10
+ *.egg-info/
11
+ *.egg
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ .coverage.*
17
+ htmlcov/
18
+ .tox/
19
+ .nox/
20
+
21
+ # uv
22
+ .venv/
23
+ uv.lock.bak
24
+
25
+ # IDE
26
+ .idea/
27
+ .vscode/
28
+ *.swp
29
+ *.swo
30
+
31
+ # OS
32
+ .DS_Store
33
+ Thumbs.db
34
+
35
+ # Local env / secrets
36
+ .env
37
+ .env.*
38
+ !.env.example
39
+ *.pem
40
+ *.key
41
+ secrets/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pybrinson contributors
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.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: pybrinson
3
+ Version: 1.0.0
4
+ Summary: Portfolio return attribution in Python: Brinson-Hood-Beebower, Brinson-Fachler, and multi-period linking.
5
+ Project-URL: Homepage, https://github.com/gghez/pybrinson
6
+ Project-URL: Repository, https://github.com/gghez/pybrinson
7
+ Project-URL: Issues, https://github.com/gghez/pybrinson/issues
8
+ Author: pybrinson contributors
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asset-management,attribution,brinson,finance,performance,portfolio
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Office/Business :: Financial :: Investment
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.14
20
+ Description-Content-Type: text/markdown
21
+
22
+ # pybrinson
23
+
24
+ [![CI](https://github.com/gghez/pybrinson/actions/workflows/ci.yml/badge.svg)](https://github.com/gghez/pybrinson/actions/workflows/ci.yml)
25
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
26
+
27
+ Portfolio return attribution in Python — with sources you can audit.
28
+
29
+ `pybrinson` decomposes a portfolio's excess return versus a benchmark into
30
+ **allocation**, **selection**, and **interaction** effects, across any
31
+ user-defined classification (sector, country, asset class). Every formula
32
+ ships with its mathematical statement, an academic citation, and a
33
+ clickable URL — readers can verify the math without leaving the file.
34
+
35
+ It targets the gap left by the existing Python finance stack: R has the
36
+ `pa` package on CRAN and MATLAB ships `brinsonAttribution`, but no
37
+ maintained PyPI package implements the Brinson family of models.
38
+
39
+ ## Status
40
+
41
+ v1.0 — single-period BHB and Brinson-Fachler plus Cariño / GRAP /
42
+ geometric multi-period linking. Single-level classification only;
43
+ nested hierarchies are scheduled for v1.1.
44
+
45
+ ## Methods supported
46
+
47
+ | | Method | Reference |
48
+ |---|---|---|
49
+ | Single-period | Brinson-Hood-Beebower (3-effect) | Brinson, Hood & Beebower (1986) |
50
+ | Single-period | Brinson-Fachler (3-effect) | Brinson & Fachler (1985) |
51
+ | Multi-period linking | Cariño log-smoothing | Cariño (1999) |
52
+ | Multi-period linking | GRAP factors | GRAP (1997) |
53
+ | Multi-period linking | Geometric (Bacon) | Bacon (2008), chap. 6 |
54
+
55
+ Deferred to v1.1+: Menchero / Frongello linking, multi-level (nested)
56
+ classification, currency attribution.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install pybrinson
62
+ ```
63
+
64
+ `pybrinson` has **zero runtime dependencies** — just the Python standard
65
+ library. Requires Python 3.14+.
66
+
67
+ ## Quickstart
68
+
69
+ ```python
70
+ from pybrinson import Segment, bhb
71
+
72
+ segments = [
73
+ Segment("UK Equity", portfolio_weight=0.40, benchmark_weight=0.40,
74
+ portfolio_return=0.20, benchmark_return=0.10),
75
+ Segment("Japan Equity", portfolio_weight=0.30, benchmark_weight=0.20,
76
+ portfolio_return=-0.05, benchmark_return=-0.04),
77
+ Segment("US Equity", portfolio_weight=0.30, benchmark_weight=0.40,
78
+ portfolio_return=0.06, benchmark_return=0.08),
79
+ ]
80
+
81
+ result = bhb(segments, period="2024-Q1")
82
+ print(result)
83
+ ```
84
+
85
+ ```text
86
+ BHB attribution — period 2024-Q1
87
+ R_p = 8.3000% R_b = 6.4000% excess = 1.9000%
88
+
89
+ Segment Allocation Selection Interaction Total
90
+ ------------ ---------- --------- ----------- --------
91
+ UK Equity 0.0000% 4.0000% 0.0000% 4.0000%
92
+ Japan Equity -0.4000% -0.2000% -0.1000% -0.7000%
93
+ US Equity -0.8000% -0.8000% 0.2000% -1.4000%
94
+ Total -1.2000% 3.0000% 0.1000% 1.9000%
95
+ ```
96
+
97
+ The identity ``allocation + selection + interaction == excess_return``
98
+ holds within ``1e-9`` by construction; pybrinson **raises**
99
+ `AttributionError` rather than storing a silent residual when it fails.
100
+
101
+ ### Multi-period linking
102
+
103
+ ```python
104
+ from pybrinson import bhb, link_carino, Segment
105
+
106
+ period_attrs = [
107
+ bhb([Segment("Equities", 0.6, 0.5, 0.10, 0.05),
108
+ Segment("Bonds", 0.4, 0.5, 0.05, 0.10)], period="P1"),
109
+ bhb([Segment("Equities", 0.5, 0.5, 0.20, 0.10),
110
+ Segment("Bonds", 0.5, 0.5, 0.05, 0.10)], period="P2"),
111
+ ]
112
+
113
+ print(link_carino(period_attrs))
114
+ ```
115
+
116
+ See `examples/` for runnable scripts covering BHB, Brinson-Fachler, and
117
+ Cariño / GRAP / geometric linking.
118
+
119
+ ## Design principles
120
+
121
+ - **Pure Python first.** Zero runtime dependencies. Realistic attribution
122
+ inputs are small (≤1k segments × ≤1k periods); a NumPy dependency
123
+ would not pay for itself.
124
+ - **Specification-driven, externally verified.** Every function carries
125
+ its formula, an academic citation, and a clickable URL. The math is
126
+ cross-checked against published worked examples.
127
+ - **No silent residuals.** Identity failures raise. Bad inputs raise.
128
+ pybrinson never imputes, rescales or swallows residuals.
129
+ - **Typed and tested.** Public API is fully type-annotated; ships
130
+ `py.typed`; tests cover both pinned worked examples and randomised
131
+ identity checks.
132
+
133
+ ## Positioning vs `ppar` / `fincore`
134
+
135
+ | | `ppar` | `fincore` | `pybrinson` |
136
+ |---|---|---|---|
137
+ | BHB | no | yes | yes |
138
+ | Brinson-Fachler 3-effect | 2-effect only | no | yes |
139
+ | Cariño linking | yes | no | yes |
140
+ | GRAP linking | no | no | **yes** |
141
+ | Geometric linking | no | no | **yes** |
142
+ | Inline source citations | no | no | **mandatory** |
143
+ | Identity failure handling | n/a | silent residual | **raises** |
144
+ | Runtime dependencies | 9 | 2 | **0** |
145
+
146
+ See `docs/implementation-v1.md` for the audited findings against pinned
147
+ upstream commits.
148
+
149
+ ## Development
150
+
151
+ This project uses [uv](https://docs.astral.sh/uv/) and targets Python 3.14.
152
+
153
+ ```bash
154
+ uv sync # install deps, fetch Python 3.14 if needed
155
+ uv run pytest # full test suite
156
+ uv run pytest -k bhb # subset
157
+ uv build # sdist + wheel into dist/
158
+ ```
159
+
160
+ ## References
161
+
162
+ Primary papers cited in the source:
163
+
164
+ - Brinson, G. P., Hood, L. R., & Beebower, G. L. (1986). "Determinants of Portfolio Performance." *Financial Analysts Journal*, 42(4). [DOI](https://doi.org/10.2469/faj.v42.n4.39)
165
+ - Brinson, G. P., & Fachler, N. (1985). "Measuring Non-U.S. Equity Portfolio Performance." *Journal of Portfolio Management*, 11(3). [DOI](https://doi.org/10.3905/jpm.1985.409005)
166
+ - Cariño, D. R. (1999). "Combining Attribution Effects Over Time." *Journal of Performance Measurement*, 3(4).
167
+ - Groupe de Recherche en Attribution de Performance (1997). *Synthèse des modèles d'attribution de performance*.
168
+ - Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*, 2nd ed., Wiley. [Wiley page](https://www.wiley.com/en-us/Practical+Portfolio+Performance+Measurement+and+Attribution%2C+2nd+Edition-p-9780470059289)
169
+ - Bacon, C. R. (2019). *Performance Attribution: History and Progress*. CFA Institute Research Foundation. [Free PDF](https://www.cfainstitute.org/-/media/documents/book/rf-publication/2019/rf-v2019-n4-1-pdf.pdf)
170
+
171
+ ## License
172
+
173
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,152 @@
1
+ # pybrinson
2
+
3
+ [![CI](https://github.com/gghez/pybrinson/actions/workflows/ci.yml/badge.svg)](https://github.com/gghez/pybrinson/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+
6
+ Portfolio return attribution in Python — with sources you can audit.
7
+
8
+ `pybrinson` decomposes a portfolio's excess return versus a benchmark into
9
+ **allocation**, **selection**, and **interaction** effects, across any
10
+ user-defined classification (sector, country, asset class). Every formula
11
+ ships with its mathematical statement, an academic citation, and a
12
+ clickable URL — readers can verify the math without leaving the file.
13
+
14
+ It targets the gap left by the existing Python finance stack: R has the
15
+ `pa` package on CRAN and MATLAB ships `brinsonAttribution`, but no
16
+ maintained PyPI package implements the Brinson family of models.
17
+
18
+ ## Status
19
+
20
+ v1.0 — single-period BHB and Brinson-Fachler plus Cariño / GRAP /
21
+ geometric multi-period linking. Single-level classification only;
22
+ nested hierarchies are scheduled for v1.1.
23
+
24
+ ## Methods supported
25
+
26
+ | | Method | Reference |
27
+ |---|---|---|
28
+ | Single-period | Brinson-Hood-Beebower (3-effect) | Brinson, Hood & Beebower (1986) |
29
+ | Single-period | Brinson-Fachler (3-effect) | Brinson & Fachler (1985) |
30
+ | Multi-period linking | Cariño log-smoothing | Cariño (1999) |
31
+ | Multi-period linking | GRAP factors | GRAP (1997) |
32
+ | Multi-period linking | Geometric (Bacon) | Bacon (2008), chap. 6 |
33
+
34
+ Deferred to v1.1+: Menchero / Frongello linking, multi-level (nested)
35
+ classification, currency attribution.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install pybrinson
41
+ ```
42
+
43
+ `pybrinson` has **zero runtime dependencies** — just the Python standard
44
+ library. Requires Python 3.14+.
45
+
46
+ ## Quickstart
47
+
48
+ ```python
49
+ from pybrinson import Segment, bhb
50
+
51
+ segments = [
52
+ Segment("UK Equity", portfolio_weight=0.40, benchmark_weight=0.40,
53
+ portfolio_return=0.20, benchmark_return=0.10),
54
+ Segment("Japan Equity", portfolio_weight=0.30, benchmark_weight=0.20,
55
+ portfolio_return=-0.05, benchmark_return=-0.04),
56
+ Segment("US Equity", portfolio_weight=0.30, benchmark_weight=0.40,
57
+ portfolio_return=0.06, benchmark_return=0.08),
58
+ ]
59
+
60
+ result = bhb(segments, period="2024-Q1")
61
+ print(result)
62
+ ```
63
+
64
+ ```text
65
+ BHB attribution — period 2024-Q1
66
+ R_p = 8.3000% R_b = 6.4000% excess = 1.9000%
67
+
68
+ Segment Allocation Selection Interaction Total
69
+ ------------ ---------- --------- ----------- --------
70
+ UK Equity 0.0000% 4.0000% 0.0000% 4.0000%
71
+ Japan Equity -0.4000% -0.2000% -0.1000% -0.7000%
72
+ US Equity -0.8000% -0.8000% 0.2000% -1.4000%
73
+ Total -1.2000% 3.0000% 0.1000% 1.9000%
74
+ ```
75
+
76
+ The identity ``allocation + selection + interaction == excess_return``
77
+ holds within ``1e-9`` by construction; pybrinson **raises**
78
+ `AttributionError` rather than storing a silent residual when it fails.
79
+
80
+ ### Multi-period linking
81
+
82
+ ```python
83
+ from pybrinson import bhb, link_carino, Segment
84
+
85
+ period_attrs = [
86
+ bhb([Segment("Equities", 0.6, 0.5, 0.10, 0.05),
87
+ Segment("Bonds", 0.4, 0.5, 0.05, 0.10)], period="P1"),
88
+ bhb([Segment("Equities", 0.5, 0.5, 0.20, 0.10),
89
+ Segment("Bonds", 0.5, 0.5, 0.05, 0.10)], period="P2"),
90
+ ]
91
+
92
+ print(link_carino(period_attrs))
93
+ ```
94
+
95
+ See `examples/` for runnable scripts covering BHB, Brinson-Fachler, and
96
+ Cariño / GRAP / geometric linking.
97
+
98
+ ## Design principles
99
+
100
+ - **Pure Python first.** Zero runtime dependencies. Realistic attribution
101
+ inputs are small (≤1k segments × ≤1k periods); a NumPy dependency
102
+ would not pay for itself.
103
+ - **Specification-driven, externally verified.** Every function carries
104
+ its formula, an academic citation, and a clickable URL. The math is
105
+ cross-checked against published worked examples.
106
+ - **No silent residuals.** Identity failures raise. Bad inputs raise.
107
+ pybrinson never imputes, rescales or swallows residuals.
108
+ - **Typed and tested.** Public API is fully type-annotated; ships
109
+ `py.typed`; tests cover both pinned worked examples and randomised
110
+ identity checks.
111
+
112
+ ## Positioning vs `ppar` / `fincore`
113
+
114
+ | | `ppar` | `fincore` | `pybrinson` |
115
+ |---|---|---|---|
116
+ | BHB | no | yes | yes |
117
+ | Brinson-Fachler 3-effect | 2-effect only | no | yes |
118
+ | Cariño linking | yes | no | yes |
119
+ | GRAP linking | no | no | **yes** |
120
+ | Geometric linking | no | no | **yes** |
121
+ | Inline source citations | no | no | **mandatory** |
122
+ | Identity failure handling | n/a | silent residual | **raises** |
123
+ | Runtime dependencies | 9 | 2 | **0** |
124
+
125
+ See `docs/implementation-v1.md` for the audited findings against pinned
126
+ upstream commits.
127
+
128
+ ## Development
129
+
130
+ This project uses [uv](https://docs.astral.sh/uv/) and targets Python 3.14.
131
+
132
+ ```bash
133
+ uv sync # install deps, fetch Python 3.14 if needed
134
+ uv run pytest # full test suite
135
+ uv run pytest -k bhb # subset
136
+ uv build # sdist + wheel into dist/
137
+ ```
138
+
139
+ ## References
140
+
141
+ Primary papers cited in the source:
142
+
143
+ - Brinson, G. P., Hood, L. R., & Beebower, G. L. (1986). "Determinants of Portfolio Performance." *Financial Analysts Journal*, 42(4). [DOI](https://doi.org/10.2469/faj.v42.n4.39)
144
+ - Brinson, G. P., & Fachler, N. (1985). "Measuring Non-U.S. Equity Portfolio Performance." *Journal of Portfolio Management*, 11(3). [DOI](https://doi.org/10.3905/jpm.1985.409005)
145
+ - Cariño, D. R. (1999). "Combining Attribution Effects Over Time." *Journal of Performance Measurement*, 3(4).
146
+ - Groupe de Recherche en Attribution de Performance (1997). *Synthèse des modèles d'attribution de performance*.
147
+ - Bacon, C. R. (2008). *Practical Portfolio Performance Measurement and Attribution*, 2nd ed., Wiley. [Wiley page](https://www.wiley.com/en-us/Practical+Portfolio+Performance+Measurement+and+Attribution%2C+2nd+Edition-p-9780470059289)
148
+ - Bacon, C. R. (2019). *Performance Attribution: History and Progress*. CFA Institute Research Foundation. [Free PDF](https://www.cfainstitute.org/-/media/documents/book/rf-publication/2019/rf-v2019-n4-1-pdf.pdf)
149
+
150
+ ## License
151
+
152
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "pybrinson"
3
+ version = "1.0.0"
4
+ description = "Portfolio return attribution in Python: Brinson-Hood-Beebower, Brinson-Fachler, and multi-period linking."
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "pybrinson contributors" }]
10
+ keywords = [
11
+ "finance",
12
+ "portfolio",
13
+ "attribution",
14
+ "brinson",
15
+ "performance",
16
+ "asset-management",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 5 - Production/Stable",
20
+ "Intended Audience :: Financial and Insurance Industry",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Office/Business :: Financial :: Investment",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/gghez/pybrinson"
31
+ Repository = "https://github.com/gghez/pybrinson"
32
+ Issues = "https://github.com/gghez/pybrinson/issues"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=8.3",
37
+ ]
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/pybrinson"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = [
48
+ "src/pybrinson",
49
+ "tests",
50
+ "README.md",
51
+ "LICENSE",
52
+ "pyproject.toml",
53
+ ]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
57
+ addopts = "-ra --strict-markers --strict-config"
@@ -0,0 +1,55 @@
1
+ """Portfolio return attribution: Brinson-Hood-Beebower, Brinson-Fachler, multi-period linking.
2
+
3
+ Public API
4
+ ----------
5
+ Single-period attribution:
6
+
7
+ - :func:`bhb` — Brinson-Hood-Beebower (3-effect)
8
+ - :func:`fachler` — Brinson-Fachler (3-effect)
9
+
10
+ Multi-period linking:
11
+
12
+ - :func:`link_carino` — Cariño (1999) log-smoothing
13
+ - :func:`link_grap` — GRAP (1997) factor linking
14
+ - :func:`link_geometric` — Bacon-style geometric linking
15
+
16
+ Data model:
17
+
18
+ - :class:`Segment` — input row
19
+ - :class:`SegmentAttribution`, :class:`PeriodAttribution`,
20
+ :class:`LinkedAttribution` — result types
21
+
22
+ Errors:
23
+
24
+ - :class:`AttributionError` — raised on bad input or identity failure
25
+ (subclass of :class:`ValueError`)
26
+ """
27
+
28
+ from ._format import linked_to_table, period_to_table # noqa: F401 (side effect: __repr__)
29
+ from ._types import (
30
+ LinkedAttribution,
31
+ PeriodAttribution,
32
+ Segment,
33
+ SegmentAttribution,
34
+ )
35
+ from .errors import AttributionError
36
+ from .linking import link_carino, link_geometric, link_grap
37
+ from .single_period import bhb, fachler
38
+
39
+ __version__ = "1.0.0"
40
+
41
+ __all__ = [
42
+ "AttributionError",
43
+ "LinkedAttribution",
44
+ "PeriodAttribution",
45
+ "Segment",
46
+ "SegmentAttribution",
47
+ "__version__",
48
+ "bhb",
49
+ "fachler",
50
+ "link_carino",
51
+ "link_geometric",
52
+ "link_grap",
53
+ "linked_to_table",
54
+ "period_to_table",
55
+ ]
@@ -0,0 +1,94 @@
1
+ """Plain-text rendering of attribution results.
2
+
3
+ Pure stdlib formatting. Returns fixed-width tables with explicit columns
4
+ for allocation, selection, interaction and per-segment / total rows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._types import LinkedAttribution, PeriodAttribution
10
+
11
+ _HEAD = ("Segment", "Allocation", "Selection", "Interaction", "Total")
12
+ _PCT_DECIMALS = 4
13
+
14
+
15
+ def _fmt_pct(x: float) -> str:
16
+ return f"{x * 100:.{_PCT_DECIMALS}f}%"
17
+
18
+
19
+ def _row(name: str, alloc: float, sel: float, inter: float) -> tuple[str, ...]:
20
+ return (
21
+ name,
22
+ _fmt_pct(alloc),
23
+ _fmt_pct(sel),
24
+ _fmt_pct(inter),
25
+ _fmt_pct(alloc + sel + inter),
26
+ )
27
+
28
+
29
+ def _render(rows: list[tuple[str, ...]], header_lines: list[str]) -> str:
30
+ widths = [
31
+ max(len(row[col]) for row in (rows + [_HEAD]))
32
+ for col in range(len(_HEAD))
33
+ ]
34
+ sep = " "
35
+
36
+ def _line(row: tuple[str, ...]) -> str:
37
+ return sep.join(
38
+ cell.ljust(widths[i]) if i == 0 else cell.rjust(widths[i])
39
+ for i, cell in enumerate(row)
40
+ )
41
+
42
+ out: list[str] = list(header_lines)
43
+ out.append(_line(_HEAD))
44
+ out.append(sep.join("-" * w for w in widths))
45
+ for row in rows:
46
+ out.append(_line(row))
47
+ return "\n".join(out)
48
+
49
+
50
+ def period_to_table(result: PeriodAttribution) -> str:
51
+ """Render a single-period attribution result as a fixed-width table."""
52
+ label = result.period or "(unlabelled)"
53
+ header_lines = [
54
+ f"{result.method} attribution — period {label}",
55
+ f" R_p = {_fmt_pct(result.portfolio_return)} "
56
+ f"R_b = {_fmt_pct(result.benchmark_return)} "
57
+ f"excess = {_fmt_pct(result.excess_return)}",
58
+ "",
59
+ ]
60
+ rows = [
61
+ _row(s.name, s.allocation, s.selection, s.interaction)
62
+ for s in result.by_segment
63
+ ]
64
+ rows.append(
65
+ _row("Total", result.allocation, result.selection, result.interaction)
66
+ )
67
+ return _render(rows, header_lines)
68
+
69
+
70
+ def linked_to_table(result: LinkedAttribution) -> str:
71
+ """Render a multi-period linked attribution result."""
72
+ header_lines = [
73
+ f"{result.method}-linked attribution — {len(result.periods)} periods",
74
+ f" R_p = {_fmt_pct(result.portfolio_return)} "
75
+ f"R_b = {_fmt_pct(result.benchmark_return)} "
76
+ f"excess = {_fmt_pct(result.excess_return)}",
77
+ "",
78
+ ]
79
+ rows = [_row("Total", result.allocation, result.selection, result.interaction)]
80
+ return _render(rows, header_lines)
81
+
82
+
83
+ # Attach __repr__ overrides on the dataclasses. Done here (not in
84
+ # _types.py) to keep _types.py free of formatting concerns.
85
+ def _period_repr(self: PeriodAttribution) -> str:
86
+ return period_to_table(self)
87
+
88
+
89
+ def _linked_repr(self: LinkedAttribution) -> str:
90
+ return linked_to_table(self)
91
+
92
+
93
+ PeriodAttribution.__repr__ = _period_repr # type: ignore[method-assign]
94
+ LinkedAttribution.__repr__ = _linked_repr # type: ignore[method-assign]
@@ -0,0 +1,79 @@
1
+ """Internal numeric helpers: validation, aggregation, identity check.
2
+
3
+ Tolerances
4
+ ----------
5
+ ``WEIGHT_SUM_TOL = 1e-8`` — used to check ``|Σw − 1|``.
6
+ ``RESIDUAL_TOL = 1e-9`` — used to check the per-period identity
7
+ ``Σ(A + S + I) ≈ R_p − R_b`` returned by BHB / Brinson-Fachler.
8
+
9
+ These are deliberately tight: pybrinson never silently rescales weights
10
+ or absorbs a residual, because a wrong fill rule (zero-fill, drop, …)
11
+ would propagate into the identity without the reader noticing — exactly
12
+ the bug that fincore's ``residual`` field hides
13
+ (``fincore/attribution/brinson.py`` line 97-98).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from collections.abc import Sequence
20
+
21
+ from .errors import AttributionError
22
+
23
+ WEIGHT_SUM_TOL: float = 1e-8
24
+ RESIDUAL_TOL: float = 1e-9
25
+
26
+
27
+ def reject_non_finite(values: Sequence[float], *, name: str) -> None:
28
+ """Raise :class:`AttributionError` if any value is ``NaN`` or ``±inf``."""
29
+ for v in values:
30
+ if not math.isfinite(v):
31
+ raise AttributionError(
32
+ f"{name} contains a non-finite value ({v!r}). "
33
+ "pybrinson does not silently impute or drop missing data — "
34
+ "the caller must clean inputs before calling."
35
+ )
36
+
37
+
38
+ def validate_weights(weights: Sequence[float], *, name: str) -> None:
39
+ """Raise unless ``weights`` are finite and sum to ``1`` within tolerance."""
40
+ reject_non_finite(weights, name=name)
41
+ total = math.fsum(weights)
42
+ if abs(total - 1.0) > WEIGHT_SUM_TOL:
43
+ raise AttributionError(
44
+ f"{name} weights must sum to 1 within {WEIGHT_SUM_TOL:g}; "
45
+ f"got {total!r}. pybrinson never silently rescales — fix the input."
46
+ )
47
+
48
+
49
+ def aggregate_return(weights: Sequence[float], returns: Sequence[float]) -> float:
50
+ r"""Compute the weighted return :math:`\sum_i w_i r_i`.
51
+
52
+ Uses :func:`math.fsum` for an exact, order-independent sum so that the
53
+ identity check downstream is not polluted by accumulated rounding.
54
+ """
55
+ if len(weights) != len(returns):
56
+ raise AttributionError(
57
+ f"weights/returns length mismatch: {len(weights)} vs {len(returns)}"
58
+ )
59
+ return math.fsum(w * r for w, r in zip(weights, returns, strict=True))
60
+
61
+
62
+ def check_identity(
63
+ allocation: float,
64
+ selection: float,
65
+ interaction: float,
66
+ excess: float,
67
+ *,
68
+ method: str,
69
+ ) -> None:
70
+ """Raise if ``A + S + I`` does not match ``R_p − R_b`` within tolerance."""
71
+ residual = (allocation + selection + interaction) - excess
72
+ if abs(residual) > RESIDUAL_TOL:
73
+ raise AttributionError(
74
+ f"{method} identity violated: "
75
+ f"A + S + I = {allocation + selection + interaction!r}, "
76
+ f"excess = {excess!r}, residual = {residual!r}. "
77
+ "This is either a numerical pathology or a bug — pybrinson "
78
+ "refuses to store a silent residual."
79
+ )