jquantstats 0.9.2__tar.gz → 0.9.4__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.
Files changed (49) hide show
  1. {jquantstats-0.9.2 → jquantstats-0.9.4}/.gitignore +3 -0
  2. jquantstats-0.9.4/.rhiza/completions/README.md +263 -0
  3. {jquantstats-0.9.2 → jquantstats-0.9.4}/PKG-INFO +5 -3
  4. {jquantstats-0.9.2 → jquantstats-0.9.4}/pyproject.toml +29 -5
  5. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_plots/_data.py +1 -1
  6. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_plots/_portfolio.py +4 -4
  7. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_portfolio_attribution.py +3 -3
  8. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_portfolio_cost.py +17 -6
  9. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_portfolio_nav.py +10 -7
  10. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_portfolio_turnover.py +3 -3
  11. jquantstats-0.9.4/src/jquantstats/_protocol.py +75 -0
  12. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_reports/_data.py +8 -8
  13. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_reports/_protocol.py +1 -9
  14. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_basic.py +5 -2
  15. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_core.py +86 -13
  16. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_montecarlo.py +2 -2
  17. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_performance.py +8 -10
  18. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_reporting.py +1 -1
  19. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_rolling.py +1 -1
  20. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_utils/_data.py +2 -2
  21. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/data.py +2 -5
  22. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/exceptions.py +105 -0
  23. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/portfolio.py +18 -9
  24. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/result.py +16 -0
  25. jquantstats-0.9.2/.github/actions/configure-git-auth/README.md +0 -80
  26. jquantstats-0.9.2/src/jquantstats/_protocol.py +0 -41
  27. jquantstats-0.9.2/src/jquantstats/_stats/_protocol.py +0 -45
  28. {jquantstats-0.9.2 → jquantstats-0.9.4}/.rhiza/requirements/README.md +0 -0
  29. {jquantstats-0.9.2 → jquantstats-0.9.4}/.rhiza/tests/README.md +0 -0
  30. {jquantstats-0.9.2 → jquantstats-0.9.4}/.rhiza/tests/stress/README.md +0 -0
  31. {jquantstats-0.9.2 → jquantstats-0.9.4}/LICENSE +0 -0
  32. {jquantstats-0.9.2 → jquantstats-0.9.4}/README.md +0 -0
  33. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/__init__.py +0 -0
  34. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_cost_model.py +0 -0
  35. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_plots/__init__.py +0 -0
  36. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_plots/_protocol.py +0 -0
  37. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_reports/__init__.py +0 -0
  38. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_reports/_formatting.py +0 -0
  39. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_reports/_portfolio.py +0 -0
  40. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/__init__.py +0 -0
  41. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_internals.py +0 -0
  42. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_stats/_stats.py +0 -0
  43. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_types.py +0 -0
  44. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_utils/__init__.py +0 -0
  45. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_utils/_portfolio.py +0 -0
  46. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/_utils/_protocol.py +0 -0
  47. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/py.typed +0 -0
  48. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/templates/_base.html +0 -0
  49. {jquantstats-0.9.2 → jquantstats-0.9.4}/src/jquantstats/templates/portfolio_report.html +0 -0
@@ -127,3 +127,6 @@ report.html
127
127
  /prices.csv
128
128
  LICENSES.md
129
129
  /test_output/
