Source code for fynance.plot.factor

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

""" Factor tear-sheet figures (Alphalens-style).

Composable matplotlib panels for the factor-evaluation metrics in
:mod:`fynance.metrics.factor`, plus a one-call :func:`factor_tearsheet`. Each
panel returns an ``Axes`` (and :func:`factor_tearsheet` a ``Figure``) and never
calls ``show`` — usable headless, in a notebook or an app. Matplotlib is
imported lazily inside each function so ``import fynance`` stays
matplotlib-free.

"""

from __future__ import annotations

# Built-in packages
from typing import Any

# Third-party packages
import numpy as np

# Local packages
from fynance.features.horizon import horizon_returns
from fynance.metrics.factor import (
    QuantileResult,
    ic_decay,
    quantile_returns,
    roll_information_coefficient,
)

__all__ = [
    'plot_quantile_returns',
    'plot_ic_series',
    'plot_ic_decay',
    'factor_tearsheet',
]


def _compound(returns: Any) -> Any:
    """ Cumulative compounded growth of a return series (NaN treated as flat). """
    r = np.nan_to_num(np.asarray(returns, dtype=np.float64), nan=0.0)

    return np.cumprod(1.0 + r)


[docs] def plot_quantile_returns(result: QuantileResult, ax: Any = None, **kw: Any) -> Any: """ Plot the cumulative compounded return of each factor quantile. Each bucket's per-bar mean forward return (:attr:`~fynance.metrics.factor.QuantileResult.quantile_returns`) is compounded into a growth curve, so a monotone fan (bottom bucket lowest, top bucket highest) is the signature of a working factor. Returns the ``Axes``. Parameters ---------- result : fynance.metrics.factor.QuantileResult Output of :func:`fynance.metrics.quantile_returns`. ax : matplotlib.axes.Axes, optional Axis to draw on; a new one is created when omitted. **kw Forwarded to :meth:`matplotlib.axes.Axes.plot`. Returns ------- matplotlib.axes.Axes """ import matplotlib.pyplot as plt qret = np.asarray(result.quantile_returns, dtype=np.float64) Q = result.n_quantiles if ax is None: _, ax = plt.subplots() for q in range(Q): ax.plot(_compound(qret[:, q]), lw=1.3, label=f"Q{q + 1}", **kw) ax.set_title(f"Quantile cumulative returns (Q={Q})") ax.set_ylabel("Growth of 1") ax.grid(alpha=0.3) ax.legend(loc="best", fontsize=8, ncol=2) return ax
[docs] def plot_ic_series(ic: Any, w_smooth: int = 21, ax: Any = None, **kw: Any) -> Any: """ Plot a rolling Information Coefficient with a smoothed overlay. Draws the raw IC series (thin) and a trailing moving average over ``w_smooth`` bars (bold), plus a zero reference line. Returns the ``Axes``. Parameters ---------- ic : array-like Rolling IC series, e.g. from :func:`fynance.metrics.roll_information_coefficient`. w_smooth : int, optional Window of the smoothing moving average (default ``21``). ax : matplotlib.axes.Axes, optional Axis to draw on; a new one is created when omitted. **kw Forwarded to :meth:`matplotlib.axes.Axes.plot` for the raw series. Returns ------- matplotlib.axes.Axes """ import matplotlib.pyplot as plt ic = np.asarray(ic, dtype=np.float64) T = ic.shape[0] w = max(1, min(int(w_smooth), T)) # Trailing NaN-robust moving average of the IC series. smooth = np.full(T, np.nan, dtype=np.float64) for t in range(w - 1, T): window = ic[t - w + 1:t + 1] if np.any(np.isfinite(window)): smooth[t] = np.nanmean(window) if ax is None: _, ax = plt.subplots() ax.plot(ic, color="#9ecae1", lw=0.9, label="IC", **kw) ax.plot(smooth, color="#08519c", lw=1.6, label=f"IC (MA {w})") ax.axhline(0.0, color="k", lw=0.8) ax.set_title("Rolling information coefficient") ax.set_ylabel("IC") ax.grid(alpha=0.3) ax.legend(loc="best", fontsize=8) return ax
[docs] def plot_ic_decay(decay: Any, horizons: tuple[int, ...], ax: Any = None, **kw: Any) -> Any: """ Bar chart of the Information Coefficient across forward horizons. Plots the mean IC (e.g. from :func:`fynance.metrics.ic_decay`) at each horizon; a signal with genuine short-horizon edge shows a tall first bar that decays with the horizon. Returns the ``Axes``. Parameters ---------- decay : array-like Mean IC per horizon, aligned with ``horizons``. horizons : tuple of int Forward horizons in bars (the x-axis labels). ax : matplotlib.axes.Axes, optional Axis to draw on; a new one is created when omitted. **kw Forwarded to :meth:`matplotlib.axes.Axes.bar`. Returns ------- matplotlib.axes.Axes """ import matplotlib.pyplot as plt decay = np.asarray(decay, dtype=np.float64) x = np.arange(len(horizons)) if ax is None: _, ax = plt.subplots() ax.bar(x, decay, color="#3182bd", alpha=0.85, **kw) ax.axhline(0.0, color="k", lw=0.8) ax.set_xticks(x) ax.set_xticklabels([str(h) for h in horizons]) ax.set_title("IC decay by horizon") ax.set_xlabel("Horizon (bars)") ax.set_ylabel("Mean IC") ax.grid(alpha=0.3, axis="y") return ax
[docs] def factor_tearsheet( factor: Any, prices: Any, n_quantiles: int = 5, horizons: tuple[int, ...] = (1, 5, 10, 21), w: int = 63, figsize: tuple[float, float] | None = None, save: str | None = None, ) -> Any: """ Build a full Alphalens-style factor tear-sheet figure. Composes four panels into a 2x2 ``Figure`` from a factor panel and its price panel: quantile cumulative returns, the long-short spread equity, the rolling Information Coefficient and the IC decay by horizon. The one-bar forward return (built from ``prices``) is the target for the quantile and rolling-IC panels; the decay panel uses non-overlapping ``horizons`` returns. **Alignment.** ``factor[t]`` is the score known at bar ``t``; the forward returns are built from ``prices`` so no future information leaks in. Parameters ---------- factor : array-like Factor panel ``(T, N)``. prices : array-like Price panel ``(T, N)`` with the same time index as ``factor``. n_quantiles : int, optional Number of factor quantiles (default ``5``). horizons : tuple of int, optional Forward horizons for the IC-decay panel (default ``(1, 5, 10, 21)``). w : int, optional Trailing window of the rolling IC (default ``63``). figsize : tuple of float, optional Figure size; defaults to ``(11, 7)``. save : str, optional If given, the figure is written to this path with :meth:`~matplotlib.figure.Figure.savefig`. Returns ------- matplotlib.figure.Figure """ import matplotlib.pyplot as plt factor = np.asarray(factor, dtype=np.float64) prices = np.asarray(prices, dtype=np.float64) # One-bar forward return aligned with the factor (drop the last, unlabelled # bar): fwd[t] is realized after factor[t] is known. fwd = horizon_returns(prices, 1, overlapping=True) factor_aligned = factor[:-1] qres = quantile_returns(factor_aligned, fwd, n_quantiles=n_quantiles) ic = roll_information_coefficient(factor_aligned, fwd, w=w) decay = ic_decay(factor, prices, horizons=horizons) fig = plt.figure(figsize=figsize or (11, 7)) gs = fig.add_gridspec(2, 2) plot_quantile_returns(qres, ax=fig.add_subplot(gs[0, 0])) ax_spread = fig.add_subplot(gs[0, 1]) ax_spread.plot(_compound(qres.spread), color="#54278f", lw=1.4) ax_spread.axhline(1.0, color="k", lw=0.8) ax_spread.set_title("Long-short spread equity") ax_spread.set_ylabel("Growth of 1") ax_spread.grid(alpha=0.3) plot_ic_series(ic, ax=fig.add_subplot(gs[1, 0])) plot_ic_decay(decay, horizons, ax=fig.add_subplot(gs[1, 1])) fig.tight_layout() if save is not None: fig.savefig(save) return fig