Source code for fynance.backtest.capacity

#!/usr/bin/env python3
# coding: utf-8

""" Capacity analysis: net Sharpe vs AUM, and the breakeven fee.

Two questions a strategy owner asks before sizing up: how does performance
degrade as more capital is deployed (:func:`capacity_curve`), and how large a
proportional fee can the strategy absorb before it stops being worth trading
(:func:`breakeven_fee`)? Both reuse :func:`~fynance.backtest.engine.backtest`
unchanged — capacity is a sweep over cost models, not a new engine.

"""

from __future__ import annotations

# Built-in packages
from typing import Any, Callable

# Third-party packages
import numpy as np
from numpy.typing import NDArray

# Local packages
from fynance.backtest.cost import ProportionalCost
from fynance.backtest.engine import backtest

__all__ = ['capacity_curve', 'breakeven_fee']

#: Fee cap (proportional, per unit traded) above which :func:`breakeven_fee`
#: gives up and reports the strategy has no breakeven fee below it.
_FEE_CAP = 0.10


[docs] def capacity_curve( weights: Any, X: Any, aums: Any, cost_factory: Callable[[float], Any], period: int = 252, ) -> dict[str, NDArray[np.float64]]: """ Sweep net performance across a book of AUM levels. Runs :func:`~fynance.backtest.engine.backtest` once per AUM level, each time with the cost model ``cost_factory`` builds for that level. The caller encodes how cost should scale with size (e.g. a market-impact coefficient growing with the square root of AUM) inside ``cost_factory``; this function only orchestrates the sweep and collects the results. Parameters ---------- weights : array-like Position/weight book, shape ``(T,)`` for a single asset or ``(T, N)`` for a multi-asset book. X : array-like Price levels aligned with ``weights`` (same shape); passed to :func:`~fynance.backtest.engine.backtest` as ``returns_input=False``. aums : array-like 1-D array of AUM levels to sweep, typically increasing. cost_factory : callable Maps an AUM level (float) to a cost model (e.g. :class:`~fynance.backtest.cost.MarketImpactCost`) conforming to :class:`~fynance.core.protocols.CostModel`. period : int Annualization factor forwarded to :meth:`~fynance.backtest.result.BacktestResult.summary`. Returns ------- dict of str to numpy.ndarray ``aum``, ``net_sharpe``, ``net_annual_return`` and ``total_cost``, one entry per AUM level, aligned with ``aums``. Raises ------ ValueError If ``aums`` is not 1-D, or ``weights`` and ``X`` do not share the same shape. Examples -------- A single-asset strategy with a small planted edge (``0.06%`` of the signal per bar) and turnover from a noisy signal, swept across AUM levels with a square-root market-impact factory: net Sharpe degrades monotonically as impact grows with size. >>> import numpy as np >>> from fynance.backtest.cost import MarketImpactCost >>> rng = np.random.default_rng(0) >>> steps = np.cumsum(rng.normal(0.0, 0.07, size=300)) >>> weights = np.clip(steps, -1.0, 1.0) >>> noise = rng.normal(0.0, 0.01, size=300) >>> returns = np.empty(300) >>> returns[0] = noise[0] >>> returns[1:] = 0.0006 * weights[:-1] + noise[1:] >>> prices = 100.0 * np.cumprod(1.0 + returns) >>> aums = np.array([1e5, 1e7, 1e9]) >>> cost_factory = lambda aum: MarketImpactCost( ... impact=0.0017 * np.sqrt(aum / 1e6), exponent=1.5 ... ) >>> out = capacity_curve(weights, prices, aums, cost_factory) >>> sorted(out) ['aum', 'net_annual_return', 'net_sharpe', 'total_cost'] >>> bool(np.all(np.diff(out['net_sharpe']) <= 1e-9)) True """ w = np.asarray(weights, dtype=np.float64) prices = np.asarray(X, dtype=np.float64) aum_arr = np.asarray(aums, dtype=np.float64) if aum_arr.ndim != 1: raise ValueError(f"aums must be 1-D, got shape {aum_arr.shape}") if w.shape != prices.shape: raise ValueError( f"weights shape {w.shape} != X shape {prices.shape}" ) n = aum_arr.shape[0] net_sharpe = np.empty(n, dtype=np.float64) net_annual_return = np.empty(n, dtype=np.float64) total_cost = np.empty(n, dtype=np.float64) for i, aum in enumerate(aum_arr): cost = cost_factory(float(aum)) res = backtest(prices, w, cost=cost, returns_input=False) stats = res.summary(period=period) net_sharpe[i] = stats["sharpe"] net_annual_return[i] = stats["annual_return"] total_cost[i] = stats["total_cost"] return { "aum": aum_arr, "net_sharpe": net_sharpe, "net_annual_return": net_annual_return, "total_cost": total_cost, }
[docs] def breakeven_fee( weights: Any, X: Any, period: int = 252, tol: float = 1e-6, ) -> float: """ Proportional fee at which the net Sharpe ratio crosses zero. Bisects on a :class:`~fynance.backtest.cost.ProportionalCost` fee: the bracket's upper bound is doubled from a small seed until the net Sharpe turns non-positive, capped at :data:`_FEE_CAP` (10% per trade traded). Parameters ---------- weights : array-like Position/weight book, shape ``(T,)`` or ``(T, N)``. X : array-like Price levels aligned with ``weights``; passed to :func:`~fynance.backtest.engine.backtest` as ``returns_input=False``. period : int Annualization factor for the Sharpe ratio. tol : float Bisection tolerance on the fee (absolute). Returns ------- float The proportional fee at which the net Sharpe ratio is (approximately) zero. Raises ------ ValueError If the gross (fee=0) Sharpe ratio is already non-positive (message ``'strategy unprofitable gross'``), or if the net Sharpe ratio is still positive at the 10%-per-trade cap (message ``'no breakeven below 10%'``). Examples -------- Same planted-edge, noisy-turnover strategy as in :func:`capacity_curve`: a proportional fee somewhere below the 10%-per-trade cap wipes out the net Sharpe ratio. >>> import numpy as np >>> rng = np.random.default_rng(0) >>> steps = np.cumsum(rng.normal(0.0, 0.07, size=1000)) >>> weights = np.clip(steps, -1.0, 1.0) >>> noise = rng.normal(0.0, 0.01, size=1000) >>> returns = np.empty(1000) >>> returns[0] = noise[0] >>> returns[1:] = 0.0006 * weights[:-1] + noise[1:] >>> prices = 100.0 * np.cumprod(1.0 + returns) >>> fee = breakeven_fee(weights, prices) >>> bool(0.0 < fee < 0.10) True """ def _sharpe(fee: float) -> float: cost = ProportionalCost(fee=fee) if fee > 0.0 else None res = backtest(X, weights, cost=cost, returns_input=False) return float(res.summary(period=period)["sharpe"]) gross_sharpe = _sharpe(0.0) if gross_sharpe <= 0.0: raise ValueError("strategy unprofitable gross") lo, hi = 0.0, 1e-4 while _sharpe(hi) > 0.0: if hi >= _FEE_CAP: raise ValueError("no breakeven below 10%") hi = min(2.0 * hi, _FEE_CAP) while hi - lo > tol: mid = 0.5 * (lo + hi) if _sharpe(mid) > 0.0: lo = mid else: hi = mid return 0.5 * (lo + hi)