Skip to content

Visualization

Plotly-based interactive charting for trade metrics. MetricsPlots takes a CanonicalSeries and produces publication-quality figures for performance analysis.

Available Charts

  • performance_chart -- Three-pane layout (cumulative gains, drawdowns, daily gains) with optional supply/demand breakdown.
  • histogram -- Distribution of daily gains with KDE overlay.
  • rolling_returns -- Rolling-window return curves at configurable horizons.

All figures are returned as plotly.graph_objects.Figure instances, compatible with Streamlit, Jupyter, and static export.

Metrics Visualization - Plotly-based interactive plots for metrics.

Classes

MetricsPlots dataclass

MetricsPlots(canonical: CanonicalSeries)

Visualization utilities for metrics.

Uses Plotly to create interactive performance charts and histograms.

Functions

performance_chart
performance_chart(show_total: bool = True, show_supply: bool = False, show_demand: bool = False) -> Figure

Create a 3-pane performance chart.

Panes (share x-axis by date): 1. Top: Cumulative gains (line) - can show total, supply, demand 2. Middle: Drawdowns (filled area below 0) - updates based on selection 3. Bottom: Daily gains (bars) - updates based on selection

When a single series is selected (supply or demand only), the drawdown and daily gains panes show that specific series. Otherwise, they show the total/combined data.

Args: show_total: Show total cumulative gains (default True) show_supply: Show supply-only cumulative gains show_demand: Show demand-only cumulative gains

Returns: Plotly Figure

