Files
ferro-ta/python/ferro_ta/analysis/attribution.py
T
2026-03-23 23:34:28 +05:30

348 lines
11 KiB
Python

"""
ferro_ta.attribution — Performance attribution and trade analysis.
=================================================================
Compute trade-level statistics and attribute equity-curve performance to
individual signals or time periods. Designed to work with the output of
``ferro_ta.backtest.backtest()``.
Functions
---------
trade_stats(pnl, hold_bars)
Compute win rate, avg win/loss, profit factor, and avg hold duration.
from_backtest(result)
Extract the trade list (PnL per trade, hold duration) from a
:class:`~ferro_ta.backtest.BacktestResult`.
attribution_by_month(bar_returns, timestamps)
Attribute per-bar returns to calendar months.
attribution_by_signal(bar_returns, signal_labels)
Attribute per-bar returns to signal labels.
TradeStats
Named-tuple-style result container returned by ``trade_stats``.
Rust backend
------------
ferro_ta._ferro_ta.trade_stats
ferro_ta._ferro_ta.monthly_contribution
ferro_ta._ferro_ta.signal_attribution
"""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import (
monthly_contribution as _rust_monthly_contribution,
)
from ferro_ta._ferro_ta import (
signal_attribution as _rust_signal_attribution,
)
from ferro_ta._ferro_ta import (
trade_stats as _rust_trade_stats,
)
from ferro_ta._utils import _to_f64
__all__ = [
"TradeStats",
"trade_stats",
"from_backtest",
"attribution_by_month",
"attribution_by_signal",
]
# ---------------------------------------------------------------------------
# TradeStats container
# ---------------------------------------------------------------------------
class TradeStats:
"""Container for trade-level statistics.
Attributes
----------
win_rate : float — fraction of trades with PnL > 0
avg_win : float — mean PnL of winning trades (0 if none)
avg_loss : float — mean PnL of losing trades (negative; 0 if none)
profit_factor : float — gross profit / |gross loss| (inf if no losses)
avg_hold_bars : float — mean hold duration in bars
n_trades : int — total number of trades
"""
__slots__ = (
"win_rate",
"avg_win",
"avg_loss",
"profit_factor",
"avg_hold_bars",
"n_trades",
)
def __init__(
self,
win_rate: float,
avg_win: float,
avg_loss: float,
profit_factor: float,
avg_hold_bars: float,
n_trades: int,
) -> None:
self.win_rate = win_rate
self.avg_win = avg_win
self.avg_loss = avg_loss
self.profit_factor = profit_factor
self.avg_hold_bars = avg_hold_bars
self.n_trades = n_trades
def __repr__(self) -> str:
return (
f"TradeStats(n_trades={self.n_trades}, "
f"win_rate={self.win_rate:.2%}, "
f"profit_factor={self.profit_factor:.2f}, "
f"avg_hold={self.avg_hold_bars:.1f} bars)"
)
def to_dict(self) -> dict[str, Any]:
"""Return stats as a plain dict."""
return {
"n_trades": self.n_trades,
"win_rate": self.win_rate,
"avg_win": self.avg_win,
"avg_loss": self.avg_loss,
"profit_factor": self.profit_factor,
"avg_hold_bars": self.avg_hold_bars,
}
# ---------------------------------------------------------------------------
# trade_stats
# ---------------------------------------------------------------------------
def trade_stats(
pnl: ArrayLike,
hold_bars: Optional[ArrayLike] = None,
) -> TradeStats:
"""Compute trade-level performance statistics.
Parameters
----------
pnl : array-like — per-trade PnL (positive = win, negative = loss)
hold_bars : array-like, optional — hold duration in bars for each trade.
If ``None``, defaults to an array of ones (hold duration unknown).
Returns
-------
:class:`TradeStats`
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.attribution import trade_stats
>>> pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0])
>>> hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0])
>>> ts = trade_stats(pnl, hold)
>>> print(ts)
TradeStats(n_trades=6, win_rate=50.00%, profit_factor=...)
"""
p = _to_f64(pnl)
n = len(p)
if n == 0:
raise ValueError("pnl must be non-empty")
if hold_bars is None:
h = np.ones(n, dtype=np.float64)
else:
h = _to_f64(hold_bars)
win_rate, avg_win, avg_loss, profit_factor, avg_hold = _rust_trade_stats(p, h)
return TradeStats(
win_rate=win_rate,
avg_win=avg_win,
avg_loss=avg_loss,
profit_factor=profit_factor,
avg_hold_bars=avg_hold,
n_trades=n,
)
# ---------------------------------------------------------------------------
# from_backtest
# ---------------------------------------------------------------------------
def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
"""Extract per-trade PnL and hold durations from a BacktestResult.
Scans the ``positions`` and ``strategy_returns`` arrays of *result* to
find trade entries and exits, then computes per-trade PnL and duration.
Parameters
----------
result : :class:`~ferro_ta.backtest.BacktestResult`
Returns
-------
tuple ``(pnl, hold_bars)`` — 1-D float64 arrays of length n_trades.
Notes
-----
A "trade" is defined as a continuous run of non-zero position. PnL is
the sum of ``strategy_returns`` during that period. Hold duration is
the number of bars in the run.
"""
pos = np.asarray(result.positions, dtype=np.float64)
ret = np.asarray(result.strategy_returns, dtype=np.float64)
n = len(pos)
pnl_list: list[float] = []
hold_list: list[float] = []
i = 0
while i < n:
if pos[i] == 0.0:
i += 1
continue
# Start of a trade
j = i + 1
while j < n and pos[j] == pos[i]:
j += 1
# Trade from i to j-1
trade_pnl = float(np.sum(ret[i:j]))
pnl_list.append(trade_pnl)
hold_list.append(float(j - i))
i = j
if not pnl_list:
return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64)
return (
np.array(pnl_list, dtype=np.float64),
np.array(hold_list, dtype=np.float64),
)
# ---------------------------------------------------------------------------
# attribution_by_month
# ---------------------------------------------------------------------------
def attribution_by_month(
bar_returns: ArrayLike,
timestamps: Optional[ArrayLike] = None,
) -> dict[str, float]:
"""Attribute per-bar returns to calendar months.
Parameters
----------
bar_returns : array-like — per-bar strategy returns
timestamps : array-like of int64, optional — UTC timestamps in
nanoseconds (e.g. ``pandas.DatetimeIndex.astype('int64')``).
If ``None``, bars are grouped into calendar-agnostic monthly buckets
of 21 bars (approximate trading month).
Returns
-------
dict mapping month label (str ``'YYYY-MM'`` or ``'period_N'``) to
total return for that month.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.attribution import attribution_by_month
>>> rng = np.random.default_rng(0)
>>> ret = rng.normal(0, 0.01, 252)
>>> contrib = attribution_by_month(ret)
>>> list(contrib.keys())[:3]
['period_0', 'period_1', 'period_2']
"""
ret = _to_f64(bar_returns)
n = len(ret)
if timestamps is not None:
# Convert ns timestamps → month index
ts = np.asarray(timestamps, dtype=np.int64)
# Month = year*12 + month_of_year (0-based)
# ns → seconds → datetime calculation (fast path without pandas)
try:
import pandas as pd
dti = pd.to_datetime(ts, unit="ns", utc=True)
month_idx = (dti.year * 12 + dti.month - 1).astype(np.int64) # type: ignore[union-attr]
offset = int(month_idx[0])
month_idx = (month_idx - offset).values.astype(np.int64)
except ImportError:
# Fallback: 21-bar buckets
month_idx = np.arange(n, dtype=np.int64) // 21
else:
month_idx = np.arange(n, dtype=np.int64) // 21
months_arr, contrib_arr = _rust_monthly_contribution(ret, month_idx)
months = np.asarray(months_arr, dtype=np.int64)
contribs = np.asarray(contrib_arr, dtype=np.float64)
if timestamps is not None:
try:
import pandas as pd
ts = np.asarray(timestamps, dtype=np.int64)
dti = pd.to_datetime(ts, unit="ns", utc=True)
month_idx_full = (dti.year * 12 + dti.month - 1).astype(np.int64).values # type: ignore[union-attr]
offset = int(month_idx_full[0])
labels = {}
for m, c in zip(months, contribs):
abs_month = int(m) + offset
year = abs_month // 12
month_of_year = abs_month % 12 + 1
labels[f"{year:04d}-{month_of_year:02d}"] = float(c)
return labels
except ImportError:
pass
return {f"period_{int(m)}": float(c) for m, c in zip(months, contribs)}
# ---------------------------------------------------------------------------
# attribution_by_signal
# ---------------------------------------------------------------------------
def attribution_by_signal(
bar_returns: ArrayLike,
signal_labels: ArrayLike,
) -> dict[str, float]:
"""Attribute per-bar returns to signal labels.
Parameters
----------
bar_returns : array-like — per-bar strategy returns
signal_labels : array-like of int — signal label per bar.
Use ``-1`` for flat (no position) bars.
Returns
-------
dict mapping signal label (str) to total attributed return.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.attribution import attribution_by_signal
>>> rng = np.random.default_rng(0)
>>> ret = rng.normal(0, 0.01, 100)
>>> labels = np.where(np.arange(100) < 50, 0, 1) # signal 0 or signal 1
>>> contrib = attribution_by_signal(ret, labels)
>>> sorted(contrib.keys())
['signal_0', 'signal_1']
"""
ret = _to_f64(bar_returns)
lbl = np.asarray(signal_labels, dtype=np.int64)
labels_arr, contrib_arr = _rust_signal_attribution(ret, lbl)
labels = np.asarray(labels_arr, dtype=np.int64)
contribs = np.asarray(contrib_arr, dtype=np.float64)
return {f"signal_{int(lbl)}": float(c) for lbl, c in zip(labels, contribs)}