Skip to content

API Reference

The public API surface of jquantstats. All stable exports are importable directly from the top-level package:

from jquantstats import Portfolio, Data, CostModel
Export What it is Start here when…
Portfolio Position-level analytics: NAV, turnover, costs, attribution, lag() you have prices and positions
Data Returns-level analytics: 50+ stats, plots, HTML report you have returns (or prices only)
CostModel Declarative trading-cost specification (per-unit or turnover-bps) you want cost-adjusted analytics
Result Lightweight container returned by some report helpers you consume report internals
NativeFrame, NativeFrameOrScalar Type aliases for narwhals-compatible input frames you type-annotate your own code

Exceptions live in jquantstats.exceptions and all inherit from JQuantStatsError, so except JQuantStatsError catches the whole family.

See API Stability for the versioning and deprecation policy, and the FAQ for common errors.


Portfolio

jquantstats.Portfolio dataclass

Bases: PortfolioNavMixin, PortfolioAttributionMixin, PortfolioTurnoverMixin, PortfolioCostMixin, PortfolioTransformMixin, PortfolioConstructorMixin

Portfolio analytics class for quant finance.

Stores the three raw inputs — cash positions, prices, and AUM — and exposes the standard derived data series, analytics facades, transforms, and attribution tools.

Derived data series:

  • profits — per-asset daily cash P&L
  • profit — aggregate daily portfolio profit
  • nav_accumulated — cumulative additive NAV
  • nav_compounded — compounded NAV
  • returns — daily returns (profit / AUM)
  • monthly — monthly compounded returns
  • highwater — running high-water mark
  • drawdown — drawdown from high-water mark
  • all — merged view of all derived series

  • Lazy composition accessors: stats, plots, report

  • Portfolio transforms: truncate, lag, smoothed_holding
  • Attribution: tilt, timing, tilt_timing_decomp
  • Turnover: turnover, turnover_weekly, turnover_summary
  • Cost analysis: cost_adjusted_returns, trading_cost_impact
  • Utility: correlation

Attributes:

Name Type Description
cashposition DataFrame

Polars DataFrame of positions per asset over time (includes date column if present).

prices DataFrame

Polars DataFrame of prices per asset over time (includes date column if present).

aum float

Assets under management used as base NAV offset.

Analytics facades

  • .stats : delegates to the legacy Stats pipeline via .data; all 50+ metrics available.
  • .plots : portfolio-specific Plots; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps.
  • .report : HTML Report; self-contained portfolio performance report.
  • .data : bridge to the legacy Data / Stats / DataPlots pipeline.

.plots and .report are intentionally not delegated to the legacy path: the legacy path operates on a bare returns series, while the analytics path has access to raw prices, positions, and AUM for richer portfolio-specific visualisations.

Cost models

Two independent cost models are provided. They are not interchangeable:

Model A — position-delta (stateful, set at construction): cost_per_unit: float — one-way cost per unit of position change (e.g. 0.01 per share). Used by .position_delta_costs and .net_cost_nav. Best for: equity portfolios where cost scales with shares traded.

Model B — turnover-bps (stateless, passed at call time): cost_bps: float — one-way cost in basis points of AUM turnover (e.g. 5 bps). Used by .cost_adjusted_returns(cost_bps) and .trading_cost_impact(max_bps). Best for: macro / fund-of-funds portfolios where cost scales with notional traded.

To sweep a range of cost assumptions use trading_cost_impact(max_bps=20) (Model B). To compute a net-NAV curve set cost_per_unit at construction and read .net_cost_nav (Model A).

Date column requirement

Most analytics work with or without a date column. The following features require a temporal date column (pl.Date or pl.Datetime):

  • portfolio.plots.correlation_heatmap()
  • portfolio.plots.lead_lag_ir_plot()
  • stats.monthly_win_rate() — returns NaN per column when no date is present
  • stats.annual_breakdown() — raises ValueError when no date is present
  • stats.max_drawdown_duration() — returns period count (int) instead of days

Portfolios without a date column (integer-indexed) are fully supported for NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics.

Examples:

>>> import polars as pl
>>> from datetime import date
>>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
>>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
>>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
>>> pf.assets
['A']
Source code in src/jquantstats/portfolio.py
@dataclasses.dataclass(frozen=True, slots=True)
class Portfolio(
    PortfolioNavMixin,
    PortfolioAttributionMixin,
    PortfolioTurnoverMixin,
    PortfolioCostMixin,
    PortfolioTransformMixin,
    PortfolioConstructorMixin,
):
    """Portfolio analytics class for quant finance.

    Stores the three raw inputs — cash positions, prices, and AUM — and
    exposes the standard derived data series, analytics facades, transforms,
    and attribution tools.

    Derived data series:

    - `profits` — per-asset daily cash P&L
    - `profit` — aggregate daily portfolio profit
    - `nav_accumulated` — cumulative additive NAV
    - `nav_compounded` — compounded NAV
    - `returns` — daily returns (profit / AUM)
    - `monthly` — monthly compounded returns
    - `highwater` — running high-water mark
    - `drawdown` — drawdown from high-water mark
    - `all` — merged view of all derived series

    - Lazy composition accessors: `stats`, `plots`, `report`
    - Portfolio transforms: `truncate`, `lag`,
      `smoothed_holding`
    - Attribution: `tilt`, `timing`, `tilt_timing_decomp`
    - Turnover: `turnover`, `turnover_weekly`,
      `turnover_summary`
    - Cost analysis: `cost_adjusted_returns`,
      `trading_cost_impact`
    - Utility: `correlation`

    Attributes:
        cashposition: Polars DataFrame of positions per asset over time
            (includes date column if present).
        prices: Polars DataFrame of prices per asset over time (includes date
            column if present).
        aum: Assets under management used as base NAV offset.

    Analytics facades
    -----------------
    - ``.stats``   : delegates to the legacy ``Stats`` pipeline via ``.data``; all 50+ metrics available.
    - ``.plots``   : portfolio-specific ``Plots``; NAV overlays, lead-lag IR, rolling Sharpe/vol, heatmaps.
    - ``.report``  : HTML ``Report``; self-contained portfolio performance report.
    - ``.data``    : bridge to the legacy ``Data`` / ``Stats`` / ``DataPlots`` pipeline.

    ``.plots`` and ``.report`` are intentionally *not* delegated to the legacy path: the legacy
    path operates on a bare returns series, while the analytics path has access to raw prices,
    positions, and AUM for richer portfolio-specific visualisations.

    Cost models
    -----------
    Two independent cost models are provided. They are not interchangeable:

    **Model A — position-delta (stateful, set at construction):**
        ``cost_per_unit: float``  — one-way cost per unit of position change (e.g. 0.01 per share).
        Used by ``.position_delta_costs`` and ``.net_cost_nav``.
        Best for: equity portfolios where cost scales with shares traded.

    **Model B — turnover-bps (stateless, passed at call time):**
        ``cost_bps: float``  — one-way cost in basis points of AUM turnover (e.g. 5 bps).
        Used by ``.cost_adjusted_returns(cost_bps)`` and ``.trading_cost_impact(max_bps)``.
        Best for: macro / fund-of-funds portfolios where cost scales with notional traded.

    To sweep a range of cost assumptions use ``trading_cost_impact(max_bps=20)`` (Model B).
    To compute a net-NAV curve set ``cost_per_unit`` at construction and read ``.net_cost_nav`` (Model A).

    Date column requirement
    -----------------------
    Most analytics work with or without a ``date`` column. The following features require a
    temporal ``date`` column (``pl.Date`` or ``pl.Datetime``):

    - ``portfolio.plots.correlation_heatmap()``
    - ``portfolio.plots.lead_lag_ir_plot()``
    - ``stats.monthly_win_rate()``      — returns NaN per column when no date is present
    - ``stats.annual_breakdown()``      — raises ``ValueError`` when no date is present
    - ``stats.max_drawdown_duration()`` — returns period count (int) instead of days

    Portfolios without a ``date`` column (integer-indexed) are fully supported for
    NAV, returns, Sharpe, drawdown, cost analytics, and most rolling metrics.

    Examples:
        >>> import polars as pl
        >>> from datetime import date
        >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
        >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
        >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
        >>> pf.assets
        ['A']
    """

    cashposition: pl.DataFrame
    prices: pl.DataFrame
    aum: float
    cost_per_unit: float = 0.0
    cost_bps: float = 0.0

    # ── Internal cache fields ─────────────────────────────────────────────────
    # All cache fields are initialised to ``None`` in ``__post_init__`` via
    # ``object.__setattr__`` (required for frozen dataclasses) and populated
    # lazily on first property access.
    #
    # Lifecycle:
    #   - Initialised: ``__post_init__`` sets every field to ``None``.
    #   - Populated: each property computes its value on the first call and
    #     writes it back via ``object.__setattr__``.
    #   - Invalidation: not required — ``Portfolio`` is a *frozen* dataclass,
    #     so its inputs never change and all derived values remain valid for the
    #     lifetime of the instance.
    _data_bridge: "Data | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _stats_cache: "Stats | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _plots_cache: "PortfolioPlots | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _report_cache: "Report | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _utils_cache: "PortfolioUtils | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _profits_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _returns_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _tilt_cache: "Portfolio | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)
    _turnover_cache: "pl.DataFrame | None" = dataclasses.field(init=False, repr=False, compare=False, hash=False)

    @staticmethod
    def _build_data_bridge(ret: pl.DataFrame) -> "Data":
        """Build a `Data` bridge from a returns frame.

        Splits out the ``'date'`` column (if present) into an index and passes
        the remaining numeric columns as returns.  Used internally to populate
        ``_data_bridge`` at construction time so the ``data`` property is O(1).

        Args:
            ret: Returns DataFrame, optionally with a leading ``'date'`` column.

        Returns:
            A `Data` instance backed by *ret*.
        """
        from .data import Data

        returns_only = ret.select("returns")
        if "date" in ret.columns:
            return Data(returns=returns_only, index=ret.select("date"))
        return Data(returns=returns_only, index=pl.DataFrame({"index": list(range(ret.height))}))

    def __post_init__(self) -> None:
        """Validate input types, shapes, and parameters post-initialization."""
        if not isinstance(self.prices, pl.DataFrame):
            raise InvalidPricesTypeError(type(self.prices).__name__)
        if not isinstance(self.cashposition, pl.DataFrame):
            raise InvalidCashPositionTypeError(type(self.cashposition).__name__)
        if self.cashposition.shape[0] != self.prices.shape[0]:
            raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0])
        if self.aum <= 0.0:
            raise NonPositiveAumError(self.aum)
        for slot in _CACHE_SLOTS:
            object.__setattr__(self, slot, None)

    def _date_range(self) -> tuple[int, date | datetime | None, date | datetime | None]:
        """Return (rows, start, end) for the portfolio's returns series.

        ``start`` and ``end`` are ``None`` when there is no ``'date'`` column.
        """
        ret = self.returns
        rows = ret.height
        if "date" in ret.columns:
            return rows, cast(date | None, ret["date"].min()), cast(date | None, ret["date"].max())
        return rows, None, None

    @property
    def cost_model(self) -> CostModel:
        """Return the active cost model as a `CostModel` instance.

        Returns:
            A `CostModel` whose ``cost_per_unit`` and ``cost_bps`` fields
            reflect the values stored on this portfolio.
        """
        return CostModel(cost_per_unit=self.cost_per_unit, cost_bps=self.cost_bps)

    def __repr__(self) -> str:
        """Return a string representation of the Portfolio object."""
        rows, start, end = self._date_range()
        if start is not None:
            return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})"
        return f"Portfolio(assets={self.assets}, rows={rows})"

    def describe(self) -> pl.DataFrame:
        """Return a tidy summary of shape, date range and asset names.

        Returns:
        -------
        pl.DataFrame
            One row per asset with columns: asset, start, end, rows.

        Examples:
            >>> import polars as pl
            >>> from datetime import date
            >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
            >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
            >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
            >>> df = pf.describe()
            >>> list(df.columns)
            ['asset', 'start', 'end', 'rows']
        """
        rows, start, end = self._date_range()
        return pl.DataFrame(
            {
                "asset": self.assets,
                "start": [start] * len(self.assets),
                "end": [end] * len(self.assets),
                "rows": [rows] * len(self.assets),
            }
        )

    # ── Factory classmethods ──────────────────────────────────────────────────

    @classmethod
    def from_cash_position(
        cls,
        prices: pl.DataFrame,
        cash_position: pl.DataFrame | pl.Expr,
        aum: float,
        cost_per_unit: float = 0.0,
        cost_bps: float = 0.0,
        cost_model: CostModel | None = None,
    ) -> Self:
        """Create a Portfolio directly from cash positions aligned with prices.

        Args:
            prices: Price levels per asset over time (may include a date column).
            cash_position: Cash exposure per asset over time, either as a
                DataFrame or as a Polars expression evaluated against *prices*.
            aum: Assets under management used as the base NAV offset.
            cost_per_unit: One-way trading cost per unit of position change.
                Defaults to 0.0 (no cost).  Ignored when *cost_model* is given.
            cost_bps: One-way trading cost in basis points of AUM turnover.
                Defaults to 0.0 (no cost).  Ignored when *cost_model* is given.
            cost_model: Optional `CostModel`
                instance.  When supplied, its ``cost_per_unit`` and
                ``cost_bps`` values take precedence over the individual
                parameters above.

        Returns:
            A Portfolio instance with the provided cash positions.

        Raises:
            PositionExprColumnError: If *cash_position* is an expression that
                creates columns not present in *prices* (e.g. via ``.alias``);
                such expressions leave the original asset columns untouched,
                silently treating raw prices as positions.
        """
        if isinstance(cash_position, pl.Expr):
            cash_position = _evaluate_position_expr(prices, cash_position, "cash_position")
        if cost_model is not None:
            cost_per_unit = cost_model.cost_per_unit
            cost_bps = cost_model.cost_bps
        return cls(prices=prices, cashposition=cash_position, aum=aum, cost_per_unit=cost_per_unit, cost_bps=cost_bps)

    # ── Internal helpers ───────────────────────────────────────────────────────

    @staticmethod
    def _assert_clean_series(series: pl.Series, name: str = "") -> None:
        """Raise `UncleanSeriesError` if *series* contains nulls or non-finite values.

        Args:
            series: The series to validate.
            name: Optional series name included in the error message.

        Raises:
            UncleanSeriesError: If the series contains null or non-finite values.
        """
        if series.null_count() != 0:
            raise UncleanSeriesError(name, "null")
        if not series.is_finite().all():
            raise UncleanSeriesError(name, "non-finite")

    # ── Core data properties ───────────────────────────────────────────────────

    @property
    def assets(self) -> list[str]:
        """List the asset column names from prices (numeric columns).

        Returns:
            list[str]: Names of numeric columns in prices; typically excludes
            ``'date'``.
        """
        return [c for c in self.prices.columns if self.prices[c].dtype.is_numeric()]

    # ── Lazy composition accessors ─────────────────────────────────────────────

    @property
    @cached_in_slot("_data_bridge")
    def data(self) -> "Data":
        """Build a legacy `Data` object from this portfolio's returns.

        This bridges the two entry points: ``Portfolio`` compiles the NAV curve from
        prices and positions; the returned `Data` object
        gives access to the full legacy analytics pipeline (``data.stats``,
        ``data.plots``, ``data.reports``).

        Returns:
            `Data`: A Data object whose ``returns`` column
            is the portfolio's daily return series and whose ``index`` holds the date
            column (or a synthetic integer index for date-free portfolios).

        Examples:
            >>> import polars as pl
            >>> from datetime import date
            >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
            >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
            >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
            >>> d = pf.data
            >>> "returns" in d.returns.columns
            True
        """
        return Portfolio._build_data_bridge(self.returns)

    @property
    @cached_in_slot("_stats_cache")
    def stats(self) -> "Stats":
        """Return a Stats object built from the portfolio's daily returns.

        Delegates to the legacy `Stats` pipeline via
        `data`, so all analytics (Sharpe, drawdown, summary, etc.) are
        available through the shared implementation.

        The result is cached after first access so repeated calls are O(1).
        """
        return self.data.stats

    @property
    @cached_in_slot("_plots_cache")
    def plots(self) -> PortfolioPlots:
        """Convenience accessor returning a PortfolioPlots facade for this portfolio.

        Use this to create Plotly visualizations such as snapshots, lagged
        performance curves, and lead/lag IR charts.

        Returns:
            `PortfolioPlots`: Helper object with
            plotting methods.

        The result is cached after first access so repeated calls are O(1).
        """
        return PortfolioPlots(self)

    @property
    @cached_in_slot("_report_cache")
    def report(self) -> Report:
        """Convenience accessor returning a Report facade for this portfolio.

        Use this to generate a self-contained HTML performance report
        containing statistics tables and interactive charts.

        Returns:
            `Report`: Helper object with
            report methods.

        The result is cached after first access so repeated calls are O(1).
        """
        return Report(self)

    @property
    @cached_in_slot("_utils_cache")
    def utils(self) -> "PortfolioUtils":
        """Convenience accessor returning a PortfolioUtils facade for this portfolio.

        Use this for common data transformations such as converting returns to
        prices, computing log returns, rebasing, aggregating by period, and
        computing exponential standard deviation.

        Returns:
            `PortfolioUtils`: Helper object with
            utility transform methods.

        The result is cached after first access so repeated calls are O(1).
        """
        from ._utils import PortfolioUtils

        return PortfolioUtils(self)

