Performance·Chart Visualizations·Beginner

Plot Trades on Chart

Overlay individual trade entry and exit markers directly on interactive candlestick charts with color-coded winning and losing trade annotations, connecting entry-to-exit lines, and per-trade PnL labels for intuitive visual strategy performance review and debugging.

performancevisualization

Trade Visualization on Candlestick Charts

Overview

This notebook presents a methodology for visualizing executed trades as an annotation layer on OHLCV (Open-High-Low-Close-Volume) candlestick charts. The visualization is implemented utilizing the Plotly library, enabling interactive exploration of trade entries, exits, and associated profit and loss (PnL) at exit points.

Key Features

  • Generation of candlestick charts using OHLCV data.
  • Identification of trade entry points with directional markers.
  • Categorization of trade exit points by predefined reasons (e.g., Take Profit, Stop Loss, Flip) with corresponding color-coded markers.
  • Calculation and display of PnL at each exit point.
  • Export capabilities for interactive HTML and static image formats (PNG).

1. Library Imports

This section outlines the essential Python libraries required for data manipulation, numerical operations, and interactive plotting.

[6]
import pandas as pd     # Data manipulation and analysis
import numpy as np      # Numerical operations, especially for random number generation
import plotly.graph_objects as go # Interactive charting with Plotly Graph Objects
from plotly.subplots import make_subplots # For creating subplots in Plotly figures
import datetime         # Date and time manipulation
import random           # Generation of pseudo-random numbers

2. Synthetic OHLCV Data Generation

This section details the function generate_ohlcv, which creates synthetic Open-High-Low-Close-Volume (OHLCV) data. This dataset simulates price movements based on a random walk model, providing a representative data source for visualization without reliance on external market data.

[7]
def generate_ohlcv(n_bars: int = 200, start_price: float = 50_000,
                   freq: str = "1h") -> pd.DataFrame:
    """
    Generates synthetic OHLCV price data using a random walk model.

    Parameters
    ----------
    n_bars      : int
        The number of candlestick bars to generate.
    start_price : float
        The initial price level for the series.
    freq        : str
        A Pandas frequency string specifying the interval for the datetime index (e.g., '1h', '1d').

    Returns
    -------
    pd.DataFrame
        A DataFrame containing 'datetime', 'open', 'high', 'low', 'close', and 'volume' columns.
    """
    np.random.seed(42) # Ensure reproducibility of random price movements
    closes  = [start_price]
    for _ in range(n_bars - 1):
        # Generate a random return for price movement to simulate a random walk
        ret = np.random.normal(0.0002, 0.012)
        closes.append(closes[-1] * (1 + ret))

    ohlcv_data = []
    for i, close in enumerate(closes):
        # Determine the open price based on the previous bar's close, or the start price for the first bar
        open_  = closes[i-1] if i > 0 else close
        # Introduce noise to high and low prices relative to open and close to simulate intra-bar volatility
        noise  = close * 0.005
        high   = max(open_, close) + abs(np.random.normal(0, noise))
        low    = min(open_, close) - abs(np.random.normal(0, noise))
        # Generate random volume data for the bar
        volume = np.random.uniform(50, 500)
        ohlcv_data.append({"open": open_, "high": high, "low": low, "close": close, "volume": volume})

    # Create a datetime index and construct the DataFrame.
    # Utilizes timezone-aware datetime.datetime.now(datetime.UTC) for modern datetime handling.
    dates = pd.date_range(end=datetime.datetime.now(datetime.timezone.utc), periods=n_bars, freq=freq)
    df    = pd.DataFrame(ohlcv_data, index=dates).reset_index().rename(columns={"index": "datetime"})
    return df


ohlcv_df = generate_ohlcv(200)
print(f"Generated {len(ohlcv_df)} bars. Price range: ${ohlcv_df['low'].min():.0f} – ${ohlcv_df['high'].max():.0f}.")
print("Sample of generated OHLCV data:")
print(ohlcv_df.head(3).to_string())
Generated 200 bars. Price range: $42835 – $53200.
Sample of generated OHLCV data:
                          datetime          open          high           low         close      volume
0 2026-06-08 01:31:51.984280+00:00  50000.000000  50285.742574  49910.553160  50000.000000  363.657083
1 2026-06-08 02:31:51.984280+00:00  50000.000000  50449.088311  49758.770967  50308.028492  182.116330
2 2026-06-08 03:31:51.984280+00:00  50308.028492  50655.150876  50105.257439  50234.620445  440.182543

