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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/jquantstats/_portfolio_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class _PortfolioMembers:
aum: float
cost_per_unit: float
cost_bps: float
annual_fee: float

# Derived series / accessors defined on sibling mixins or ``Portfolio``.
@property
Expand Down
11 changes: 11 additions & 0 deletions src/jquantstats/_portfolio_constructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def from_cash_position(
cost_per_unit: float = 0.0,
cost_bps: float = 0.0,
cost_model: CostModel | None = None,
annual_fee: float = 0.0,
) -> Self:
"""Create a Portfolio directly from cash positions aligned with prices."""
...
Expand All @@ -109,6 +110,7 @@ def from_risk_position(
cost_per_unit: float = 0.0,
cost_bps: float = 0.0,
cost_model: CostModel | None = None,
annual_fee: float = 0.0,
) -> Self:
"""Create a Portfolio from per-asset risk positions.

Expand Down Expand Up @@ -140,6 +142,9 @@ def from_risk_position(
instance. When supplied, its ``cost_per_unit`` and
``cost_bps`` values take precedence over the individual
parameters above.
annual_fee: Flat annual management fee as a fraction of AUM
(e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee).
Used as the default by `deduct_management_fee`.

Returns:
A Portfolio instance whose cash positions are risk_position
Expand Down Expand Up @@ -182,6 +187,7 @@ def _vol(asset: str) -> pl.Series:
aum=aum,
cost_per_unit=cost_per_unit,
cost_bps=cost_bps,
annual_fee=annual_fee,
)

@classmethod
Expand All @@ -193,6 +199,7 @@ def from_position(
cost_per_unit: float = 0.0,
cost_bps: float = 0.0,
cost_model: CostModel | None = None,
annual_fee: float = 0.0,
) -> Self:
"""Create a Portfolio from share/unit positions.

