Plotly Defeats Matplotlib for Stock Visualization: Here’s the Math

Updated Feb 6, 2026

Interactive Charts Are Non-Negotiable for Technical Analysis

Static matplotlib charts are fine for notebooks, but once you’re analyzing actual stock data with multiple indicators, you need interactivity. Not because it looks pretty — because technical analysis requires precise visual inspection of price crossovers, volume spikes, and indicator divergences at specific timestamps. You can’t squint at a 252-day trading chart saved as PNG and catch the moment RSI dipped below 30 while MACD crossed bullish.

Plotly handles this elegantly with hover tooltips, zoom persistence, and synchronized crosshairs across subplots. Matplotlib requires explicit event handlers and doesn’t export interactivity to HTML. I’d pick Plotly for any stock visualization work, even though its API can be clunky.

A person reads 'Python for Unix and Linux System Administration' indoors.
Photo by Christina Morillo on Pexels

Building the Visualization Layer

We’re continuing from Part 1’s data pipeline. Assuming you’ve already fetched OHLCV data via yfinance, the typical dataframe looks like this:

import yfinance as yf
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# Fetch SPY data (S&P 500 ETF)
ticker = yf.Ticker("SPY")
df = ticker.history(period="1y", interval="1d")

# yfinance returns MultiIndex columns sometimes, flatten if needed
if isinstance(df.columns, pd.MultiIndex):
    df.columns = ['_'.join(col).strip() for col in df.columns.values]

print(df.head())
# Output:
#                   Open        High         Low       Close     Volume  Dividends  Stock Splits
# Date                                                                                           
# 2025-02-04  589.25  592.10  587.30  590.45  78234500        0.0           0.0

The critical columns are Open, High, Low, Close, Volume. Everything else derives from these.

Computing Technical Indicators

Before we visualize, we need indicators. Most analysts use Moving Averages (SMA/EMA), RSI, MACD, and Bollinger Bands. Let’s implement them without external libraries — understanding the math matters more than calling ta.rsi().

Simple Moving Average (SMA)

The nn-period SMA at time tt is just the arithmetic mean of the last nn closing prices:

SMAt(n)=1ni=0n1Cti\text{SMA}_t(n) = \frac{1}{n} \sum_{i=0}^{n-1} C_{t-i}

where CtC_t is the closing price at time tt. Pandas makes this trivial:

df['SMA_20'] = df['Close'].rolling(window=20).mean()
df['SMA_50'] = df['Close'].rolling(window=50).mean()

The first 19 rows will be NaN because you can’t compute a 20-day average without 20 days.

Exponential Moving Average (EMA)

EMA weighs recent prices more heavily using an exponential decay factor α=2n+1\alpha = \frac{2}{n+1}:

EMAt=αCt+(1α)EMAt1\text{EMA}_t = \alpha \cdot C_t + (1 – \alpha) \cdot \text{EMA}_{t-1}

Pandas implements this with .ewm():

df['EMA_12'] = df['Close'].ewm(span=12, adjust=False).mean()
df['EMA_26'] = df['Close'].ewm(span=26, adjust=False).mean()

The adjust=False flag ensures the recursive formula above is used exactly. The docs claim adjust=True is more numerically stable for large datasets, but in practice I haven’t seen a difference for stock data (typically <10k rows).

MACD (Moving Average Convergence Divergence)

MACD is the difference between two EMAs, typically 12-day and 26-day:

MACDt=EMAt(12)EMAt(26)\text{MACD}_t = \text{EMA}_t(12) – \text{EMA}_t(26)

The signal line is a 9-day EMA of the MACD itself:

Signalt=EMAt(MACD,9)\text{Signal}_t = \text{EMA}_t(\text{MACD}, 9)

The histogram is just their difference:

Histogramt=MACDtSignalt\text{Histogram}_t = \text{MACD}_t – \text{Signal}_t

df['MACD'] = df['EMA_12'] - df['EMA_26']
df['MACD_signal'] = df['MACD'].ewm(span=9, adjust=False).mean()
df['MACD_hist'] = df['MACD'] - df['MACD_signal']