3. Synthetic Trade Generation

This section defines the generate_trades function, which creates synthetic trade records. Each record includes an entry and exit point, a trade direction (long or short), and a PnL percentage. This synthetic trade data is subsequently used to annotate the generated OHLCV charts.

Trade Generation Strategy

This synthetic trade generator employs a simplified strategy for demonstrative purposes:

  • Entry Points: Trades are initiated at random close prices within the OHLCV data. The selection avoids the initial or terminal bars of the dataset to ensure sufficient data for trade duration.
  • Holding Period: Each trade is assigned a random holding duration, typically between 3 and 15 bars.
  • Direction: The trade direction (long or short) is randomly determined.
  • Exit Logic: Trades are exited at the close price of a randomly determined bar within the defined holding period.
  • PnL Calculation: The PnL percentage is calculated based on the entry and exit prices, adjusted for the trade direction.
  • Reason for Close: If the PnL is positive, the trade exit reason is designated as 'take_profit'; otherwise, it is 'stop_loss'.
  • Non-Overlapping Trades: The generator ensures that trade holding periods do not overlap, thereby providing distinct trade events for visualization.
[8]
def generate_trades(ohlcv_df: pd.DataFrame, n_trades: int = 15) -> pd.DataFrame:
    """
    Generates synthetic trade records aligned with the OHLCV data.

    Each trade record specifies an entry bar, an exit bar, corresponding prices,
    and a reason for trade closure.

    Parameters
    ----------
    ohlcv_df : pd.DataFrame
        The OHLCV DataFrame generated by `generate_ohlcv`.
    n_trades : int
        The number of synthetic trades to generate.

    Returns
    -------
    pd.DataFrame
        A DataFrame containing trade entry/exit records, including 'entry_time',
        'exit_time', 'direction', 'entry_price', 'exit_price', 'pnl_pct', and 'reason'.
    """
    random.seed(7) # Ensure reproducibility of random trade generation
    trades = []
    used_bars = set() # Tracks bars occupied by trades to prevent overlaps
    n_bars = len(ohlcv_df)

    for _ in range(n_trades):
        # Select a random, non-overlapping entry bar index.
        # Ensures sufficient subsequent bars for trade duration.
        entry_idx = random.randint(5, n_bars - 20)

        # Add a safeguard to prevent infinite loops if no non-overlapping slot is found
        max_attempts = 1000
        attempts = 0
        while any(idx in used_bars for idx in range(entry_idx, entry_idx + 16)):
            entry_idx = random.randint(5, n_bars - 20)
            attempts += 1
            if attempts > max_attempts:
                # If too many attempts, skip this trade and try for the next one
                # print(f"Warning: Could not find a non-overlapping entry for trade {_ + 1} after {max_attempts} attempts. Skipping trade.")
                break

        # If we broke out of the while loop due to max_attempts, skip this iteration
        if attempts > max_attempts:
            continue

        # Determine a random holding period and calculate the corresponding exit index.
        hold_bars = random.randint(3, 15)
        exit_idx  = min(entry_idx + hold_bars, n_bars - 1) # Ensure exit_idx does not exceed DataFrame bounds

        # Extract entry and exit prices from the OHLCV data.
        entry_price = float(ohlcv_df.iloc[entry_idx]["close"])
        exit_price  = float(ohlcv_df.iloc[exit_idx]["close"])
        direction   = random.choice(["long", "short"])

        # Calculate PnL percentage based on trade direction.
        if direction == "long":
            pnl_pct = ((exit_price - entry_price) / entry_price) * 100
        else: # "short" direction
            pnl_pct = ((entry_price - exit_price) / entry_price) * 100

        # Assign a reason for trade closure based on the calculated PnL.
        reason = "take_profit" if pnl_pct > 0 else "stop_loss"

        # Append the trade details to the list.
        trades.append({
            "entry_time":   ohlcv_df.iloc[entry_idx]["datetime"],
            "exit_time":    ohlcv_df.iloc[exit_idx]["datetime"],
            "direction":    direction,
            "entry_price":  round(entry_price, 2),
            "exit_price":   round(exit_price, 2),
            "pnl_pct":      round(pnl_pct, 2),
            "reason":       reason,
        })
        # Mark bars within the trade's duration as used to prevent subsequent overlaps.
        used_bars.update(range(entry_idx, exit_idx + 1))

    return pd.DataFrame(trades)