Source code in src/progridpy/metrics/visualization.py
def performance_chart(
    self,
    show_total: bool = True,
    show_supply: bool = False,
    show_demand: bool = False,
) -> go.Figure:
    """
    Create a 3-pane performance chart.

    Panes (share x-axis by date):
    1. Top: Cumulative gains (line) - can show total, supply, demand
    2. Middle: Drawdowns (filled area below 0) - updates based on selection
    3. Bottom: Daily gains (bars) - updates based on selection

    When a single series is selected (supply or demand only), the drawdown
    and daily gains panes show that specific series. Otherwise, they show
    the total/combined data.

    Args:
        show_total: Show total cumulative gains (default True)
        show_supply: Show supply-only cumulative gains
        show_demand: Show demand-only cumulative gains

    Returns:
        Plotly Figure
    """
    # Determine which series are active
    active_series: list[str] = []
    if show_total:
        active_series.append("total")
    if show_supply and self.canonical.cumulative_gains_supply is not None:
        active_series.append("supply")
    if show_demand and self.canonical.cumulative_gains_demand is not None:
        active_series.append("demand")

    # Determine which data to use for drawdowns and daily gains panes
    # Mode 1: Total (all three flags True) - Show all curves in panel 1, total in panels 2 & 3
    # Mode 2: Supply only (show_supply True, others False) - Show supply in all panels
    # Mode 3: Demand only (show_demand True, others False) - Show demand in all panels
    # Mode 4: None (all False) - Show nothing
    if show_total and show_supply and show_demand:
        # Total mode: panels 2 & 3 use total data only
        daily_for_bars = self.canonical.daily_gains
        drawdown_for_area = self.canonical.drawdowns
    elif len(active_series) == 1 and active_series[0] == "supply":
        # Supply-only mode
        daily_for_bars = self.canonical.daily_gains_supply
        drawdown_for_area = self.canonical.drawdowns_supply
    elif len(active_series) == 1 and active_series[0] == "demand":
        # Demand-only mode
        daily_for_bars = self.canonical.daily_gains_demand
        drawdown_for_area = self.canonical.drawdowns_demand
    elif not active_series:
        # None mode: don't set data (will handle below by skipping trace addition)
        daily_for_bars = None
        drawdown_for_area = None
    else:
        # Fallback to total (shouldn't happen with new logic)
        daily_for_bars = self.canonical.daily_gains
        drawdown_for_area = self.canonical.drawdowns

    # Create subplots with shared x-axis
    fig = make_subplots(
        rows=3,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.08,
        row_heights=[0.5, 0.25, 0.25],
        subplot_titles=("Cumulative Gains", "Drawdowns", "Daily Gains"),
    )

    # Top pane: Cumulative gains line chart(s)
    if show_total:
        fig.add_trace(
            go.Scatter(
                x=self.canonical.cumulative_gains.index,
                y=self.canonical.cumulative_gains.values,
                mode="lines",
                name="Total",
                line={"color": COLORS["primary"], "width": 2},
                fill="tozeroy",
                fillcolor="rgba(59, 130, 246, 0.1)",
                hovertemplate="Date: %{x}<br>Total: $%{y:,.2f}<extra></extra>",
            ),
            row=1,
            col=1,
        )

    # Supply cumulative gains (if available and selected)
    if show_supply and self.canonical.cumulative_gains_supply is not None:
        fig.add_trace(
            go.Scatter(
                x=self.canonical.cumulative_gains_supply.index,
                y=self.canonical.cumulative_gains_supply.values,
                mode="lines",
                name="Supply",
                line={"color": "#10B981", "width": 2},  # Green
                hovertemplate="Date: %{x}<br>Supply: $%{y:,.2f}<extra></extra>",
            ),
            row=1,
            col=1,
        )

    # Demand cumulative gains (if available and selected)
    if show_demand and self.canonical.cumulative_gains_demand is not None:
        fig.add_trace(
            go.Scatter(
                x=self.canonical.cumulative_gains_demand.index,
                y=self.canonical.cumulative_gains_demand.values,
                mode="lines",
                name="Demand",
                line={"color": "#F59E0B", "width": 2},  # Orange/Amber
                hovertemplate="Date: %{x}<br>Demand: $%{y:,.2f}<extra></extra>",
            ),
            row=1,
            col=1,
        )

    # Middle pane: Drawdowns filled area (responds to selection)
    if drawdown_for_area is not None:
        fig.add_trace(
            go.Scatter(
                x=drawdown_for_area.index,
                y=drawdown_for_area.values,
                mode="lines",
                name="Drawdowns",
                line={"color": COLORS["loss"], "width": 1.5},
                fill="tozeroy",
                fillcolor="rgba(239, 68, 68, 0.3)",
                hovertemplate="Date: %{x}<br>Drawdown: $%{y:,.2f}<extra></extra>",
                showlegend=False,
            ),
            row=2,
            col=1,
        )

    # Bottom pane: Daily gains bar chart (responds to selection)
    if daily_for_bars is not None:
        colors = [COLORS["gain"] if v >= 0 else COLORS["loss"] for v in daily_for_bars.values]
        fig.add_trace(
            go.Bar(
                x=daily_for_bars.index,
                y=daily_for_bars.values,
                name="Daily Gains",
                marker_color=colors,
                hovertemplate="Date: %{x}<br>Daily: $%{y:,.2f}<extra></extra>",
                showlegend=False,
            ),
            row=3,
            col=1,
        )

    # Show legend if multiple traces are shown in cumulative pane
    show_legend = sum([show_total, show_supply, show_demand]) > 1

    # Update layout for clean, modern look
    # Legend positioned below chart to avoid overlap with plotly modebar
    fig.update_layout(
        template="plotly_white",
        showlegend=show_legend,
        legend={
            "orientation": "h",
            "yanchor": "top",
            "y": -0.08,
            "xanchor": "center",
            "x": 0.5,
        },
        height=750,
        margin={"l": 60, "r": 30, "t": 80, "b": 80},  # Increased bottom margin for legend
        font={"family": "Inter, system-ui, sans-serif", "size": 14},
        hovermode="x unified",
    )

    # Update axes for each subplot
    fig.update_xaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
        showline=True,
        linewidth=1,
        linecolor=COLORS["grid"],
    )

    fig.update_yaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
        showline=True,
        linewidth=1,
        linecolor=COLORS["grid"],
        tickformat="$,.0f",
    )

    # Update subplot titles styling
    for annotation in fig.layout.annotations:
        annotation.font = {"size": 17, "color": COLORS["secondary"]}

    return fig
gains_histogram
gains_histogram(frequency: str, actual_series: Series | None = None) -> Figure