130
+
131
+ # local planning file
132
+ plan.md
@@ -0,0 +1,263 @@
1
+ # Shell Completion for Rhiza Make Targets
2
+
3
+ This directory contains shell completion scripts for Bash and Zsh that provide tab-completion for make targets in Rhiza-based projects.
4
+
5
+ ## Features
6
+
7
+ - ✅ Tab-complete all available make targets
8
+ - ✅ Show target descriptions in Zsh
9
+ - ✅ Complete common make variables (DRY_RUN, BUMP, ENV, etc.)
10
+ - ✅ Works with any Rhiza-based project
11
+ - ✅ Auto-discovers targets from Makefile and included .mk files
12
+
13
+ ## Installation
14
+
15
+ ### Bash
16
+
17
+ #### Method 1: Source in your shell config
18
+
19
+ Add to your `~/.bashrc` or `~/.bash_profile`:
20
+
21
+ ```bash
22
+ # Rhiza make completion
23
+ if [ -f /path/to/project/.rhiza/completions/rhiza-completion.bash ]; then
24
+ source /path/to/project/.rhiza/completions/rhiza-completion.bash
25
+ fi
26
+ ```
27
+
28
+ Replace `/path/to/project` with the actual path to your Rhiza project.
29
+
30
+ #### Method 2: System-wide installation
31
+
32
+ ```bash
33
+ # Copy to bash completion directory
34
+ sudo cp .rhiza/completions/rhiza-completion.bash /etc/bash_completion.d/rhiza
35
+
36
+ # Reload completions
37
+ source /etc/bash_completion.d/rhiza
38
+ ```
39
+
40
+ #### Method 3: User-local installation
41
+
42
+ ```bash
43
+ # Create local completion directory
44
+ mkdir -p ~/.local/share/bash-completion/completions
45
+
46
+ # Copy completion script
47
+ cp .rhiza/completions/rhiza-completion.bash ~/.local/share/bash-completion/completions/make
48
+
49
+ # Reload bash
50
+ source ~/.bashrc
51
+ ```
52
+
53
+ ### Zsh
54
+
55
+ #### Method 1: User-local installation (Recommended)
56
+
57
+ ```bash
58
+ # Create completion directory
59
+ mkdir -p ~/.zsh/completion
60
+
61
+ # Copy completion script
62
+ cp .rhiza/completions/rhiza-completion.zsh ~/.zsh/completion/_make
63
+
64
+ # Add to ~/.zshrc (if not already present)
65
+ echo 'fpath=(~/.zsh/completion $fpath)' >> ~/.zshrc
66
+ echo 'autoload -U compinit && compinit' >> ~/.zshrc
67
+
68
+ # Reload zsh
69
+ source ~/.zshrc
70
+ ```
71
+
72
+ #### Method 2: Source directly
73
+
74
+ Add to your `~/.zshrc`:
75
+
76
+ ```zsh
77
+ # Rhiza make completion
78
+ if [ -f /path/to/project/.rhiza/completions/rhiza-completion.zsh ]; then
79
+ source /path/to/project/.rhiza/completions/rhiza-completion.zsh
80
+ fi
81
+ ```
82
+
83
+ #### Method 3: System-wide installation
84
+
85
+ ```bash
86
+ # Copy to system completion directory
87
+ sudo cp .rhiza/completions/rhiza-completion.zsh /usr/local/share/zsh/site-functions/_make
88
+
89
+ # Reload zsh
90
+ exec zsh
91
+ ```
92
+
93
+ ## Usage
94
+
95
+ Once installed, you can tab-complete make targets:
96
+
97
+ ```bash
98
+ # Tab-complete targets
99
+ make <TAB>
100
+
101
+ # Complete with prefix
102
+ make te<TAB> # Expands to: make test
103
+
104
+ # Complete variables
105
+ make BUMP=<TAB> # Shows: patch, minor, major
106
+
107
+ # Works with any target
108
+ make doc<TAB> # Shows: docs, docker-build, docker-run, etc.
109
+ ```
110
+
111
+ ### Zsh Benefits
112
+
113
+ In Zsh, you'll also see descriptions for targets:
114
+
115
+ ```bash
116
+ make <TAB>
117
+ # Shows:
118
+ # test -- run all tests
119
+ # fmt -- check the pre-commit hooks and the linting
120
+ # install -- install
121
+ # book -- build documentation site via zensical
122
+ # ...
123
+ ```
124
+
125
+ ## Common Variables
126
+
127
+ The completion scripts understand these common variables:
128
+
129
+ | Variable | Values | Description |
130
+ |----------|--------|-------------|
131
+ | `DRY_RUN` | `1` | Preview mode without making changes |
132
+ | `BUMP` | `patch`, `minor`, `major` | Version bump type |
133
+ | `ENV` | `dev`, `staging`, `prod` | Target environment |
134
+ | `COVERAGE_FAIL_UNDER` | (number) | Minimum coverage threshold |
135
+ | `PYTHON_VERSION` | (version) | Override Python version |
136
+
137
+ Example usage:
138
+
139
+ ```bash
140
+ # Tab-complete after typing DRY_
141
+ make DRY_<TAB> # Expands to: make DRY_RUN=1
142
+
143
+ # Tab-complete variable values
144
+ make BUMP=<TAB> # Shows: patch minor major
145
+
146
+ # Combine with targets
147
+ make bump BUMP=<TAB>
148
+ ```
149
+
150
+ ## Troubleshooting
151
+
152
+ ### Bash: Completions not working
153
+
154
+ 1. Check if bash-completion is installed:
155
+ ```bash
156
+ # Debian/Ubuntu
157
+ sudo apt-get install bash-completion
158
+
159
+ # macOS
160
+ brew install bash-completion@2
161
+ ```
162
+
163
+ 2. Ensure completion is enabled in your shell:
164
+ ```bash
165
+ # Add to ~/.bashrc if not present
166
+ if [ -f /etc/bash_completion ]; then
167
+ . /etc/bash_completion
168
+ fi
169
+ ```
170
+
171
+ 3. Reload your shell configuration:
172
+ ```bash
173
+ source ~/.bashrc
174
+ ```
175
+
176
+ ### Zsh: Completions not working
177
+
178
+ 1. Check if compinit is called in your `~/.zshrc`:
179
+ ```zsh
180
+ autoload -U compinit && compinit
181
+ ```
182
+
183
+ 2. Clear the completion cache:
184
+ ```bash
185
+ rm -f ~/.zcompdump
186
+ compinit
187
+ ```
188
+
189
+ 3. Ensure the script is in your fpath:
190
+ ```zsh
191
+ echo $fpath
192
+ ```
193
+
194
+ 4. Reload your shell configuration:
195
+ ```zsh
196
+ source ~/.zshrc
197
+ ```
198
+
199
+ ### No targets appearing
200
+
201
+ 1. Ensure you're in a directory with a Makefile:
202
+ ```bash
203
+ ls -la Makefile
204
+ ```
205
+
206
+ 2. Test that make can parse the Makefile:
207
+ ```bash
208
+ make -qp 2>/dev/null | head
209
+ ```
210
+
211
+ 3. Manually source the completion script to test:
212
+ ```bash
213
+ # Bash
214
+ source .rhiza/completions/rhiza-completion.bash
215
+
216
+ # Zsh
217
+ source .rhiza/completions/rhiza-completion.zsh
218
+ ```
219
+
220
+ ## Optional Aliases
221
+
222
+ You can add shortcuts in your shell config:
223
+
224
+ ```bash
225
+ # Add to ~/.bashrc or ~/.zshrc
226
+ alias m='make'
227
+
228
+ # For bash:
229
+ complete -F _rhiza_make_completion m
230
+
231
+ # For zsh:
232
+ compdef _rhiza_make m
233
+ ```
234
+
235
+ Then use:
236
+ ```bash
237
+ m te<TAB> # Expands to: m test
238
+ ```
239
+
240
+ ## Technical Details
241
+
242
+ ### How it works
243
+
244
+ 1. **Target Discovery**: Parses `make -qp` output to find all targets
245
+ 2. **Description Extraction**: Looks for `##` comments after target names
246
+ 3. **Variable Detection**: Includes common Makefile variables
247
+ 4. **Dynamic Completion**: Regenerates list each time you tab
248
+
249
+ ### Performance
250
+
251
+ - Completions are generated on-demand (when you press Tab)
252
+ - For large Makefiles (100+ targets), there may be a small delay
253
+ - Results are not cached to ensure targets are always current
254
+
255
+ ## See Also
256
+
257
+ - [Tools Reference](../../docs/reference/TOOLS_REFERENCE.md) - Complete command reference
258
+ - [Quick Reference](../../docs/guides/QUICK_REFERENCE.md) - Quick command reference
259
+ - [Extending Rhiza](../../docs/guides/EXTENDING_RHIZA.md) - How to add custom targets
260
+
261
+ ---
262
+
263
+ *Last updated: 2026-02-15*
@@ -1,14 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jquantstats
3
- Version: 0.9.2
3
+ Version: 0.9.4
4
4
  Summary: Analytics for quants