assets property

List the asset column names from prices (numeric columns).

Returns:

Type Description
list[str]

list[str]: Names of numeric columns in prices; typically excludes

list[str]

'date'.

cost_model property

Return the active cost model as a CostModel instance.

Returns:

Type Description
CostModel

A CostModel whose cost_per_unit and cost_bps fields

CostModel

reflect the values stored on this portfolio.

data property

Build a legacy Data object from this portfolio's returns.

This bridges the two entry points: Portfolio compiles the NAV curve from prices and positions; the returned Data object gives access to the full legacy analytics pipeline (data.stats, data.plots, data.reports).

Returns:

Type Description
Data

Data: A Data object whose returns column

Data

is the portfolio's daily return series and whose index holds the date

Data

column (or a synthetic integer index for date-free portfolios).

Examples:

>>> import polars as pl
>>> from datetime import date
>>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
>>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
>>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
>>> d = pf.data
>>> "returns" in d.returns.columns
True

plots property

Convenience accessor returning a PortfolioPlots facade for this portfolio.

Use this to create Plotly visualizations such as snapshots, lagged performance curves, and lead/lag IR charts.

Returns:

Type Description
PortfolioPlots

PortfolioPlots: Helper object with

PortfolioPlots

plotting methods.

The result is cached after first access so repeated calls are O(1).

report property

Convenience accessor returning a Report facade for this portfolio.

Use this to generate a self-contained HTML performance report containing statistics tables and interactive charts.

Returns:

Type Description
Report

Report: Helper object with

Report

report methods.

The result is cached after first access so repeated calls are O(1).

stats property

Return a Stats object built from the portfolio's daily returns.

Delegates to the legacy Stats pipeline via data, so all analytics (Sharpe, drawdown, summary, etc.) are available through the shared implementation.

The result is cached after first access so repeated calls are O(1).

utils property

Convenience accessor returning a PortfolioUtils facade for this portfolio.

Use this for common data transformations such as converting returns to prices, computing log returns, rebasing, aggregating by period, and computing exponential standard deviation.

Returns:

Type Description
PortfolioUtils

PortfolioUtils: Helper object with

PortfolioUtils

utility transform methods.

The result is cached after first access so repeated calls are O(1).

__post_init__()

Validate input types, shapes, and parameters post-initialization.

Source code in src/jquantstats/portfolio.py
def __post_init__(self) -> None:
    """Validate input types, shapes, and parameters post-initialization."""
    if not isinstance(self.prices, pl.DataFrame):
        raise InvalidPricesTypeError(type(self.prices).__name__)
    if not isinstance(self.cashposition, pl.DataFrame):
        raise InvalidCashPositionTypeError(type(self.cashposition).__name__)
    if self.cashposition.shape[0] != self.prices.shape[0]:
        raise RowCountMismatchError(self.prices.shape[0], self.cashposition.shape[0])
    if self.aum <= 0.0:
        raise NonPositiveAumError(self.aum)
    for slot in _CACHE_SLOTS:
        object.__setattr__(self, slot, None)

__repr__()

Return a string representation of the Portfolio object.

Source code in src/jquantstats/portfolio.py
def __repr__(self) -> str:
    """Return a string representation of the Portfolio object."""
    rows, start, end = self._date_range()
    if start is not None:
        return f"Portfolio(assets={self.assets}, rows={rows}, start={start}, end={end})"
    return f"Portfolio(assets={self.assets}, rows={rows})"

describe()

Return a tidy summary of shape, date range and asset names.

Returns:

pl.DataFrame One row per asset with columns: asset, start, end, rows.

Examples:

>>> import polars as pl
>>> from datetime import date
>>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
>>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
>>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
>>> df = pf.describe()
>>> list(df.columns)
['asset', 'start', 'end', 'rows']
Source code in src/jquantstats/portfolio.py
def describe(self) -> pl.DataFrame:
    """Return a tidy summary of shape, date range and asset names.

    Returns:
    -------
    pl.DataFrame
        One row per asset with columns: asset, start, end, rows.

    Examples:
        >>> import polars as pl
        >>> from datetime import date
        >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
        >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
        >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e6)
        >>> df = pf.describe()
        >>> list(df.columns)
        ['asset', 'start', 'end', 'rows']
    """
    rows, start, end = self._date_range()
    return pl.DataFrame(
        {
            "asset": self.assets,
            "start": [start] * len(self.assets),
            "end": [end] * len(self.assets),
            "rows": [rows] * len(self.assets),
        }
    )

from_cash_position(prices, cash_position, aum, cost_per_unit=0.0, cost_bps=0.0, cost_model=None) classmethod

Create a Portfolio directly from cash positions aligned with prices.

Parameters:

Name Type Description Default
prices DataFrame

Price levels per asset over time (may include a date column).

required
cash_position DataFrame | Expr

Cash exposure per asset over time, either as a DataFrame or as a Polars expression evaluated against prices.

required
aum float

Assets under management used as the base NAV offset.

required
cost_per_unit float

One-way trading cost per unit of position change. Defaults to 0.0 (no cost). Ignored when cost_model is given.

0.0
cost_bps float

One-way trading cost in basis points of AUM turnover. Defaults to 0.0 (no cost). Ignored when cost_model is given.

0.0
cost_model CostModel | None

Optional CostModel instance. When supplied, its cost_per_unit and cost_bps values take precedence over the individual parameters above.

None

Returns:

Type Description
Self

A Portfolio instance with the provided cash positions.

Raises:

Type Description
PositionExprColumnError

If cash_position is an expression that creates columns not present in prices (e.g. via .alias); such expressions leave the original asset columns untouched, silently treating raw prices as positions.

Source code in src/jquantstats/portfolio.py
@classmethod
def from_cash_position(
    cls,
    prices: pl.DataFrame,
    cash_position: pl.DataFrame | pl.Expr,
    aum: float,
    cost_per_unit: float = 0.0,
    cost_bps: float = 0.0,
    cost_model: CostModel | None = None,
) -> Self:
    """Create a Portfolio directly from cash positions aligned with prices.

    Args:
        prices: Price levels per asset over time (may include a date column).
        cash_position: Cash exposure per asset over time, either as a
            DataFrame or as a Polars expression evaluated against *prices*.
        aum: Assets under management used as the base NAV offset.
        cost_per_unit: One-way trading cost per unit of position change.
            Defaults to 0.0 (no cost).  Ignored when *cost_model* is given.
        cost_bps: One-way trading cost in basis points of AUM turnover.
            Defaults to 0.0 (no cost).  Ignored when *cost_model* is given.
        cost_model: Optional `CostModel`
            instance.  When supplied, its ``cost_per_unit`` and
            ``cost_bps`` values take precedence over the individual
            parameters above.

    Returns:
        A Portfolio instance with the provided cash positions.

    Raises:
        PositionExprColumnError: If *cash_position* is an expression that
            creates columns not present in *prices* (e.g. via ``.alias``);
            such expressions leave the original asset columns untouched,
            silently treating raw prices as positions.
    """
    if isinstance(cash_position, pl.Expr):
        cash_position = _evaluate_position_expr(prices, cash_position, "cash_position")
    if cost_model is not None:
        cost_per_unit = cost_model.cost_per_unit
        cost_bps = cost_model.cost_bps
    return cls(prices=prices, cashposition=cash_position, aum=aum, cost_per_unit=cost_per_unit, cost_bps=cost_bps)

Data

jquantstats.Data dataclass

A container for financial returns data and an optional benchmark.

Provides methods for analyzing and manipulating financial returns data, including resampling, truncation, and access to statistical metrics and visualizations via the stats and plots properties.

Attributes:

Name Type Description
returns DataFrame

DataFrame containing returns data with assets as columns.

benchmark DataFrame | None

Optional benchmark returns DataFrame. Defaults to None.

index DataFrame

DataFrame containing the date index for the returns data.