Traders watch for crossovers: when MACD crosses above the signal line, it’s bullish; below is bearish. The histogram visually encodes this — positive bars mean bullish momentum.

Relative Strength Index (RSI)

RSI measures momentum on a 0-100 scale. Values above 70 suggest overbought conditions, below 30 suggests oversold. The formula involves average gains and losses over nn periods (typically 14):

RS=Average GainAverage Loss,RSI=1001001+RS\text{RS} = \frac{\text{Average Gain}}{\text{Average Loss}}, \quad \text{RSI} = 100 – \frac{100}{1 + \text{RS}}

The “average” here is actually an exponential moving average (Wilder’s smoothing method), not a simple mean:

def compute_rsi(series, period=14):
    delta = series.diff()
    gain = (delta.where(delta > 0, 0)).fillna(0)
    loss = (-delta.where(delta < 0, 0)).fillna(0)

    # Wilder's smoothing: first value is SMA, rest are EMA-like
    avg_gain = gain.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
    avg_loss = loss.ewm(alpha=1/period, min_periods=period, adjust=False).mean()

    rs = avg_gain / avg_loss
    rsi = 100 - (100 / (1 + rs))
    return rsi

df['RSI'] = compute_rsi(df['Close'])

This is one of those cases where the implementation differs slightly from the textbook formula. Wilder’s original method uses a custom smoothing that’s not quite EMA — but ewm(alpha=1/period) approximates it well enough. If you run this on SPY data from 2024, you’ll notice RSI rarely touches the 30/70 extremes during low-volatility months. That’s normal.

Bollinger Bands

Bollinger Bands consist of a middle band (SMA) and upper/lower bands at ±k\pm k standard deviations (typically k=2k=2):

Upper=SMAt(n)+kσt(n),Lower=SMAt(n)kσt(n)\text{Upper} = \text{SMA}_t(n) + k \cdot \sigma_t(n), \quad \text{Lower} = \text{SMA}_t(n) – k \cdot \sigma_t(n)

where σt(n)\sigma_t(n) is the rolling standard deviation of the last nn prices:

df['BB_middle'] = df['Close'].rolling(window=20).mean()
df['BB_std'] = df['Close'].rolling(window=20).std()
df['BB_upper'] = df['BB_middle'] + 2 * df['BB_std']
df['BB_lower'] = df['BB_middle'] - 2 * df['BB_std']

Bands widen during high volatility (large σ\sigma) and narrow during consolidation. Price touching the upper band isn’t automatically a sell signal — trending markets can “walk the band” for weeks.

Plotting with Plotly: Candlesticks and Subplots

Now the fun part. We’ll create a multi-panel chart: candlestick + volume + MACD + RSI.

fig = make_subplots(
    rows=4, cols=1,
    shared_xaxes=True,
    vertical_spacing=0.03,
    row_heights=[0.5, 0.15, 0.2, 0.15],
    subplot_titles=('Price & Indicators', 'Volume', 'MACD', 'RSI')
)

# Row 1: Candlestick chart
fig.add_trace(
    go.Candlestick(
        x=df.index,
        open=df['Open'],
        high=df['High'],
        low=df['Low'],
        close=df['Close'],
        name='Price',
        increasing_line_color='#26a69a',
        decreasing_line_color='#ef5350'
    ),
    row=1, col=1
)

# Add SMA lines
fig.add_trace(
    go.Scatter(x=df.index, y=df['SMA_20'], mode='lines', 
               name='SMA 20', line=dict(color='orange', width=1)),
    row=1, col=1
)
fig.add_trace(
    go.Scatter(x=df.index, y=df['SMA_50'], mode='lines', 
               name='SMA 50', line=dict(color='blue', width=1)),
    row=1, col=1
)

# Add Bollinger Bands with fill
fig.add_trace(
    go.Scatter(x=df.index, y=df['BB_upper'], mode='lines', 
               name='BB Upper', line=dict(color='gray', width=0.5, dash='dash'),
               showlegend=False),
    row=1, col=1
)
fig.add_trace(
    go.Scatter(x=df.index, y=df['BB_lower'], mode='lines', 
               name='BB Lower', line=dict(color='gray', width=0.5, dash='dash'),
               fill='tonexty', fillcolor='rgba(128,128,128,0.1)',
               showlegend=False),
    row=1, col=1
)