Create a histogram of gains for a given frequency.

Args: frequency: One of "daily", "7d", "30d", "90d", "365d" actual_series: Optional daily gains series to overlay for comparison (e.g., actual trading data vs backtest). The same frequency transformation will be applied automatically.

Returns: Plotly Figure with histogram

Raises: ValueError: If frequency is not one of the valid options

Source code in src/progridpy/metrics/visualization.py
def gains_histogram(self, frequency: str, actual_series: pd.Series | None = None) -> go.Figure:
    """
    Create a histogram of gains for a given frequency.

    Args:
        frequency: One of "daily", "7d", "30d", "90d", "365d"
        actual_series: Optional daily gains series to overlay for comparison
            (e.g., actual trading data vs backtest). The same frequency
            transformation will be applied automatically.

    Returns:
        Plotly Figure with histogram

    Raises:
        ValueError: If frequency is not one of the valid options
    """
    daily = self.canonical.daily_gains

    if frequency == "daily":
        data = daily
        title = "Daily Gains Distribution"
    elif frequency == "7d":
        data = rolling_period_sums(daily, 7).dropna()
        title = "7-Day Rolling Gains Distribution"
    elif frequency == "30d":
        data = rolling_period_sums(daily, 30).dropna()
        title = "30-Day Rolling Gains Distribution"
    elif frequency == "90d":
        data = rolling_period_sums(daily, 90).dropna()
        title = "90-Day Rolling Gains Distribution"
    elif frequency == "365d":
        data = rolling_period_sums(daily, 365).dropna()
        title = "365-Day Rolling Gains Distribution"
    else:
        raise ValueError(f"Invalid frequency: {frequency}. Must be one of: daily, 7d, 30d, 90d, 365d")

    # Process actual_series with the same frequency transformation
    actual_data: pd.Series | None = None
    if actual_series is not None:
        if frequency == "daily":
            actual_data = actual_series
        elif frequency == "7d":
            actual_data = rolling_period_sums(actual_series, 7).dropna()
        elif frequency == "30d":
            actual_data = rolling_period_sums(actual_series, 30).dropna()
        elif frequency == "90d":
            actual_data = rolling_period_sums(actual_series, 90).dropna()
        elif frequency == "365d":
            actual_data = rolling_period_sums(actual_series, 365).dropna()

    fig = go.Figure()

    # Calculate bin width for KDE scaling (use ~50 bins)
    data_range = data.max() - data.min()
    n_bins = 50
    bin_width = data_range / n_bins
    n_samples = len(data)

    # Modern color palette
    overall_color = "#6366F1"  # Indigo
    year_colors = [
        "#10B981",  # Emerald
        "#F43F5E",  # Rose
        "#F59E0B",  # Amber
        "#0EA5E9",  # Sky
        "#8B5CF6",  # Violet
        "#EC4899",  # Pink
        "#14B8A6",  # Teal
    ]

    # Single overall histogram (all data)
    fig.add_trace(
        go.Histogram(
            x=data.values,
            name="Overall",
            marker_color=overall_color,
            opacity=0.85,
            xbins={"size": bin_width},
            hovertemplate="Range: %{x:$,.0f}<br>Count: %{y}<extra></extra>",
        )
    )

    # Add actual series histogram overlay (if provided)
    if actual_data is not None and len(actual_data) > 0:
        fig.add_trace(
            go.Histogram(
                x=actual_data.values,
                name="Actual",
                marker_color="#F97316",  # Orange
                opacity=0.7,
                xbins={"size": bin_width},
                hovertemplate="Actual<br>Range: %{x:$,.0f}<br>Count: %{y}<extra></extra>",
            )
        )

    # Add per-year histograms (hidden by default, toggleable via legend)
    years = sorted(data.index.year.unique())
    for i, year in enumerate(years):
        year_data = data[data.index.year == year]
        color = year_colors[i % len(year_colors)]
        fig.add_trace(
            go.Histogram(
                x=year_data.values,
                name=str(year),
                marker_color=color,
                visible="legendonly",
                opacity=0.8,
                xbins={"size": bin_width},
                hovertemplate=f"{year}<br>Range: %{{x:$,.0f}}<br>Count: %{{y}}<extra></extra>",
            )
        )

    # Add KDE curve (no legend entry, always visible)
    if len(data) >= 2:
        kde = gaussian_kde(data.values)
        x_kde = np.linspace(data.min(), data.max(), 200)
        y_kde = kde(x_kde) * n_samples * bin_width

        fig.add_trace(
            go.Scatter(
                x=x_kde,
                y=y_kde,
                mode="lines",
                name="KDE",
                line={"color": "#F87171", "width": 2.5},
                showlegend=False,
                hoverinfo="skip",
            )
        )

    # Add KDE curve for actual series (if provided)
    if actual_data is not None and len(actual_data) >= 2:
        actual_kde = gaussian_kde(actual_data.values)
        x_kde_actual = np.linspace(actual_data.min(), actual_data.max(), 200)
        actual_n_samples = len(actual_data)
        y_kde_actual = actual_kde(x_kde_actual) * actual_n_samples * bin_width

        fig.add_trace(
            go.Scatter(
                x=x_kde_actual,
                y=y_kde_actual,
                mode="lines",
                name="Actual KDE",
                line={"color": "#FB923C", "width": 2.5},  # Lighter orange
                showlegend=False,
                hoverinfo="skip",
            )
        )

    # Calculate max y for vertical lines (estimate from histogram)
    hist_counts, _ = np.histogram(data.values, bins=n_bins)
    max_y = hist_counts.max() * 1.1

    # Add vertical line at zero (non-toggleable)
    fig.add_vline(x=0, line_width=2, line_dash="dash", line_color=COLORS["secondary"])

    # Mean line (toggleable via legend, label toggles with line)
    mean_val = float(data.mean())
    fig.add_trace(
        go.Scatter(
            x=[mean_val, mean_val, mean_val],
            y=[0, max_y, max_y * 1.08],
            mode="lines+text",
            text=["", "", "Mean"],
            textposition="top center",
            textfont={"size": 10, "color": COLORS["primary"]},
            name=f"Mean: ${mean_val:,.0f}",
            line={"color": COLORS["primary"], "width": 2},
            hoverinfo="skip",
        )
    )

    # Median line (toggleable via legend, label toggles with line)
    median_val = float(data.median())
    # Offset median label to avoid overlap with mean if they're close
    median_textpos = "top right" if abs(mean_val - median_val) < data_range * 0.05 else "top center"
    fig.add_trace(
        go.Scatter(
            x=[median_val, median_val, median_val],
            y=[0, max_y, max_y * 1.08],
            mode="lines+text",
            text=["", "", "Median"],
            textposition=median_textpos,
            textfont={"size": 10, "color": "#8B5CF6"},
            name=f"Median: ${median_val:,.0f}",
            line={"color": "#8B5CF6", "width": 2},
            hoverinfo="skip",
        )
    )

    # Percentile configuration
    percentile_config = [
        ("P1", 1, "#B91C1C"),
        ("P5", 5, "#DC2626"),
        ("P10", 10, "#EF4444"),
        ("P25", 25, "#F59E0B"),
        ("P75", 75, "#22C55E"),
        ("P90", 90, "#16A34A"),
        ("P95", 95, "#15803D"),
        ("P99", 99, "#166534"),
    ]

    # Add percentile lines (toggleable via legend, labels toggle with lines)
    for label, pct, color in percentile_config:
        pct_val = float(data.quantile(pct / 100))
        fig.add_trace(
            go.Scatter(
                x=[pct_val, pct_val, pct_val],
                y=[0, max_y, max_y * 1.08],
                mode="lines+text",
                text=["", "", label],
                textposition="top center",
                textfont={"size": 9, "color": color},
                name=f"{label}: ${pct_val:,.0f}",
                line={"color": color, "width": 1.5, "dash": "dot"},
                hoverinfo="skip",
            )
        )

    # Update layout
    fig.update_layout(
        title={
            "text": title,
            "x": 0.5,
            "xanchor": "center",
            "font": {"size": PLOT_TITLE_FONT_SIZE_PX, "color": COLORS["secondary"]},
        },
        template="plotly_white",
        showlegend=True,
        legend={
            "orientation": "v",
            "yanchor": "top",
            "y": 0.99,
            "xanchor": "right",
            "x": 0.99,
            "bgcolor": "rgba(255,255,255,0.9)",
            "bordercolor": "#E5E7EB",
            "borderwidth": 1,
        },
        height=600,
        margin={"l": 60, "r": 30, "t": 80, "b": 50},
        font={"family": "Inter, system-ui, sans-serif", "size": 14},
        barmode="overlay",
        bargap=0,
        xaxis_title="Gain ($)",
        yaxis_title="Frequency",
    )

    fig.update_xaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
        tickformat="$,.0f",
    )

    fig.update_yaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
    )

    return fig
