Skip to content

Address quality findings #909-#912 - #913

Merged
tschm merged 1 commit into
mainfrom
fix/quality-findings-909-912
Aug 2, 2026
Merged

Address quality findings #909-#912#913
tschm merged 1 commit into
mainfrom
fix/quality-findings-909-912

Conversation

@tschm

@tschm tschm commented Aug 2, 2026

Copy link
Copy Markdown
Member

Addresses the four findings from the /rhiza:quality scorecard.

Closes #909
Closes #910
Closes #911
Closes #912

#909 — deptry scans api/

api/ (the [web] extra's FastAPI service) was outside the deptry scan, so its
dependencies read as unused and were silenced. Added it to DEPTRY_FOLDERS from the
repo-owned Makefile and dropped fastapi from DEP002.

kaleido, uvicorn and python-multipart stay on the ignore list — none of them is
ever imported (a Plotly export backend selected at runtime, a CLI-invoked ASGI server,
and a parser Starlette imports on our behalf) — but each now carries a comment
explaining why, so the list records facts instead of hiding gaps.

#910 — property-based tests

The suite already had 14 hypothesis properties; the gap was breadth. Eight more, chosen
for what snapshots and line coverage structurally cannot catch:

Invariant Guards
worst <= best order statistics
CVaR <= VaR tail-mean vs. tail-edge
avg_drawdown >= max_drawdown drawdown sign convention
exposure, monthly_win_rate in [0, 1] bounded ratios
self-capture == 1 the refactored _capture_ratio
summary() == the metric methods it tabulates facade drift
truncate() / smoothed_holding(0) are no-ops the refactored transforms

The summary() property surfaced a contract worth pinning explicitly: where a metric
raises (an all-zero series divides by zero in win_rate), summary substitutes NaN via
_safe rather than propagating. The test asserts that branch instead of assuming both
paths succeed.

#911 — complexity

truncate(): CC 10 → 2. Extracted _date_range_mask and _row_slice_bounds. While
there, _rebuild() and _numeric_assets remove a three-way duplication — all three
transforms re-listed the same from_cash_position(...) call and the same numeric-column
filter.

_stats/_reporting.py: 592 → 331 lines. Split along the section comments already in
the file — _capture.py (up/down capture, which also collapses two near-identical
methods into one _capture_ratio) and _summary.py (summary + annual_breakdown,
which carries the large cross-mixin stub block because it calls across every other
mixin).

_plots/_portfolio.py: 588 lines → a package, mirroring the existing
_plots/_data/ layout: _nav, _rolling, _diagnostics mixins composed by _core.
The public import path is unchanged. rolling_sharpe_plot and rolling_volatility_plot
now share _line_per_column.

Also CC 10 → below: yearly_returns and rolling_beta in _plots/_data/.

A coverage gap this uncovered

Extracting _beta_assets out of a conditional expression exposed a line the 100% gate
had been passing over. coverage.py applies branch tracking to statements, not to
ternary expressions — so the fallback side of x if cond else y was invisible. Once
it became an if/return, it showed up as genuinely untested. Fixed with a test that
exercises the fallback through the DataLike protocol, not by restoring the ternary.

#912 — pre-1.0 stability policy

STABILITY.md did already have a pre-release note, but it sat below the guarantee table
and said only "no stability guarantee". Added an up-front status callout and a
Before v1.0.0 section: what is already settled (the exported names and constructors),
what may still move (individual metric signatures), how changes are announced, and
pinning advice. Also corrected the stale private-module list — _stats.py, _plots.py
and _reports.py are packages now, and _portfolio_data.py no longer exists.

Not done

#911's acceptance criterion said no module over ~400 lines. Seven remain, at 410–468:
_stats/_performance.py, _stats/_basic_core.py, portfolio.py, data.py,
exceptions.py, _stats/_basic.py, _reports/_html.py. Each is a single coherent
concern (exceptions.py is 19 exception classes; data.py is the Data class), so
splitting them would trade cohesion for a line count rather than buy clarity — unlike
the two 590-line modules this PR did split, which had visible internal seams. Flagging
rather than deciding: say the word and they can follow.

Verification

All eight gates pass: fmt, typecheck (ty + mypy --strict, 62 files), docs-coverage
(100%), deptry, security, rhiza-test (39), test (1240 passed, 100% line and branch),
and both import-linter contracts. Average complexity improved 2.84 → 2.74; no function
above CC 9.

🤖 Generated with Claude Code

Four findings from the /rhiza:quality scorecard, in one branch.

#909 deptry: add api/ to DEPTRY_FOLDERS so the FastAPI service is scanned,
and drop fastapi from the DEP002 ignore list now that its import is observed
directly. kaleido, uvicorn and python-multipart stay ignored — none of the
three is ever imported (image-export backend, CLI-invoked server, and a
Starlette-internal parser) — but each now carries a comment saying why.

#910 property tests: eight new invariants over the metric surface, chosen to
cover what snapshots cannot — ordering (worst <= best, CVaR <= VaR,
avg_drawdown >= max_drawdown), bounded ratios (exposure, monthly_win_rate),
the self-capture identity, agreement between summary() and the metric methods
it tabulates, and the truncate/smoothed_holding no-ops.

#911 complexity: truncate() drops from CC 10 to 2 by extracting the date-mask
and row-bounds helpers, with _rebuild() and _numeric_assets removing the
three-way duplication across the transforms. _stats/_reporting.py (592 lines)
splits into _reporting/_capture/_summary; _plots/_portfolio.py (588) becomes a
package of _nav/_rolling/_diagnostics mixins, mirroring _plots/_data/. Also
brings yearly_returns and rolling_beta down from CC 10.

Extracting _beta_assets exposed a line coverage had never reached: the
fallback side of a conditional *expression* is invisible to branch coverage,
so the 100% gate had been passing over it. Covered by a new test rather than
folded back into a ternary.

#912 stability docs: the pre-1.0 policy was present but buried below the
guarantee table and thin on specifics. Adds an up-front status callout and a
"Before v1.0.0" section covering what is already settled, what may still move,
how changes are announced, and pinning advice. Also corrects the stale
private-module list (_stats.py etc. are packages now; _portfolio_data.py is
gone).

Coverage stays at 100% (line and branch), both import-linter contracts hold,
and all eight gates pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 2, 2026 18:20
line={"width": 2, "color": "#1f77b4"},
)
)
if baseline == baseline: # only add when baseline is finite (NaN != NaN)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses /rhiza:quality scorecard findings by expanding deptry’s scan scope to include the optional FastAPI service, strengthening correctness guarantees with additional Hypothesis property tests, and reducing complexity/size by refactoring stats/plots modules into smaller, focused units while preserving public entry points.