Source code in src/jquantstats/data.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
@dataclasses.dataclass(frozen=True, slots=True)
class Data:
    """A container for financial returns data and an optional benchmark.

    Provides methods for analyzing and manipulating financial returns data,
    including resampling, truncation, and access to statistical metrics and
    visualizations via the ``stats`` and ``plots`` properties.

    Attributes:
        returns (pl.DataFrame): DataFrame containing returns data with assets
            as columns.
        benchmark (pl.DataFrame | None): Optional benchmark returns DataFrame.
            Defaults to None.
        index (pl.DataFrame): DataFrame containing the date index for the
            returns data.

    """

    returns: pl.DataFrame
    index: pl.DataFrame
    benchmark: pl.DataFrame | None = None

    def __post_init__(self) -> None:
        """Validate the Data object after initialization."""
        # You need at least two points
        if self.index.shape[0] < 2:
            raise ValueError("Index must contain at least two timestamps.")  # noqa: TRY003

        # Check index is monotonically increasing
        datetime_col = self.index[self.index.columns[0]]
        if not datetime_col.is_sorted():
            raise ValueError("Index must be monotonically increasing.")  # noqa: TRY003

        # Check row count matches returns
        if self.returns.shape[0] != self.index.shape[0]:
            raise ValueError("Returns and index must have the same number of rows.")  # noqa: TRY003

        # Check row count matches benchmark (if provided)
        if self.benchmark is not None and self.benchmark.shape[0] != self.index.shape[0]:
            raise ValueError("Benchmark and index must have the same number of rows.")  # noqa: TRY003

    @classmethod
    def from_returns(
        cls,
        returns: NativeFrame,
        rf: NativeFrameOrScalar = 0.0,
        benchmark: NativeFrame | None = None,
        date_col: str = "Date",
        null_strategy: Literal["raise", "drop", "forward_fill"] | None = None,
    ) -> Data:
        """Create a Data object from returns and optional benchmark.

        Args:
            returns (NativeFrame): Financial returns data. First column should
                be the date column, remaining columns are asset returns.
            rf (float | NativeFrame): Risk-free rate. Defaults to 0.0 (no
                risk-free rate adjustment).

                - If float: Constant risk-free rate applied to all dates.
                - If NativeFrame: Time-varying risk-free rate with dates
                  matching returns.

            benchmark (NativeFrame | None): Benchmark returns. Defaults to
                None (no benchmark). First column should be the date column,
                remaining columns are benchmark returns. Returns and
                benchmark are aligned on their common dates; if either frame
                contains dates the other lacks, those rows are dropped and a
                `BenchmarkAlignmentWarning` is emitted.
            date_col (str): Name of the date column in the DataFrames.
                Defaults to ``"Date"``.
            null_strategy ({"raise", "drop", "forward_fill"} | None): How to
                handle ``null`` (missing) values in *returns* and *benchmark*.
                Defaults to ``None`` (nulls propagate through calculations).

                - ``None`` — no null checking; nulls propagate through all
                  downstream calculations.
                - ``"raise"`` — raise `NullsInReturnsError` if any null is
                  found.
                - ``"drop"`` — silently drop every row that contains at least
                  one null.
                - ``"forward_fill"`` — fill each null with the most recent
                  non-null value in the same column.

                Note: Affects only Polars ``null`` values (i.e. ``None`` /
                missing entries). IEEE-754 ``NaN`` values are **not** affected
                and continue to propagate as per IEEE-754 semantics.

        Returns:
            Data: Object containing excess returns and benchmark (if any),
            with methods for analysis and visualization through the ``stats``
            and ``plots`` properties.

        Raises:
            MissingDateColumnError: If *date_col* is not a column of
                *returns*, *benchmark*, or a DataFrame-valued *rf*. Raised
                before any joins so the offending frame is named explicitly.
            NullsInReturnsError: If *null_strategy* is ``"raise"`` and the
                data contains null values.
            ValueError: If there are no overlapping dates between returns and
                benchmark.

        Warns:
            BenchmarkAlignmentWarning: If aligning returns and benchmark on
                their common dates drops rows from either frame.

        Examples:
            Basic usage:

            ```python
            from jquantstats import Data
            import polars as pl

            returns = pl.DataFrame({
                "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
                "Asset1": [0.01, -0.02, 0.03]
            }).with_columns(pl.col("Date").str.to_date())

            data = Data.from_returns(returns=returns)
            ```

            With benchmark and risk-free rate:

            ```python
            benchmark = pl.DataFrame({
                "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
                "Market": [0.005, -0.01, 0.02]
            }).with_columns(pl.col("Date").str.to_date())

            data = Data.from_returns(returns=returns, benchmark=benchmark, rf=0.0002)
            ```

            Handling nulls automatically:

            ```python
            returns_with_nulls = pl.DataFrame({
                "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
                "Asset1": [0.01, None, 0.03]
            }).with_columns(pl.col("Date").str.to_date())

            # Drop rows with nulls (mirrors pandas/QuantStats behaviour)
            data = Data.from_returns(returns=returns_with_nulls, null_strategy="drop")

            # Or forward-fill nulls
            data = Data.from_returns(returns=returns_with_nulls, null_strategy="forward_fill")
            ```

        """
        returns_pl = _to_polars(returns)
        benchmark_pl = _to_polars(benchmark) if benchmark is not None else None
        # accept ints (e.g. rf=0) by coercing to float
        rf_converted: float | pl.DataFrame = float(rf) if isinstance(rf, int | float) else _to_polars(rf)

        frames: list[tuple[str, pl.DataFrame | None]] = [("returns", returns_pl), ("benchmark", benchmark_pl)]
        if isinstance(rf_converted, pl.DataFrame):
            frames.append(("rf", rf_converted))
        _require_date_col(frames, date_col)

        returns_pl = _apply_null_strategy(returns_pl, date_col, "returns", null_strategy)
        if benchmark_pl is not None:
            benchmark_pl = _apply_null_strategy(benchmark_pl, date_col, "benchmark", null_strategy)
            returns_pl, benchmark_pl = _align_returns_benchmark(returns_pl, benchmark_pl, date_col)

        index = returns_pl.select(date_col)
        excess_returns = _subtract_risk_free(returns_pl, rf_converted, date_col).drop(date_col)
        excess_benchmark = (
            _subtract_risk_free(benchmark_pl, rf_converted, date_col).drop(date_col)
            if benchmark_pl is not None
            else None
        )

        return cls(returns=excess_returns, benchmark=excess_benchmark, index=index)

    @classmethod
    def from_prices(
        cls,
        prices: NativeFrame,
        rf: NativeFrameOrScalar = 0.0,
        benchmark: NativeFrame | None = None,
        date_col: str = "Date",
        null_strategy: Literal["raise", "drop", "forward_fill"] | None = None,
    ) -> Data:
        """Create a Data object from prices and optional benchmark.

        Converts price levels to returns via percentage change and delegates
        to `from_returns`. The first row of each asset is dropped because no
        prior price is available to compute a return.

        Args:
            prices (NativeFrame): Price-level data. First column should be
                the date column; remaining columns are asset prices.
            rf (float | NativeFrame): Risk-free rate. Forwarded unchanged to
                `from_returns`. Defaults to 0.0 (no risk-free rate
                adjustment).
            benchmark (NativeFrame | None): Benchmark prices. Converted to
                returns in the same way as ``prices`` before being forwarded
                to `from_returns`. Defaults to None (no benchmark).
            date_col (str): Name of the date column in the DataFrames.
                Defaults to ``"Date"``.
            null_strategy ({"raise", "drop", "forward_fill"} | None): How to
                handle ``null`` (missing) values after converting prices to
                returns. Forwarded unchanged to `from_returns`. Defaults to
                ``None`` (nulls propagate through calculations).

                - ``None`` — no null checking; nulls propagate.
                - ``"raise"`` — raise `NullsInReturnsError` if any null is
                  found in the derived returns.
                - ``"drop"`` — silently drop every row that contains at least
                  one null.
                - ``"forward_fill"`` — fill each null with the most recent
                  non-null value.

                Note: Prices that contain nulls will produce null returns via
                ``pct_change()``. If you expect missing price entries, pass
                ``null_strategy="drop"`` or ``null_strategy="forward_fill"``.

        Returns:
            Data: Object containing excess returns derived from the supplied
            prices, with methods for analysis and visualization through the
            ``stats`` and ``plots`` properties.

        Raises:
            MissingDateColumnError: If *date_col* is not a column of *prices*
                or *benchmark*. Raised before returns are derived so the
                offending frame is named explicitly.

        Examples:
            ```python
            from jquantstats import Data
            import polars as pl

            prices = pl.DataFrame({
                "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
                "Asset1": [100.0, 101.0, 99.0]
            }).with_columns(pl.col("Date").str.to_date())

            data = Data.from_prices(prices=prices)
            ```

        """
        prices_pl = _to_polars(prices)
        if date_col not in prices_pl.columns:
            raise MissingDateColumnError("prices", column=date_col, available=list(prices_pl.columns))
        asset_cols = [c for c in prices_pl.columns if c != date_col]
        returns_pl = prices_pl.with_columns([pl.col(c).pct_change().alias(c) for c in asset_cols]).slice(1)

        benchmark_returns: NativeFrame | None = None
        if benchmark is not None:
            benchmark_pl = _to_polars(benchmark)
            if date_col not in benchmark_pl.columns:
                raise MissingDateColumnError("benchmark", column=date_col, available=list(benchmark_pl.columns))
            bench_cols = [c for c in benchmark_pl.columns if c != date_col]
            benchmark_returns = benchmark_pl.with_columns([pl.col(c).pct_change().alias(c) for c in bench_cols]).slice(
                1
            )

        return cls.from_returns(
            returns=returns_pl,
            rf=rf,
            benchmark=benchmark_returns,
            date_col=date_col,
            null_strategy=null_strategy,
        )

    def __repr__(self) -> str:
        """Return a string representation of the Data object."""
        rows = len(self.index)
        date_cols = self.date_col
        if date_cols:
            date_column = date_cols[0]
            start = self.index[date_column].min()
            end = self.index[date_column].max()
            return f"Data(assets={self.assets}, rows={rows}, start={start!s}, end={end!s})"
        return f"Data(assets={self.assets}, rows={rows})"  # pragma: no cover  # __post_init__ requires ≥1 index column

    @property
    def plots(self) -> DataPlots:
        """Provides access to visualization methods for the financial data.

        Returns:
            DataPlots: An instance of the DataPlots class initialized with this data.

        """
        from ._plots import DataPlots

        return DataPlots(self)

    @property
    def stats(self) -> Stats:
        """Provides access to statistical analysis methods for the financial data.

        Returns:
            Stats: An instance of the Stats class initialized with this data.

        """
        from ._stats import Stats

        return Stats(self)

    @property
    def reports(self) -> Reports:
        """Provides access to reporting methods for the financial data.

        Returns:
            Reports: An instance of the Reports class initialized with this data.

        """
        from ._reports import Reports

        return Reports(self)

    @property
    def utils(self) -> DataUtils:
        """Provides access to utility transforms and conversions for the financial data.

        Returns:
            DataUtils: An instance of the DataUtils class initialized with this data.

        """
        from ._utils import DataUtils

        return DataUtils(self)

    @property
    def date_col(self) -> list[str]:
        """Return the column names of the index DataFrame.

        Returns:
            list[str]: List of column names in the index DataFrame, typically containing
                      the date column name.

        """
        return list(self.index.columns)

    @property
    def assets(self) -> list[str]:
        """Return the combined list of asset column names from returns and benchmark.

        Returns:
            list[str]: List of all asset column names from both returns and benchmark
                      (if available).

        """
        if self.benchmark is not None:
            return list(self.returns.columns) + list(self.benchmark.columns)
        return list(self.returns.columns)

    @property
    def all(self) -> pl.DataFrame:
        """Combine index, returns, and benchmark data into a single DataFrame.

        This property provides a convenient way to access all data in a single DataFrame,
        which is useful for analysis and visualization.

        Returns:
            pl.DataFrame: A DataFrame containing the index, all returns data, and benchmark data
                         (if available) combined horizontally.

        """
        if self.benchmark is None:
            return pl.concat([self.index, self.returns], how="horizontal_extend")
        else:
            return pl.concat([self.index, self.returns, self.benchmark], how="horizontal_extend")

    def resample(self, every: str = "1mo") -> Data:
        """Resample returns and benchmark to a different frequency.

        Args:
            every (str): Resampling frequency (e.g., ``'1mo'``, ``'1y'``).
                Defaults to ``'1mo'``.

        Returns:
            Data: Resampled data at the requested frequency.

        """

        def resample_frame(dframe: pl.DataFrame) -> pl.DataFrame:
            """Resample a single DataFrame to the target frequency using compound returns."""
            dframe = self.index.hstack(dframe)  # Add the date column for resampling

            return dframe.group_by_dynamic(
                index_column=self.index.columns[0], every=every, period=every, closed="right", label="right"
            ).agg(
                [
                    ((pl.col(col) + 1.0).product() - 1.0).alias(col)
                    for col in dframe.columns
                    if col != self.index.columns[0]
                ]
            )

        resampled_returns = resample_frame(self.returns)
        resampled_benchmark = resample_frame(self.benchmark) if self.benchmark is not None else None
        resampled_index = resampled_returns.select(self.index.columns[0])

        return Data(
            returns=resampled_returns.drop(self.index.columns[0]),
            benchmark=resampled_benchmark.drop(self.index.columns[0]) if resampled_benchmark is not None else None,
            index=resampled_index,
        )

    def describe(self) -> pl.DataFrame:
        """Return a tidy summary of shape, date range and asset names.

        Returns:
            pl.DataFrame: One row per asset with columns: asset, start, end,
            rows, has_benchmark.

        """
        date_column = self.date_col[0]
        start = self.index[date_column].min()
        end = self.index[date_column].max()
        rows = len(self.index)
        return pl.DataFrame(
            {
                "asset": self.returns.columns,
                "start": [start] * len(self.returns.columns),
                "end": [end] * len(self.returns.columns),
                "rows": [rows] * len(self.returns.columns),
                "has_benchmark": [self.benchmark is not None] * len(self.returns.columns),
            }
        )

    def copy(self) -> Data:
        """Create a deep copy of the Data object.

        Returns:
            Data: A new Data object with copies of the returns and benchmark.

        """
        if self.benchmark is not None:
            return Data(returns=self.returns.clone(), benchmark=self.benchmark.clone(), index=self.index.clone())
        return Data(returns=self.returns.clone(), index=self.index.clone())

    def head(self, n: int = 5) -> Data:
        """Return the first n rows of the combined returns and benchmark data.

        Args:
            n (int, optional): Number of rows to return. Defaults to 5.

        Returns:
            Data: A new Data object containing the first n rows of the combined data.

        """
        benchmark_head = self.benchmark.head(n) if self.benchmark is not None else None
        return Data(returns=self.returns.head(n), benchmark=benchmark_head, index=self.index.head(n))

    def tail(self, n: int = 5) -> Data:
        """Return the last n rows of the combined returns and benchmark data.

        Args:
            n (int, optional): Number of rows to return. Defaults to 5.

        Returns:
            Data: A new Data object containing the last n rows of the combined data.

        """
        benchmark_tail = self.benchmark.tail(n) if self.benchmark is not None else None
        return Data(returns=self.returns.tail(n), benchmark=benchmark_tail, index=self.index.tail(n))

    def truncate(
        self,
        start: date | datetime | str | int | None = None,
        end: date | datetime | str | int | None = None,
    ) -> Data:
        """Return a new Data object truncated to the inclusive [start, end] range.

        When the index is temporal (Date/Datetime), truncation is performed by
        comparing the date column against ``start`` and ``end`` values.

        When the index is integer-based, row slicing is used instead, and
        ``start`` and ``end`` must be non-negative integers.  Passing
        non-integer bounds to an integer-indexed Data raises `TypeError`.

        Args:
            start: Optional lower bound (inclusive).  A date/datetime value
                when the index is temporal; a non-negative `int` row
                index when the data has no temporal index.
            end: Optional upper bound (inclusive).  Same type rules as
                ``start``.

        Returns:
            Data: A new Data object filtered to the specified range.

        Raises:
            TypeError: When the index is not temporal and a non-integer bound
                is supplied.

        """
        date_column = self.index.columns[0]

        if self.index[date_column].dtype.is_temporal():
            new_index, new_returns, new_benchmark = self._truncate_temporal(date_column, start, end)
        else:
            new_index, new_returns, new_benchmark = self._truncate_integer(start, end)

        return Data(returns=new_returns, benchmark=new_benchmark, index=new_index)

    def _truncate_temporal(
        self,
        date_column: str,
        start: date | datetime | str | int | None,
        end: date | datetime | str | int | None,
    ) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame | None]:
        """Truncate a temporal index by comparing the date column to [start, end]."""
        cond = pl.lit(True)
        if start is not None:
            cond = cond & (pl.col(date_column) >= pl.lit(start))
        if end is not None:
            cond = cond & (pl.col(date_column) <= pl.lit(end))
        mask = self.index.select(cond.alias("mask"))["mask"]
        new_benchmark = self.benchmark.filter(mask) if self.benchmark is not None else None
        return self.index.filter(mask), self.returns.filter(mask), new_benchmark

    def _truncate_integer(
        self,
        start: date | datetime | str | int | None,
        end: date | datetime | str | int | None,
    ) -> tuple[pl.DataFrame, pl.DataFrame, pl.DataFrame | None]:
        """Truncate an integer index by row slicing; bounds must be integers."""
        if start is not None and not isinstance(start, int):
            raise IntegerIndexBoundError("start", type(start).__name__)
        if end is not None and not isinstance(end, int):
            raise IntegerIndexBoundError("end", type(end).__name__)
        row_start = start if start is not None else 0
        row_end = end + 1 if end is not None else self.index.height
        length = max(0, row_end - row_start)
        new_benchmark = self.benchmark.slice(row_start, length) if self.benchmark is not None else None
        return self.index.slice(row_start, length), self.returns.slice(row_start, length), new_benchmark

    @property
    def _periods_per_year(self) -> float:
        """Estimate the number of periods per year based on average frequency in the index.

        For temporal (Date/Datetime) indices, computes the mean gap between observations
        and converts to an annualised period count (e.g. ~252 for daily, ~52 for weekly).

        For integer indices (date-free portfolios), falls back to 252 trading days per year
        because integer diffs have no time meaning.
        """
        datetime_col = self.index[self.index.columns[0]]

        if not datetime_col.dtype.is_temporal():
            return 252.0

        sorted_dt = datetime_col.sort()
        diffs = sorted_dt.diff().drop_nulls()
        mean_diff = diffs.mean()

        if isinstance(mean_diff, timedelta):
            seconds = mean_diff.total_seconds()
        else:  # pragma: no cover  # Polars always returns timedelta for temporal diff
            seconds = cast(float, mean_diff) if mean_diff is not None else 1.0

        return (365 * 24 * 60 * 60) / seconds

    def items(self) -> Iterator[tuple[str, pl.Series]]:
        """Iterate over all assets and their corresponding data series.

        This method provides a convenient way to iterate over all assets in the data,
        yielding each asset name and its corresponding data series.

        Yields:
            tuple[str, pl.Series]: A tuple containing the asset name and its data series.

        """
        matrix = self.all

        for col in self.assets:
            yield col, matrix.get_column(col)

all property

Combine index, returns, and benchmark data into a single DataFrame.

This property provides a convenient way to access all data in a single DataFrame, which is useful for analysis and visualization.

Returns:

Type Description
DataFrame

pl.DataFrame: A DataFrame containing the index, all returns data, and benchmark data (if available) combined horizontally.

assets property

Return the combined list of asset column names from returns and benchmark.

Returns:

Type Description
list[str]

list[str]: List of all asset column names from both returns and benchmark (if available).

date_col property

Return the column names of the index DataFrame.

Returns:

Type Description
list[str]

list[str]: List of column names in the index DataFrame, typically containing the date column name.

plots property

Provides access to visualization methods for the financial data.

Returns:

Name Type Description
DataPlots DataPlots

An instance of the DataPlots class initialized with this data.

reports property

Provides access to reporting methods for the financial data.

Returns:

Name Type Description
Reports Reports

An instance of the Reports class initialized with this data.

stats property

Provides access to statistical analysis methods for the financial data.

Returns:

Name Type Description
Stats Stats

An instance of the Stats class initialized with this data.

utils property

Provides access to utility transforms and conversions for the financial data.

Returns:

Name Type Description
DataUtils DataUtils

An instance of the DataUtils class initialized with this data.

__post_init__()

Validate the Data object after initialization.

Source code in src/jquantstats/data.py
def __post_init__(self) -> None:
    """Validate the Data object after initialization."""
    # You need at least two points
    if self.index.shape[0] < 2:
        raise ValueError("Index must contain at least two timestamps.")  # noqa: TRY003

    # Check index is monotonically increasing
    datetime_col = self.index[self.index.columns[0]]
    if not datetime_col.is_sorted():
        raise ValueError("Index must be monotonically increasing.")  # noqa: TRY003

    # Check row count matches returns
    if self.returns.shape[0] != self.index.shape[0]:
        raise ValueError("Returns and index must have the same number of rows.")  # noqa: TRY003

    # Check row count matches benchmark (if provided)
    if self.benchmark is not None and self.benchmark.shape[0] != self.index.shape[0]:
        raise ValueError("Benchmark and index must have the same number of rows.")  # noqa: TRY003

__repr__()

Return a string representation of the Data object.

Source code in src/jquantstats/data.py
def __repr__(self) -> str:
    """Return a string representation of the Data object."""
    rows = len(self.index)
    date_cols = self.date_col
    if date_cols:
        date_column = date_cols[0]
        start = self.index[date_column].min()
        end = self.index[date_column].max()
        return f"Data(assets={self.assets}, rows={rows}, start={start!s}, end={end!s})"
    return f"Data(assets={self.assets}, rows={rows})"  # pragma: no cover  # __post_init__ requires ≥1 index column

copy()

Create a deep copy of the Data object.

Returns:

Name Type Description
Data Data

A new Data object with copies of the returns and benchmark.

Source code in src/jquantstats/data.py
def copy(self) -> Data:
    """Create a deep copy of the Data object.

    Returns:
        Data: A new Data object with copies of the returns and benchmark.

    """
    if self.benchmark is not None:
        return Data(returns=self.returns.clone(), benchmark=self.benchmark.clone(), index=self.index.clone())
    return Data(returns=self.returns.clone(), index=self.index.clone())

describe()

Return a tidy summary of shape, date range and asset names.

Returns:

Type Description
DataFrame

pl.DataFrame: One row per asset with columns: asset, start, end,

DataFrame

rows, has_benchmark.

Source code in src/jquantstats/data.py
def describe(self) -> pl.DataFrame:
    """Return a tidy summary of shape, date range and asset names.

    Returns:
        pl.DataFrame: One row per asset with columns: asset, start, end,
        rows, has_benchmark.

    """
    date_column = self.date_col[0]
    start = self.index[date_column].min()
    end = self.index[date_column].max()
    rows = len(self.index)
    return pl.DataFrame(
        {
            "asset": self.returns.columns,
            "start": [start] * len(self.returns.columns),
            "end": [end] * len(self.returns.columns),
            "rows": [rows] * len(self.returns.columns),
            "has_benchmark": [self.benchmark is not None] * len(self.returns.columns),
        }
    )

from_prices(prices, rf=0.0, benchmark=None, date_col='Date', null_strategy=None) classmethod

Create a Data object from prices and optional benchmark.

Converts price levels to returns via percentage change and delegates to from_returns. The first row of each asset is dropped because no prior price is available to compute a return.

Parameters:

Name Type Description Default
prices NativeFrame

Price-level data. First column should be the date column; remaining columns are asset prices.

required
rf float | NativeFrame

Risk-free rate. Forwarded unchanged to from_returns. Defaults to 0.0 (no risk-free rate adjustment).

0.0
benchmark NativeFrame | None

Benchmark prices. Converted to returns in the same way as prices before being forwarded to from_returns. Defaults to None (no benchmark).

None
date_col str

Name of the date column in the DataFrames. Defaults to "Date".

'Date'
null_strategy {'raise', 'drop', 'forward_fill'} | None

How to handle null (missing) values after converting prices to returns. Forwarded unchanged to from_returns. Defaults to None (nulls propagate through calculations).

  • None — no null checking; nulls propagate.
  • "raise" — raise NullsInReturnsError if any null is found in the derived returns.
  • "drop" — silently drop every row that contains at least one null.
  • "forward_fill" — fill each null with the most recent non-null value.

Note: Prices that contain nulls will produce null returns via pct_change(). If you expect missing price entries, pass null_strategy="drop" or null_strategy="forward_fill".

None

Returns:

Name Type Description
Data Data

Object containing excess returns derived from the supplied

Data

prices, with methods for analysis and visualization through the

Data

stats and plots properties.

Raises:

Type Description
MissingDateColumnError

If date_col is not a column of prices or benchmark. Raised before returns are derived so the offending frame is named explicitly.

Examples:

from jquantstats import Data
import polars as pl

prices = pl.DataFrame({
    "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
    "Asset1": [100.0, 101.0, 99.0]
}).with_columns(pl.col("Date").str.to_date())

data = Data.from_prices(prices=prices)
Source code in src/jquantstats/data.py
@classmethod
def from_prices(
    cls,
    prices: NativeFrame,
    rf: NativeFrameOrScalar = 0.0,
    benchmark: NativeFrame | None = None,
    date_col: str = "Date",
    null_strategy: Literal["raise", "drop", "forward_fill"] | None = None,
) -> Data:
    """Create a Data object from prices and optional benchmark.

    Converts price levels to returns via percentage change and delegates
    to `from_returns`. The first row of each asset is dropped because no
    prior price is available to compute a return.

    Args:
        prices (NativeFrame): Price-level data. First column should be
            the date column; remaining columns are asset prices.
        rf (float | NativeFrame): Risk-free rate. Forwarded unchanged to
            `from_returns`. Defaults to 0.0 (no risk-free rate
            adjustment).
        benchmark (NativeFrame | None): Benchmark prices. Converted to
            returns in the same way as ``prices`` before being forwarded
            to `from_returns`. Defaults to None (no benchmark).
        date_col (str): Name of the date column in the DataFrames.
            Defaults to ``"Date"``.
        null_strategy ({"raise", "drop", "forward_fill"} | None): How to
            handle ``null`` (missing) values after converting prices to
            returns. Forwarded unchanged to `from_returns`. Defaults to
            ``None`` (nulls propagate through calculations).

            - ``None`` — no null checking; nulls propagate.
            - ``"raise"`` — raise `NullsInReturnsError` if any null is
              found in the derived returns.
            - ``"drop"`` — silently drop every row that contains at least
              one null.
            - ``"forward_fill"`` — fill each null with the most recent
              non-null value.

            Note: Prices that contain nulls will produce null returns via
            ``pct_change()``. If you expect missing price entries, pass
            ``null_strategy="drop"`` or ``null_strategy="forward_fill"``.

    Returns:
        Data: Object containing excess returns derived from the supplied
        prices, with methods for analysis and visualization through the
        ``stats`` and ``plots`` properties.

    Raises:
        MissingDateColumnError: If *date_col* is not a column of *prices*
            or *benchmark*. Raised before returns are derived so the
            offending frame is named explicitly.

    Examples:
        ```python
        from jquantstats import Data
        import polars as pl

        prices = pl.DataFrame({
            "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
            "Asset1": [100.0, 101.0, 99.0]
        }).with_columns(pl.col("Date").str.to_date())

        data = Data.from_prices(prices=prices)
        ```

    """
    prices_pl = _to_polars(prices)
    if date_col not in prices_pl.columns:
        raise MissingDateColumnError("prices", column=date_col, available=list(prices_pl.columns))
    asset_cols = [c for c in prices_pl.columns if c != date_col]
    returns_pl = prices_pl.with_columns([pl.col(c).pct_change().alias(c) for c in asset_cols]).slice(1)

    benchmark_returns: NativeFrame | None = None
    if benchmark is not None:
        benchmark_pl = _to_polars(benchmark)
        if date_col not in benchmark_pl.columns:
            raise MissingDateColumnError("benchmark", column=date_col, available=list(benchmark_pl.columns))
        bench_cols = [c for c in benchmark_pl.columns if c != date_col]
        benchmark_returns = benchmark_pl.with_columns([pl.col(c).pct_change().alias(c) for c in bench_cols]).slice(
            1
        )

    return cls.from_returns(
        returns=returns_pl,
        rf=rf,
        benchmark=benchmark_returns,
        date_col=date_col,
        null_strategy=null_strategy,
    )

from_returns(returns, rf=0.0, benchmark=None, date_col='Date', null_strategy=None) classmethod

Create a Data object from returns and optional benchmark.

Parameters:

Name Type Description Default
returns NativeFrame

Financial returns data. First column should be the date column, remaining columns are asset returns.

required
rf float | NativeFrame

Risk-free rate. Defaults to 0.0 (no risk-free rate adjustment).

  • If float: Constant risk-free rate applied to all dates.
  • If NativeFrame: Time-varying risk-free rate with dates matching returns.
0.0
benchmark NativeFrame | None

Benchmark returns. Defaults to None (no benchmark). First column should be the date column, remaining columns are benchmark returns. Returns and benchmark are aligned on their common dates; if either frame contains dates the other lacks, those rows are dropped and a BenchmarkAlignmentWarning is emitted.

None
date_col str

Name of the date column in the DataFrames. Defaults to "Date".

'Date'
null_strategy {'raise', 'drop', 'forward_fill'} | None

How to handle null (missing) values in returns and benchmark. Defaults to None (nulls propagate through calculations).

  • None — no null checking; nulls propagate through all downstream calculations.
  • "raise" — raise NullsInReturnsError if any null is found.
  • "drop" — silently drop every row that contains at least one null.
  • "forward_fill" — fill each null with the most recent non-null value in the same column.

Note: Affects only Polars null values (i.e. None / missing entries). IEEE-754 NaN values are not affected and continue to propagate as per IEEE-754 semantics.

None

Returns:

Name Type Description
Data Data

Object containing excess returns and benchmark (if any),

Data

with methods for analysis and visualization through the stats

Data

and plots properties.

Raises:

Type Description
MissingDateColumnError

If date_col is not a column of returns, benchmark, or a DataFrame-valued rf. Raised before any joins so the offending frame is named explicitly.

NullsInReturnsError

If null_strategy is "raise" and the data contains null values.

ValueError

If there are no overlapping dates between returns and benchmark.

Warns:

Type Description
BenchmarkAlignmentWarning

If aligning returns and benchmark on their common dates drops rows from either frame.

Examples:

Basic usage:

from jquantstats import Data
import polars as pl

returns = pl.DataFrame({
    "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
    "Asset1": [0.01, -0.02, 0.03]
}).with_columns(pl.col("Date").str.to_date())

data = Data.from_returns(returns=returns)

With benchmark and risk-free rate:

benchmark = pl.DataFrame({
    "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
    "Market": [0.005, -0.01, 0.02]
}).with_columns(pl.col("Date").str.to_date())

data = Data.from_returns(returns=returns, benchmark=benchmark, rf=0.0002)

Handling nulls automatically:

returns_with_nulls = pl.DataFrame({
    "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
    "Asset1": [0.01, None, 0.03]
}).with_columns(pl.col("Date").str.to_date())

# Drop rows with nulls (mirrors pandas/QuantStats behaviour)
data = Data.from_returns(returns=returns_with_nulls, null_strategy="drop")

# Or forward-fill nulls
data = Data.from_returns(returns=returns_with_nulls, null_strategy="forward_fill")
Source code in src/jquantstats/data.py
@classmethod
def from_returns(
    cls,
    returns: NativeFrame,
    rf: NativeFrameOrScalar = 0.0,
    benchmark: NativeFrame | None = None,
    date_col: str = "Date",
    null_strategy: Literal["raise", "drop", "forward_fill"] | None = None,
) -> Data:
    """Create a Data object from returns and optional benchmark.

    Args:
        returns (NativeFrame): Financial returns data. First column should
            be the date column, remaining columns are asset returns.
        rf (float | NativeFrame): Risk-free rate. Defaults to 0.0 (no
            risk-free rate adjustment).

            - If float: Constant risk-free rate applied to all dates.
            - If NativeFrame: Time-varying risk-free rate with dates
              matching returns.

        benchmark (NativeFrame | None): Benchmark returns. Defaults to
            None (no benchmark). First column should be the date column,
            remaining columns are benchmark returns. Returns and
            benchmark are aligned on their common dates; if either frame
            contains dates the other lacks, those rows are dropped and a
            `BenchmarkAlignmentWarning` is emitted.
        date_col (str): Name of the date column in the DataFrames.
            Defaults to ``"Date"``.
        null_strategy ({"raise", "drop", "forward_fill"} | None): How to
            handle ``null`` (missing) values in *returns* and *benchmark*.
            Defaults to ``None`` (nulls propagate through calculations).

            - ``None`` — no null checking; nulls propagate through all
              downstream calculations.
            - ``"raise"`` — raise `NullsInReturnsError` if any null is
              found.
            - ``"drop"`` — silently drop every row that contains at least
              one null.
            - ``"forward_fill"`` — fill each null with the most recent
              non-null value in the same column.

            Note: Affects only Polars ``null`` values (i.e. ``None`` /
            missing entries). IEEE-754 ``NaN`` values are **not** affected
            and continue to propagate as per IEEE-754 semantics.

    Returns:
        Data: Object containing excess returns and benchmark (if any),
        with methods for analysis and visualization through the ``stats``
        and ``plots`` properties.

    Raises:
        MissingDateColumnError: If *date_col* is not a column of
            *returns*, *benchmark*, or a DataFrame-valued *rf*. Raised
            before any joins so the offending frame is named explicitly.
        NullsInReturnsError: If *null_strategy* is ``"raise"`` and the
            data contains null values.
        ValueError: If there are no overlapping dates between returns and
            benchmark.

    Warns:
        BenchmarkAlignmentWarning: If aligning returns and benchmark on
            their common dates drops rows from either frame.

    Examples:
        Basic usage:

        ```python
        from jquantstats import Data
        import polars as pl

        returns = pl.DataFrame({
            "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
            "Asset1": [0.01, -0.02, 0.03]
        }).with_columns(pl.col("Date").str.to_date())

        data = Data.from_returns(returns=returns)
        ```

        With benchmark and risk-free rate:

        ```python
        benchmark = pl.DataFrame({
            "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
            "Market": [0.005, -0.01, 0.02]
        }).with_columns(pl.col("Date").str.to_date())

        data = Data.from_returns(returns=returns, benchmark=benchmark, rf=0.0002)
        ```

        Handling nulls automatically:

        ```python
        returns_with_nulls = pl.DataFrame({
            "Date": ["2023-01-01", "2023-01-02", "2023-01-03"],
            "Asset1": [0.01, None, 0.03]
        }).with_columns(pl.col("Date").str.to_date())

        # Drop rows with nulls (mirrors pandas/QuantStats behaviour)
        data = Data.from_returns(returns=returns_with_nulls, null_strategy="drop")

        # Or forward-fill nulls
        data = Data.from_returns(returns=returns_with_nulls, null_strategy="forward_fill")
        ```

    """
    returns_pl = _to_polars(returns)
    benchmark_pl = _to_polars(benchmark) if benchmark is not None else None
    # accept ints (e.g. rf=0) by coercing to float
    rf_converted: float | pl.DataFrame = float(rf) if isinstance(rf, int | float) else _to_polars(rf)

    frames: list[tuple[str, pl.DataFrame | None]] = [("returns", returns_pl), ("benchmark", benchmark_pl)]
    if isinstance(rf_converted, pl.DataFrame):
        frames.append(("rf", rf_converted))
    _require_date_col(frames, date_col)

    returns_pl = _apply_null_strategy(returns_pl, date_col, "returns", null_strategy)
    if benchmark_pl is not None:
        benchmark_pl = _apply_null_strategy(benchmark_pl, date_col, "benchmark", null_strategy)
        returns_pl, benchmark_pl = _align_returns_benchmark(returns_pl, benchmark_pl, date_col)

    index = returns_pl.select(date_col)
    excess_returns = _subtract_risk_free(returns_pl, rf_converted, date_col).drop(date_col)
    excess_benchmark = (
        _subtract_risk_free(benchmark_pl, rf_converted, date_col).drop(date_col)
        if benchmark_pl is not None
        else None
    )

    return cls(returns=excess_returns, benchmark=excess_benchmark, index=index)

head(n=5)

Return the first n rows of the combined returns and benchmark data.

Parameters:

Name Type Description Default
n int

Number of rows to return. Defaults to 5.

5

Returns:

Name Type Description
Data Data

A new Data object containing the first n rows of the combined data.

Source code in src/jquantstats/data.py
def head(self, n: int = 5) -> Data:
    """Return the first n rows of the combined returns and benchmark data.

    Args:
        n (int, optional): Number of rows to return. Defaults to 5.

    Returns:
        Data: A new Data object containing the first n rows of the combined data.

    """
    benchmark_head = self.benchmark.head(n) if self.benchmark is not None else None
    return Data(returns=self.returns.head(n), benchmark=benchmark_head, index=self.index.head(n))

items()

Iterate over all assets and their corresponding data series.

This method provides a convenient way to iterate over all assets in the data, yielding each asset name and its corresponding data series.

Yields:

Type Description
tuple[str, Series]

tuple[str, pl.Series]: A tuple containing the asset name and its data series.

Source code in src/jquantstats/data.py
def items(self) -> Iterator[tuple[str, pl.Series]]:
    """Iterate over all assets and their corresponding data series.

    This method provides a convenient way to iterate over all assets in the data,
    yielding each asset name and its corresponding data series.

    Yields:
        tuple[str, pl.Series]: A tuple containing the asset name and its data series.

    """
    matrix = self.all

    for col in self.assets:
        yield col, matrix.get_column(col)

resample(every='1mo')

Resample returns and benchmark to a different frequency.

Parameters:

Name Type Description Default
every str

Resampling frequency (e.g., '1mo', '1y'). Defaults to '1mo'.

'1mo'

Returns:

Name Type Description
Data Data

Resampled data at the requested frequency.

Source code in src/jquantstats/data.py
def resample(self, every: str = "1mo") -> Data:
    """Resample returns and benchmark to a different frequency.

    Args:
        every (str): Resampling frequency (e.g., ``'1mo'``, ``'1y'``).
            Defaults to ``'1mo'``.

    Returns:
        Data: Resampled data at the requested frequency.

    """

    def resample_frame(dframe: pl.DataFrame) -> pl.DataFrame:
        """Resample a single DataFrame to the target frequency using compound returns."""
        dframe = self.index.hstack(dframe)  # Add the date column for resampling

        return dframe.group_by_dynamic(
            index_column=self.index.columns[0], every=every, period=every, closed="right", label="right"
        ).agg(
            [
                ((pl.col(col) + 1.0).product() - 1.0).alias(col)
                for col in dframe.columns
                if col != self.index.columns[0]
            ]
        )

    resampled_returns = resample_frame(self.returns)
    resampled_benchmark = resample_frame(self.benchmark) if self.benchmark is not None else None
    resampled_index = resampled_returns.select(self.index.columns[0])

    return Data(
        returns=resampled_returns.drop(self.index.columns[0]),
        benchmark=resampled_benchmark.drop(self.index.columns[0]) if resampled_benchmark is not None else None,
        index=resampled_index,
    )

tail(n=5)

Return the last n rows of the combined returns and benchmark data.

Parameters:

Name Type Description Default
n int

Number of rows to return. Defaults to 5.

5

Returns:

Name Type Description
Data Data

A new Data object containing the last n rows of the combined data.

Source code in src/jquantstats/data.py
def tail(self, n: int = 5) -> Data:
    """Return the last n rows of the combined returns and benchmark data.

    Args:
        n (int, optional): Number of rows to return. Defaults to 5.

    Returns:
        Data: A new Data object containing the last n rows of the combined data.

    """
    benchmark_tail = self.benchmark.tail(n) if self.benchmark is not None else None
    return Data(returns=self.returns.tail(n), benchmark=benchmark_tail, index=self.index.tail(n))

truncate(start=None, end=None)

Return a new Data object truncated to the inclusive [start, end] range.

When the index is temporal (Date/Datetime), truncation is performed by comparing the date column against start and end values.

When the index is integer-based, row slicing is used instead, and start and end must be non-negative integers. Passing non-integer bounds to an integer-indexed Data raises TypeError.

Parameters:

Name Type Description Default
start date | datetime | str | int | None

Optional lower bound (inclusive). A date/datetime value when the index is temporal; a non-negative int row index when the data has no temporal index.

None
end date | datetime | str | int | None

Optional upper bound (inclusive). Same type rules as start.

None

Returns:

Name Type Description
Data Data

A new Data object filtered to the specified range.

Raises:

Type Description
TypeError

When the index is not temporal and a non-integer bound is supplied.

Source code in src/jquantstats/data.py
def truncate(
    self,
    start: date | datetime | str | int | None = None,
    end: date | datetime | str | int | None = None,
) -> Data:
    """Return a new Data object truncated to the inclusive [start, end] range.

    When the index is temporal (Date/Datetime), truncation is performed by
    comparing the date column against ``start`` and ``end`` values.

    When the index is integer-based, row slicing is used instead, and
    ``start`` and ``end`` must be non-negative integers.  Passing
    non-integer bounds to an integer-indexed Data raises `TypeError`.

    Args:
        start: Optional lower bound (inclusive).  A date/datetime value
            when the index is temporal; a non-negative `int` row
            index when the data has no temporal index.
        end: Optional upper bound (inclusive).  Same type rules as
            ``start``.

    Returns:
        Data: A new Data object filtered to the specified range.

    Raises:
        TypeError: When the index is not temporal and a non-integer bound
            is supplied.

    """
    date_column = self.index.columns[0]

    if self.index[date_column].dtype.is_temporal():
        new_index, new_returns, new_benchmark = self._truncate_temporal(date_column, start, end)
    else:
        new_index, new_returns, new_benchmark = self._truncate_integer(start, end)

    return Data(returns=new_returns, benchmark=new_benchmark, index=new_index)