Expand All @@ -213,6 +220,9 @@ def from_position(
cost_model: Optional `CostModel` instance.
When supplied, its ``cost_per_unit`` and ``cost_bps`` values
take precedence over the individual parameters above.
annual_fee: Flat annual management fee as a fraction of AUM
(e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee).
Used as the default by `deduct_management_fee`.

Returns:
A Portfolio instance whose cash positions equal *position* x *prices*.
Expand Down Expand Up @@ -241,4 +251,5 @@ def from_position(
cost_per_unit=cost_per_unit,
cost_bps=cost_bps,
cost_model=cost_model,
annual_fee=annual_fee,
)
74 changes: 73 additions & 1 deletion src/jquantstats/_portfolio_cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from ._portfolio_base import _PortfolioMembers
from ._stats._core import _std_is_negligible
from .exceptions import InvalidMaxBpsError, NegativeCostBpsError
from .exceptions import InvalidMaxBpsError, NegativeAnnualFeeError, NegativeCostBpsError


class PortfolioCostMixin(_PortfolioMembers):
Expand Down Expand Up @@ -140,6 +140,78 @@ def cost_adjusted_returns(self, cost_bps: float | None = None) -> pl.DataFrame:
daily_cost = self.turnover["turnover"] * (effective_bps / 10_000.0)
return base.with_columns((pl.col("returns") - daily_cost).alias("returns"))

def deduct_management_fee(
self,
annual_fee: float | None = None,
base: pl.DataFrame | None = None,
) -> pl.DataFrame:
"""Return daily portfolio returns net of a flat annual management fee.

Management fees accrue per calendar day on total AUM regardless of
trading activity. The per-period deduction is::

daily_deduction_t = annual_fee * days_elapsed_t / 365

where ``days_elapsed_t`` is the number of calendar days between row
*t* and the previous row. Weekends and holidays are therefore charged
to the next trading day. The first row always accrues zero (no prior
date), consistent with the turnover convention.

The deduction is linear and composes naturally with
`cost_adjusted_returns`::

adj = pf.cost_adjusted_returns(cost_bps=5)
net = pf.deduct_management_fee(annual_fee=0.0085, base=adj)

When no ``date`` column is present every period is assumed to span
exactly one calendar day.

Args:
annual_fee: Flat annual management fee as a fraction (e.g. 0.0085
for 85 bps). Must be non-negative. Defaults to
``self.annual_fee`` set at construction time.
base: Returns DataFrame to deduct the fee from. Must have the
same schema as `returns` (``'returns'`` column, optional
``'date'`` column). Defaults to ``self.returns``.

Returns:
pl.DataFrame: Same schema as *base* (or `returns`) but with the
``returns`` column reduced by the pro-rata daily fee.

Raises:
TypeError: If ``annual_fee`` is not a number.
ValueError: If ``annual_fee`` is not finite (NaN or infinity).
NegativeAnnualFeeError: If ``annual_fee`` is negative.

Examples:
>>> from jquantstats.portfolio import Portfolio
>>> import polars as pl
>>> from datetime import date
>>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)]
>>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]})
>>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1000.0, 1000.0]})
>>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5)
>>> net = pf.deduct_management_fee(annual_fee=0.0)
>>> float(net["returns"][1]) == float(pf.returns["returns"][1])
True
"""
effective_fee = annual_fee if annual_fee is not None else self.annual_fee
if isinstance(effective_fee, bool) or not isinstance(effective_fee, int | float):
raise TypeError(f"annual_fee must be a number, got {type(effective_fee).__name__}") # noqa: TRY003
effective_fee = float(effective_fee)
if not math.isfinite(effective_fee):
raise ValueError(f"annual_fee must be finite, got {effective_fee}") # noqa: TRY003
if effective_fee < 0:
raise NegativeAnnualFeeError(effective_fee)
if base is None:
base = self.returns
if "date" in base.columns and base["date"].dtype.is_temporal():
days_elapsed = base["date"].diff().dt.total_days().fill_null(0).cast(pl.Float64)
else:
days_elapsed = pl.Series([0.0] + [1.0] * (base.height - 1))
daily_deduction = days_elapsed * (effective_fee / 365.0)
return base.with_columns((pl.col("returns") - daily_deduction).alias("returns"))

def trading_cost_impact(self, max_bps: int = 20) -> pl.DataFrame:
"""Estimate the impact of trading costs on the Sharpe ratio.

Expand Down
19 changes: 19 additions & 0 deletions src/jquantstats/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,25 @@ def __init__(self, cost_bps: float) -> None:
self.cost_bps = cost_bps


class NegativeAnnualFeeError(JQuantStatsError, ValueError):
"""Raised when an annual management fee is negative.

Args:
annual_fee: The negative fee value that was supplied.

Examples:
>>> raise NegativeAnnualFeeError(-0.01)
Traceback (most recent call last):
...
jquantstats.exceptions.NegativeAnnualFeeError: annual_fee must be non-negative, got -0.01.
"""

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


class InvalidMaxBpsError(JQuantStatsError, ValueError):
"""Raised when ``max_bps`` is not a positive integer.

Expand Down
25 changes: 22 additions & 3 deletions src/jquantstats/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
- Portfolio transforms — `truncate`, `lag`, `smoothed_holding`
- Attribution — `tilt`, `timing`, `tilt_timing_decomp`
- Turnover analysis — `turnover`, `turnover_weekly`, `turnover_summary`
- Cost analysis — `cost_adjusted_returns`, `trading_cost_impact`
- Cost analysis — `cost_adjusted_returns`, `trading_cost_impact`, `deduct_management_fee`
- Utility — `correlation`
"""

Expand Down Expand Up @@ -104,7 +104,7 @@ class Portfolio(
- Turnover: `turnover`, `turnover_weekly`,
`turnover_summary`
- Cost analysis: `cost_adjusted_returns`,
`trading_cost_impact`
`trading_cost_impact`, `deduct_management_fee`
- Utility: `correlation`

Attributes:
Expand Down Expand Up @@ -139,6 +139,13 @@ class Portfolio(
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.

**Management fee (flat annual, set at construction or passed at call time):**
``annual_fee: float`` — flat annual management fee as a fraction of AUM (e.g. 0.0085 for 85 bps p.a.).
Used by ``.deduct_management_fee(annual_fee)``.
The fee accrues pro-rata per calendar day (``annual_fee * days / 365``), so weekends and
holidays are charged to the next trading day and the deduction sums to ``annual_fee`` over a
full year. Cost and fee deductions compose linearly in any order.

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).

Expand Down Expand Up @@ -171,6 +178,7 @@ class Portfolio(
aum: float
cost_per_unit: float = 0.0
cost_bps: float = 0.0
annual_fee: float = 0.0

# ── Internal cache fields ─────────────────────────────────────────────────
# All cache fields are initialised to ``None`` in ``__post_init__`` via
Expand Down Expand Up @@ -295,6 +303,7 @@ def from_cash_position(
cost_per_unit: float = 0.0,
cost_bps: float = 0.0,
cost_model: CostModel | None = None,
annual_fee: float = 0.0,
) -> Self:
"""Create a Portfolio directly from cash positions aligned with prices.

Expand All @@ -311,6 +320,9 @@ def from_cash_position(
instance. When supplied, its ``cost_per_unit`` and
``cost_bps`` values take precedence over the individual
parameters above.
annual_fee: Flat annual management fee as a fraction of AUM
(e.g. 0.0085 for 85 bps p.a.). Defaults to 0.0 (no fee).
Used as the default by `deduct_management_fee`.

Returns:
A Portfolio instance with the provided cash positions.
Expand All @@ -326,7 +338,14 @@ def from_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)
return cls(
prices=prices,
cashposition=cash_position,
aum=aum,
cost_per_unit=cost_per_unit,
cost_bps=cost_bps,
annual_fee=annual_fee,
)

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

Expand Down
Loading