Changes:

  • Include api/ in deptry scanning and refine DEP002 ignores with explanatory comments.
  • Add property-based tests for key metric invariants and for refactored behaviors (summary table contract, capture identity, transform no-ops, rolling beta fallback).
  • Split large _stats/_reporting.py into _reporting.py + _capture.py + _summary.py, and split _plots/_portfolio.py into a package with mixin modules.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_jquantstats/test_properties.py Adds Hypothesis property tests for ordering, bounds, self-capture, summary consistency, and transform identities.
tests/test_jquantstats/test_migration/test_plot.py Adds a regression test for rolling_beta asset fallback via DataLike without a returns attribute.
src/jquantstats/_stats/_summary.py Introduces a dedicated mixin for summary() and annual_breakdown() extracted from reporting.
src/jquantstats/_stats/_stats.py Wires new _CaptureStatsMixin and _SummaryStatsMixin into the composed Stats type.
src/jquantstats/_stats/_reporting.py Narrows reporting mixin to temporal reporting metrics after extraction of capture/summary.
src/jquantstats/_stats/_capture.py Introduces capture ratio mixin with shared implementation for up/down capture.
src/jquantstats/_portfolio_transform.py Reduces duplication/complexity by centralizing rebuild logic and truncation helpers.
src/jquantstats/_plots/_portfolio/_core.py New PortfolioPlots facade composing plot mixins (package-based portfolio plots).
src/jquantstats/_plots/_portfolio/init.py Exports PortfolioPlots from the new _plots/_portfolio/ package.
src/jquantstats/_plots/_portfolio/_nav.py NAV-focused plots split into a mixin module.
src/jquantstats/_plots/_portfolio/_rolling.py Rolling/annual risk plots split into a mixin module and shared trace builder.
src/jquantstats/_plots/_portfolio/_diagnostics.py Diagnostics plots split into a mixin module.
src/jquantstats/_plots/_portfolio.py Removes the old monolithic module in favor of the new package layout.
src/jquantstats/_plots/_data/_styling.py Adds _yearly_bar_colors to capture chart-specific color semantics.
src/jquantstats/_plots/_data/_rolling.py Extracts reusable rolling-beta expression and assets selection helper.
src/jquantstats/_plots/_data/_periodic.py Extracts shared aggregation expressions and uses yearly-specific bar coloring.
pyproject.toml Updates deptry ignores: removes fastapi from DEP002 and documents remaining ignores.
Makefile Appends api/ to DEPTRY_FOLDERS so deptry observes [web] imports.
docs/STABILITY.md Adds explicit pre-1.0 stability policy and updates private-module list.
Suppressed comments (1)

src/jquantstats/_plots/_portfolio/_nav.py:216

  • Raising a bare TypeError here provides no actionable error message to users. Including the expected shape (list of non-negative ints) and the received value makes debugging much easier.
        if windows is None:
            windows = [0, 1, 2, 3, 4]
        if not isinstance(windows, list) or not all(isinstance(x, int) and x >= 0 for x in windows):
            raise TypeError


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +99 to +112
def annual_breakdown(self) -> pl.DataFrame:
"""Summary statistics broken down by calendar year.

Groups the data by calendar year using the date index, computes a
full `summary` for each year, and stacks the results with an
additional ``year`` column.

Returns:
pl.DataFrame: Columns ``year``, ``metric``, one per asset, sorted
by ``year``.

Raises:
ValueError: If the data has no date index.
"""
Comment on lines +39 to +42
"""
if not isinstance(start, int) or not isinstance(end, int):
raise TypeError
if start > end:
Comment on lines +170 to +174
if lags is None:
lags = [0, 1, 2, 3, 4]
if not isinstance(lags, list) or not all(isinstance(x, int) for x in lags):
raise TypeError

@tschm
tschm merged commit 1e55dff into main Aug 2, 2026
64 checks passed
@tschm
tschm deleted the fix/quality-findings-909-912 branch August 2, 2026 18:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants