Source code for fynance.backtest.cost

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

""" Transaction cost models for the vectorized backtest engine.

Concretizes the :class:`~fynance.core.protocols.CostModel` seam. Four models
ship: :class:`ProportionalCost` (linear, turnover-based), :class:`MarketImpactCost`
(adds a convex, super-linear market-impact term — the square-root impact law),
:class:`HoldingCost` (per-bar carry on the held book: borrow, financing and a
cash credit) and :class:`CompositeCost` (stacks any number of the above, or
any other :class:`~fynance.core.protocols.CostModel`-conforming model, into
one).

"""

from __future__ import annotations

# Built-in packages
from typing import Sequence

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

# Local packages
from fynance.core.protocols import CostModel
from fynance.portfolio.sizing import transaction_cost

__all__ = ['ProportionalCost', 'MarketImpactCost', 'HoldingCost', 'CompositeCost']


[docs] class ProportionalCost: """ Proportional transaction cost: ``(fee + slippage) * turnover``. Turnover at each step is the absolute traded weight :math:`\\sum_i |w_{t,i} - w_{t-1,i}|` (the first step charges the initial position). Conforms to :class:`~fynance.core.protocols.CostModel`. Parameters ---------- fee : float Proportional fee per unit traded (e.g. ``0.001`` = 10 bps). slippage : float Additional proportional slippage per unit traded. Examples -------- >>> import numpy as np >>> cost = ProportionalCost(fee=0.01) >>> cost(np.array([[1.0, 0.0], [0.5, 0.5], [0.5, 0.5]])) array([0.01, 0.01, 0. ]) """ def __init__(self, fee: float = 0.0, slippage: float = 0.0): """ Store the cost rates. """ self.fee = fee self.slippage = slippage
[docs] def __call__(self, weights: NDArray) -> NDArray[np.float64]: """ Return the per-step proportional cost of a weight book. """ rate = self.fee + self.slippage if rate == 0.0: w = np.asarray(weights, dtype=np.float64) return np.zeros(w.shape[0], dtype=np.float64) return transaction_cost(weights, fee=rate)
[docs] def components(self, weights: NDArray) -> dict[str, NDArray[np.float64]]: """ Per-step cost broken down by component (here a single one). Returns ``{"transaction": fee+slippage turnover cost}``; the values sum to :meth:`__call__`. The optional ``components`` convention lets the engine carry a cost breakdown for the tearsheet's cumulative-fees panel. """ return {"transaction": self(weights)}
[docs] class MarketImpactCost: """ Non-linear market-impact cost: linear fee + convex impact term. Charges, per step, a proportional fee plus a **super-linear** market-impact term on the same turnover (the standard square-root impact law, where the cost grows faster than the trade size): .. math:: c_t = fee \\cdot \\tau_t + impact \\cdot \\tau_t^{\\,exponent} where the turnover :math:`\\tau_t = \\sum_i |w_{t,i} - w_{t-1,i}|` is defined exactly as in :class:`ProportionalCost` (the first step charges the initial position). With ``exponent=1`` and ``impact=0`` it reduces to :class:`ProportionalCost`. Conforms to :class:`~fynance.core.protocols.CostModel`. Parameters ---------- fee : float Proportional (linear) fee per unit traded (e.g. ``0.001`` = 10 bps). impact : float Coefficient of the convex impact term, in the same units as ``fee``. exponent : float Convexity exponent of the impact term (``> 1`` is super-linear). Default ``1.5`` (the square-root impact law: cost per unit traded grows as :math:`\\sqrt{\\tau}`). Examples -------- >>> import numpy as np >>> cost = MarketImpactCost(fee=0.0, impact=0.1, exponent=2.0) >>> cost(np.array([[1.0, 0.0], [0.0, 1.0], [0.0, 1.0]])) array([0.1, 0.4, 0. ]) """ def __init__( self, fee: float = 0.0, impact: float = 0.0, exponent: float = 1.5, ): """ Store the cost rates and the impact convexity exponent. """ if exponent <= 0: raise ValueError(f"exponent must be positive, got {exponent}") self.fee = fee self.impact = impact self.exponent = exponent
[docs] def __call__(self, weights: NDArray) -> NDArray[np.float64]: """ Return the per-step (linear + convex impact) cost of a weight book. """ # transaction_cost with unit fee returns the raw per-step turnover. turnover = transaction_cost(weights, fee=1.0) return self.fee * turnover + self.impact * turnover ** self.exponent
[docs] def components(self, weights: NDArray) -> dict[str, NDArray[np.float64]]: """ Per-step cost split into its linear and convex parts. Returns ``{"transaction": fee turnover, "market_impact": convex term}``; the values sum to :meth:`__call__`. The optional ``components`` convention lets the engine carry a cost breakdown for the tearsheet's cumulative-fees panel. """ turnover = transaction_cost(weights, fee=1.0) return { "transaction": self.fee * turnover, "market_impact": self.impact * turnover ** self.exponent, }
[docs] class HoldingCost: r""" Per-bar carry cost on the held book (not turnover). Unlike :class:`ProportionalCost` and :class:`MarketImpactCost`, which charge on *trades* (the change between consecutive weights), :class:`HoldingCost` charges on the *held* weights at each bar: shorting, leverage and idle cash all carry a cost — or, for idle cash, a credit — for every bar the position is held, regardless of turnover. Three additive terms, each de-annualized to a per-bar rate (``rate / period``): - **borrow**: cost of the short gross exposure :math:`\sum_i \max(-w_{t,i}, 0)`, at the annualized ``borrow`` rate. - **financing**: cost of leverage in excess of fully invested (total gross exposure above 1), :math:`\max\big(0, \sum_i |w_{t,i}| - 1\big)`, at the annualized ``financing`` rate. - **cash**: a *credit* (non-positive) on unused cash, :math:`\max\big(0, 1 - \sum_i |w_{t,i}|\big)`, at the annualized ``cash_rate``. .. math:: c_t = \frac{borrow}{period} \sum_i \max(-w_{t,i}, 0) + \frac{financing}{period} \max\Big(0, \sum_i |w_{t,i}| - 1\Big) - \frac{cash\_rate}{period} \max\Big(0, 1 - \sum_i |w_{t,i}|\Big) The total cost at each bar is the sum of the three terms; see :meth:`components` for the per-term breakdown. Conforms to :class:`~fynance.core.protocols.CostModel`. Parameters ---------- borrow : float Annualized borrow rate charged on short gross exposure (e.g. ``0.02`` = 2%/yr). Default 0.0. financing : float Annualized financing rate charged on leverage above 1x (gross exposure in excess of fully invested). Default 0.0. cash_rate : float Annualized rate credited on unused cash (idle capital not deployed into any position). Default 0.0. period : int Number of bars per year used to de-annualize the rates (e.g. ``252`` for daily bars, ``12`` for monthly). Default 252. Examples -------- >>> import numpy as np >>> cost = HoldingCost(borrow=0.02, financing=0.01, cash_rate=0.005, period=250) >>> w = np.array([[0.5, 0.0], [-0.5, 1.5], [0.0, 0.0]]) >>> cost(w) array([-1.e-05, 8.e-05, -2.e-05]) """ def __init__( self, borrow: float = 0.0, financing: float = 0.0, cash_rate: float = 0.0, period: int = 252, ): """ Store the annualized rates and the de-annualization period. """ self.borrow = borrow self.financing = financing self.cash_rate = cash_rate self.period = period def _exposures(self, weights: NDArray) -> tuple[NDArray, NDArray]: """ Return per-bar (short gross, total gross) exposure. """ w = np.asarray(weights, dtype=np.float64) if w.ndim == 1: return np.maximum(-w, 0.0), np.abs(w) return np.maximum(-w, 0.0).sum(axis=1), np.abs(w).sum(axis=1)
[docs] def __call__(self, weights: NDArray) -> NDArray[np.float64]: """ Return the per-step (borrow + financing - cash credit) cost. """ comps = self.components(weights) return comps["borrow"] + comps["financing"] + comps["cash"]
[docs] def components(self, weights: NDArray) -> dict[str, NDArray[np.float64]]: """ Per-step cost split into its borrow/financing/cash terms. Returns ``{"borrow", "financing", "cash"}``; the values sum to :meth:`__call__`. ``cash`` entries are non-positive (a credit on idle capital). The optional ``components`` convention lets the engine carry a cost breakdown for the tearsheet's cumulative-fees panel. """ short, gross = self._exposures(weights) return { "borrow": (self.borrow / self.period) * short, "financing": (self.financing / self.period) * np.maximum(0.0, gross - 1.0), "cash": -(self.cash_rate / self.period) * np.maximum(0.0, 1.0 - gross), }
[docs] class CompositeCost: """ Stack several cost models into one. Sums the per-step cost of every model in ``models`` (each of which must conform to :class:`~fynance.core.protocols.CostModel`), so a single :class:`CompositeCost` instance can be passed to :func:`~fynance.backtest.engine.backtest` wherever one ``cost`` model is expected. Conforms to :class:`~fynance.core.protocols.CostModel` itself. Parameters ---------- models : sequence of CostModel The cost models to stack, in the order their components are merged by :meth:`components`. Examples -------- >>> import numpy as np >>> cost = CompositeCost( ... [ProportionalCost(fee=0.001), HoldingCost(borrow=0.02, period=250)] ... ) >>> w = np.array([[1.0, 0.0], [-0.5, 0.5], [-0.5, 0.5]]) >>> cost(w) array([1.00e-03, 2.04e-03, 4.00e-05]) """ def __init__(self, models: Sequence[CostModel]): """ Store the sequence of cost models to stack. """ self.models = list(models)
[docs] def __call__(self, weights: NDArray) -> NDArray[np.float64]: """ Return the per-step cost summed over every stacked model. """ w = np.asarray(weights, dtype=np.float64) n = w.shape[0] total = np.zeros(n, dtype=np.float64) for model in self.models: total = total + np.asarray(model(weights), dtype=np.float64) return total
[docs] def components(self, weights: NDArray) -> dict[str, NDArray[np.float64]]: """ Merge the per-step components of every stacked model. Each model contributes its own :meth:`components` breakdown (or, if a model does not expose ``components``, its total under a key equal to its class name). Component keys are merged in ``models`` order; when a key already produced by an earlier model recurs, the *later* occurrence is disambiguated by prefixing it with its owning class name, e.g. ``"transaction"`` then ``"MarketImpactCost.transaction"``. A third collision on the same class+key (e.g. several stacked ``ProportionalCost``) is further disambiguated by the model's index, ``"ProportionalCost[2].transaction"``, so no contribution is ever overwritten. The merged values always sum to :meth:`__call__`. """ merged: dict[str, NDArray[np.float64]] = {} for i, model in enumerate(self.models): cls_name = type(model).__name__ if hasattr(model, "components"): comps = model.components(weights) else: comps = {cls_name: model(weights)} for key, value in comps.items(): out_key = key if out_key in merged: out_key = f"{cls_name}.{key}" if out_key in merged: out_key = f"{cls_name}[{i}].{key}" merged[out_key] = np.asarray(value, dtype=np.float64) return merged