#!/usr/bin/env python3
# coding: utf-8
""" Trade-level analytics — round-trip extraction and summary statistics.
Where :mod:`fynance.metrics.trading` describes *churn* (how often the position
flips), this module describes individual **round-trip trades**: it segments a
position series into maximal runs of constant nonzero sign and reports, per
run, the entry/exit bars, side and compounded net return. :func:`trade_summary`
then aggregates a set of such trades into the usual trading-desk statistics
(win rate, profit factor, streaks, ...).
"""
from __future__ import annotations
# Third-party packages
import numpy as np
from numba import njit
from numpy.typing import NDArray
__all__ = ['extract_trades', 'trade_summary']
#: Structured dtype of the array returned by :func:`extract_trades`.
TRADE_DTYPE = np.dtype([
('asset', np.int16), ('t_in', np.int64), ('t_out', np.int64),
('side', np.int8), ('ret', np.float64), ('bars', np.int64),
])
# --------------------------------------------------------------------------- #
# numba kernel #
# --------------------------------------------------------------------------- #
@njit(cache=True)
def _extract_trades_kernel(positions, returns):
""" Scan one column and extract its maximal constant-sign runs.
A run breaks whenever ``sign(positions[t])`` changes -- including a
transition through/from ``0`` (flat) -- so a direct long -> short flip
with no flat bar in between yields two immediately adjacent trades (the
first's ``t_out`` is the bar right before the second's ``t_in``), rather
than being merged or skipped. ``NaN`` compares false to both ``> 0`` and
``< 0``, so a ``NaN`` position is treated as flat (breaks any open run).
A run still open at the last bar is closed there and returned as-is
(see the ``extract_trades`` docstring).
"""
T = positions.shape[0]
t_in_out = np.empty(T, dtype=np.int64)
t_out_out = np.empty(T, dtype=np.int64)
side_out = np.empty(T, dtype=np.int8)
ret_out = np.empty(T, dtype=np.float64)
bars_out = np.empty(T, dtype=np.int64)
n = 0
in_trade = False
cur_sign = np.int8(0)
cur_t_in = 0
compounded = 1.0
for t in range(T):
s = np.int8(0)
if positions[t] > 0:
s = np.int8(1)
elif positions[t] < 0:
s = np.int8(-1)
if in_trade and s == cur_sign:
compounded *= (1.0 + positions[t] * returns[t])
else:
if in_trade:
t_in_out[n] = cur_t_in
t_out_out[n] = t - 1
side_out[n] = cur_sign
ret_out[n] = compounded - 1.0
bars_out[n] = (t - 1) - cur_t_in + 1
n += 1
in_trade = False
if s != 0:
in_trade = True
cur_sign = s
cur_t_in = t
compounded = 1.0 + positions[t] * returns[t]
if in_trade:
t_in_out[n] = cur_t_in
t_out_out[n] = T - 1
side_out[n] = cur_sign
ret_out[n] = compounded - 1.0
bars_out[n] = (T - 1) - cur_t_in + 1
n += 1
return t_in_out[:n], t_out_out[:n], side_out[:n], ret_out[:n], bars_out[:n]
# --------------------------------------------------------------------------- #
# public API #
# --------------------------------------------------------------------------- #
def _max_streak(mask: NDArray) -> int:
""" Longest run of consecutive ``True`` values in a 1-D boolean array. """
best = run = 0
for v in mask:
if v:
run += 1
best = max(best, run)
else:
run = 0
return best
[docs]
def trade_summary(trades: NDArray) -> dict[str, float]:
r""" Summary statistics of a set of round-trip trades.
Parameters
----------
trades : np.ndarray[TRADE_DTYPE, ndim=1]
Output of :func:`extract_trades` (or any array sharing its dtype and
field semantics). ``max_win_streak`` / ``max_loss_streak`` are
computed over ``trades`` in the order given, so pass a single
asset's trades (already time-ordered) for a meaningful streak --
e.g. ``trades[trades['asset'] == k]``.
Returns
-------
dict of str to float
- ``n_trades`` -- number of trades.
- ``win_rate`` -- share of trades with ``ret > 0`` (of all trades,
not just wins + losses).
- ``profit_factor`` -- ``sum(wins) / abs(sum(losses))``; ``inf`` if
there are winning trades and no losing ones; ``NaN`` if there are
no trades or none with a nonzero return either way.
- ``avg_win``, ``avg_loss`` -- mean ``ret`` of winning / losing
trades (``avg_loss`` is negative); ``NaN`` if that side is empty.
- ``payoff_ratio`` -- ``avg_win / abs(avg_loss)``; ``NaN`` unless
both sides are non-empty.
- ``expectancy`` -- mean ``ret`` over *all* trades.
- ``max_win_streak``, ``max_loss_streak`` -- longest run of
consecutive winning / losing trades (a breakeven ``ret == 0``
trade breaks both streaks).
- ``mean_bars``, ``median_bars`` -- mean / median holding period.
``trades`` empty: ``n_trades``, ``win_rate``, ``max_win_streak`` and
``max_loss_streak`` are ``0.0``; every other field is ``NaN`` (there
is nothing to average).
Examples
--------
>>> import numpy as np
>>> positions = np.array([1.0, 1.0, -1.0, -1.0, 0.0, 1.0])
>>> returns = np.array([0.5, 0.5, 0.5, -0.5, 0.0, 0.25])
>>> trades = extract_trades(positions, returns)
>>> s = trade_summary(trades)
>>> s['n_trades'], s['win_rate']
(3.0, 0.6666666666666666)
>>> round(s['profit_factor'], 4)
6.0
>>> s['max_win_streak'], s['max_loss_streak']
(1.0, 1.0)
An empty set of trades reports zero counts and ``NaN`` averages:
>>> empty = extract_trades(np.zeros(4), np.zeros(4))
>>> trade_summary(empty)['n_trades'], trade_summary(empty)['profit_factor']
(0.0, nan)
See Also
--------
extract_trades
"""
n = trades.shape[0]
if n == 0:
return {
'n_trades': 0.0,
'win_rate': 0.0,
'profit_factor': float('nan'),
'avg_win': float('nan'),
'avg_loss': float('nan'),
'payoff_ratio': float('nan'),
'expectancy': float('nan'),
'max_win_streak': 0.0,
'max_loss_streak': 0.0,
'mean_bars': float('nan'),
'median_bars': float('nan'),
}
ret = trades['ret'].astype(np.float64)
bars = trades['bars'].astype(np.float64)
win_mask = ret > 0.0
loss_mask = ret < 0.0
wins = ret[win_mask]
losses = ret[loss_mask]
sum_wins = float(wins.sum()) if wins.size else 0.0
sum_losses = float(losses.sum()) if losses.size else 0.0
abs_losses = abs(sum_losses)
if abs_losses > 0.0:
profit_factor = sum_wins / abs_losses
elif sum_wins > 0.0:
profit_factor = float('inf')
else:
profit_factor = float('nan')
avg_win = float(wins.mean()) if wins.size else float('nan')
avg_loss = float(losses.mean()) if losses.size else float('nan')
payoff_ratio = (
avg_win / abs(avg_loss) if wins.size and losses.size else float('nan')
)
return {
'n_trades': float(n),
'win_rate': float(win_mask.sum()) / n,
'profit_factor': profit_factor,
'avg_win': avg_win,
'avg_loss': avg_loss,
'payoff_ratio': payoff_ratio,
'expectancy': float(ret.mean()),
'max_win_streak': float(_max_streak(win_mask)),
'max_loss_streak': float(_max_streak(loss_mask)),
'mean_bars': float(bars.mean()),
'median_bars': float(np.median(bars)),
}
if __name__ == '__main__':
import doctest
doctest.testmod()