Team Ai
Modelpublic

ParallelLLC/algorithmic_trading

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
27likes32downloads
charts.py394 linesDownload Raw Back to algotrader
1"""Plotly figures for the Lab.2 3Colour system (dark surface, validated for CVD separation):4 5* **blue** is always *your strategy's honest result* — the realised equity6  curve, the out-of-sample fold, the observed Sharpe.7* **orange** is always *the thing it is measured against* — buy & hold, the8  in-sample fold, the null distribution.9 10Holding that mapping across every figure means a reader learns it once.11"""12 13from __future__ import annotations14 15from typing import Dict, Optional16 17import numpy as np18import pandas as pd19import plotly.graph_objects as go20 21SURFACE = "#1a1a19"22PAGE = "#0d0d0d"23INK = "#ffffff"24INK_SECONDARY = "#c3c2b7"25INK_MUTED = "#898781"26GRID = "#2c2c2a"27AXIS = "#383835"28 29SUBJECT = "#3987e5"  # categorical slot 130REFERENCE = "#d95926"  # categorical slot 231NEGATIVE = "#e66767"  # negative arm of the diverging pair (drawdowns)32 33FONT = 'system-ui, -apple-system, "Segoe UI", sans-serif'34 35_EMPTY_NOTE = "Run an analysis to populate this chart."36 37 38def _base_layout(title: str, height: int = 340, **kwargs) -> dict:39    return dict(40        title=dict(text=title, font=dict(size=15, color=INK), x=0, xanchor="left", pad=dict(b=8)),41        paper_bgcolor=PAGE,42        plot_bgcolor=SURFACE,43        font=dict(family=FONT, size=12, color=INK_SECONDARY),44        height=height,45        margin=dict(l=56, r=24, t=48, b=40),46        hovermode="x unified",47        hoverlabel=dict(bgcolor=SURFACE, bordercolor=AXIS, font=dict(color=INK, family=FONT)),48        xaxis=dict(gridcolor=GRID, linecolor=AXIS, zeroline=False, tickfont=dict(color=INK_MUTED)),49        yaxis=dict(gridcolor=GRID, linecolor=AXIS, zeroline=False, tickfont=dict(color=INK_MUTED)),50        legend=dict(51            orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,52            font=dict(color=INK_SECONDARY, size=11), bgcolor="rgba(0,0,0,0)",53        ),54        **kwargs,55    )56 57 58def empty_figure(message: str = _EMPTY_NOTE, height: int = 340) -> go.Figure:59    fig = go.Figure()60    fig.update_layout(**_base_layout("", height=height))61    fig.update_xaxes(visible=False)62    fig.update_yaxes(visible=False)63    fig.add_annotation(64        text=message, showarrow=False, xref="paper", yref="paper", x=0.5, y=0.5,65        font=dict(color=INK_MUTED, size=13),66    )67    return fig68 69 70def equity_chart(report, benchmark_label: str = "Buy & hold") -> go.Figure:71    """Strategy equity against its benchmark, both indexed to the same start."""72    bt = report.backtest73    strat = bt.equity / bt.equity.iloc[0] * 100.074    bench = bt.benchmark_equity / bt.benchmark_equity.iloc[0] * 100.075 76    fig = go.Figure()77    fig.add_trace(78        go.Scatter(79            x=bench.index, y=bench.to_numpy(), name=benchmark_label, mode="lines",80            line=dict(color=REFERENCE, width=2, dash="dash"),81            hovertemplate=benchmark_label + "  %{y:.1f}<extra></extra>",82        )83    )84    fig.add_trace(85        go.Scatter(86            x=strat.index, y=strat.to_numpy(), name=report.strategy.name, mode="lines",87            line=dict(color=SUBJECT, width=2),88            hovertemplate=report.strategy.name + "  %{y:.1f}<extra></extra>",89        )90    )91 92    # Direct-label the two endpoints; the axis and tooltip carry everything else.93    for series, color, label in ((strat, SUBJECT, report.strategy.name), (bench, REFERENCE, benchmark_label)):94        fig.add_annotation(95            x=series.index[-1], y=float(series.iloc[-1]),96            text=f"  {label}: {series.iloc[-1]:.0f}", showarrow=False,97            xanchor="left", font=dict(color=color, size=11),98        )99 100    fig.update_layout(**_base_layout("Growth of 100 (net of costs)", height=360))101    fig.update_layout(margin=dict(l=56, r=140, t=48, b=40))102    return fig103 104 105def drawdown_chart(report) -> go.Figure:106    """Underwater plot — how deep, and for how long."""107    from .metrics import drawdown_series108 109    dd = drawdown_series(report.backtest.equity) * 100.0110    fig = go.Figure(111        go.Scatter(112            x=dd.index, y=dd.to_numpy(), mode="lines", name="Drawdown",113            line=dict(color=NEGATIVE, width=2), fill="tozeroy",114            fillcolor="rgba(230,103,103,0.18)",115            hovertemplate="Drawdown %{y:.1f}%<extra></extra>",116        )117    )118    trough = float(dd.min())119    fig.add_annotation(120        x=dd.idxmin(), y=trough, text=f"worst {trough:.1f}%", showarrow=True,121        arrowhead=0, arrowcolor=AXIS, ay=24, font=dict(color=INK_SECONDARY, size=11),122    )123    fig.update_layout(**_base_layout("Drawdown", height=240, showlegend=False))124    fig.update_yaxes(ticksuffix="%")125    return fig126 127 128def permutation_chart(report) -> go.Figure:129    """The headline chart: your Sharpe against Sharpes from shuffled markets."""130    perm = report.permutation131    if perm is None or perm.null.size == 0:132        return empty_figure("Permutation test was skipped.", height=320)133 134    null = perm.null135    fig = go.Figure()136    fig.add_trace(137        go.Histogram(138            x=null, name="Shuffled markets (no real edge)", nbinsx=44,139            marker=dict(color="rgba(217,89,38,0.55)", line=dict(color=REFERENCE, width=1)),140            hovertemplate="Sharpe %{x:.2f}<br>%{y} shuffles<extra></extra>",141        )142    )143 144    top = np.histogram(null, bins=44)[0].max() if null.size else 1145    fig.add_trace(146        go.Scatter(147            x=[perm.observed, perm.observed], y=[0, top * 1.08], mode="lines",148            name="Your strategy", line=dict(color=SUBJECT, width=2),149            hovertemplate="Your Sharpe %{x:.2f}<extra></extra>",150        )151    )152    fig.add_annotation(153        x=perm.observed, y=top * 1.08, text=f"  your Sharpe {perm.observed:.2f}",154        showarrow=False, xanchor="left", font=dict(color=SUBJECT, size=11),155    )156 157    beats = (null >= perm.observed).mean() * 100.0158    fig.update_layout(159        **_base_layout(160            f"Permutation test — {beats:.0f}% of structure-free markets did this well or better "161            f"(p = {perm.p_value:.3f})",162            height=320,163        )164    )165    fig.update_layout(hovermode="closest", bargap=0.02)166    fig.update_xaxes(title=dict(text="Annualised Sharpe ratio", font=dict(color=INK_MUTED, size=11)))167    # Headroom so the "your Sharpe" label never collides with the plot edge.168    fig.update_yaxes(169        title=dict(text="Shuffled markets", font=dict(color=INK_MUTED, size=11)),170        range=[0, top * 1.28],171    )172    return fig173 174 175def walkforward_chart(report) -> go.Figure:176    """In-sample vs out-of-sample Sharpe, fold by fold."""177    folds = report.walkforward.get("folds") or []178    if not folds:179        return empty_figure(report.walkforward.get("note") or _EMPTY_NOTE, height=300)180 181    labels = [f"Fold {f['fold']}<br><span style='font-size:10px'>{f['test_start'][:7]}</span>" for f in folds]182    fig = go.Figure()183    fig.add_trace(184        go.Bar(185            x=labels, y=[f["is_sharpe"] for f in folds], name="In-sample (tuned)",186            marker=dict(color=REFERENCE, line=dict(color=SURFACE, width=2)),187            hovertemplate="In-sample Sharpe %{y:.2f}<extra></extra>",188        )189    )190    fig.add_trace(191        go.Bar(192            x=labels, y=[f["oos_sharpe"] for f in folds], name="Out-of-sample (blind)",193            marker=dict(color=SUBJECT, line=dict(color=SURFACE, width=2)),194            hovertemplate="Out-of-sample Sharpe %{y:.2f}<extra></extra>",195        )196    )197    eff = report.walkforward.get("efficiency", 0.0)198    fig.update_layout(199        **_base_layout(f"Walk-forward — {eff:.0%} of the tuned Sharpe survived out of sample", height=300)200    )201    fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.08, hovermode="x unified")202    fig.add_hline(y=0, line=dict(color=AXIS, width=1))203    return fig204 205 206def score_chart(verdict: Dict[str, object], significance_label: str = "Beats shuffled markets") -> go.Figure:207    """The five components behind the Reality Score."""208    components = verdict.get("components") or {}209    if not components:210        return empty_figure(height=260)211 212    pretty = {213        "significance": significance_label,214        "selection": "Survives selection bias",215        "walk_forward": "Holds up walking forward",216        "overfitting": "Not overfit (PBO)",217        "robustness": "Survives 3x costs",218    }219    keys = list(pretty)220    values = [float(components.get(k, 0.0)) for k in keys]221 222    fig = go.Figure(223        go.Bar(224            x=values, y=[pretty[k] for k in keys], orientation="h",225            marker=dict(color=SUBJECT, line=dict(color=SURFACE, width=2)),226            text=[f"{v:.0f}" for v in values], textposition="outside",227            textfont=dict(color=INK_SECONDARY, size=11),228            hovertemplate="%{y}: %{x:.0f}/100<extra></extra>",229        )230    )231    fig.update_layout(**_base_layout("Where the score comes from", height=260, showlegend=False))232    fig.update_layout(margin=dict(l=190, r=48, t=48, b=32), hovermode="closest")233    fig.update_xaxes(range=[0, 108], tickvals=[0, 25, 50, 75, 100])234    fig.update_yaxes(autorange="reversed")235    return fig236 237 238def arena_chart(table: pd.DataFrame) -> go.Figure:239    """Leaderboard bars. One measure, one colour — the table carries the rest."""240    if table is None or table.empty:241        return empty_figure(height=380)242 243    ordered = table.iloc[::-1]244    fig = go.Figure(245        go.Bar(246            x=ordered["Sharpe"].to_numpy(), y=ordered["Strategy"].tolist(), orientation="h",247            marker=dict(color=SUBJECT, line=dict(color=SURFACE, width=2)),248            customdata=np.column_stack([ordered["p-value"].to_numpy(), ordered["DSR"].to_numpy()]),249            hovertemplate="%{y}<br>Sharpe %{x:.2f}<br>p = %{customdata[0]:.3f}"250                          "<br>Deflated Sharpe %{customdata[1]:.2f}<extra></extra>",251        )252    )253    # Direct-label only what matters: the ones that actually cleared significance.254    for _, row in ordered.iterrows():255        if np.isfinite(row["p-value"]) and row["p-value"] < 0.05:256            fig.add_annotation(257                x=row["Sharpe"], y=row["Strategy"], text="  p &lt; 0.05", showarrow=False,258                xanchor="left" if row["Sharpe"] >= 0 else "right",259                font=dict(color=INK_SECONDARY, size=10),260            )261    fig.update_layout(262        **_base_layout(263            "Strategy arena — Sharpe ratio, ordered by strength of evidence",264            height=max(300, 42 * len(table)),265            showlegend=False,266        )267    )268    fig.update_layout(margin=dict(l=180, r=96, t=48, b=32), hovermode="closest")269    fig.add_vline(x=0, line=dict(color=AXIS, width=1))270    return fig271 272 273def cross_permutation_chart(report) -> go.Figure:274    """Sharpe against books of identical shape holding randomly chosen names."""275    perm = report.permutation276    if perm is None or perm.null.size == 0:277        return empty_figure("Name-shuffle test was skipped.", height=320)278 279    fig = go.Figure()280    fig.add_trace(281        go.Histogram(282            x=perm.null, name="Same book, random names", nbinsx=40,283            marker=dict(color="rgba(217,89,38,0.55)", line=dict(color=REFERENCE, width=1)),284            hovertemplate="Sharpe %{x:.2f}<br>%{y} shuffles<extra></extra>",285        )286    )287    top = np.histogram(perm.null, bins=40)[0].max() if perm.null.size else 1288    fig.add_trace(289        go.Scatter(290            x=[perm.observed, perm.observed], y=[0, top * 1.08], mode="lines",291            name="Your book", line=dict(color=SUBJECT, width=2),292            hovertemplate="Your Sharpe %{x:.2f}<extra></extra>",293        )294    )295    fig.add_annotation(296        x=perm.observed, y=top * 1.08, text=f"  your Sharpe {perm.observed:.2f}",297        showarrow=False, xanchor="left", font=dict(color=SUBJECT, size=11),298    )299    beats = (perm.null >= perm.observed).mean() * 100.0300    fig.update_layout(301        **_base_layout(302            f"Name-shuffle test — {beats:.0f}% of books with the same shape but random names "303            f"did this well or better (p = {perm.p_value:.3f})",304            height=320,305        )306    )307    fig.update_layout(hovermode="closest", bargap=0.02)308    fig.update_xaxes(title=dict(text="Annualised Sharpe ratio", font=dict(color=INK_MUTED, size=11)))309    fig.update_yaxes(310        title=dict(text="Shuffled books", font=dict(color=INK_MUTED, size=11)),311        range=[0, top * 1.28],312    )313    return fig314 315 316def attribution_chart(attribution: Dict[str, object]) -> go.Figure:317    """Factor betas. One measure across categories, so one colour."""318    if not attribution or not attribution.get("available"):319        return empty_figure((attribution or {}).get("note", _EMPTY_NOTE), height=280)320 321    betas = attribution.get("betas") or {}322    if not betas:323        return empty_figure("No factor exposures to show.", height=280)324 325    names = list(betas)326    values = [betas[n] for n in names]327    fig = go.Figure(328        go.Bar(329            x=values, y=[n.replace("_", " ") for n in names], orientation="h",330            marker=dict(color=SUBJECT, line=dict(color=SURFACE, width=2)),331            text=[f"{v:+.2f}" for v in values], textposition="outside",332            textfont=dict(color=INK_SECONDARY, size=11),333            hovertemplate="%{y} beta %{x:.2f}<extra></extra>",334        )335    )336    alpha = attribution.get("alpha_annual", 0.0)337    t_stat = attribution.get("alpha_t_stat", 0.0)338    fig.update_layout(339        **_base_layout(340            f"Style exposure — alpha {alpha:+.1%}/yr (t = {t_stat:.1f}), "341            f"R² {attribution.get('r_squared', 0):.0%}",342            height=280,343            showlegend=False,344        )345    )346    fig.update_layout(margin=dict(l=120, r=88, t=48, b=32), hovermode="closest")347    # Outside labels need room or the widest beta reads as "+0".348    span = max(abs(min(values)), abs(max(values)), 0.1)349    fig.update_xaxes(range=[min(0, min(values)) - 0.25 * span, max(0, max(values)) + 0.35 * span])350    fig.add_vline(x=0, line=dict(color=AXIS, width=1))351    return fig352 353 354def weights_chart(report) -> go.Figure:355    """Gross and net exposure over time — is the book actually neutral?"""356    held = report.backtest.held357    gross = held.abs().sum(axis=1)358    net = held.sum(axis=1)359 360    fig = go.Figure()361    fig.add_trace(362        go.Scatter(363            x=gross.index, y=gross.to_numpy(), name="Gross", mode="lines",364            line=dict(color=REFERENCE, width=2, dash="dash"),365            hovertemplate="Gross %{y:.2f}x<extra></extra>",366        )367    )368    fig.add_trace(369        go.Scatter(370            x=net.index, y=net.to_numpy(), name="Net", mode="lines",371            line=dict(color=SUBJECT, width=2),372            hovertemplate="Net %{y:.2f}x<extra></extra>",373        )374    )375    fig.update_layout(**_base_layout("Book exposure", height=240))376    fig.add_hline(y=0, line=dict(color=AXIS, width=1))377    return fig378 379 380def exposure_chart(report) -> go.Figure:381    """What the strategy was actually holding, over time."""382    pos = report.backtest.position383    fig = go.Figure(384        go.Scatter(385            x=pos.index, y=pos.to_numpy(), mode="lines", name="Exposure",386            line=dict(color=SUBJECT, width=2, shape="hv"), fill="tozeroy",387            fillcolor="rgba(57,135,229,0.16)",388            hovertemplate="Exposure %{y:.2f}x<extra></extra>",389        )390    )391    fig.update_layout(**_base_layout("Position held", height=200, showlegend=False))392    fig.add_hline(y=0, line=dict(color=AXIS, width=1))393    return fig394