cumulative_gains_by_year
cumulative_gains_by_year() -> Figure

Create a line chart showing cumulative gains by year.

Each year is a separate colored line, normalized to start at 0 on day 1. X-axis: Day of year (1-365/366) Y-axis: Cumulative gain ($)

Returns: Plotly Figure with cumulative gains by year

Source code in src/progridpy/metrics/visualization.py
def cumulative_gains_by_year(self) -> go.Figure:
    """
    Create a line chart showing cumulative gains by year.

    Each year is a separate colored line, normalized to start at 0 on day 1.
    X-axis: Day of year (1-365/366)
    Y-axis: Cumulative gain ($)

    Returns:
        Plotly Figure with cumulative gains by year
    """
    daily = self.canonical.daily_gains

    # Create DataFrame with year and day of year
    df = pd.DataFrame({"gain": daily})
    df["year"] = df.index.year
    df["day_of_year"] = df.index.dayofyear

    fig = go.Figure()

    # Define a color palette for years
    year_colors = [
        "#3B82F6",  # Blue
        "#10B981",  # Emerald
        "#F59E0B",  # Amber
        "#EF4444",  # Red
        "#8B5CF6",  # Purple
        "#EC4899",  # Pink
        "#14B8A6",  # Teal
    ]

    years_sorted = sorted(df["year"].unique())
    for i, year in enumerate(years_sorted):
        year_data = df[df["year"] == year].copy()
        year_data = year_data.sort_values("day_of_year")
        year_data["cumulative"] = year_data["gain"].cumsum()

        color = year_colors[i % len(year_colors)]

        fig.add_trace(
            go.Scatter(
                x=year_data["day_of_year"],
                y=year_data["cumulative"],
                mode="lines",
                name=str(year),
                line={"color": color, "width": 2},
                hovertemplate=f"Year: {year}<br>Day: %{{x}}<br>Cumulative: $%{{y:,.2f}}<extra></extra>",
            )
        )

    fig.update_layout(
        title={
            "text": "Cumulative Gains by Year",
            "x": 0.5,
            "xanchor": "center",
            "font": {"size": PLOT_TITLE_FONT_SIZE_PX, "color": COLORS["secondary"]},
        },
        template="plotly_white",
        height=400,
        margin={"l": 60, "r": 160, "t": 80, "b": 50},
        font={"family": "Inter, system-ui, sans-serif", "size": 14},
        xaxis_title="Day of Year",
        yaxis_title="Cumulative Gain ($)",
        legend={
            "orientation": "v",
            "yanchor": "top",
            "y": 1.0,
            "xanchor": "left",
            "x": 1.02,
            "bgcolor": "rgba(255,255,255,0.9)",
            "bordercolor": "#E5E7EB",
            "borderwidth": 1,
        },
        hovermode="x unified",
    )

    fig.update_xaxes(
        range=[1, 366],
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
    )

    fig.update_yaxes(
        tickformat="$,.0f",
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
    )

    return fig