# Row 2: Volume bars
colors = ['red' if close < open else 'green' 
          for close, open in zip(df['Close'], df['Open'])]
fig.add_trace(
    go.Bar(x=df.index, y=df['Volume'], name='Volume', 
           marker=dict(color=colors), showlegend=False),
    row=2, col=1
)

# Row 3: MACD
fig.add_trace(
    go.Scatter(x=df.index, y=df['MACD'], mode='lines', 
               name='MACD', line=dict(color='blue', width=1)),
    row=3, col=1
)
fig.add_trace(
    go.Scatter(x=df.index, y=df['MACD_signal'], mode='lines', 
               name='Signal', line=dict(color='red', width=1)),
    row=3, col=1
)
fig.add_trace(
    go.Bar(x=df.index, y=df['MACD_hist'], name='Histogram', 
           marker=dict(color=['green' if v >= 0 else 'red' for v in df['MACD_hist']]),
           showlegend=False),
    row=3, col=1
)

# Row 4: RSI with threshold lines
fig.add_trace(
    go.Scatter(x=df.index, y=df['RSI'], mode='lines', 
               name='RSI', line=dict(color='purple', width=1.5)),
    row=4, col=1
)
fig.add_hline(y=70, line=dict(color='red', dash='dash', width=0.8), row=4, col=1)
fig.add_hline(y=30, line=dict(color='green', dash='dash', width=0.8), row=4, col=1)

# Layout adjustments
fig.update_layout(
    title='SPY Technical Analysis Dashboard',
    xaxis_rangeslider_visible=False,
    height=900,
    template='plotly_white',
    hovermode='x unified'
)

fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor='lightgray')
fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor='lightgray')

fig.write_html("stock_analysis.html")
fig.show()

This outputs an interactive HTML file you can open in a browser. Hover over any date to see exact OHLCV values, indicator readings, and volume — all synchronized across subplots.

Why Plotly’s API Feels Clunky (But It’s Worth It)

Look at that code block. Nearly 80 lines just to plot four panels. Compare that to matplotlib’s plt.plot(x, y) simplicity. But matplotlib can’t export interactive HTML without Bokeh or mpld3 hacks, and those don’t handle candlestick charts gracefully.

Plotly’s verbosity comes from its flexibility: every trace (line, bar, candlestick) is an explicit object with full control over color, width, hover text, and legend behavior. The make_subplots API isn’t intuitive — row_heights must sum to 1.0 (or close), and if you forget shared_xaxes=True, your zoom won’t sync across panels. I’ve debugged that exact issue three times.

But once you have the template working, it’s copy-paste for other tickers. And the payoff is huge: you can zoom into a specific week, inspect exact crossover points, and screenshot clean visuals for reports — no matplotlib savefig DPI wrestling.

Detecting Signals Programmatically

Visuals are great for manual analysis, but you’ll want automated signal detection for backtesting (we’ll cover that in Part 4). Here’s a simple crossover detector:

def detect_sma_crossovers(df):
    """Detect golden cross (SMA20 > SMA50) and death cross (SMA20 < SMA50)."""
    df['SMA_diff'] = df['SMA_20'] - df['SMA_50']
    df['SMA_cross'] = np.sign(df['SMA_diff']).diff()

    golden_cross = df[df['SMA_cross'] == 2].index  # -1 to +1 transition
    death_cross = df[df['SMA_cross'] == -2].index  # +1 to -1 transition

    return golden_cross, death_cross

gc, dc = detect_sma_crossovers(df)
print(f"Golden crosses: {gc.tolist()}")
print(f"Death crosses: {dc.tolist()}")
# Example output (dates vary):
# Golden crosses: [Timestamp('2025-07-15'), Timestamp('2025-11-22')]
# Death crosses: [Timestamp('2025-09-10')]

The .diff() trick is elegant: when the sign of SMA_diff flips from negative to positive, np.sign() changes from -1 to +1, and the difference is +2. You can apply the same logic to MACD crossovers:

