#!/usr/bin/env python3
# coding: utf-8
""" Trading-profile metrics — churn of a position series or book.
These describe *how* a strategy trades (turnover of direction) rather than how
its equity curve performs, so — like :func:`information_coefficient` — they take
a **position** series, not an equity curve, and are intentionally kept out of
the ``METRICS`` registry.
"""
from __future__ import annotations
# Third-party packages
import numpy as np
from numpy.typing import NDArray
__all__ = [
'annual_turnover',
'exposure_summary',
'gross_exposure',
'net_exposure',
'sign_changes',
'trades_per_year',
'turnover_series',
]
def sign_changes(positions: NDArray, *, axis: int = 0) -> NDArray | int:
r""" Number of position sign changes (long <-> flat <-> short).
Counts the steps where ``sign(pos_t) != sign(pos_{t-1})`` along the time
``axis`` — the round-trip churn a turnover-blind ``total_cost`` hides. Flat
(``0``) is a distinct state, so ``long -> flat`` and ``flat -> short`` each
count as one change. Pairs straddling a ``NaN`` are not counted.
Parameters
----------
positions : array_like
Position / weight series, shape ``(T,)`` or ``(T, n_assets)``.
axis : int, optional
Time axis. Default 0.
Returns
-------
int or numpy.ndarray
Total count for a 1-D series; a per-asset count vector for a 2-D book.
Examples
--------
>>> import numpy as np
>>> sign_changes(np.array([1.0, 1.0, -1.0, -1.0, 0.0, 1.0]))
3
>>> sign_changes(np.array([[1.0, 0.0], [-1.0, 0.0], [-1.0, 1.0]]))
array([1, 1])
"""
s = np.sign(np.asarray(positions, dtype=np.float64))
t = s.shape[axis]
if t < 2:
return 0 if s.ndim == 1 else np.zeros(s.shape[1 - axis], dtype=int)
prev = np.take(s, np.arange(t - 1), axis=axis)
cur = np.take(s, np.arange(1, t), axis=axis)
changed = (prev != cur) & ~(np.isnan(prev) | np.isnan(cur))
out = changed.sum(axis=axis)
return int(out) if np.ndim(out) == 0 else out.astype(int)
def trades_per_year(positions: NDArray, period: int = 252, *,
axis: int = 0) -> NDArray | float:
r""" Annualized number of position sign changes.
:func:`sign_changes` scaled to a yearly rate, ``n_changes / T * period``, so
two strategies sampled at different frequencies stay comparable.
Parameters
----------
positions : array_like
Position / weight series, shape ``(T,)`` or ``(T, n_assets)``.
period : int, optional
Annualization factor (bars per year, 252 for daily). Default 252.
axis : int, optional
Time axis. Default 0.
Returns
-------
float or numpy.ndarray
Annualized rate for a 1-D series; a per-asset vector for a 2-D book.
Examples
--------
>>> import numpy as np
>>> pos = np.array([1.0, -1.0, 1.0, -1.0]) # flips direction every bar
>>> float(trades_per_year(pos, period=252))
189.0
"""
p = np.asarray(positions, dtype=np.float64)
t = p.shape[axis]
sc = sign_changes(p, axis=axis)
if t < 1:
return 0.0 if p.ndim == 1 else np.zeros(p.shape[1 - axis])
rate = np.asarray(sc, dtype=np.float64) / t * period
return float(rate) if np.ndim(rate) == 0 else rate
def _as_book(W: NDArray) -> NDArray:
""" Coerce to a 2-D ``(T, N)`` book (a 1-D series becomes a single column). """
w = np.asarray(W, dtype=np.float64)
return w if w.ndim == 2 else w.reshape(w.shape[0], -1)
[docs]
def turnover_series(W: NDArray) -> NDArray:
r""" One-way turnover per bar, :math:`\sum_i |w_{t,i} - w_{t-1,i}|`.
The first bar charges the initial position from flat (:math:`|w_{0,i}|`),
same convention as :func:`fynance.portfolio.sizing.transaction_cost` — in
fact ``turnover_series(W) * fee == transaction_cost(W, fee)`` exactly,
:func:`turnover_series` is just the fee-free churn underneath it.
Parameters
----------
W : array_like
Weights held at each step, shape ``(T,)`` or ``(T, N)``. A 1-D input
is reshaped to ``(T, 1)``.
Returns
-------
numpy.ndarray
Turnover per bar, shape ``(T,)``.
Examples
--------
>>> import numpy as np
>>> W = np.array([[1.0, 0.0], [0.5, -0.5], [-1.0, -1.0], [0.0, 0.0]])
>>> turnover_series(W)
array([1., 1., 2., 2.])
See Also
--------
annual_turnover : same churn, annualized to a single rate.
fynance.portfolio.sizing.transaction_cost : the fee-weighted counterpart.
"""
w = _as_book(W)
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 turnover
[docs]
def annual_turnover(W: NDArray, period: int = 252) -> float:
r""" Annualized one-way turnover, ``mean(turnover_series(W)) * period``.
Parameters
----------
W : array_like
Weights held at each step, shape ``(T,)`` or ``(T, N)``. A 1-D input
is reshaped to ``(T, 1)``.
period : int, optional
Annualization factor (bars per year, 252 for daily). Default 252.
Returns
-------
float
Annualized turnover rate.
Examples
--------
>>> import numpy as np
>>> W = np.array([[1.0, 0.0], [0.5, -0.5], [-1.0, -1.0], [0.0, 0.0]])
>>> annual_turnover(W, period=252)
378.0
See Also
--------
turnover_series : the underlying per-bar turnover.
"""
return float(np.mean(turnover_series(W)) * period)
[docs]
def gross_exposure(W: NDArray) -> NDArray:
r""" Gross exposure per bar, :math:`\sum_i |w_{t,i}|` (total book leverage).
Parameters
----------
W : array_like
Weights held at each step, shape ``(T,)`` or ``(T, N)``. A 1-D input
is reshaped to ``(T, 1)``.
Returns
-------
numpy.ndarray
Gross exposure per bar, shape ``(T,)``.
Examples
--------
>>> import numpy as np
>>> W = np.array([[1.0, 0.0], [0.5, -0.5], [-1.0, -1.0], [0.0, 0.0]])
>>> gross_exposure(W)
array([1., 1., 2., 0.])
See Also
--------
net_exposure : the signed (long/short bias) counterpart.
"""
return np.abs(_as_book(W)).sum(axis=1)
[docs]
def net_exposure(W: NDArray) -> NDArray:
r""" Net exposure per bar, :math:`\sum_i w_{t,i}` (long/short bias).
Parameters
----------
W : array_like
Weights held at each step, shape ``(T,)`` or ``(T, N)``. A 1-D input
is reshaped to ``(T, 1)``.
Returns
-------
numpy.ndarray
Net exposure per bar, shape ``(T,)``. Positive is net long, negative
net short, zero flat (fully hedged or no position).
Examples
--------
>>> import numpy as np
>>> W = np.array([[1.0, 0.0], [0.5, -0.5], [-1.0, -1.0], [0.0, 0.0]])
>>> net_exposure(W)
array([ 1., 0., -2., 0.])
See Also
--------
gross_exposure : the unsigned (total leverage) counterpart.
"""
return _as_book(W).sum(axis=1)
[docs]
def exposure_summary(W: NDArray, period: int = 252) -> dict:
r""" One-shot summary of a book's turnover and exposure.
Combines :func:`annual_turnover`, :func:`gross_exposure` and
:func:`net_exposure` into the small set of numbers that describe how a
book trades (churn) and sits (leverage, long/short bias) — the position
-level analogue of :func:`fynance.metrics.summary.summary` for an equity
curve.
Parameters
----------
W : array_like
Weights held at each step, shape ``(T,)`` or ``(T, N)``. A 1-D input
is reshaped to ``(T, 1)``.
period : int, optional
Annualization factor (bars per year, 252 for daily). Default 252.
Returns
-------
dict
``{'annual_turnover', 'mean_gross', 'max_gross', 'mean_net',
'min_net', 'max_net', 'pct_long', 'pct_short', 'pct_flat'}``; the
``pct_*`` entries are the percentage of bars with net exposure
``> 0``, ``< 0`` and ``== 0`` respectively (they sum to 100).
Examples
--------
>>> import numpy as np
>>> W = np.array([[1.0, 0.0], [0.5, -0.5], [-1.0, -1.0], [0.0, 0.0]])
>>> out = exposure_summary(W, period=252)
>>> out['annual_turnover'], out['mean_gross'], out['mean_net']
(378.0, 1.0, -0.25)
>>> out['pct_long'], out['pct_short'], out['pct_flat']
(25.0, 25.0, 50.0)
See Also
--------
annual_turnover : the churn entry alone.
gross_exposure : the per-bar gross exposure series.
net_exposure : the per-bar net exposure series.
"""
gross = gross_exposure(W)
net = net_exposure(W)
n = net.shape[0]
return {
'annual_turnover': annual_turnover(W, period=period),
'mean_gross': float(np.mean(gross)),
'max_gross': float(np.max(gross)),
'mean_net': float(np.mean(net)),
'min_net': float(np.min(net)),
'max_net': float(np.max(net)),
'pct_long': float(np.sum(net > 0) / n * 100.0),
'pct_short': float(np.sum(net < 0) / n * 100.0),
'pct_flat': float(np.sum(net == 0) / n * 100.0),
}