daily_gains_bar_chart
daily_gains_bar_chart(days: int | str = 30) -> Figure

Create a bar chart showing daily gains for recent days.

Args: days: Number of recent days to show (7, 30, 90) or "latest_year" for all days in the current/latest year

Returns: Plotly Figure with daily gain bars (green=positive, red=negative)

Source code in src/progridpy/metrics/visualization.py
def daily_gains_bar_chart(self, days: int | str = 30) -> go.Figure:
    """
    Create a bar chart showing daily gains for recent days.

    Args:
        days: Number of recent days to show (7, 30, 90) or "latest_year"
              for all days in the current/latest year

    Returns:
        Plotly Figure with daily gain bars (green=positive, red=negative)
    """
    daily = self.canonical.daily_gains

    if days == "latest_year":
        # Get latest year in the data
        latest_year = daily.index.year.max()
        data = daily[daily.index.year == latest_year]
        title = f"Daily Gains - {latest_year}"
    else:
        # Get last N days
        data = daily.tail(int(days))
        title = f"Daily Gains - Last {days} Days"

    # Create colors based on positive/negative values
    colors = [COLORS["gain"] if v >= 0 else COLORS["loss"] for v in data.values]

    fig = go.Figure()

    fig.add_trace(
        go.Bar(
            x=data.index,
            y=data.values,
            marker_color=colors,
            hovertemplate="Date: %{x}<br>Gain: $%{y:,.2f}<extra></extra>",
            name="Daily Gain",
        )
    )

    # Add zero line
    fig.add_hline(
        y=0,
        line_width=1,
        line_dash="solid",
        line_color=COLORS["secondary"],
    )

    # Update layout
    fig.update_layout(
        title={
            "text": title,
            "x": 0.5,
            "xanchor": "center",
            "font": {"size": PLOT_TITLE_FONT_SIZE_PX, "color": COLORS["secondary"]},
        },
        template="plotly_white",
        height=350,
        margin={"l": 60, "r": 30, "t": 80, "b": 50},
        font={"family": "Inter, system-ui, sans-serif", "size": 14},
        xaxis_title="Date",
        yaxis_title="Gain ($)",
        showlegend=False,
    )

    fig.update_xaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
        tickformat="%Y-%m-%d",
        tickangle=-45,
    )

    fig.update_yaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
        tickformat="$,.0f",
    )

    return fig