CostModel

jquantstats.CostModel dataclass

Unified representation of a portfolio transaction-cost model.

Eliminates the implicit "pick one" contract between the two independent cost parameters (cost_per_unit and cost_bps) on Portfolio. A CostModel instance encapsulates one model at a time and can be passed to any Portfolio factory method instead of specifying the raw float parameters.

Attributes:

Name Type Description
cost_per_unit float

One-way cost per unit of position change (Model A). Defaults to 0.0.

cost_bps float

One-way cost in basis points of AUM turnover (Model B). Defaults to 0.0.

Raises:

Type Description
ValueError

If cost_per_unit or cost_bps is negative, or if both are non-zero (which would silently double-count costs).

Examples:

>>> CostModel.per_unit(0.01)
CostModel(cost_per_unit=0.01, cost_bps=0.0)
>>> CostModel.turnover_bps(5.0)
CostModel(cost_per_unit=0.0, cost_bps=5.0)
>>> CostModel.zero()
CostModel(cost_per_unit=0.0, cost_bps=0.0)
Source code in src/jquantstats/_cost_model.py
@dataclasses.dataclass(frozen=True)
class CostModel:
    """Unified representation of a portfolio transaction-cost model.

    Eliminates the implicit "pick one" contract between the two independent
    cost parameters (``cost_per_unit`` and ``cost_bps``) on
    `Portfolio`.  A ``CostModel``
    instance encapsulates one model at a time and can be passed to any
    Portfolio factory method instead of specifying the raw float parameters.

    Attributes:
        cost_per_unit: One-way cost per unit of position change (Model A).
            Defaults to 0.0.
        cost_bps: One-way cost in basis points of AUM turnover (Model B).
            Defaults to 0.0.

    Raises:
        ValueError: If ``cost_per_unit`` or ``cost_bps`` is negative, or if
            both are non-zero (which would silently double-count costs).

    Examples:
        >>> CostModel.per_unit(0.01)
        CostModel(cost_per_unit=0.01, cost_bps=0.0)
        >>> CostModel.turnover_bps(5.0)
        CostModel(cost_per_unit=0.0, cost_bps=5.0)
        >>> CostModel.zero()
        CostModel(cost_per_unit=0.0, cost_bps=0.0)
    """

    cost_per_unit: float = 0.0
    cost_bps: float = 0.0

    def __post_init__(self) -> None:
        if self.cost_per_unit < 0:
            raise ValueError(f"cost_per_unit must be non-negative, got {self.cost_per_unit}")  # noqa: TRY003
        if self.cost_bps < 0:
            raise ValueError(f"cost_bps must be non-negative, got {self.cost_bps}")  # noqa: TRY003
        if self.cost_per_unit > 0 and self.cost_bps > 0:
            raise ValueError(  # noqa: TRY003
                "Only one cost model may be active at a time: "
                f"got cost_per_unit={self.cost_per_unit} and cost_bps={self.cost_bps}. "
                "Use CostModel.per_unit() or CostModel.turnover_bps() to make intent explicit."
            )

    # ── Named constructors ────────────────────────────────────────────────────

    @classmethod
    def per_unit(cls, cost: float) -> CostModel:
        """Create a Model A (position-delta) cost model.

        Args:
            cost: One-way cost per unit of position change.  Must be
                non-negative.

        Returns:
            A `CostModel` with ``cost_per_unit=cost`` and
            ``cost_bps=0.0``.

        Examples:
            >>> CostModel.per_unit(0.01)
            CostModel(cost_per_unit=0.01, cost_bps=0.0)
        """
        return cls(cost_per_unit=cost, cost_bps=0.0)

    @classmethod
    def turnover_bps(cls, bps: float) -> CostModel:
        """Create a Model B (turnover-bps) cost model.

        Args:
            bps: One-way cost in basis points of AUM turnover.  Must be
                non-negative.

        Returns:
            A `CostModel` with ``cost_per_unit=0.0`` and
            ``cost_bps=bps``.

        Examples:
            >>> CostModel.turnover_bps(5.0)
            CostModel(cost_per_unit=0.0, cost_bps=5.0)
        """
        return cls(cost_per_unit=0.0, cost_bps=bps)

    @classmethod
    def zero(cls) -> CostModel:
        """Create a zero-cost model (no transaction costs).

        Returns:
            A `CostModel` with both parameters set to 0.0.

        Examples:
            >>> CostModel.zero()
            CostModel(cost_per_unit=0.0, cost_bps=0.0)
        """
        return cls(cost_per_unit=0.0, cost_bps=0.0)

per_unit(cost) classmethod

Create a Model A (position-delta) cost model.

Parameters:

Name Type Description Default
cost float

One-way cost per unit of position change. Must be non-negative.

required

Returns:

Type Description
CostModel

A CostModel with cost_per_unit=cost and

CostModel

cost_bps=0.0.

Examples:

>>> CostModel.per_unit(0.01)
CostModel(cost_per_unit=0.01, cost_bps=0.0)
Source code in src/jquantstats/_cost_model.py
@classmethod
def per_unit(cls, cost: float) -> CostModel:
    """Create a Model A (position-delta) cost model.

    Args:
        cost: One-way cost per unit of position change.  Must be
            non-negative.

    Returns:
        A `CostModel` with ``cost_per_unit=cost`` and
        ``cost_bps=0.0``.

    Examples:
        >>> CostModel.per_unit(0.01)
        CostModel(cost_per_unit=0.01, cost_bps=0.0)
    """
    return cls(cost_per_unit=cost, cost_bps=0.0)

turnover_bps(bps) classmethod

Create a Model B (turnover-bps) cost model.

Parameters:

Name Type Description Default
bps float

One-way cost in basis points of AUM turnover. Must be non-negative.

required

Returns:

Type Description
CostModel

A CostModel with cost_per_unit=0.0 and

CostModel

cost_bps=bps.

Examples:

>>> CostModel.turnover_bps(5.0)
CostModel(cost_per_unit=0.0, cost_bps=5.0)
Source code in src/jquantstats/_cost_model.py
@classmethod
def turnover_bps(cls, bps: float) -> CostModel:
    """Create a Model B (turnover-bps) cost model.

    Args:
        bps: One-way cost in basis points of AUM turnover.  Must be
            non-negative.

    Returns:
        A `CostModel` with ``cost_per_unit=0.0`` and
        ``cost_bps=bps``.

    Examples:
        >>> CostModel.turnover_bps(5.0)
        CostModel(cost_per_unit=0.0, cost_bps=5.0)
    """
    return cls(cost_per_unit=0.0, cost_bps=bps)

zero() classmethod

Create a zero-cost model (no transaction costs).

Returns:

Type Description
CostModel

A CostModel with both parameters set to 0.0.

Examples:

>>> CostModel.zero()
CostModel(cost_per_unit=0.0, cost_bps=0.0)
Source code in src/jquantstats/_cost_model.py
@classmethod
def zero(cls) -> CostModel:
    """Create a zero-cost model (no transaction costs).

    Returns:
        A `CostModel` with both parameters set to 0.0.

    Examples:
        >>> CostModel.zero()
        CostModel(cost_per_unit=0.0, cost_bps=0.0)
    """
    return cls(cost_per_unit=0.0, cost_bps=0.0)

Result

Result bundles a Portfolio with an optional per-asset expected-returns frame (mu) and exports a standard artifact set in one call: create_reports(output_dir) writes CSVs (prices, profit, returns, positions, tilt/timing decomposition, and the mu signal when present) plus interactive HTML plots. Use it when you want a reproducible on-disk report bundle from a backtest or experiment; call portfolio.plots / portfolio.report directly when you just want figures in a notebook. mu (when given) must be a Polars DataFrame with one column per portfolio asset — anything else raises at construction time.

jquantstats.Result dataclass

Lightweight container for system outputs.

Attributes:

Name Type Description
portfolio Portfolio

The portfolio constructed by a system/experiment.

mu DataFrame | None

Optional per-asset expected-returns surface used by some systems.

Source code in src/jquantstats/result.py
@dataclass(frozen=True)
class Result:
    """Lightweight container for system outputs.

    Attributes:
        portfolio: The portfolio constructed by a system/experiment.
        mu: Optional per-asset expected-returns surface used by some systems.
    """

    portfolio: Portfolio
    mu: pl.DataFrame | None = None

    def __post_init__(self) -> None:
        """Validate that mu (when given) is a DataFrame covering every portfolio asset.

        Raises:
            TypeError: If ``mu`` is neither ``None`` nor a `polars.DataFrame`.
            MuSchemaError: If ``mu`` lacks a column for one or more portfolio assets.
        """
        if self.mu is None:
            return
        if not isinstance(self.mu, pl.DataFrame):
            raise TypeError(f"mu must be a polars DataFrame or None, got {type(self.mu).__name__}")  # noqa: TRY003
        missing = [asset for asset in self.portfolio.assets if asset not in self.mu.columns]
        if missing:
            raise MuSchemaError(missing)

    def create_reports(self, output_dir: Path) -> None:
        """Generate CSV exports and interactive HTML plots for this result.

        Args:
            output_dir: Destination directory where two subfolders will be created:
                - data/: CSV exports of prices, profit, returns, positions, and signal (if mu present).
                - plots/: Plotly HTML reports (snapshot, lead/lag IR, lagged performance,
                  smoothed holdings performance).
        """
        data = output_dir / "data"
        plots = output_dir / "plots"

        data.mkdir(parents=True, exist_ok=True)
        plots.mkdir(parents=True, exist_ok=True)

        self.portfolio.prices.write_csv(file=data / "prices.csv")
        self.portfolio.profit.write_csv(file=data / "profit.csv")
        self.portfolio.returns.write_csv(file=data / "returns.csv")
        self.portfolio.tilt_timing_decomp.write_csv(file=data / "tilt_timing_decomp.csv")

        if self.mu is not None:
            self.mu.write_csv(file=data / "signal.csv")

        self.portfolio.cashposition.write_csv(file=data / "position.csv")

        fig = self.portfolio.plots.snapshot()
        fig.write_html(file=plots / "snapshot.html", auto_open=False, include_plotlyjs="cdn")
        fig = self.portfolio.plots.lead_lag_ir_plot()
        fig.write_html(file=plots / "lag_ir.html", auto_open=False, include_plotlyjs="cdn")
        fig = self.portfolio.plots.lagged_performance_plot()
        fig.write_html(file=plots / "lagged_perf.html", auto_open=False, include_plotlyjs="cdn")
        fig = self.portfolio.plots.smoothed_holdings_performance_plot()
        fig.write_html(file=plots / "smooth_perf.html", auto_open=False, include_plotlyjs="cdn")

__post_init__()

Validate that mu (when given) is a DataFrame covering every portfolio asset.

Raises:

Type Description
TypeError

If mu is neither None nor a polars.DataFrame.

MuSchemaError

If mu lacks a column for one or more portfolio assets.

Source code in src/jquantstats/result.py
def __post_init__(self) -> None:
    """Validate that mu (when given) is a DataFrame covering every portfolio asset.

    Raises:
        TypeError: If ``mu`` is neither ``None`` nor a `polars.DataFrame`.
        MuSchemaError: If ``mu`` lacks a column for one or more portfolio assets.
    """
    if self.mu is None:
        return
    if not isinstance(self.mu, pl.DataFrame):
        raise TypeError(f"mu must be a polars DataFrame or None, got {type(self.mu).__name__}")  # noqa: TRY003
    missing = [asset for asset in self.portfolio.assets if asset not in self.mu.columns]
    if missing:
        raise MuSchemaError(missing)

create_reports(output_dir)

Generate CSV exports and interactive HTML plots for this result.

Parameters:

Name Type Description Default
output_dir Path

Destination directory where two subfolders will be created: - data/: CSV exports of prices, profit, returns, positions, and signal (if mu present). - plots/: Plotly HTML reports (snapshot, lead/lag IR, lagged performance, smoothed holdings performance).

required
Source code in src/jquantstats/result.py
def create_reports(self, output_dir: Path) -> None:
    """Generate CSV exports and interactive HTML plots for this result.

    Args:
        output_dir: Destination directory where two subfolders will be created:
            - data/: CSV exports of prices, profit, returns, positions, and signal (if mu present).
            - plots/: Plotly HTML reports (snapshot, lead/lag IR, lagged performance,
              smoothed holdings performance).
    """
    data = output_dir / "data"
    plots = output_dir / "plots"

    data.mkdir(parents=True, exist_ok=True)
    plots.mkdir(parents=True, exist_ok=True)

    self.portfolio.prices.write_csv(file=data / "prices.csv")
    self.portfolio.profit.write_csv(file=data / "profit.csv")
    self.portfolio.returns.write_csv(file=data / "returns.csv")
    self.portfolio.tilt_timing_decomp.write_csv(file=data / "tilt_timing_decomp.csv")

    if self.mu is not None:
        self.mu.write_csv(file=data / "signal.csv")

    self.portfolio.cashposition.write_csv(file=data / "position.csv")

    fig = self.portfolio.plots.snapshot()
    fig.write_html(file=plots / "snapshot.html", auto_open=False, include_plotlyjs="cdn")
    fig = self.portfolio.plots.lead_lag_ir_plot()
    fig.write_html(file=plots / "lag_ir.html", auto_open=False, include_plotlyjs="cdn")
    fig = self.portfolio.plots.lagged_performance_plot()
    fig.write_html(file=plots / "lagged_perf.html", auto_open=False, include_plotlyjs="cdn")
    fig = self.portfolio.plots.smoothed_holdings_performance_plot()
    fig.write_html(file=plots / "smooth_perf.html", auto_open=False, include_plotlyjs="cdn")

