"""Alpha-analysis plots."""
from __future__ import annotations
from statistics import NormalDist
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from .._util import cumulative_returns, to_returns_and_index
__all__ = [
"plot_cumulative_factor_returns",
"plot_ic_by_group",
"plot_ic_heatmap",
"plot_ic_hist",
"plot_ic_qq",
"plot_ic_ts",
"plot_quantile_returns_bar",
"plot_rolling_ic",
"plot_top_bottom_quantile_turnover",
]
def _new_axes(ax: Axes | None, *, figsize: tuple[float, float] = (8.0, 4.0)) -> tuple[Figure, Axes]:
if ax is not None:
return ax.figure, ax
fig, ax = plt.subplots(figsize=figsize)
return fig, ax
def _frame(data: Any) -> pd.DataFrame:
if isinstance(data, pd.DataFrame):
return data.copy()
if hasattr(data, "to_pandas"):
return data.to_pandas()
return pd.DataFrame(data)
def _series(values: Any, *, name: str = "value") -> pd.Series:
if isinstance(values, pd.Series):
return values.copy()
data, index = to_returns_and_index(values)
return pd.Series(data, index=index, name=name)
[docs]
def plot_ic_ts(ic: Any, *, window: int = 21, ax: Axes | None = None) -> Figure:
"""Plot an information-coefficient time series."""
series = _series(ic, name="ic")
fig, ax = _new_axes(ax)
ax.plot(series.index, series.to_numpy(), color="#4c78a8", linewidth=1.1, label="IC")
ax.plot(series.index, series.rolling(window).mean(), color="#f58518", linewidth=1.2, label="rolling mean")
ax.axhline(0.0, color="black", linewidth=0.5)
ax.set_title("Information coefficient")
ax.set_ylabel("IC")
ax.legend(loc="best", frameon=False)
ax.grid(alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_ic_hist(ic: Any, *, bins: int = 20, ax: Axes | None = None) -> Figure:
"""Plot an information-coefficient histogram."""
values = _series(ic, name="ic").dropna().to_numpy()
fig, ax = _new_axes(ax)
ax.hist(values, bins=min(bins, max(1, values.size)), color="#4c78a8", alpha=0.75, edgecolor="white")
if values.size:
ax.axvline(np.nanmean(values), color="#d62728", linewidth=1.2, label="mean")
ax.legend(loc="best", frameon=False)
ax.axvline(0.0, color="black", linewidth=0.5)
ax.set_title("IC distribution")
ax.set_xlabel("IC")
ax.set_ylabel("count")
ax.grid(axis="y", alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_ic_qq(ic: Any, *, ax: Axes | None = None) -> Figure:
"""Plot information coefficients against normal quantiles."""
values = np.sort(_series(ic, name="ic").dropna().to_numpy())
fig, ax = _new_axes(ax)
if values.size:
mean = float(np.nanmean(values))
std = float(np.nanstd(values, ddof=1)) if values.size > 1 else 1.0
dist = NormalDist(mu=mean, sigma=std if std > 0 else 1.0)
probs = (np.arange(1, values.size + 1) - 0.5) / values.size
theoretical = np.array([dist.inv_cdf(float(p)) for p in probs])
ax.scatter(theoretical, values, s=18, color="#4c78a8", alpha=0.75, edgecolors="none")
low = min(theoretical.min(), values.min())
high = max(theoretical.max(), values.max())
ax.plot([low, high], [low, high], color="#d62728", linewidth=1.0)
ax.set_title("IC Q-Q plot")
ax.set_xlabel("normal quantile")
ax.set_ylabel("observed IC")
ax.grid(alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_ic_by_group(data: Any, *, group_col: str = "group", ic_col: str = "ic", ax: Axes | None = None) -> Figure:
"""Plot mean information coefficient by group."""
frame = _frame(data)
grouped = frame.groupby(group_col, dropna=False)[ic_col].mean().sort_values(ascending=False)
fig, ax = _new_axes(ax)
ax.bar(grouped.index.astype(str), grouped.to_numpy(), color="#54a24b", alpha=0.9)
ax.axhline(0.0, color="black", linewidth=0.5)
ax.set_title("IC by group")
ax.set_ylabel("mean IC")
ax.tick_params(axis="x", rotation=30)
ax.grid(axis="y", alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_ic_heatmap(ic: Any, *, period: str = "month", ax: Axes | None = None) -> Figure:
"""Calendar heatmap of mean information coefficient."""
series = _series(ic, name="ic")
index = pd.to_datetime(series.index)
if period == "quarter":
labels = ["Q1", "Q2", "Q3", "Q4"]
cols = index.quarter
elif period == "week":
labels = [str(i) for i in range(1, 54)]
cols = index.isocalendar().week.astype(int)
else:
labels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
cols = index.month
table = pd.DataFrame({"ic": series.to_numpy(), "year": index.year, "col": cols}).pivot_table(
index="year", columns="col", values="ic", aggfunc="mean"
)
for col in range(1, len(labels) + 1):
if col not in table.columns:
table[col] = np.nan
table = table[sorted(table.columns)]
fig, ax = _new_axes(ax, figsize=(10.0, max(2.5, 0.4 * max(1, len(table)))))
arr = table.to_numpy()
vmax = np.nanmax(np.abs(arr)) if np.isfinite(arr).any() else 1.0
im = ax.imshow(arr, aspect="auto", cmap="RdYlGn", vmin=-vmax, vmax=vmax)
ax.set_xticks(range(len(table.columns)))
ax.set_xticklabels(labels[: len(table.columns)])
ax.set_yticks(range(len(table.index)))
ax.set_yticklabels(table.index)
fig.colorbar(im, ax=ax, label="mean IC")
ax.set_title("IC heatmap")
fig.tight_layout()
return fig
[docs]
def plot_rolling_ic(ic: Any, *, window: int = 21, ax: Axes | None = None) -> Figure:
"""Plot rolling mean information coefficient."""
series = _series(ic, name="ic")
rolling = series.rolling(window).mean()
fig, ax = _new_axes(ax)
ax.plot(rolling.index, rolling.to_numpy(), color="#4c78a8", linewidth=1.2)
ax.axhline(0.0, color="black", linewidth=0.5)
ax.set_title(f"Rolling {window}-period IC")
ax.set_ylabel("IC")
ax.grid(alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_quantile_returns_bar(data: Any, *, quantile_col: str = "quantile", return_col: str = "return", ax: Axes | None = None) -> Figure:
"""Plot mean return by signal quantile."""
frame = _frame(data)
grouped = frame.groupby(quantile_col, dropna=False)[return_col].mean().sort_index()
values = grouped.to_numpy() * 100.0
colors = np.where(values >= 0.0, "#54a24b", "#d62728")
fig, ax = _new_axes(ax)
ax.bar(grouped.index.astype(str), values, color=colors, alpha=0.9)
ax.axhline(0.0, color="black", linewidth=0.5)
ax.set_title("Returns by quantile")
ax.set_ylabel("mean return (%)")
ax.grid(axis="y", alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_top_bottom_quantile_turnover(
data: Any,
*,
quantile_col: str = "quantile",
turnover_col: str = "turnover",
ax: Axes | None = None,
) -> Figure:
"""Plot turnover for bottom and top quantiles."""
frame = _frame(data)
grouped = frame.groupby(quantile_col, dropna=False)[turnover_col].mean().sort_index()
selected = grouped.loc[[grouped.index.min(), grouped.index.max()]]
fig, ax = _new_axes(ax)
ax.bar([str(x) for x in selected.index], selected.to_numpy() * 100.0, color=["#4c78a8", "#f58518"], alpha=0.9)
ax.set_title("Top and bottom quantile turnover")
ax.set_xlabel("quantile")
ax.set_ylabel("turnover (%)")
ax.grid(axis="y", alpha=0.2)
fig.tight_layout()
return fig
[docs]
def plot_cumulative_factor_returns(factor_returns: Any, *, ax: Axes | None = None) -> Figure:
"""Plot compounded factor returns."""
values, index = to_returns_and_index(factor_returns)
fig, ax = _new_axes(ax)
ax.plot(index, cumulative_returns(values), color="#4c78a8", linewidth=1.4)
ax.axhline(0.0, color="black", linewidth=0.5)
ax.set_title("Cumulative factor returns")
ax.set_ylabel("cumulative return")
ax.grid(alpha=0.2)
fig.tight_layout()
return fig