daily_gains_heatmap
daily_gains_heatmap(year: int) -> Figure

Create a GitHub-style heatmap showing daily gains for a specific year.

Args: year: The year to display

Returns: Plotly Figure with heatmap (weeks x days of week)

Source code in src/progridpy/metrics/visualization.py
def daily_gains_heatmap(self, year: int) -> go.Figure:
    """
    Create a GitHub-style heatmap showing daily gains for a specific year.

    Args:
        year: The year to display

    Returns:
        Plotly Figure with heatmap (weeks x days of week)
    """
    daily = self.canonical.daily_gains

    # Filter to the specified year
    year_data = daily[daily.index.year == year]

    if len(year_data) == 0:
        # Return empty figure if no data for this year
        fig = go.Figure()
        fig.add_annotation(
            text=f"No data for {year}",
            xref="paper",
            yref="paper",
            x=0.5,
            y=0.5,
            showarrow=False,
        )
        return fig

    # Create a matrix: 7 rows (days of week) x 53 columns (weeks)
    # Initialize with NaN
    heatmap_data = np.full((7, 53), np.nan)

    for date, value in year_data.items():
        week_num = date.isocalendar().week - 1  # 0-indexed
        day_of_week = date.weekday()  # 0=Mon, 6=Sun
        if week_num < 53:  # Handle edge cases
            heatmap_data[day_of_week, week_num] = value

    # Day labels (Mon-Sun)
    day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

    # Calculate color scale bounds (symmetric around 0)
    max_abs = max(abs(np.nanmin(heatmap_data)), abs(np.nanmax(heatmap_data)))
    if max_abs == 0:
        max_abs = 1  # Avoid division by zero

    fig = go.Figure(
        data=go.Heatmap(
            z=heatmap_data,
            x=list(range(1, 54)),  # Week numbers
            y=day_labels,
            colorscale=[
                [0, "#EF4444"],  # Red for losses
                [0.5, "#F8FAFC"],  # Light gray for neutral
                [1, "#10B981"],  # Green for gains
            ],
            zmid=0,
            zmin=-max_abs,
            zmax=max_abs,
            hovertemplate="Week %{x}<br>%{y}<br>Gain: $%{z:,.0f}<extra></extra>",
            showscale=True,
            colorbar={
                "title": "Gain ($)",
                "tickformat": "$,.0f",
                "len": 0.8,
            },
        )
    )

    fig.update_layout(
        title={
            "text": f"Daily Gains Heatmap - {year}",
            "x": 0.5,
            "xanchor": "center",
            "font": {"size": PLOT_TITLE_FONT_SIZE_PX, "color": COLORS["secondary"]},
        },
        template="plotly_white",
        height=200,
        margin={"l": 60, "r": 80, "t": 50, "b": 30},
        font={"family": "Inter, system-ui, sans-serif", "size": 12},
        xaxis_title="Week",
        yaxis_title="",
    )

    fig.update_xaxes(
        showgrid=False,
        dtick=4,  # Show every 4th week
    )

    fig.update_yaxes(
        showgrid=False,
        autorange="reversed",  # Mon at top
    )

    return fig