trades_df = generate_trades(ohlcv_df)
print(f"Generated {len(trades_df)} synthetic trades.")
print("Sample of generated trade data:")
print(trades_df[["direction","entry_price","exit_price","pnl_pct","reason"]].head(5).to_string(index=False))
Generated 12 synthetic trades.
Sample of generated trade data:
direction  entry_price  exit_price  pnl_pct      reason
    short     45301.78    46097.74    -1.76   stop_loss
     long     46241.54    45961.39    -0.61   stop_loss
    short     45086.46    44905.92     0.40 take_profit
     long     44114.44    44899.55     1.78 take_profit
    short     51244.01    49464.48     3.47 take_profit

4. Chart Construction Functions

This section defines a suite of functions responsible for generating the individual components of the trade annotation chart. These functions produce Plotly go.Candlestick traces, go.Scatter traces for entry/exit markers, and trade connector lines, facilitating a modular approach to chart construction.

[9]
def build_candlestick_trace(df: pd.DataFrame) -> go.Candlestick:
    """
    Constructs a Plotly Candlestick trace from OHLCV data.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame containing 'datetime', 'open', 'high', 'low', 'close' columns.

    Returns
    -------
    go.Candlestick
        A Plotly Candlestick trace object for price visualization.
    """
    return go.Candlestick(
        x=df["datetime"],
        open=df["open"], high=df["high"],
        low=df["low"],   close=df["close"],
        increasing_line_color="#2ecc71", # Green for increasing candles
        decreasing_line_color="#e74c3c", # Red for decreasing candles
        name="Price",
        showlegend=False, # Candlestick trace does not require a legend entry
    )


def build_entry_markers(trades: pd.DataFrame) -> list[go.Scatter]:
    """
    Constructs entry marker traces for long and short trades.

    Long entries are represented by an upward-pointing triangle (▲) positioned below the entry price.
    Short entries are represented by a downward-pointing triangle (▼) positioned above the entry price.

    Parameters
    ----------
    trades : pd.DataFrame
        DataFrame containing trade records with 'entry_time', 'entry_price', and 'direction' columns.

    Returns
    -------
    list[go.Scatter]
        A list of `go.Scatter` traces, specifically one for each trade direction (long and short).
    """
    longs  = trades[trades["direction"] == "long"]
    shorts = trades[trades["direction"] == "short"]
    traces = []

    if not longs.empty:
        traces.append(go.Scatter(
            x=longs["entry_time"], y=longs["entry_price"] * 0.997, # Position slightly below entry price
            mode="markers",
            marker=dict(symbol="triangle-up", size=14, color="#3498db", line=dict(width=1, color="#fff")),
            name="Long Entry", legendgroup="entries", # Group legend entries for better organization
            hovertemplate="<b>LONG ENTRY</b><br>Price: $%{y:,.2f}<extra></extra>", # Custom hover text
        ))

    if not shorts.empty:
        traces.append(go.Scatter(
            x=shorts["entry_time"], y=shorts["entry_price"] * 1.003, # Position slightly above entry price
            mode="markers",
            marker=dict(symbol="triangle-down", size=14, color="#e67e22", line=dict(width=1, color="#fff")),
            name="Short Entry", legendgroup="entries",
            hovertemplate="<b>SHORT ENTRY</b><br>Price: $%{y:,.2f}<extra></extra>",
        ))

    return traces


