Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ MKDOCS_EXTRA_PACKAGES = --with 'mkdocstrings[python]'
# Always include the Rhiza API (template-managed)
include .rhiza/rhiza.mk

# The optional FastAPI service under api/ (the [web] extra) is repo-owned and not
# contributed by any template bundle, so deptry never saw it and reported its
# dependencies as unused. Append it to the shared scan; DEPTRY_FOLDERS is the
# accumulating variable python.mk builds up, so this must come after the include.
DEPTRY_FOLDERS += api

# Architectural import contracts (import-linter): the analytics subpackages
# (_stats, _plots, _reports, _utils) annotate against the structural Protocols in
# _protocol.py and must never import the concrete Data / Portfolio at runtime.
Expand Down
52 changes: 45 additions & 7 deletions docs/STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ icon: material/shield-check
This document defines the public API surface of **jquantstats** and the
stability guarantees that apply from **v1.0.0** onwards.

!!! info "Current status: 0.x — the guarantees below are not yet in force"

jquantstats has not reached `v1.0.0`. The surface described here is what
*will* be frozen at 1.0, and is already treated as settled in practice, but
the formal Semantic Versioning contract begins at that tag. See
[Before v1.0.0](#before-v100) for the policy that applies today.

## Stable public exports

The following names are exported from the top-level `jquantstats` package and
Expand Down Expand Up @@ -49,8 +56,9 @@ From **v1.0.0** onwards jquantstats follows [Semantic Versioning](https://semver
Anything that is **not** in the table above is considered internal and
may change or be removed in any release:

- Private modules: `_stats.py`, `_plots.py`, `_reports.py`,
`_types.py`, `_portfolio_data.py`.
- Private modules and subpackages: `_stats/`, `_plots/`, `_reports/`,
`_utils/`, the `_portfolio_*.py` mixins, `_types.py`, `_protocol.py`,
`_cost_model.py`, `_cache.py`, `_data_reshape.py`.
- Private classes, functions, or attributes whose names begin with an
underscore (e.g. `Data._raw_returns`, `Stats._df`).
- Sub-module paths such as `jquantstats.portfolio`
Expand Down Expand Up @@ -80,8 +88,38 @@ Example timeline:
| `1.3.0` | `old_name` deprecated; `DeprecationWarning` raised on use; `new_name` available |
| `1.4.0` | `old_name` removed |

## Pre-release versions

Releases tagged `0.x.y` carry **no stability guarantee**. The API may
change in any release. Once `v1.0.0` is tagged the guarantees above
apply.
## Before v1.0.0

Releases tagged `0.x.y` carry **no formal stability guarantee** — Semantic
Versioning, as described above, begins at `v1.0.0`. That is the contractual
position. In practice the project is more conservative than that, and the
paragraphs below describe what a caller can actually rely on today.

**What is already settled.** The exports in the table above — `Portfolio`,
`Data`, `Stats`, `Plots` and the two type aliases — are not expected to be
renamed or removed before 1.0. The constructors (`Portfolio.from_position`,
`from_cash_position`, `from_risk_position`, `Data.from_returns`) and the
`.stats` / `.plots` / `.report` accessors are likewise treated as fixed
points. Changes here would be disruptive enough that they are held for the
1.0 boundary.

**What may still move.** Individual metric methods on `Stats` may gain
keyword arguments, change default values, or be renamed for consistency as
the QuantStats-parity work settles; chart signatures on `Plots` may change
as the Plotly builders are refactored. Anything private — see
[What is *not* stable](#what-is-not-stable) — may change in any release,
including the internal module layout, which has been reorganised more than
once during 0.x.

**How changes are communicated.** Every release notes its changes in the
[changelog](changelog.md). A minor bump (`0.10 → 0.11`) is where a breaking
change to a public name will appear; patch releases (`0.10.0 → 0.10.1`) are
bug fixes only. Where a rename is avoidable, the old name is kept for one
minor version with a `DeprecationWarning`, following the same courtesy as the
post-1.0 [deprecation policy](#deprecation-policy) — but before 1.0 this is a
practice, not a promise.

**Pinning advice.** Pin to a minor version (`jquantstats>=0.10,<0.11`) if you
need the surface to hold still; a bare `jquantstats>=0.10` may pick up a
breaking change at the next minor. Once `v1.0.0` is tagged, the guarantees in
the sections above replace everything here.
15 changes: 13 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,20 @@ include = [
[tool.bandit]
exclude_dirs = ["src/tests"] # Exclude test directories from security scanning

# Ignore specific dependency issues
# Ignore specific dependency issues.
#
# DEP002 flags a declared dependency with no import site. The three below have
# none by design, so the ignore records a fact rather than hiding a gap:
# kaleido - Plotly's static image-export backend. Selected at runtime
# by plotly.io, never imported by us.
# uvicorn - ASGI server for api/app.py. Invoked as a command
# (`uvicorn api.app:app`), not imported.
# python-multipart - parses multipart/form-data for FastAPI's Form/UploadFile.
# Imported by Starlette, never by us.
# fastapi is deliberately absent: api/ is now part of the deptry scan (see the
# DEPTRY_FOLDERS line in the Makefile), so its import there is observed directly.
[tool.deptry.per_rule_ignores]
DEP002 = ["kaleido", "fastapi", "uvicorn", "python-multipart"] # DEP002: Unused direct dependencies
DEP002 = ["kaleido", "uvicorn", "python-multipart"]

# Package to module name mapping
[tool.deptry.package_module_name_map]
Expand Down
33 changes: 20 additions & 13 deletions src/jquantstats/_plots/_data/_periodic.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,28 @@
import plotly.graph_objects as go
import polars as pl

from ._styling import _apply_base_layout, _bar_colors, _hex_to_rgba, _ticker_colors
from ._styling import _apply_base_layout, _bar_colors, _ticker_colors, _yearly_bar_colors

if TYPE_CHECKING:
from jquantstats._protocol import DataLike


def _period_agg_exprs(tickers: list[str], compounded: bool) -> list[pl.Expr]:
"""Per-ticker aggregation expressions for a period bucket.

Args:
tickers: Asset column names to aggregate.
compounded: Compound returns within the bucket when True, sum them
when False.

Returns:
One aliased expression per ticker.
"""
if compounded:
return [((1.0 + pl.col(t)).product() - 1.0).alias(t) for t in tickers]
return [pl.col(t).sum().alias(t) for t in tickers]


def _monthly_heatmap_matrix(
monthly: pl.DataFrame, years: list[int]
) -> tuple[list[list[float | None]], list[list[str]]]:
Expand Down Expand Up @@ -103,21 +119,14 @@ def yearly_returns(self, title: str = "Yearly Returns", compounded: bool = True)
tickers = [c for c in df.columns if c != date_col]
colors = _ticker_colors(tickers)

agg_exprs = (
[((1.0 + pl.col(t)).product() - 1.0).alias(t) for t in tickers]
if compounded
else [pl.col(t).sum().alias(t) for t in tickers]
)
agg_exprs = _period_agg_exprs(tickers, compounded)
yearly = (
df.with_columns(pl.col(date_col).dt.year().alias("_year")).group_by("_year").agg(agg_exprs).sort("_year")
)

fig = go.Figure()
for ticker in tickers:
values = yearly[ticker].to_list()
bar_colors = [
colors[ticker] if v is not None and v >= 0 else _hex_to_rgba(colors[ticker], 0.5) for v in values
]
bar_colors = _yearly_bar_colors(yearly[ticker].to_list(), colors[ticker])
fig.add_trace(
go.Bar(
x=yearly["_year"],
Expand Down Expand Up @@ -153,9 +162,7 @@ def monthly_returns(self, title: str = "Monthly Returns", compounded: bool = Tru

monthly = df.group_by_dynamic(
index_column=date_col, every="1mo", period="1mo", closed="right", label="right"
).agg(
[((1.0 + pl.col(t)).product() - 1.0).alias(t) if compounded else pl.col(t).sum().alias(t) for t in tickers]
)
).agg(_period_agg_exprs(tickers, compounded))

fig = go.Figure()
for ticker in tickers:
Expand Down
56 changes: 43 additions & 13 deletions src/jquantstats/_plots/_data/_rolling.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,54 @@
from jquantstats._protocol import DataLike


def _rolling_beta_expr(asset: str, bench_col: str, window: int) -> pl.Expr:
"""Trailing-window OLS beta of *asset* against *bench_col*.

Beta is ``cov(asset, bench) / var(bench)``, expanded into rolling means so
the whole estimate is a single Polars expression.

Args:
asset: Asset column name.
bench_col: Benchmark column name.
window: Trailing window size in rows.

Returns:
An expression aliased ``beta``.
"""
mean_x = pl.col(asset).rolling_mean(window_size=window)
mean_y = pl.col(bench_col).rolling_mean(window_size=window)
mean_xy = (pl.col(asset) * pl.col(bench_col)).rolling_mean(window_size=window)
mean_y2 = (pl.col(bench_col) ** 2).rolling_mean(window_size=window)
return ((mean_xy - mean_x * mean_y) / (mean_y2 - mean_y**2)).alias("beta")


class _RollingPlotsMixin:
"""Rolling-window metric plots for :class:`DataPlots`."""

__slots__ = ()

_data: DataLike

def _beta_assets(self, df: pl.DataFrame, date_col: str, bench_col: str) -> list[str]:
"""Asset columns to plot beta for.

Prefers the explicit ``returns`` frame when the data exposes one, and
otherwise falls back to every column of *df* that is neither the date
nor the benchmark.

Args:
df: The combined index/returns/benchmark frame.
date_col: Name of the date column.
bench_col: Name of the benchmark column.

Returns:
The asset column names.
"""
returns_df = getattr(self._data, "returns", None)
if returns_df is not None:
return list(returns_df.columns)
return [c for c in df.columns if c != date_col and c != bench_col]

def rolling_sharpe(
self,
rolling_period: int = 126,
Expand Down Expand Up @@ -219,26 +260,15 @@ def rolling_beta(
raise NoBenchmarkError

bench_col = benchmark_df.columns[0]
returns_df = getattr(self._data, "returns", None)
assets = (
list(returns_df.columns)
if returns_df is not None
else [c for c in df.columns if c != date_col and c != bench_col]
)
assets = self._beta_assets(df, date_col, bench_col)
colors = _ticker_colors(assets)
windows = [w for w in (rolling_period, rolling_period2) if w is not None]
line_styles = ["solid", "dash"]

fig = go.Figure()
for asset in assets:
for w, dash in zip(windows, line_styles, strict=False):
mean_x = pl.col(asset).rolling_mean(window_size=w)
mean_y = pl.col(bench_col).rolling_mean(window_size=w)
mean_xy = (pl.col(asset) * pl.col(bench_col)).rolling_mean(window_size=w)
mean_y2 = (pl.col(bench_col) ** 2).rolling_mean(window_size=w)
beta_expr = ((mean_xy - mean_x * mean_y) / (mean_y2 - mean_y**2)).alias("beta")

beta_df = df.with_columns(beta_expr)
beta_df = df.with_columns(_rolling_beta_expr(asset, bench_col, w))
label = f"{asset} ({w}d)"
fig.add_trace(
go.Scatter(
Expand Down
19 changes: 19 additions & 0 deletions src/jquantstats/_plots/_data/_styling.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ def _bar_colors(values: list[float | None], positive_color: str, single_asset: b
return [positive_color if v is not None and v > 0 else negative_color for v in values]


def _yearly_bar_colors(values: list[float | None], positive_color: str) -> list[str]:
"""Bar colors for the yearly-returns chart.

Deliberately distinct from `_bar_colors`: the yearly chart treats a flat
zero year as positive (``>= 0``) and fades negatives to alpha 0.5 rather
than 0.4, so the two cannot share an implementation without changing what
is rendered.

Args:
values: The per-year return values; ``None`` counts as negative.
positive_color: The asset's base color.

Returns:
One color string per value.
"""
negative_color = _hex_to_rgba(positive_color, 0.5)
return [positive_color if v is not None and v >= 0 else negative_color for v in values]


def _compute_drawdown_periods(prices: list[float], n: int) -> list[dict[str, Any]]:
"""Identify the top *n* drawdown periods from a cumulative price series.

Expand Down
Loading
Loading