5
- Project-URL: repository, https://github.com/jebel-quant/jquantstats
5
+ Project-URL: Homepage, https://github.com/jebel-quant/jquantstats
6
+ Project-URL: Repository, https://github.com/jebel-quant/jquantstats
6
7
  Author-email: tschm <thomas.schmelzer@gmail.com>
7
8
  License-Expression: MIT
8
9
  License-File: LICENSE
9
10
  Classifier: Development Status :: 5 - Production/Stable
10
11
  Classifier: Intended Audience :: Financial and Insurance Industry
11
12
  Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
12
14
  Classifier: Programming Language :: Python :: 3 :: Only
13
15
  Classifier: Programming Language :: Python :: 3.11
14
16
  Classifier: Programming Language :: Python :: 3.12
@@ -20,7 +22,7 @@ Requires-Dist: jinja2>=3.1.0
20
22
  Requires-Dist: narwhals>=2.0.0
21
23
  Requires-Dist: numpy>=2.0.0
22
24
  Requires-Dist: plotly>=6.0.0
23
- Requires-Dist: polars>=1.18.0
25
+ Requires-Dist: polars>=1.35.2
24
26
  Requires-Dist: scipy>=1.14.1
25
27
  Provides-Extra: plot
26
28
  Requires-Dist: kaleido==1.3.0; extra == 'plot'