Exceptions

jquantstats.exceptions

Domain-specific exception types for the jquantstats package.

This module defines a hierarchy of exceptions that provide meaningful context when data-validation errors occur within the package.

All exceptions inherit from JQuantStatsError so callers can catch the entire family with a single except JQuantStatsError clause if they prefer.

Examples:

>>> raise MissingDateColumnError("prices")
Traceback (most recent call last):
    ...
jquantstats.exceptions.MissingDateColumnError: ...

BenchmarkAlignmentWarning

Bases: UserWarning

Emitted when aligning returns and benchmark drops rows from either side.

Returns and benchmark are aligned on their common dates with an inner join. Rows whose date appears in only one of the two frames are silently discarded by that join; this warning surfaces how many rows were lost so a partially overlapping benchmark cannot truncate the analysis unnoticed.

Suppress it once the overlap is understood::

import warnings
from jquantstats.exceptions import BenchmarkAlignmentWarning

warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning)
Source code in src/jquantstats/exceptions.py
class BenchmarkAlignmentWarning(UserWarning):
    """Emitted when aligning returns and benchmark drops rows from either side.

    Returns and benchmark are aligned on their common dates with an inner
    join.  Rows whose date appears in only one of the two frames are
    silently discarded by that join; this warning surfaces how many rows
    were lost so a partially overlapping benchmark cannot truncate the
    analysis unnoticed.

    Suppress it once the overlap is understood::

        import warnings
        from jquantstats.exceptions import BenchmarkAlignmentWarning

        warnings.filterwarnings("ignore", category=BenchmarkAlignmentWarning)
    """

IntegerIndexBoundError

Bases: JQuantStatsError, TypeError

Raised when a row-index bound is not an integer.

Parameters:

Name Type Description Default
param str

Name of the offending parameter (e.g. "start" or "end").

required
actual_type str

The type.__name__ of the value that was supplied.

required

Examples:

>>> raise IntegerIndexBoundError("start", "str")
Traceback (most recent call last):
    ...
jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str.
Source code in src/jquantstats/exceptions.py
class IntegerIndexBoundError(JQuantStatsError, TypeError):
    """Raised when a row-index bound is not an integer.

    Args:
        param: Name of the offending parameter (e.g. ``"start"`` or ``"end"``).
        actual_type: The ``type.__name__`` of the value that was supplied.

    Examples:
        >>> raise IntegerIndexBoundError("start", "str")
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.IntegerIndexBoundError: start must be an integer, got str.
    """

    def __init__(self, param: str, actual_type: str) -> None:
        """Initialize with the parameter name and the offending type."""
        super().__init__(f"{param} must be an integer, got {actual_type}.")
        self.param = param
        self.actual_type = actual_type

__init__(param, actual_type)

Initialize with the parameter name and the offending type.

Source code in src/jquantstats/exceptions.py
def __init__(self, param: str, actual_type: str) -> None:
    """Initialize with the parameter name and the offending type."""
    super().__init__(f"{param} must be an integer, got {actual_type}.")
    self.param = param
    self.actual_type = actual_type

InvalidCashPositionTypeError

Bases: JQuantStatsError, TypeError

Raised when cashposition is not a polars.DataFrame.

Parameters:

Name Type Description Default
actual_type str

The type.__name__ of the value that was supplied.

required

Examples:

>>> raise InvalidCashPositionTypeError("dict")
Traceback (most recent call last):
    ...
jquantstats.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict.
Source code in src/jquantstats/exceptions.py
class InvalidCashPositionTypeError(JQuantStatsError, TypeError):
    """Raised when ``cashposition`` is not a `polars.DataFrame`.

    Args:
        actual_type: The ``type.__name__`` of the value that was supplied.

    Examples:
        >>> raise InvalidCashPositionTypeError("dict")
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.InvalidCashPositionTypeError: cashposition must be pl.DataFrame, got dict.
    """

    def __init__(self, actual_type: str) -> None:
        """Initialize with the offending type name."""
        super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.")
        self.actual_type = actual_type

__init__(actual_type)

Initialize with the offending type name.

Source code in src/jquantstats/exceptions.py
def __init__(self, actual_type: str) -> None:
    """Initialize with the offending type name."""
    super().__init__(f"cashposition must be pl.DataFrame, got {actual_type}.")
    self.actual_type = actual_type

InvalidMaxBpsError

Bases: JQuantStatsError, ValueError

Raised when max_bps is not a positive integer.

Parameters:

Name Type Description Default
max_bps object

The invalid value that was supplied.

required

Examples:

>>> raise InvalidMaxBpsError(0)
Traceback (most recent call last):
    ...
jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0.
Source code in src/jquantstats/exceptions.py
class InvalidMaxBpsError(JQuantStatsError, ValueError):
    """Raised when ``max_bps`` is not a positive integer.

    Args:
        max_bps: The invalid value that was supplied.

    Examples:
        >>> raise InvalidMaxBpsError(0)
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.InvalidMaxBpsError: max_bps must be a positive integer, got 0.
    """

    def __init__(self, max_bps: object) -> None:
        """Initialize with the offending value."""
        super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.")
        self.max_bps = max_bps

__init__(max_bps)

Initialize with the offending value.

Source code in src/jquantstats/exceptions.py
def __init__(self, max_bps: object) -> None:
    """Initialize with the offending value."""
    super().__init__(f"max_bps must be a positive integer, got {max_bps!r}.")
    self.max_bps = max_bps

InvalidPricesTypeError

Bases: JQuantStatsError, TypeError

Raised when prices is not a polars.DataFrame.

Parameters:

Name Type Description Default
actual_type str

The type.__name__ of the value that was supplied.

required

Examples:

>>> raise InvalidPricesTypeError("list")
Traceback (most recent call last):
    ...
jquantstats.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list.
Source code in src/jquantstats/exceptions.py
class InvalidPricesTypeError(JQuantStatsError, TypeError):
    """Raised when ``prices`` is not a `polars.DataFrame`.

    Args:
        actual_type: The ``type.__name__`` of the value that was supplied.

    Examples:
        >>> raise InvalidPricesTypeError("list")
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.InvalidPricesTypeError: prices must be pl.DataFrame, got list.
    """

    def __init__(self, actual_type: str) -> None:
        """Initialize with the offending type name."""
        super().__init__(f"prices must be pl.DataFrame, got {actual_type}.")
        self.actual_type = actual_type

__init__(actual_type)

Initialize with the offending type name.

Source code in src/jquantstats/exceptions.py
def __init__(self, actual_type: str) -> None:
    """Initialize with the offending type name."""
    super().__init__(f"prices must be pl.DataFrame, got {actual_type}.")
    self.actual_type = actual_type

JQuantStatsError

Bases: Exception

Base class for all JQuantStats domain errors.

Source code in src/jquantstats/exceptions.py
class JQuantStatsError(Exception):
    """Base class for all JQuantStats domain errors."""

MissingDateColumnError

Bases: JQuantStatsError, ValueError

Raised when a required date column is absent from a DataFrame.

Parameters:

Name Type Description Default
frame_name str

Descriptive name of the frame missing the column (e.g. "prices").

required
column str | None

Name of the date column that was looked up (e.g. the date_col argument). When omitted, the default 'date' column is assumed.

None
available list[str] | None

Column names actually present in the frame, included in the error message to help diagnose the mismatch.

None

Examples:

>>> raise MissingDateColumnError("prices")
Traceback (most recent call last):
    ...
jquantstats.exceptions.MissingDateColumnError: ...
Source code in src/jquantstats/exceptions.py
class MissingDateColumnError(JQuantStatsError, ValueError):
    """Raised when a required date column is absent from a DataFrame.

    Args:
        frame_name: Descriptive name of the frame missing the column (e.g. ``"prices"``).
        column: Name of the date column that was looked up (e.g. the
            ``date_col`` argument). When omitted, the default ``'date'``
            column is assumed.
        available: Column names actually present in the frame, included in
            the error message to help diagnose the mismatch.

    Examples:
        >>> raise MissingDateColumnError("prices")  # doctest: +ELLIPSIS
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.MissingDateColumnError: ...
    """

    def __init__(self, frame_name: str, column: str | None = None, available: list[str] | None = None) -> None:
        """Initialize with the frame name and, optionally, the missing column and available columns."""
        if column is None:
            msg = f"DataFrame '{frame_name}' is missing the required 'date' column."
        else:
            cols = ", ".join(f"'{c}'" for c in available) if available else ""
            msg = (
                f"DataFrame '{frame_name}' has no column '{column}' to use as the date column"
                + (f"; available columns: {cols}" if cols else "")
                + ". Pass date_col=<name of an existing column>."
            )
        super().__init__(msg)
        self.frame_name = frame_name
        self.column = column
        self.available = list(available) if available is not None else None

__init__(frame_name, column=None, available=None)

Initialize with the frame name and, optionally, the missing column and available columns.

Source code in src/jquantstats/exceptions.py
def __init__(self, frame_name: str, column: str | None = None, available: list[str] | None = None) -> None:
    """Initialize with the frame name and, optionally, the missing column and available columns."""
    if column is None:
        msg = f"DataFrame '{frame_name}' is missing the required 'date' column."
    else:
        cols = ", ".join(f"'{c}'" for c in available) if available else ""
        msg = (
            f"DataFrame '{frame_name}' has no column '{column}' to use as the date column"
            + (f"; available columns: {cols}" if cols else "")
            + ". Pass date_col=<name of an existing column>."
        )
    super().__init__(msg)
    self.frame_name = frame_name
    self.column = column
    self.available = list(available) if available is not None else None

MuSchemaError

Bases: JQuantStatsError, ValueError

Raised when a mu (expected-returns) frame doesn't match the portfolio's assets.

Parameters:

Name Type Description Default
missing list[str]

Portfolio asset columns absent from the mu frame.

required

Examples:

>>> raise MuSchemaError(["AAPL"])
Traceback (most recent call last):
    ...
jquantstats.exceptions.MuSchemaError: ...
Source code in src/jquantstats/exceptions.py
class MuSchemaError(JQuantStatsError, ValueError):
    """Raised when a ``mu`` (expected-returns) frame doesn't match the portfolio's assets.

    Args:
        missing: Portfolio asset columns absent from the mu frame.

    Examples:
        >>> raise MuSchemaError(["AAPL"])  # doctest: +ELLIPSIS
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.MuSchemaError: ...
    """

    def __init__(self, missing: list[str]) -> None:
        """Initialize with the asset columns missing from the mu frame."""
        cols = ", ".join(f"'{c}'" for c in missing)
        super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.")
        self.missing = missing

__init__(missing)

Initialize with the asset columns missing from the mu frame.

Source code in src/jquantstats/exceptions.py
def __init__(self, missing: list[str]) -> None:
    """Initialize with the asset columns missing from the mu frame."""
    cols = ", ".join(f"'{c}'" for c in missing)
    super().__init__(f"mu is missing expected-return columns for portfolio asset(s): {cols}.")
    self.missing = missing

NegativeCostBpsError

Bases: JQuantStatsError, ValueError

Raised when a trading cost in basis points is negative.

Parameters:

Name Type Description Default
cost_bps float

The negative cost value that was supplied.

required

Examples:

>>> raise NegativeCostBpsError(-1.0)
Traceback (most recent call last):
    ...
jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0.
Source code in src/jquantstats/exceptions.py
class NegativeCostBpsError(JQuantStatsError, ValueError):
    """Raised when a trading cost in basis points is negative.

    Args:
        cost_bps: The negative cost value that was supplied.

    Examples:
        >>> raise NegativeCostBpsError(-1.0)
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NegativeCostBpsError: cost_bps must be non-negative, got -1.0.
    """

    def __init__(self, cost_bps: float) -> None:
        """Initialize with the offending cost value."""
        super().__init__(f"cost_bps must be non-negative, got {cost_bps}.")
        self.cost_bps = cost_bps

__init__(cost_bps)

Initialize with the offending cost value.

Source code in src/jquantstats/exceptions.py
def __init__(self, cost_bps: float) -> None:
    """Initialize with the offending cost value."""
    super().__init__(f"cost_bps must be non-negative, got {cost_bps}.")
    self.cost_bps = cost_bps

NoAssetColumnsError

Bases: JQuantStatsError, ValueError

Raised when a DataFrame contains no numeric asset columns to aggregate.

Parameters:

Name Type Description Default
frame_name str

Descriptive name of the frame without asset columns (e.g. "profits").

required

Examples:

>>> raise NoAssetColumnsError("profits")
Traceback (most recent call last):
    ...
jquantstats.exceptions.NoAssetColumnsError: ...
Source code in src/jquantstats/exceptions.py
class NoAssetColumnsError(JQuantStatsError, ValueError):
    """Raised when a DataFrame contains no numeric asset columns to aggregate.

    Args:
        frame_name: Descriptive name of the frame without asset columns (e.g. ``"profits"``).

    Examples:
        >>> raise NoAssetColumnsError("profits")  # doctest: +ELLIPSIS
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NoAssetColumnsError: ...
    """

    def __init__(self, frame_name: str) -> None:
        """Initialize with the name of the frame lacking asset columns."""
        super().__init__(
            f"DataFrame '{frame_name}' contains no numeric asset columns; "
            f"at least one numeric column besides 'date' is required."
        )
        self.frame_name = frame_name

