edgekit

edgekit.viz

The chart vocabulary for backtest results: matplotlib on the Agg backend, base64-inlined so a chart embeds in a report with zero external requests. Charts consume the canonical trade frame (date + r + dir); the daily/monthly series are derived by trades_to_daily / trades_to_monthly.

Every chart below is previewed inline. For the full set in one place, see the chart gallery.

What's inside. Series helpers (trades_to_daily, trades_to_monthly), a PNG encoder (fig_to_base64), the THEMES palette, axes-level plotters that draw onto a supplied ax (equity_curve, drawdown, monthly_bars, yearly_bars, r_histogram, draw_candles, trade_overlay), and figure-level builders that return a Figure (monthly_heatmap, mc_fan, corr_heatmap).

!matplotlib is a lazy [viz] extra
matplotlib is an optional dependency, imported inside the functions (via viz._plt()). A missing install raises an ImportError with a clear pointer to pip install edgekit[viz] — so import edgekit stays lean. Install the extra before calling any chart.

Themes#

THEMES is a dict with two palettes: "dark" (GitHub-dark, #0d1117 background) and "light" (blue-accent print). Each has keys bg, text, card, border, heading, muted, green, red, blue, gold. theme(name="dark") looks one up.

from edgekit import viz
th = viz.theme("dark")        # -> {"bg": "#0d1117", "green": "#3fb950", ...}
th = viz.THEMES["light"]

Series helpers#

trades_to_daily#

Trade frame (date + r) → net R per calendar day on a gap-filled calendar (0 on untraded days). If account is given it is treated as dollars-per-R and the series is scaled to dollars.

trades_to_daily(trades: pd.DataFrame, account: float | None = None) -> pd.Series
  • trades — canonical trade frame with date + r.
  • account — dollars-per-R scalar; if given, the series is in dollars.

trades_to_monthly#

Trade frame → month-end ("ME") summed series (R, or dollars if account given).

trades_to_monthly(trades: pd.DataFrame, account: float | None = None) -> pd.Series
daily = viz.trades_to_daily(trades)                 # net R per calendar day
monthly = viz.trades_to_monthly(trades, account=500.0)   # dollars per month

Encoding#

fig_to_base64#

Render a figure to a base64 PNG string and close it — the primitive every inlined chart uses.

fig_to_base64(fig, dpi: int = 95) -> str
b64 = viz.fig_to_base64(fig)               # a base64 PNG; embed as data:image/png;base64,{b64}

save_png#

Write a figure to disk (tight bbox, theme facecolor kept) and close it — the on-disk twin of fig_to_base64 for when a report wants a real PNG rather than an inline data-URI. Returns the pathlib.Path.

save_png(fig, path, dpi: int = 115)
  • fig — the matplotlib figure to write (it is closed after saving).
  • path — destination path (str or Path); returned as a Path.
  • dpi — render resolution (default 115, higher than the inline 95).
p = viz.save_png(viz.equity_with_drawdown(daily.cumsum()), "equity.png")   # -> Path("equity.png")

Axes-level charts#

Each plots onto a supplied ax and returns it — compose several into one figure.

equity_curve(ax, eq, label: str | None = None, color: str | None = None)
drawdown(ax, eq, cap: float | None = None, color: str | None = None)   # underwater; cap draws the hard-limit line
monthly_bars(ax, monthly, green=None, red=None)
yearly_bars(ax, yearly, color=None)
r_histogram(ax, r, color=None)
draw_candles(ax, seg: pd.DataFrame, up=None, down=None)                # OHLC candles: wicks + bodies
trade_overlay(ax, trades: pd.DataFrame)                                # up-triangle long / down-triangle short, coloured by R
  • equity_curve — line plot of a cumulative equity series.
  • drawdown — the underwater curve; cap draws the hard drawdown-limit line.
  • monthly_bars / yearly_bars — green/red bars of a monthly/yearly return series.
  • r_histogram — histogram of per-trade R with the mean marked.
  • draw_candles — OHLC candles (wicks + bodies) from an OHLC segment.
  • trade_overlay — entry markers coloured by R, drawn over a candle chart.
axes_charts.py
plt = viz._plt()
daily = viz.trades_to_daily(trades)
monthly = viz.trades_to_monthly(trades)

fig, axes = plt.subplots(2, 3, figsize=(14, 7))
viz.equity_curve(axes[0, 0], daily.cumsum(), label="equity")
viz.drawdown(axes[0, 1], daily.cumsum(), cap=0.10)     # 10% hard-limit line
viz.monthly_bars(axes[0, 2], monthly)
viz.yearly_bars(axes[1, 0], daily.groupby(daily.index.year).sum())
viz.r_histogram(axes[1, 1], trades["r"])
viz.draw_candles(axes[1, 2], bars.iloc[-40:])
viz.trade_overlay(axes[1, 2], trades.tail(10))
equity_curve chart
drawdown chart
monthly_bars chart
yearly_bars chart
r_histogram chart

Analysis charts (axes-level)#

The diagnostic layer — each draws onto a supplied ax and returns it, so they compose into a grid just like the primitives above. They answer the questions the gauntlet asks: is the edge stable, fat-tailed, concentrated in one bucket, or cost-fragile?

rolling_metrics#

Rolling Sharpe / profit-factor / win-rate over a daily-R series — is the edge durable or fading? A flat-ish line is a durable edge; a curve that decays to zero after the design window is the classic overfit signature.

rolling_metrics(ax, daily_r, window: int = 63, metric: str = "sharpe")
  • daily_r — a daily-R series (e.g. from trades_to_daily).
  • window — rolling window in trading days (default 63 ≈ one quarter).
  • metric"sharpe" (annualised, 252d), "pf" (gross win / gross loss, capped at 10, with a PF=1 line) or "win" (fraction of positive days, with a 0.5 line). Anything else raises ValueError.
rolling_metrics chart

return_distribution#

Histogram of per-trade R with a fitted-normal overlay plus 5% VaR / CVaR lines; skew and excess-kurtosis go in the title. The normal overlay makes the fat left tail obvious — a symmetric normal badly understates the tails of a trend edge.

return_distribution(ax, r, theme: str = "dark")
  • r — per-trade R-multiples (non-finite values are dropped).
  • theme — palette name for the fit / VaR colours ("dark" or "light").
return_distribution chart

trade_scatter#

Per-trade R (y) vs holding period in bars (x), green wins / red losses. Reveals whether the edge lives in quick scalps or long holds, and whether losers cluster at a horizon. Uses r and bars_held (falls back to trade sequence if bars_held is absent).

trade_scatter(ax, trades: pd.DataFrame)
trade_scatter chart

contribution_bars#

Total R grouped by a column, horizontal bars coloured green/red by sign — which sleeve / instrument / setup actually paid? A book leaning on one bucket is less robust than one whose P&L is spread. Falls back to a single all bucket if the column is missing.

contribution_bars(ax, trades: pd.DataFrame, by: str = "tag")
  • by — the grouping column (default "tag").
contribution_bars chart

seasonality#

Mean R by calendar month (by="month") or weekday (by="dow"), green/red. A crude look for calendar effects — read sparse cells as colour, not signal.

seasonality(ax, trades: pd.DataFrame, by: str = "month")
  • by"month" (Jan–Dec) or "dow" (Mon–Sun).
seasonality chart

qq_plot#

Sample quantiles vs standard-normal quantiles with a mean/std reference line. Points bowing below the line on the left = a fatter-than-normal loss tail. Uses the stdlib normal inverse-CDF (no scipy).

qq_plot(ax, r)
qq_plot chart

cost_sensitivity#

Profit factor (left axis) and EV-R (right axis) vs cost multiplier — the cost-stress picture, with the PF=1 breakeven line. A real edge degrades gracefully and holds PF>1 at 2–3x; a fake one collapses.

cost_sensitivity(ax, stress: dict)
  • stress — exactly what edgekit.costs.cost_stress returns fed trade_stats: {mult: {"pf": .., "ev_r": ..}}.
cost_sensitivity chart

regime_bars#

Bar a chosen metric per year, green/red — is the edge spread across regimes? One-good-year edges are fragile.

regime_bars(ax, regime_df, metric: str = "ev_r")
  • regime_df — what edgekit.validation.regime_by_year returns (index=year, columns n/ev/win/pf/total_r).
  • metric — a column name or an _r alias ("ev_r"ev, "total_r"total_r); falls back to the first column.
regime_bars chart
analysis_charts.py
plt = viz._plt()
daily = viz.trades_to_daily(trades)

fig, axes = plt.subplots(2, 3, figsize=(15, 8))
viz.rolling_metrics(axes[0, 0], daily, window=63, metric="sharpe")
viz.return_distribution(axes[0, 1], trades["r"], theme="dark")
viz.qq_plot(axes[0, 2], trades["r"])
viz.trade_scatter(axes[1, 0], trades)          # needs a bars_held column
viz.contribution_bars(axes[1, 1], trades, by="tag")
viz.seasonality(axes[1, 2], trades, by="month")

# cost-stress + per-year regime, from the validation layer
stress = ek.costs.cost_stress(trades["r"].to_numpy(), mults=(1, 2, 3))
fig2, (a, b) = plt.subplots(1, 2, figsize=(12, 4))
viz.cost_sensitivity(a, stress)
viz.regime_bars(b, ek.validation.regime_by_year(trades), metric="ev_r")

Figure-level charts#

Each returns a matplotlib Figure ready for fig_to_base64 or a report's .chart().

monthly_heatmap(monthly_returns, theme: str = "dark", title: str = "Monthly return")
mc_fan(paths, actual=None, theme: str = "dark", title: str = "Monte-Carlo fan")
corr_heatmap(corr, theme: str = "dark", title: str = "Correlation")
  • monthly_heatmap — year × month RdYlGn heatmap (accepts a Series indexed by month).
  • mc_fan — percentile fan (5/25/50/75/95 bands + median) of an (n_sims, horizon) paths array; overlays a 1-D actual path if given.
  • corr_heatmap — correlation-matrix heatmap from a square DataFrame.
figure_charts.py
fig = viz.monthly_heatmap(viz.trades_to_monthly(trades), theme="dark")

# forward Monte-Carlo fan from block_bootstrap paths
import numpy as np
paths = np.cumsum(np.random.default_rng(1).standard_normal((2000, 252)), axis=1)
fig = viz.mc_fan(paths, actual=paths[0], title="Forward equity — 1y")

corr = portfolio.correlation({"trend": r_trend, "breakout": r_breakout})
fig = viz.corr_heatmap(corr)
monthly_heatmap chart
mc_fan chart
Monte-Carlo fan with terminal distribution
With terminal_hist=True: the fan plus a marginal distribution of terminal outcomes.
corr_heatmap chart

Tear-sheet panels (figure-level)#

The larger composed figures a full report leans on. Each returns a themed Figure ready for fig_to_base64, save_png, or a report's .chart().

equity_with_drawdown#

Two stacked shared-x panels: equity curve on top, underwater drawdown below — the single most-read backtest picture, so the pain reads at the same x as the gains.

equity_with_drawdown(eq, theme: str = "dark", title: str = "Equity")
  • eq — a cumulative level series (cumsum R, or dollars).
  • theme / title — palette name and the top-panel title.
equity_with_drawdown chart

allocation_area#

Stacked area of a weights DataFrame (rows=time, cols=assets) over time — how the book's capital shifts across sleeves through the sample. Columns stack in frame order and the legend names the assets.

allocation_area(weights, theme: str = "dark", title: str = "Allocation")
allocation_area chart

rolling_correlation#

Rolling correlation of two daily-R series — a diversification check. Two books that look uncorrelated on average can lock together in a crisis. Aligns on the shared index before rolling; the zero line is drawn and the y-axis clamped to [-1, 1].

rolling_correlation(book_a, book_b, window: int = 63, theme: str = "dark")
  • book_a / book_b — two daily-R series.
  • window — rolling window in days (default 63).
rolling_correlation chart

mc_terminal_hist#

Histogram of Monte-Carlo terminal outcomes with 5th / median / 95th lines. Plan around the 5th, not the median.

mc_terminal_hist(paths_or_terminal, theme: str = "dark", title: str = "Terminal P&L")
  • paths_or_terminal — an (n_sims, horizon) paths array (terminals = last column) OR a 1-D array of terminal values.
mc_terminal_hist chart

permutation_hist#

Null-distribution histogram with the real stat drawn as a line plus the p-value in the title — the Masters permutation picture, p = (#{null >= real} + 1) / (N + 1). A real stat deep in the right tail (p<0.01) is unlikely to be luck; one inside the null cloud is noise.

permutation_hist(null_stats, real_stat, theme: str = "dark", title: str = "Permutation null")
  • null_stats — the array of statistics from the permuted (label-shuffled) runs.
  • real_stat — the observed statistic on the true labels.
permutation_hist chart
tearsheet_panels.py
daily = viz.trades_to_daily(trades)

fig1 = viz.equity_with_drawdown(daily.cumsum(), theme="dark", title="Cumulative R")

# diversification: this book vs another daily-R series
fig2 = viz.rolling_correlation(daily, other_daily, window=63)

# visualise the gauntlet
mc = ek.validation.block_bootstrap_mc(daily.to_numpy(), horizon=252, n=5000)
fig3 = viz.mc_terminal_hist(mc["terminal"], title="1-yr terminal R")

perm = ek.validation.mcpt(trades["r"].to_numpy(), n=1000)
fig4 = viz.permutation_hist(perm["null"], perm["stat"], title="Permutation null (EV-R)")

# capital mix over time
fig5 = viz.allocation_area(weights_df)

See also#