daily_gains_heatmap_combined
daily_gains_heatmap_combined() -> Figure

Create a combined GitHub-style heatmap showing daily gains for all years.

Returns: Plotly Figure with stacked heatmaps (one per year, shared x-axis)

Source code in src/progridpy/metrics/visualization.py
def daily_gains_heatmap_combined(self) -> go.Figure:
    """
    Create a combined GitHub-style heatmap showing daily gains for all years.

    Returns:
        Plotly Figure with stacked heatmaps (one per year, shared x-axis)
    """
    daily = self.canonical.daily_gains
    # Sort years ascending - oldest first in array, then reverse y-axis
    years = sorted(daily.index.year.unique())

    if len(years) == 0:
        fig = go.Figure()
        fig.add_annotation(
            text="No data available",
            xref="paper",
            yref="paper",
            x=0.5,
            y=0.5,
            showarrow=False,
        )
        return fig

    # Use percentiles for color bounds to handle outliers
    # This prevents large outliers from washing out the color scale
    all_values = daily.dropna().values
    if len(all_values) > 0:
        p5 = np.percentile(all_values, 5)
        p95 = np.percentile(all_values, 95)
        color_bound = max(abs(p5), abs(p95))
        if color_bound == 0:
            color_bound = 1
    else:
        color_bound = 1

    # Build combined matrix: stack all years vertically (7 rows per year)
    # Years in ascending order, days Mon(0) to Sun(6) within each year
    total_rows = 7 * len(years)
    combined_data = np.full((total_rows, 53), np.nan)
    # Store dates for hover display
    date_labels = [[None for _ in range(53)] for _ in range(total_rows)]

    for year_idx, year in enumerate(years):
        year_data = daily[daily.index.year == year]
        row_offset = year_idx * 7

        for date, value in year_data.items():
            week_num = date.isocalendar().week - 1  # 0-indexed
            day_of_week = date.weekday()  # 0=Mon, 6=Sun
            if week_num < 53:
                combined_data[row_offset + day_of_week, week_num] = value
                date_labels[row_offset + day_of_week][week_num] = date.strftime("%Y-%m-%d")

    # GitHub-style discrete color scale with fewer, darker colors
    colorscale = [
        [0, "#991B1B"],  # Dark red - large losses
        [0.3, "#DC2626"],  # Red - medium losses
        [0.45, "#FCA5A5"],  # Light red - small losses
        [0.5, "#E5E7EB"],  # Gray - neutral
        [0.55, "#86EFAC"],  # Light green - small gains
        [0.7, "#22C55E"],  # Green - medium gains
        [1, "#166534"],  # Dark green - large gains
    ]

    # Y-axis: use numeric values, then set custom tick labels for years
    y_values = list(range(total_rows))

    fig = go.Figure(
        data=go.Heatmap(
            z=combined_data,
            x=list(range(1, 54)),
            y=y_values,
            colorscale=colorscale,
            zmid=0,
            zmin=-color_bound,
            zmax=color_bound,
            xgap=2,  # Gap between cells for GitHub style
            ygap=2,
            customdata=date_labels,
            hovertemplate="%{customdata}<br>Gain: $%{z:,.0f}<extra></extra>",
            hoverongaps=False,  # Don't show hover for empty cells
            showscale=True,
            colorbar={
                "title": "Gain ($)",
                "tickformat": "$,.0f",
                "len": 0.7,
                "y": 0.5,
                "thickness": 15,
            },
        )
    )

    # Set year labels at center of each 7-row section
    tick_positions = [year_idx * 7 + 3 for year_idx in range(len(years))]
    tick_labels = [str(year) for year in years]

    fig.update_layout(
        title={
            "text": "Daily Gains Heatmap",
            "x": 0.5,
            "xanchor": "center",
            "font": {"size": PLOT_TITLE_FONT_SIZE_PX, "color": COLORS["secondary"]},
        },
        template="plotly_white",
        height=18 * total_rows + 100,
        margin={"l": 60, "r": 100, "t": 80, "b": 40},
        font={"family": "Inter, system-ui, sans-serif", "size": 12},
        xaxis_title="Week",
        yaxis_title="",
    )

    fig.update_xaxes(
        showgrid=False,
        dtick=4,
        constrain="domain",
        zeroline=False,
    )
    fig.update_yaxes(
        showgrid=False,
        tickmode="array",
        tickvals=tick_positions,
        ticktext=tick_labels,
        autorange="reversed",
        zeroline=False,
        scaleanchor="x",
        scaleratio=1,
    )

    return fig