@@ -1,7 +1,7 @@
1
1
  # Main project metadata
2
2
  [project]
3
3
  name = 'jquantstats'
4
- version = "0.9.2"
4
+ version = "0.9.4"
5
5
  description = "Analytics for quants"
6
6
  authors = [{name='tschm', email= 'thomas.schmelzer@gmail.com'}]
7
7
  readme = "README.md"
@@ -11,7 +11,7 @@ dependencies = [
11
11
  "narwhals>=2.0.0",
12
12
  "numpy>=2.0.0",
13
13
  "plotly>=6.0.0",
14
- "polars>=1.18.0",
14
+ "polars>=1.35.2",
15
15
  "scipy>=1.14.1"
16
16
  ]
17
17
  license = "MIT"
@@ -26,11 +26,13 @@ classifiers = [
26
26
  "Programming Language :: Python :: 3.11",
27
27
  "Programming Language :: Python :: 3.12",
28
28
  "Programming Language :: Python :: 3.13",
29
+ "License :: OSI Approved :: MIT License",
29
30
  ]
30
31
 
31
32
  # Project URLs for documentation and reference
32
33
  [project.urls]
33
- repository = "https://github.com/jebel-quant/jquantstats"
34
+ Homepage = "https://github.com/jebel-quant/jquantstats"
35
+ Repository = "https://github.com/jebel-quant/jquantstats"
34
36
 
35
37
  # Optional dependencies that can be installed with extras (e.g., pip install jquantstats[plot])
36
38
  [project.optional-dependencies]
