diff --git a/src/jquantstats/_portfolio_base.py b/src/jquantstats/_portfolio_base.py index 4addbd3b..898c41d4 100644 --- a/src/jquantstats/_portfolio_base.py +++ b/src/jquantstats/_portfolio_base.py @@ -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 diff --git a/src/jquantstats/_portfolio_constructors.py b/src/jquantstats/_portfolio_constructors.py index 1f3d42ee..253f4db1 100644 --- a/src/jquantstats/_portfolio_constructors.py +++ b/src/jquantstats/_portfolio_constructors.py @@ -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.""" ... @@ -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. @@ -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 @@ -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 @@ -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. @@ -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*. @@ -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, ) diff --git a/src/jquantstats/_portfolio_cost.py b/src/jquantstats/_portfolio_cost.py index 3b672305..ea76ed33 100644 --- a/src/jquantstats/_portfolio_cost.py +++ b/src/jquantstats/_portfolio_cost.py @@ -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): @@ -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. diff --git a/src/jquantstats/exceptions.py b/src/jquantstats/exceptions.py index 4ba0ce19..683ba13c 100644 --- a/src/jquantstats/exceptions.py +++ b/src/jquantstats/exceptions.py @@ -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. diff --git a/src/jquantstats/portfolio.py b/src/jquantstats/portfolio.py index d2172b9c..74c9145d 100644 --- a/src/jquantstats/portfolio.py +++ b/src/jquantstats/portfolio.py @@ -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` """ @@ -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: @@ -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). @@ -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 @@ -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. @@ -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. @@ -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 ─────────────────────────────────────────────────────── diff --git a/tests/test_jquantstats/test_portfolio/test_costs.py b/tests/test_jquantstats/test_portfolio/test_costs.py index b39ab787..99e83128 100644 --- a/tests/test_jquantstats/test_portfolio/test_costs.py +++ b/tests/test_jquantstats/test_portfolio/test_costs.py @@ -1,8 +1,8 @@ """Tests for Portfolio cost-related functionality. Covers: cost_adjusted_returns, trading_cost_impact, position_delta_costs, -net_cost_nav, cost_per_unit field, from_position factory, and cost-parameter -forwarding through transforms. +net_cost_nav, cost_per_unit field, from_position factory, cost-parameter +forwarding through transforms, and deduct_management_fee. """ from __future__ import annotations @@ -496,3 +496,201 @@ def test_cost_adjusted_returns_explicit_overrides_construction_cost_bps(cost_bps adj_override = cost_bps_pf.cost_adjusted_returns(0.0) adj_base = cost_bps_pf.cost_adjusted_returns(5.0) assert float(adj_override["returns"].sum()) >= float(adj_base["returns"].sum()) + + +# ─── deduct_management_fee ──────────────────────────────────────────────────── + + +@pytest.fixture +def dated_portfolio(): + """5-day single-asset portfolio with consecutive calendar dates.""" + start = date(2020, 1, 1) + dates = pl.date_range(start=start, end=start + timedelta(days=4), interval="1d", eager=True).cast(pl.Date) + prices = pl.DataFrame({"date": dates, "A": [100.0, 101.0, 102.0, 103.0, 104.0]}) + pos = pl.DataFrame({"date": dates, "A": [1000.0] * 5}) + return Portfolio(prices=prices, cashposition=pos, aum=1e5) + + +@pytest.fixture +def dated_portfolio_with_fee(): + """5-day portfolio with annual_fee=0.01 set at construction.""" + start = date(2020, 1, 1) + dates = pl.date_range(start=start, end=start + timedelta(days=4), interval="1d", eager=True).cast(pl.Date) + prices = pl.DataFrame({"date": dates, "A": [100.0, 101.0, 102.0, 103.0, 104.0]}) + pos = pl.DataFrame({"date": dates, "A": [1000.0] * 5}) + return Portfolio(prices=prices, cashposition=pos, aum=1e5, annual_fee=0.01) + + +@pytest.fixture +def undated_portfolio(): + """5-row single-asset portfolio with no date column.""" + prices = pl.DataFrame({"A": [100.0, 101.0, 102.0, 103.0, 104.0]}) + pos = pl.DataFrame({"A": [1000.0] * 5}) + return Portfolio(prices=prices, cashposition=pos, aum=1e5) + + +def test_deduct_management_fee_zero_fee_equals_base_returns(dated_portfolio): + """deduct_management_fee(0.0) must equal base returns exactly.""" + base = dated_portfolio.returns + adj = dated_portfolio.deduct_management_fee(annual_fee=0.0) + pt.assert_series_equal(adj["returns"], base["returns"]) + + +def test_deduct_management_fee_positive_fee_reduces_returns(dated_portfolio): + """Positive annual_fee must strictly reduce total returns.""" + base_sum = float(dated_portfolio.returns["returns"].sum()) + adj_sum = float(dated_portfolio.deduct_management_fee(annual_fee=0.01)["returns"].sum()) + assert adj_sum < base_sum + + +def test_deduct_management_fee_first_row_unchanged(dated_portfolio): + """First row deduction must be zero (no prior date).""" + base = dated_portfolio.returns + adj = dated_portfolio.deduct_management_fee(annual_fee=0.01) + assert float(adj["returns"][0]) == pytest.approx(float(base["returns"][0]), abs=TOL_FLOAT64) + + +def test_deduct_management_fee_per_period_deduction_analytical(dated_portfolio): + """Each row (except the first) must be reduced by annual_fee * days / 365.""" + annual_fee = 0.01 + adj = dated_portfolio.deduct_management_fee(annual_fee=annual_fee) + base = dated_portfolio.returns + expected_deduction = annual_fee * 1.0 / 365.0 # consecutive calendar days → 1 day each + for i in range(1, adj.height): + actual_diff = float(base["returns"][i]) - float(adj["returns"][i]) + assert actual_diff == pytest.approx(expected_deduction, rel=TOL_COMPOUNDING) + + +def test_deduct_management_fee_sums_to_annual_fee_over_full_year(): + """Total fee deducted over 366 consecutive daily rows (365 charged days) must equal annual_fee.""" + start = date(2020, 1, 1) + # 366 rows: rows 2..366 each carry 1 day → 365 charged days summing to annual_fee + dates = pl.date_range(start=start, end=start + timedelta(days=365), interval="1d", eager=True).cast(pl.Date) + n = dates.len() + prices = pl.DataFrame({"date": dates, "A": pl.Series([100.0] * n)}) + pos = pl.DataFrame({"date": dates, "A": pl.Series([1000.0] * n)}) + pf = Portfolio(prices=prices, cashposition=pos, aum=1e5) + + annual_fee = 0.0085 + base = pf.returns + adj = pf.deduct_management_fee(annual_fee=annual_fee) + total_deducted = float((base["returns"] - adj["returns"]).sum()) + assert total_deducted == pytest.approx(annual_fee, rel=1e-9) + + +def test_deduct_management_fee_preserves_date_column(dated_portfolio): + """deduct_management_fee must preserve the date column when present.""" + adj = dated_portfolio.deduct_management_fee(annual_fee=0.01) + assert "date" in adj.columns + + +def test_deduct_management_fee_without_date_uses_one_day_per_period(undated_portfolio): + """Without a date column each period is assumed to span one calendar day.""" + annual_fee = 0.01 + adj = undated_portfolio.deduct_management_fee(annual_fee=annual_fee) + base = undated_portfolio.returns + expected_deduction = annual_fee / 365.0 + # First row unchanged + assert float(adj["returns"][0]) == pytest.approx(float(base["returns"][0]), abs=TOL_FLOAT64) + # Subsequent rows deducted by 1/365 * annual_fee + for i in range(1, adj.height): + actual_diff = float(base["returns"][i]) - float(adj["returns"][i]) + assert actual_diff == pytest.approx(expected_deduction, rel=TOL_COMPOUNDING) + + +def test_deduct_management_fee_weekend_charges_accumulated(): + """A weekend gap (Mon after Sat/Sun) must carry 3 days of fee deduction.""" + # Mon-Fri then Mon: the gap from Fri 2020-01-10 to Mon 2020-01-13 spans 3 calendar days. + dates_with_weekend_gap = [ + date(2020, 1, 6), # Mon + date(2020, 1, 7), # Tue + date(2020, 1, 8), # Wed + date(2020, 1, 9), # Thu + date(2020, 1, 10), # Fri + date(2020, 1, 13), # Mon (3 calendar days after Fri) + ] + prices = pl.DataFrame({"date": dates_with_weekend_gap, "A": [100.0] * 6}) + pos = pl.DataFrame({"date": dates_with_weekend_gap, "A": [1000.0] * 6}) + pf = Portfolio(prices=prices, cashposition=pos, aum=1e5) + + annual_fee = 0.01 + base = pf.returns + adj = pf.deduct_management_fee(annual_fee=annual_fee) + # Row 5 (2020-01-13) has 3 calendar days since 2020-01-10 + deduction_row5 = float(base["returns"][5]) - float(adj["returns"][5]) + assert deduction_row5 == pytest.approx(annual_fee * 3.0 / 365.0, rel=TOL_COMPOUNDING) + + +def test_deduct_management_fee_defaults_to_construction_annual_fee(dated_portfolio_with_fee): + """deduct_management_fee() with no argument uses self.annual_fee.""" + adj_default = dated_portfolio_with_fee.deduct_management_fee() + adj_explicit = dated_portfolio_with_fee.deduct_management_fee(annual_fee=0.01) + pt.assert_series_equal(adj_default["returns"], adj_explicit["returns"]) + + +def test_deduct_management_fee_explicit_overrides_construction_annual_fee(dated_portfolio_with_fee): + """An explicit annual_fee argument overrides self.annual_fee.""" + adj_zero = dated_portfolio_with_fee.deduct_management_fee(annual_fee=0.0) + adj_fee = dated_portfolio_with_fee.deduct_management_fee(annual_fee=0.01) + assert float(adj_zero["returns"].sum()) >= float(adj_fee["returns"].sum()) + + +def test_deduct_management_fee_annual_fee_stored_at_construction(): + """annual_fee passed to Portfolio constructor must be stored on the instance.""" + prices = pl.DataFrame({"A": [100.0, 101.0]}) + pos = pl.DataFrame({"A": [1000.0, 1000.0]}) + pf = Portfolio(prices=prices, cashposition=pos, aum=1e5, annual_fee=0.0085) + assert pf.annual_fee == pytest.approx(0.0085) + + +def test_deduct_management_fee_from_cash_position_forwards_annual_fee(): + """from_cash_position must forward annual_fee to the Portfolio instance.""" + prices = pl.DataFrame({"A": [100.0, 101.0]}) + pos = pl.DataFrame({"A": [1000.0, 1000.0]}) + pf = Portfolio.from_cash_position(prices=prices, cash_position=pos, aum=1e5, annual_fee=0.0085) + assert pf.annual_fee == pytest.approx(0.0085) + + +def test_deduct_management_fee_negative_fee_raises(dated_portfolio): + """deduct_management_fee must raise NegativeAnnualFeeError for negative annual_fee.""" + from jquantstats.exceptions import NegativeAnnualFeeError + + with pytest.raises(NegativeAnnualFeeError, match="annual_fee must be non-negative"): + dated_portfolio.deduct_management_fee(annual_fee=-0.01) + + +def test_deduct_management_fee_non_numeric_fee_raises(dated_portfolio): + """deduct_management_fee must raise TypeError for non-numeric annual_fee.""" + with pytest.raises(TypeError, match="annual_fee must be a number"): + dated_portfolio.deduct_management_fee(annual_fee="0.01") # type: ignore[arg-type] + with pytest.raises(TypeError, match="annual_fee must be a number"): + dated_portfolio.deduct_management_fee(annual_fee=True) # type: ignore[arg-type] + + +def test_deduct_management_fee_non_finite_fee_raises(dated_portfolio): + """deduct_management_fee must raise ValueError for NaN or infinite annual_fee.""" + with pytest.raises(ValueError, match="annual_fee must be finite"): + dated_portfolio.deduct_management_fee(annual_fee=float("inf")) + with pytest.raises(ValueError, match="annual_fee must be finite"): + dated_portfolio.deduct_management_fee(annual_fee=float("nan")) + + +def test_deduct_management_fee_composes_with_cost_adjusted_returns(dated_portfolio): + """deduct_management_fee(base=cost_adjusted_returns(...)) must compose linearly.""" + annual_fee = 0.0085 + cost_bps = 5.0 + base = dated_portfolio.returns + + # Apply trading costs first, then management fee + adj_cost = dated_portfolio.cost_adjusted_returns(cost_bps=cost_bps) + adj_both = dated_portfolio.deduct_management_fee(annual_fee=annual_fee, base=adj_cost) + + # Each deduction computed independently + cost_deduction = float((base["returns"] - adj_cost["returns"]).sum()) + fee_deduction = float( + (base["returns"] - dated_portfolio.deduct_management_fee(annual_fee=annual_fee)["returns"]).sum() + ) + + # Total deduction when composed must equal the sum of the independent deductions + total_deducted = float((base["returns"] - adj_both["returns"]).sum()) + assert total_deducted == pytest.approx(cost_deduction + fee_deduction, rel=TOL_COMPOUNDING)