def build_exit_markers(trades: pd.DataFrame) -> list[go.Scatter]:
    """
    Constructs exit marker traces, with color-coding based on the trade close reason.

    - 'take_profit' exits are denoted by a green circle.
    - 'stop_loss' exits are denoted by a red circle.
    - Additional reasons (e.g., 'flip') would be orange, though not present in the current synthetic data.

    Parameters
    ----------
    trades : pd.DataFrame
        DataFrame containing trade records with 'exit_time', 'exit_price', 'pnl_pct', and 'reason' columns.

    Returns
    -------
    list[go.Scatter]
        A list of `go.Scatter` traces, each categorized by exit reason and annotated with PnL.
    """
    colour_map = {"take_profit": "#2ecc71", "stop_loss": "#e74c3c"}
    traces     = []

    for reason, colour in colour_map.items():
        subset = trades[trades["reason"] == reason]
        if subset.empty:
            continue
        traces.append(go.Scatter(
            x=subset["exit_time"], y=subset["exit_price"],
            mode="markers+text", # Display both markers and PnL text
            marker=dict(symbol="circle", size=10, color=colour,
                        line=dict(width=1.5, color="#fff")),
            text=[f"{p:+.2f}%" for p in subset["pnl_pct"]], # PnL percentage formatted as text label
            textposition="top center",
            textfont=dict(size=9, color=colour),
            name=reason.replace("_", " ").title(), # Formatted name for legend
            hovertemplate=(
                f"<b>{reason.replace('_',' ').upper()}</b><br>" # Hover text for the reason
                "Price: $%{y:,.2f}<br>PnL: %{text}<extra></extra>" # Hover text for price and PnL
            ),
        ))

    return traces


def build_trade_connector_lines(trades: pd.DataFrame) -> list[go.Scatter]:
    """
    Generates lines connecting each trade's entry point to its corresponding exit point.

    This visualization aids in comprehending the duration and trajectory of each trade.
    Lines are color-coded based on the trade's PnL: green for profitable trades and red for losing trades.

    Parameters
    ----------
    trades : pd.DataFrame
        DataFrame containing trade records with 'entry_time', 'exit_time', 'entry_price', 'exit_price', and 'pnl_pct' columns.

    Returns
    -------
    list[go.Scatter]
        A list of `go.Scatter` traces, each representing a trade connector line.
    """
    traces = []
    for _, t in trades.iterrows():
        # Determine the line color based on PnL (green for profit, red for loss)
        colour = "#2ecc71" if t["pnl_pct"] > 0 else "#e74c3c"
        traces.append(go.Scatter(
            x=[t["entry_time"], t["exit_time"]],
            y=[t["entry_price"], t["exit_price"]],
            mode="lines",
            line=dict(color=colour, width=1, dash="dot"),
            showlegend=False, # Connector lines do not require a legend entry
            hoverinfo="skip", # No hover information is displayed for connector lines
        ))
    return traces


# Confirmation of chart function definition
# print("Chart construction functions successfully defined.") # Removed informal print statement

5. Chart Assembly and Display

This section defines the primary function, build_trade_chart, which orchestrates the assembly of all chart components into a comprehensive, interactive Plotly figure. It integrates candlestick data, trade entry/exit markers, connector lines, and an optional volume sub-panel. The resulting chart provides a detailed visual representation of price action alongside trading activity.

[10]
def build_trade_chart(ohlcv_df: pd.DataFrame, trades_df: pd.DataFrame,
                       title: str = "Trade Annotations",
                       show_volume: bool = True) -> go.Figure:
    """
    Assembles the complete annotated trading chart.

    Parameters
    ----------
    ohlcv_df    : pd.DataFrame
        DataFrame containing OHLCV price data.
    trades_df   : pd.DataFrame
        DataFrame containing trade records.
    title       : str
        The title string for the chart.
    show_volume : bool
        A boolean flag indicating whether to include a volume sub-panel below the main chart.

    Returns
    -------
    go.Figure
        A Plotly Figure object displaying the annotated candlestick chart.
    """
    # Determine subplot layout based on the inclusion of the volume sub-panel
    row_heights = [0.75, 0.25] if show_volume else [1.0]
    n_rows      = 2 if show_volume else 1

    fig = make_subplots(
        rows=n_rows, cols=1,
        shared_xaxes=True, # Enables synchronized zooming across subplots
        vertical_spacing=0.03,
        row_heights=row_heights,
    )

    # ── Candlestick Trace Integration ──────────────────────────────────────────
    fig.add_trace(build_candlestick_trace(ohlcv_df), row=1, col=1)

    # ── Trade Annotations Integration (Entry/Exit Markers and Connectors) ─────
    # Add entry markers for both long and short trades
    for trace in build_entry_markers(trades_df):
        fig.add_trace(trace, row=1, col=1)
    # Add exit markers, including PnL annotations
    for trace in build_exit_markers(trades_df):
        fig.add_trace(trace, row=1, col=1)
    # Add connector lines between trade entry and exit points
    for trace in build_trade_connector_lines(trades_df):
        fig.add_trace(trace, row=1, col=1)

    # ── Volume Bars Integration (Optional) ─────────────────────────────────────
    if show_volume:
        # Determine volume bar colors based on price movement (close vs. open)
        vol_colours = ["#2ecc71" if c >= o else "#e74c3c"
                       for c, o in zip(ohlcv_df["close"], ohlcv_df["open"])]
        fig.add_trace(go.Bar(
            x=ohlcv_df["datetime"], y=ohlcv_df["volume"],
            marker_color=vol_colours, name="Volume", showlegend=False,
        ), row=2, col=1)

    # ── Chart Layout Configuration ─────────────────────────────────────────────
    fig.update_layout(
        title=dict(text=title, font=dict(size=16)), # Chart title and font size
        height=650,
        paper_bgcolor="#1a1a2e", # Background color of the entire figure
        plot_bgcolor="#1a1a2e",   # Background color of the plotting area
        font=dict(color="#ccc"), # Default font color for text elements
        xaxis_rangeslider_visible=False, # Hides the range slider for a cleaner aesthetic
        legend=dict(orientation="h", y=1.02, x=0), # Positions the legend horizontally at the top
        margin=dict(l=40, r=20, t=60, b=40), # Adjusts plot margins for optimal display
    )
    # Configure x and y axes grid lines for enhanced readability
    fig.update_xaxes(showgrid=False, gridcolor="#2d2d44")
    fig.update_yaxes(showgrid=True,  gridcolor="#2d2d44")

    return fig