__init__(frame_name)

Initialize with the name of the frame lacking asset columns.

Source code in src/jquantstats/exceptions.py
def __init__(self, frame_name: str) -> None:
    """Initialize with the name of the frame lacking asset columns."""
    super().__init__(
        f"DataFrame '{frame_name}' contains no numeric asset columns; "
        f"at least one numeric column besides 'date' is required."
    )
    self.frame_name = frame_name

NoBenchmarkError

Bases: JQuantStatsError, AttributeError

Raised when a benchmark-dependent statistic is requested without a benchmark.

Subclasses AttributeError so existing callers that catch AttributeError for the no-benchmark path keep working unchanged.

Examples:

>>> raise NoBenchmarkError()
Traceback (most recent call last):
    ...
jquantstats.exceptions.NoBenchmarkError: No benchmark data available
Source code in src/jquantstats/exceptions.py
class NoBenchmarkError(JQuantStatsError, AttributeError):
    """Raised when a benchmark-dependent statistic is requested without a benchmark.

    Subclasses `AttributeError` so existing callers that catch
    ``AttributeError`` for the no-benchmark path keep working unchanged.

    Examples:
        >>> raise NoBenchmarkError()
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NoBenchmarkError: No benchmark data available
    """

    def __init__(self) -> None:
        """Initialize with the fixed no-benchmark message."""
        super().__init__("No benchmark data available")

__init__()

Initialize with the fixed no-benchmark message.

Source code in src/jquantstats/exceptions.py
def __init__(self) -> None:
    """Initialize with the fixed no-benchmark message."""
    super().__init__("No benchmark data available")

NonPositiveAumError

Bases: JQuantStatsError, ValueError

Raised when aum is not strictly positive.

Parameters:

Name Type Description Default
aum float

The non-positive value that was supplied.

required

Examples:

>>> raise NonPositiveAumError(0.0)
Traceback (most recent call last):
    ...
jquantstats.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0.
Source code in src/jquantstats/exceptions.py
class NonPositiveAumError(JQuantStatsError, ValueError):
    """Raised when ``aum`` is not strictly positive.

    Args:
        aum: The non-positive value that was supplied.

    Examples:
        >>> raise NonPositiveAumError(0.0)
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NonPositiveAumError: aum must be strictly positive, got 0.0.
    """

    def __init__(self, aum: float) -> None:
        """Initialize with the offending aum value."""
        super().__init__(f"aum must be strictly positive, got {aum}.")
        self.aum = aum

__init__(aum)

Initialize with the offending aum value.

Source code in src/jquantstats/exceptions.py
def __init__(self, aum: float) -> None:
    """Initialize with the offending aum value."""
    super().__init__(f"aum must be strictly positive, got {aum}.")
    self.aum = aum

NonPositivePeriodsPerYearError

Bases: JQuantStatsError, ValueError

Raised when periods_per_year is not strictly positive.

Examples:

>>> raise NonPositivePeriodsPerYearError()
Traceback (most recent call last):
    ...
jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive
Source code in src/jquantstats/exceptions.py
class NonPositivePeriodsPerYearError(JQuantStatsError, ValueError):
    """Raised when ``periods_per_year`` is not strictly positive.

    Examples:
        >>> raise NonPositivePeriodsPerYearError()
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NonPositivePeriodsPerYearError: periods_per_year must be positive
    """

    def __init__(self) -> None:
        """Initialize with the fixed non-positive periods-per-year message."""
        super().__init__("periods_per_year must be positive")

__init__()

Initialize with the fixed non-positive periods-per-year message.

Source code in src/jquantstats/exceptions.py
def __init__(self) -> None:
    """Initialize with the fixed non-positive periods-per-year message."""
    super().__init__("periods_per_year must be positive")

NonPositiveWindowError

Bases: JQuantStatsError, ValueError

Raised when a rolling-window size is not a positive integer.

Parameters:

Name Type Description Default
param str

Name of the offending parameter (e.g. "window" or "rolling_period").

required

Examples:

>>> raise NonPositiveWindowError("window")
Traceback (most recent call last):
    ...
jquantstats.exceptions.NonPositiveWindowError: window must be a positive integer
Source code in src/jquantstats/exceptions.py
class NonPositiveWindowError(JQuantStatsError, ValueError):
    """Raised when a rolling-window size is not a positive integer.

    Args:
        param: Name of the offending parameter (e.g. ``"window"`` or
            ``"rolling_period"``).

    Examples:
        >>> raise NonPositiveWindowError("window")
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NonPositiveWindowError: window must be a positive integer
    """

    def __init__(self, param: str) -> None:
        """Initialize with the name of the offending window parameter."""
        super().__init__(f"{param} must be a positive integer")
        self.param = param

__init__(param)

Initialize with the name of the offending window parameter.

Source code in src/jquantstats/exceptions.py
def __init__(self, param: str) -> None:
    """Initialize with the name of the offending window parameter."""
    super().__init__(f"{param} must be a positive integer")
    self.param = param

NullsInReturnsError

Bases: JQuantStatsError, ValueError

Raised when null values are detected in returns (or benchmark) data.

Polars propagates null through calculations whereas pandas silently drops NaN. Leaving nulls in place will cause most statistics to return null instead of a numeric result.

Use the null_strategy parameter on from_returns or from_prices to handle nulls automatically, or clean the data before construction.

Parameters:

Name Type Description Default
frame_name str

Descriptive name of the frame that contains nulls (e.g. "returns" or "benchmark").

required
columns list[str]

Names of the columns that contain at least one null.

required

Examples:

>>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"])
Traceback (most recent call last):
    ...
jquantstats.exceptions.NullsInReturnsError: ...
Source code in src/jquantstats/exceptions.py
class NullsInReturnsError(JQuantStatsError, ValueError):
    """Raised when null values are detected in returns (or benchmark) data.

    Polars propagates ``null`` through calculations whereas pandas silently
    drops ``NaN``.  Leaving nulls in place will cause most statistics to
    return ``null`` instead of a numeric result.

    Use the ``null_strategy`` parameter on `from_returns`
    or `from_prices` to handle nulls automatically, or
    clean the data before construction.

    Args:
        frame_name: Descriptive name of the frame that contains nulls
            (e.g. ``"returns"`` or ``"benchmark"``).
        columns: Names of the columns that contain at least one null.

    Examples:
        >>> raise NullsInReturnsError("returns", ["Asset1", "Asset2"])
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.NullsInReturnsError: ...
    """

    def __init__(self, frame_name: str, columns: list[str]) -> None:
        """Initialize with the frame name and the columns that contain nulls."""
        cols_str = ", ".join(f"'{c}'" for c in columns)
        super().__init__(
            f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. "
            f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls "
            f"automatically, or clean the data before construction."
        )
        self.frame_name = frame_name
        self.columns = columns

__init__(frame_name, columns)

Initialize with the frame name and the columns that contain nulls.

Source code in src/jquantstats/exceptions.py
def __init__(self, frame_name: str, columns: list[str]) -> None:
    """Initialize with the frame name and the columns that contain nulls."""
    cols_str = ", ".join(f"'{c}'" for c in columns)
    super().__init__(
        f"DataFrame '{frame_name}' contains null values in column(s): {cols_str}. "
        f"Pass null_strategy='drop' or null_strategy='forward_fill' to handle nulls "
        f"automatically, or clean the data before construction."
    )
    self.frame_name = frame_name
    self.columns = columns

PositionExprColumnError

Bases: JQuantStatsError, ValueError

Raised when a position expression creates columns that do not exist in prices.

Position expressions (cash_position, position, risk_position) are evaluated against the prices frame and must overwrite existing asset columns. An expression that creates a new column (e.g. via .alias) leaves the original asset columns untouched, which would silently treat raw prices as positions.

Parameters:

Name Type Description Default
param str

Name of the offending parameter (e.g. "cash_position").

required
extra list[str]

Column names created by the expression that are absent from prices.

required

Examples:

>>> raise PositionExprColumnError("cash_position", ["A2"])
Traceback (most recent call last):
    ...
jquantstats.exceptions.PositionExprColumnError: ...
Source code in src/jquantstats/exceptions.py
class PositionExprColumnError(JQuantStatsError, ValueError):
    """Raised when a position expression creates columns that do not exist in prices.

    Position expressions (``cash_position``, ``position``, ``risk_position``)
    are evaluated against the prices frame and must overwrite existing asset
    columns. An expression that creates a *new* column (e.g. via ``.alias``)
    leaves the original asset columns untouched, which would silently treat
    raw prices as positions.

    Args:
        param: Name of the offending parameter (e.g. ``"cash_position"``).
        extra: Column names created by the expression that are absent from prices.

    Examples:
        >>> raise PositionExprColumnError("cash_position", ["A2"])  # doctest: +ELLIPSIS
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.PositionExprColumnError: ...
    """

    def __init__(self, param: str, extra: list[str]) -> None:
        """Initialize with the parameter name and the unexpected columns it created."""
        cols = ", ".join(f"'{c}'" for c in extra)
        super().__init__(
            f"{param} expression created new column(s) {cols} that do not exist in prices. "
            f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); "
            f"asset columns the expression does not overwrite keep their raw price values."
        )
        self.param = param
        self.extra = list(extra)

__init__(param, extra)

Initialize with the parameter name and the unexpected columns it created.

Source code in src/jquantstats/exceptions.py
def __init__(self, param: str, extra: list[str]) -> None:
    """Initialize with the parameter name and the unexpected columns it created."""
    cols = ", ".join(f"'{c}'" for c in extra)
    super().__init__(
        f"{param} expression created new column(s) {cols} that do not exist in prices. "
        f"Expressions must overwrite existing asset columns (e.g. pl.col('A') * 2); "
        f"asset columns the expression does not overwrite keep their raw price values."
    )
    self.param = param
    self.extra = list(extra)

RowCountMismatchError

Bases: JQuantStatsError, ValueError

Raised when prices and cashposition have different numbers of rows.

Parameters:

Name Type Description Default
prices_rows int

Number of rows in the prices DataFrame.

required
cashposition_rows int

Number of rows in the cashposition DataFrame.

required

Examples:

>>> raise RowCountMismatchError(10, 9)
Traceback (most recent call last):
    ...
jquantstats.exceptions.RowCountMismatchError: ...
Source code in src/jquantstats/exceptions.py
class RowCountMismatchError(JQuantStatsError, ValueError):
    """Raised when ``prices`` and ``cashposition`` have different numbers of rows.

    Args:
        prices_rows: Number of rows in the prices DataFrame.
        cashposition_rows: Number of rows in the cashposition DataFrame.

    Examples:
        >>> raise RowCountMismatchError(10, 9)  # doctest: +ELLIPSIS
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.RowCountMismatchError: ...
    """

    def __init__(self, prices_rows: int, cashposition_rows: int) -> None:
        """Initialize with the row counts of the two mismatched DataFrames."""
        super().__init__(
            f"cashposition and prices must have the same number of rows, "
            f"got cashposition={cashposition_rows} and prices={prices_rows}."
        )
        self.prices_rows = prices_rows
        self.cashposition_rows = cashposition_rows

__init__(prices_rows, cashposition_rows)

Initialize with the row counts of the two mismatched DataFrames.

Source code in src/jquantstats/exceptions.py
def __init__(self, prices_rows: int, cashposition_rows: int) -> None:
    """Initialize with the row counts of the two mismatched DataFrames."""
    super().__init__(
        f"cashposition and prices must have the same number of rows, "
        f"got cashposition={cashposition_rows} and prices={prices_rows}."
    )
    self.prices_rows = prices_rows
    self.cashposition_rows = cashposition_rows

UncleanSeriesError

Bases: JQuantStatsError, ValueError

Raised when a derived series contains null or non-finite values.

Parameters:

Name Type Description Default
name str

Name of the offending series (may be empty when unknown).

required
reason str

Either "null" or "non-finite".

required

Examples:

>>> raise UncleanSeriesError("profit", "null")
Traceback (most recent call last):
    ...
jquantstats.exceptions.UncleanSeriesError: ...
Source code in src/jquantstats/exceptions.py
class UncleanSeriesError(JQuantStatsError, ValueError):
    """Raised when a derived series contains null or non-finite values.

    Args:
        name: Name of the offending series (may be empty when unknown).
        reason: Either ``"null"`` or ``"non-finite"``.

    Examples:
        >>> raise UncleanSeriesError("profit", "null")  # doctest: +ELLIPSIS
        Traceback (most recent call last):
            ...
        jquantstats.exceptions.UncleanSeriesError: ...
    """

    def __init__(self, name: str, reason: str) -> None:
        """Initialize with the series name and the kind of dirty value found."""
        label = f"series '{name}'" if name else "series"
        super().__init__(
            f"{label} contains {reason} values; inputs must produce a clean, finite series. "
            f"Check prices and positions for gaps or zero/negative prices."
        )
        self.name = name
        self.reason = reason

__init__(name, reason)

Initialize with the series name and the kind of dirty value found.

Source code in src/jquantstats/exceptions.py
def __init__(self, name: str, reason: str) -> None:
    """Initialize with the series name and the kind of dirty value found."""
    label = f"series '{name}'" if name else "series"
    super().__init__(
        f"{label} contains {reason} values; inputs must produce a clean, finite series. "
        f"Check prices and positions for gaps or zero/negative prices."
    )
    self.name = name
    self.reason = reason