@@ -45,12 +47,29 @@ web = [
45
47
  dev = [
46
48
  "pandas>=2.2.3", # required by quantstats (comparison tests only — not a runtime dependency)
47
49
  "pyarrow>=22.0.0",
48
- "yfinance==1.3.0",
49
- "ipython==9.13.0",
50
+ "yfinance==1.4.1",
51
+ "ipython==9.14.1",
50
52
  "quantstats==0.0.81", # reference implementation used in test_quantstats.py for metric validation
51
53
  "httpx>=0.28.1",
52
54
  "marimo>=0.23.6",
53
55
  ]
56
+ test = [
57
+ "pytest>=8.0",
58
+ "pytest-cov>=6.0",
59
+ "pytest-html>=4.0",
60
+ "pytest-mock>=3.0",
61
+ "pytest-xdist>=3.0",
62
+ "pytest-timeout>=2.0",
63
+ "pytest-benchmark>=5.2.3",
64
+ "hypothesis>=6.150.0",
65
+ "syrupy>=4.9.1",
66
+ "pygal>=3.1.0",
67
+ "python-dotenv>=1.0",
68
+ ]
69
+ lint = [
70
+ "pre-commit>=4.0",
71
+ "ty>=0.0.30",
72
+ ]
54
73
 
55
74
 
56
75
  # Build system configuration
@@ -101,10 +120,15 @@ pyarrow = "pyarrow"
101
120
  httpx = "httpx"
102
121
 
103
122
  # Coverage configuration
123
+ [tool.coverage.run]
124
+ branch = true
125
+
104
126
  [tool.coverage.report]
127
+ fail_under = 100
105
128
  exclude_lines = [
106
129
  "pragma: no cover",
107
130
  "if TYPE_CHECKING:", # type-only imports are never executed at runtime
131
+ "@overload", # overload stubs have no runtime body
108
132
  ]
109
133
 
110
134
 
@@ -967,7 +967,7 @@ class DataPlots:
967
967
  fig = go.Figure()
968
968
  for ticker in tickers:
969
969
  hist_returns = df[ticker].tail(sample_len).fill_null(0.0).cast(pl.Float64).to_numpy()
970
- if hist_returns.size == 0:
970
+ if hist_returns.size == 0: # pragma: no cover
971
971
  continue
972
972
 
973
973
  simulated_metrics = [
@@ -188,7 +188,7 @@ class PortfolioPlots:
188
188
  fig.update_yaxes(type="log", row=1, col=1)
189
189
  # Ensure the first y-axis is explicitly set for environments
190
190
  # where subplot updates may not propagate to layout alias.
191
- if hasattr(fig.layout, "yaxis"):
191
+ if hasattr(fig.layout, "yaxis"): # pragma: no branch — plotly figures always have .yaxis
192
192
  fig.layout.yaxis.type = "log"
193
193
 
194
194
  return fig
@@ -212,7 +212,7 @@ class PortfolioPlots:
212
212
 
213
213
  if log_scale:
214
214
  fig.update_yaxes(type="log")
215
- if hasattr(fig.layout, "yaxis"):
215
+ if hasattr(fig.layout, "yaxis"): # pragma: no branch — plotly figures always have .yaxis
216
216
  fig.layout.yaxis.type = "log"
217
217
 
218
218
  def lagged_performance_plot(self, lags: list[int] | None = None, log_scale: bool = False) -> go.Figure:
@@ -267,7 +267,7 @@ class PortfolioPlots:
267
267
  ValueError: If ``window`` is not a positive integer.
268
268
  """
269
269
  if not isinstance(window, int) or window <= 0:
270
- raise ValueError
270
+ raise ValueError(f"window must be a positive integer, got {window!r}") # noqa: TRY003
271
271
 
272
272
  rolling = self._portfolio.stats.rolling_sharpe(rolling_period=window)
273
273
 
@@ -308,7 +308,7 @@ class PortfolioPlots:
308
308
  ValueError: If ``window`` is not a positive integer.
309
309
  """
310
310
  if not isinstance(window, int) or window <= 0:
311
- raise ValueError
311
+ raise ValueError(f"window must be a positive integer, got {window!r}") # noqa: TRY003
312
312
 
313
313
  rolling = self._portfolio.stats.rolling_volatility(rolling_period=window)
314
314
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
5
  from typing import TYPE_CHECKING, Self
7
6
 
8
7
  import polars as pl
@@ -65,8 +64,9 @@ class PortfolioAttributionMixin:
65
64
  cost_per_unit=self.cost_per_unit,
66
65
  cost_bps=self.cost_bps,
67
66
  )
68
- with contextlib.suppress(AttributeError, TypeError):
69
- object.__setattr__(self, "_tilt_cache", result)
67
+ # Direct write is safe: Portfolio is a frozen, slotted dataclass that
68
+ # declares every cache field, so object.__setattr__ cannot fail here.
69
+ object.__setattr__(self, "_tilt_cache", result)
70
70
  return result
71
71
 
72
72
  @property
@@ -2,10 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  from typing import TYPE_CHECKING
6
7
 
7
8
  import polars as pl
8
9
 
10
+ from .exceptions import InvalidMaxBpsError, NegativeCostBpsError
11
+
9
12
  if TYPE_CHECKING:
10
13
  from .data import Data
11
14
 
@@ -120,7 +123,9 @@ class PortfolioCostMixin:
120
123
  ``returns`` column reduced by the per-period trading cost.
121
124
 
122
125
  Raises:
123
- ValueError: If ``cost_bps`` is negative.
126
+ TypeError: If ``cost_bps`` is not a number.
127
+ ValueError: If ``cost_bps`` is not finite (NaN or infinity).
128
+ NegativeCostBpsError: If ``cost_bps`` is negative.
124
129
 
125
130
  Examples:
126
131
  >>> from jquantstats.portfolio import Portfolio
@@ -135,8 +140,13 @@ class PortfolioCostMixin:
135
140
  True
136
141
  """
137
142
  effective_bps = cost_bps if cost_bps is not None else self.cost_bps
143
+ if isinstance(effective_bps, bool) or not isinstance(effective_bps, int | float):
144
+ raise TypeError(f"cost_bps must be a number, got {type(effective_bps).__name__}") # noqa: TRY003
145
+ effective_bps = float(effective_bps)
146
+ if not math.isfinite(effective_bps):
147
+ raise ValueError(f"cost_bps must be finite, got {effective_bps}") # noqa: TRY003
138
148
  if effective_bps < 0:
139
- raise ValueError
149
+ raise NegativeCostBpsError(effective_bps)
140
150
  base = self.returns
141
151
  daily_cost = self.turnover["turnover"] * (effective_bps / 10_000.0)
142
152
  return base.with_columns((pl.col("returns") - daily_cost).alias("returns"))
@@ -160,7 +170,7 @@ class PortfolioCostMixin:
160
170
  ``max_bps`` inclusive.
161
171
 
162
172
  Raises:
163
- ValueError: If ``max_bps`` is not a positive integer.
173
+ InvalidMaxBpsError: If ``max_bps`` is not a positive integer.
164
174
 
165
175
  Examples:
166
176
  >>> from jquantstats.portfolio import Portfolio
@@ -183,11 +193,12 @@ class PortfolioCostMixin:
183
193
  [0, 1, 2, 3, 4, 5]
184
194
  """
185
195
  if not isinstance(max_bps, int) or max_bps < 1:
186
- raise ValueError
196
+ raise InvalidMaxBpsError(max_bps)
187
197
  import numpy as np
188
198
 
199
+ from ._stats._core import _std_is_negligible
200
+
189
201
  periods = self.data._periods_per_year # one Data object, outside the loop
190
- _eps = np.finfo(np.float64).eps
191
202
  sqrt_periods = float(np.sqrt(periods))
192
203
  cost_levels = list(range(0, max_bps + 1))
193
204
 
@@ -204,7 +215,7 @@ class PortfolioCostMixin:
204
215
  sharpe_values: list[float] = []
205
216
  for mean_raw, std_raw in zip(means_row, stds_row, strict=False):
206
217
  mean_val = 0.0 if mean_raw is None else float(mean_raw)
207
- if std_raw is None or float(std_raw) <= _eps * max(abs(mean_val), _eps) * 10:
218
+ if _std_is_negligible(std_raw, mean_val):
208
219
  sharpe_values.append(float("nan"))
209
220
  else:
210
221
  sharpe_values.append(mean_val / float(std_raw) * sqrt_periods)
@@ -2,12 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
5
  from typing import TYPE_CHECKING
7
6
 
8
7
  import polars as pl
9
8
 
10
- from .exceptions import MissingDateColumnError
9
+ from .exceptions import MissingDateColumnError, NoAssetColumnsError
11
10
 
12
11
 
13
12
  class PortfolioNavMixin:
@@ -68,8 +67,9 @@ class PortfolioNavMixin:
68
67
  pl.when(pl.col(c).is_finite()).then(pl.col(c)).otherwise(0.0).fill_null(0.0).alias(c) for c in assets
69
68
  )
