#!/usr/bin/env python3
# coding: utf-8
""" Position sizing and transaction-cost primitives for realistic backtests.
Time-series leverage rules (Kelly, volatility targeting) and a turnover-based
transaction-cost model — the building blocks for going from a raw signal to a
net-of-cost P&L.
Main entry points
-----------------
- :func:`kelly_fraction` — (fractional) Kelly leverage from return moments.
- :func:`vol_target` — causal leverage that targets a constant volatility.
- :func:`transaction_cost` — per-step cost from weight turnover.
"""
from __future__ import annotations
# Third-party packages
import numpy as np
from numpy.typing import NDArray
# Local packages
from fynance.features.indicators import realized_volatility
__all__ = ['kelly_fraction', 'transaction_cost', 'vol_target']
[docs]
def kelly_fraction(returns: NDArray, fraction: float = 1.0) -> float:
r""" Fractional Kelly leverage from a return series.
Under a Gaussian approximation the growth-optimal leverage is
:math:`f^\star = \mu / \sigma^2`; ``fraction`` scales it down
(fractional Kelly, e.g. 0.5 for half-Kelly).
Parameters
----------
returns : array_like
Series of (arithmetic) returns.
fraction : float, optional
Multiplier on the full Kelly leverage. Default 1.0.
Returns
-------
float
Kelly leverage (0 if the variance is null).
Examples
--------
>>> import numpy as np
>>> r = np.array([0.01, -0.02, 0.03, 0.00, 0.02])
>>> round(kelly_fraction(r, fraction=0.5), 4)
13.5135
"""
returns = np.asarray(returns, dtype=np.float64).ravel()
var = returns.var(ddof=0)
if var <= 0:
return 0.0
return float(fraction * returns.mean() / var)
[docs]
def vol_target(
X: NDArray,
target_vol: float = 0.15,
period: int = 252,
w: int = 21,
max_leverage: float = 5.0,
) -> NDArray:
r""" Causal volatility-targeting leverage series.
Leverage that scales inversely with the *past* realized volatility so the
strategy targets a constant annualized volatility ``target_vol``:
:math:`\\ell_t = target\\_vol / \\hat\\sigma_t`, capped at ``max_leverage``.
Strictly causal — uses :func:`fynance.features.indicators.realized_volatility`.
Parameters
----------
X : array_like
Price/level series.
target_vol : float, optional
Target annualized volatility. Default 0.15.
period : int, optional
Annualization factor. Default 252.
w : int, optional
Rolling window for the realized volatility. Default 21.
max_leverage : float, optional
Cap on the leverage. Default 5.0.
Returns
-------
np.ndarray
Leverage series aligned to ``X`` (0 where volatility is not yet defined).
"""
X = np.asarray(X, dtype=np.float64)
vol = np.asarray(realized_volatility(X, w=w, period=period))
with np.errstate(divide='ignore', invalid='ignore'):
lev = np.where(vol > 0, target_vol / vol, 0.0)
return np.clip(lev, 0.0, max_leverage)
[docs]
def transaction_cost(
weights: NDArray, fee: float = 0.001, axis: int = 0,
) -> NDArray:
r""" Per-step transaction cost from weight turnover.
Cost at each step is ``fee`` times the traded amount (turnover):
:math:`c_t = fee \\cdot \\sum_i |w_{t,i} - w_{t-1,i}|`, with the first step
charging the initial position.
Parameters
----------
weights : array_like
Portfolio weights over time, shape ``(T,)`` or ``(T, n_assets)``.
fee : float, optional
Proportional cost per unit traded (e.g. 0.001 = 10 bps). Default 0.001.
axis : {0, 1}, optional
Time axis. Default 0.
Returns
-------
np.ndarray
Cost per step, shape ``(T,)``.
Examples
--------
>>> import numpy as np
>>> w = np.array([[1.0, 0.0], [0.5, 0.5], [0.5, 0.5]])
>>> transaction_cost(w, fee=0.01)
array([0.01, 0.01, 0. ])
"""
w = np.asarray(weights, dtype=np.float64)
if axis == 1:
w = w.T
if w.ndim == 1:
w = w.reshape(-1, 1)
turnover = np.empty(w.shape[0])
turnover[0] = np.abs(w[0]).sum()
turnover[1:] = np.abs(np.diff(w, axis=0)).sum(axis=1)
return fee * turnover