# ── Chart Generation and Display Execution ─────────────────────────────────
# Instantiate the chart with generated OHLCV data and trade records.
fig = build_trade_chart(
    ohlcv_df, trades_df,
    title="BTC/USDT — 1H — Trade Annotations",
    show_volume=True,
)

# ── Performance Statistics Overlay ─────────────────────────────────────────
# Calculate trade performance metrics.
n_wins   = (trades_df["pnl_pct"] > 0).sum()
n_losses = (trades_df["pnl_pct"] <= 0).sum()
total    = len(trades_df)

# Add an annotation to display calculated trade statistics directly on the chart.
fig.add_annotation(
    text=f"Total Trades: {total} | Wins: {n_wins} | Losses: {n_losses} | "
         f"Win Rate: {n_wins/total*100:.0f}%",
    xref="paper", yref="paper", x=0.01, y=0.98, # Positions the annotation in the top-left corner
    showarrow=False, bgcolor="#2d2d44", font=dict(color="#fff", size=11),
    bordercolor="#444", borderwidth=1,
)

# Display the generated Plotly figure.
fig.show()

# ── Chart Export to Local File ────────────────────────────────────────────
# Exports the interactive chart to an HTML file in the current working directory.
fig.write_html("trade_chart.html")
print(f"Chart exported to trade_chart.html.")
print(f"Number of trades plotted: {len(trades_df)}.")
Chart exported to trade_chart.html.
Number of trades plotted: 12.

6. Chart Export Options

This section outlines the available methods for exporting the generated Plotly chart. The chart can be exported as an interactive HTML file or as a static PNG image. Note that static image export requires an additional library.

HTML Export (Recommended)

Exporting the chart to HTML preserves its full interactivity, including zooming, panning, and hovering over data points. This method is recommended for sharing interactive visualizations.

fig.write_html("trade_chart.html") # Exports the interactive chart to 'trade_chart.html'

Static PNG Export (Requires Kaleido Library)

For generating static image outputs suitable for reports or presentations, the chart can be exported as a PNG file. This functionality is contingent upon the installation of the kaleido library.

To install kaleido:

pip install kaleido

Once kaleido is installed, the chart can be exported as follows:

# fig.write_image("trade_chart.png", width=1400, height=700, scale=2) # Exports chart as a static PNG image

Conclusion

This notebook demonstrates an effective method for visualizing executed trades on interactive candlestick charts using Plotly. By generating synthetic OHLCV data and trade records, we were able to illustrate how to annotate charts with entry/exit points, trade directions, and PnL at exit. The modular approach, utilizing distinct functions for chart components, enhances readability and maintainability. This visualization technique provides valuable insights into trading performance and market interactions, offering a clear and interactive way to review trading strategies.