70
69
 
71
- with contextlib.suppress(AttributeError, TypeError):
72
- object.__setattr__(self, "_profits_cache", result)
70
+ # Direct write is safe: Portfolio is a frozen, slotted dataclass that
71
+ # declares every cache field, so object.__setattr__ cannot fail here.
72
+ object.__setattr__(self, "_profits_cache", result)
73
73
  return result
74
74
 
75
75
  @property
@@ -78,12 +78,15 @@ class PortfolioNavMixin:
78
78
 
79
79
  Aggregates per-asset profits into a single ``'profit'`` column and
80
80
  validates that no day's total profit is NaN/null.
81
+
82
+ Raises:
83
+ NoAssetColumnsError: If the profits frame has no numeric asset columns.
81
84
  """
82
85
  df_profits = self.profits
83
86
  assets = [c for c in df_profits.columns if df_profits[c].dtype.is_numeric()]
84
87
 
85
88
  if not assets:
86
- raise ValueError
89
+ raise NoAssetColumnsError("profits")
87
90
 
88
91
  non_assets = [c for c in df_profits.columns if c not in set(assets)]
89
92
 
@@ -121,8 +124,8 @@ class PortfolioNavMixin:
121
124
  result = self.nav_accumulated.with_columns(
122
125
  (pl.col("profit") / self.aum).alias("returns"),
123
126
  )
124
- with contextlib.suppress(AttributeError, TypeError):
125
- object.__setattr__(self, "_returns_cache", result)
127
+ # Direct write is safe: see the comment on the profits cache above.
128
+ object.__setattr__(self, "_returns_cache", result)
126
129
  return result
127
130
 
128
131
  @property
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
5
  from typing import TYPE_CHECKING
7
6
 
8
7
  import polars as pl
@@ -61,8 +60,9 @@ class PortfolioTurnoverMixin:
61
60
  cols.append(daily_abs_chg)
62
61
  result = self.cashposition.select(cols)
63
62
 
64
- with contextlib.suppress(AttributeError, TypeError):
65
- object.__setattr__(self, "_turnover_cache", result)
63
+ # Direct write is safe: Portfolio is a frozen, slotted dataclass that
64
+ # declares every cache field, so object.__setattr__ cannot fail here.
65
+ object.__setattr__(self, "_turnover_cache", result)
66
66
  return result
67
67
 
68
68
  @property
@@ -0,0 +1,75 @@
1
+ """Shared protocol definitions used across jquantstats subpackages.
2
+
3
+ Design rationale
4
+ ----------------
5
+ The analytics subpackages (``_stats``, ``_plots``, ``_reports``, ``_utils``)
6
+ must not import the concrete `Data` / `Portfolio` classes at runtime — that
7
+ would create circular imports, since those classes compose the subpackages.
8
+ Instead, each consumer annotates against a structural Protocol:
9
+
10
+ - `DataLike` and `StatsLike` (this module) are shared by every subpackage —
11
+ there is exactly one definition of each.
12
+ - ``PortfolioLike`` is deliberately *not* shared: each subpackage declares its
13
+ own (``_plots/_protocol.py``, ``_reports/_protocol.py``,
14
+ ``_utils/_protocol.py``) listing only the members it actually consumes
15
+ (interface segregation). Keep it that way — a merged PortfolioLike would
16
+ re-couple the subpackages to the full Portfolio surface.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterator
22
+ from typing import Protocol, runtime_checkable
23
+
24
+ import polars as pl
25
+
26
+
27
+ class StatsLike(Protocol): # pragma: no cover
28
+ """Structural interface for the statistics facade used by reports."""
29
+
30
+ def summary(self) -> pl.DataFrame:
31
+ """Full summary DataFrame (one row per metric, one column per asset)."""
32
+ ...
33
+
34
+
35
+ @runtime_checkable
36
+ class DataLike(Protocol): # pragma: no cover
37
+ """Authoritative structural interface for Data consumers.
38
+
39
+ Union of the members required by the stats mixins, plots, reports, and
40
+ utils — annotating against the superset is harmless for consumers that
41
+ use only part of it, and keeps a single definition.
42
+ """
43
+
44
+ returns: pl.DataFrame
45
+ index: pl.DataFrame
46
+ benchmark: pl.DataFrame | None
47
+
48
+ @property
49
+ def all(self) -> pl.DataFrame:
50
+ """Combined DataFrame of date index, return, and benchmark columns."""
51
+ ...
52
+
53
+ @property
54
+ def assets(self) -> list[str]:
55
+ """Names of the asset return columns."""
56
+ ...
57
+
58
+ @property
59
+ def date_col(self) -> list[str]:
60
+ """Column names used as the date/time index."""
61
+ ...
62
+
63
+ @property
64
+ def stats(self) -> StatsLike:
65
+ """Statistics facade used by reports."""
66
+ ...
67
+
68
+ @property
69
+ def _periods_per_year(self) -> float:
70
+ """Estimated number of return periods per calendar year."""
71
+ ...
72
+
73
+ def items(self) -> Iterator[tuple[str, pl.Series]]:
74
+ """Iterate over (asset_name, returns_series) pairs."""
75
+ ...