quantile_box_plots
quantile_box_plots() -> Figure

Create box plots showing distribution of gains across different time periods.

Returns: Plotly Figure with box plots for daily, weekly, monthly, quarterly, yearly gains

Source code in src/progridpy/metrics/visualization.py
def quantile_box_plots(self) -> go.Figure:
    """
    Create box plots showing distribution of gains across different time periods.

    Returns:
        Plotly Figure with box plots for daily, weekly, monthly, quarterly, yearly gains
    """
    daily = self.canonical.daily_gains

    # Calculate gains for different periods
    periods = {
        "Daily": daily,
        "Weekly": rolling_period_sums(daily, 7).dropna(),
        "Monthly": rolling_period_sums(daily, 30).dropna(),
        "Quarterly": rolling_period_sums(daily, 90).dropna(),
        "Yearly": rolling_period_sums(daily, 365).dropna(),
    }

    # Modern colors for each period
    period_colors = {
        "Daily": "#6366F1",  # Indigo
        "Weekly": "#0EA5E9",  # Sky
        "Monthly": "#10B981",  # Emerald
        "Quarterly": "#F59E0B",  # Amber
        "Yearly": "#EC4899",  # Pink
    }

    fig = go.Figure()

    for period_name, period_data in periods.items():
        if len(period_data) > 0:
            box_color = period_colors[period_name]

            fig.add_trace(
                go.Box(
                    y=period_data.values,
                    name=period_name,
                    marker_color=box_color,
                    boxmean=True,  # Show mean as dashed line
                    hovertemplate=(f"{period_name}<br>Value: $%{{y:,.0f}}<extra></extra>"),
                )
            )

    fig.update_layout(
        title={
            "text": "Gains Distribution by Period",
            "x": 0.5,
            "xanchor": "center",
            "font": {"size": PLOT_TITLE_FONT_SIZE_PX, "color": COLORS["secondary"]},
        },
        template="plotly_white",
        height=400,
        margin={"l": 60, "r": 30, "t": 80, "b": 50},
        font={"family": "Inter, system-ui, sans-serif", "size": 14},
        yaxis_title="Gain ($)",
        showlegend=False,
    )

    fig.update_yaxes(
        showgrid=True,
        gridwidth=1,
        gridcolor=COLORS["grid"],
        tickformat="$,.0f",
        zeroline=True,
        zerolinewidth=2,
        zerolinecolor=COLORS["secondary"],
    )

    return fig

Functions