df['MACD_cross'] = np.sign(df['MACD_hist']).diff()
bullish = df[df['MACD_cross'] == 2].index
bearish = df[df['MACD_cross'] == -2].index

One gotcha: this produces false signals during choppy sideways markets. You’ll get 5-10 crosses in a month, most unprofitable. Filtering by RSI extremes helps — only take bullish crosses when RSI < 50, for example.

Volume-Weighted Average Price (VWAP)

Institutional traders care about VWAP — the average price weighted by volume. It’s computed as:

VWAPt=i=1tPiVii=1tVi\text{VWAP}_t = \frac{\sum_{i=1}^{t} P_i \cdot V_i}{\sum_{i=1}^{t} V_i}

where PiP_i is the typical price (average of high, low, close) and ViV_i is volume. In pandas:

df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 3
df['VWAP'] = (df['Typical_Price'] * df['Volume']).cumsum() / df['Volume'].cumsum()

This is a cumulative calculation, so it resets daily for intraday charts. For daily data, VWAP trends upward over time (because it’s cumulative from the first bar). For day trading, you’d reset it at market open each day:

# Assuming df has a DatetimeIndex
df['Date'] = df.index.date
df['VWAP'] = df.groupby('Date').apply(
    lambda x: ((x['Typical_Price'] * x['Volume']).cumsum() / x['Volume'].cumsum())
).reset_index(level=0, drop=True)

I haven’t tested this on multi-year datasets with millions of rows — my best guess is it’ll be slow because groupby.apply isn’t vectorized. For real-time systems, you’d compute VWAP incrementally.

When Plotly Breaks: Large Datasets

Plotly struggles with >50k datapoints. I tested this with 1-minute SPY data over 3 months (~30k bars) and the HTML file was 25MB, taking 4 seconds to render. The browser’s JavaScript engine chokes on the JSON payload.

The fix is downsampling or aggregation. For 1-minute data, resample to 5-minute or 15-minute bars:

df_resampled = df.resample('5T').agg({
    'Open': 'first',
    'High': 'max',
    'Low': 'min',
    'Close': 'last',
    'Volume': 'sum'
})

This reduces 30k bars to 6k without losing much visual fidelity. Alternatively, use scattergl instead of scatter for GPU-accelerated WebGL rendering:

fig.add_trace(go.Scattergl(x=df.index, y=df['Close'], mode='lines'))

It’s faster but doesn’t support all features (e.g., fill='tonexty' sometimes glitches). The docs claim WebGL works for “millions of points,” but in practice I’ve seen rendering artifacts above 100k.

The Case Against Plotly: Static Reports

If you’re generating PDFs for clients or regulatory filings, Plotly’s interactivity is useless. Exporting to PNG via fig.write_image() requires kaleido, which adds 50MB to your Docker image and occasionally segfaults on ARM servers (I’ve hit this on AWS Graviton instances). Matplotlib’s savefig() just works.

For batch report generation, I’d stick with matplotlib + seaborn. For exploratory analysis and dashboards, Plotly wins decisively.

What About mplfinance?

The mplfinance library wraps matplotlib with finance-specific defaults (candlesticks, volume bars, indicators). It’s cleaner than raw matplotlib but still produces static images:

import mplfinance as mpf

mpf.plot(df, type='candle', volume=True, mav=(20, 50), 
         style='charles', title='SPY', savefig='chart.png')

This is fine for quick visual checks. But for anything you’re sharing or analyzing deeply, the lack of interactivity is a dealbreaker. I’m not entirely sure why mplfinance doesn’t support HTML export — maybe it’s a design philosophy thing.

What’s Next

Use Plotly for stock visualization unless you’re printing reports. Compute indicators yourself instead of relying on ta or ta-lib — you’ll catch edge cases faster when you understand the math. And don’t trust crossover signals blindly; combine them with volume confirmation and broader market context.

In Part 3, we’ll pull financial news headlines and run sentiment analysis to see if NLP can actually predict price movements. Spoiler: the correlation is weaker than you’d hope, but there are pockets where it works. The challenge is separating signal from noise when every headline screams urgency.

US Stock Market Analyzer Series (2/5)

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 436 | TOTAL 2,659