2025, Sep 26 17:00

From Fragmented Panels to One Timeline: Side-by-Side Regime Candlesticks with Volume Profiles in Matplotlib

Plot regime-based candlesticks and volume profiles on one continuous canvas using Matplotlib GridSpec and a shared y-axis for a cleaner left-to-right timeline.

When you split a time series into regimes and draw a volume profile for each slice, plotting them separately hides the sequential nature of the market. What you actually want is a single canvas that shows each regime back-to-back along the x-axis while preserving a shared price scale. Below is a concise walkthrough of how to move from three isolated plots to one continuous, side-by-side layout without changing the computation logic.

Problem setup and the per-regime plotting that causes fragmentation

The dataset contains OHLCV bars with a Regime column set to 1, 2, or 3. The code below groups the data into 5-minute bars for candlesticks, builds a volume profile from the original 1-minute data for each regime, and plots each regime in its own figure. The result is three separate charts rather than one continuous view along the x-axis.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

payload = {
    'Date' : ['2025-08-22 16:00:00','2025-08-22 16:01:00','2025-08-22 16:02:00','2025-08-22 16:03:00','2025-08-22 16:04:00','2025-08-22 16:05:00','2025-08-22 16:06:00','2025-08-22 16:07:00','2025-08-22 16:08:00','2025-08-22 16:09:00','2025-08-22 16:10:00','2025-08-22 16:11:00','2025-08-22 16:12:00','2025-08-22 16:13:00','2025-08-22 16:14:00','2025-08-22 16:15:00','2025-08-22 16:16:00','2025-08-22 16:17:00','2025-08-22 16:18:00','2025-08-22 16:19:00','2025-08-22 16:20:00','2025-08-22 16:21:00','2025-08-22 16:22:00','2025-08-22 16:23:00','2025-08-22 16:24:00'],
    'Open': [11717.9,11717.95,11716.6,11717.4,11719.5,11727.25,11725.55,11724.35,11725.45,11724.15,11728.2,11726.6,11727.6,11729.1,11724.1,11722.8,11721.8,11720.8,11718.8,11716.7,11716.9,11722.5,11721.6,11727.8,11728.1],
    'Low': [11715.9,11716,11715.35,11716.45,11719.5,11724.3,11723.55,11723.15,11723.85,11724.15,11725.2,11726.6,11727.6,11724.2,11722.6,11721.6,11719.7,11715.8,11716.5,11716,11716.9,11721.3,11721.4,11726.35,11727],
    'High': [11718.1,11718.1,11717.9,11719.4,11727.15,11727.45,11726,11725.65,11727.2,11727.85,11728.2,11728.7,11729.5,11729.1,11725.5,11723.9,11722,11720.8,11719.8,11717.7,11722.9,11724.3,11727.8,11728.3,11728.8],
    'Close' : [11718.05,11716.5,11717,11719.3,11727.15,11725.65,11724.15,11725.35,11724.05,11727.65,11726.7,11727.8,11729.2,11724.2,11722.6,11721.7,11721.2,11718.7,11716.6,11716.8,11722.6,11721.5,11727.6,11728,11727.2],
    'Volume': [130,88,125,93,154,102,118,92,105,116,84,88,108,99,82,109,98,130,71,86,96,83,80,93,73],
    'Regime': [1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,3,3,3,3,3,3,3],
}

frame = pd.DataFrame(data=payload)
frame['Date'] = pd.to_datetime(frame['Date'])
frame.set_index('Date', inplace=True)

five_min = frame.resample('5T').agg({'Open': 'first','High': 'max','Low': 'min','Close': 'last','Volume': 'sum','Regime': 'last'})
five_min = five_min.dropna()

five_min['date'] = five_min.index.date
frame['date'] = frame.index.date

for regime_val in frame['Regime'].unique():
    slice_5 = five_min[five_min['Regime'] == regime_val]
    slice_1 = frame[frame['Regime'] == regime_val]
    if slice_5.empty:
        continue

    fig_one, axis = plt.subplots(figsize=(10, 6))

    slice_5_reset = slice_5.reset_index()
    x_idx = np.arange(len(slice_5_reset))

    p_min = slice_1['Low'].min()
    p_max = slice_1['High'].max()
    bin_count = 200
    price_bins = np.linspace(p_min, p_max, bin_count + 1)
    step = price_bins[1] - price_bins[0]
    centers = (price_bins[:-1] + price_bins[1:]) / 2
    vp = np.zeros(bin_count)

    for _, r in slice_1.iterrows():
        lo = r['Low']
        hi = r['High']
        vol = r['Volume']
        if hi == lo:
            bidx = np.digitize(lo, price_bins) - 1
            if 0 <= bidx < bin_count:
                vp[bidx] += vol
        else:
            vpu = vol / (hi - lo)
            sb = np.digitize(lo, price_bins)
            eb = np.digitize(hi, price_bins)
            for b in range(sb, eb + 1):
                if b > 0 and b <= bin_count:
                    b_start = price_bins[b - 1]
                    b_end = price_bins[b]
                    seg_start = max(lo, b_start)
                    seg_end = min(hi, b_end)
                    part = (seg_end - seg_start) * vpu
                    vp[b - 1] += part

    span = len(slice_5_reset)
    if vp.max() > 0:
        vp_scaled = (vp / vp.max()) * span
    else:
        vp_scaled = vp

    poc_i = np.argmax(vp)
    poc_val = centers[poc_i]

    axis.fill_betweenx(centers, 0, vp_scaled, color='blue', alpha=0.3, step='mid')
    axis.axhline(poc_val, color='red', linestyle='-', linewidth=1)

    body_w = 0.6
    for i in range(len(slice_5_reset)):
        o = slice_5_reset['Open'][i]
        h = slice_5_reset['High'][i]
        l = slice_5_reset['Low'][i]
        c = slice_5_reset['Close'][i]
        if c > o:
            clr = 'green'
            base = o
            hgt = c - o
        else:
            clr = 'red'
            base = c
            hgt = o - c
        axis.vlines(x_idx[i], l, h, color='black', linewidth=0.5)
        axis.bar(x_idx[i], hgt, body_w, base, color=clr, edgecolor='black')

    axis.set_xlim(-1, span + 1)
    axis.set_ylim(p_min - step, p_max + step)
    axis.set_xticks(x_idx)
    axis.set_xticklabels(slice_5_reset['Date'].dt.strftime('%H:%M'), rotation=45)
    axis.set_title(f'30-min Candlestick with Volume Profile - Regime: {regime_val}')
    axis.set_xlabel('Time')
    axis.set_ylabel('Price')

    plt.tight_layout()
    plt.show()

Why the plots don’t line up in one view

Each regime is rendered into its own figure, so nothing tells Matplotlib to place them next to each other or to share a common y-axis. Even if the individual panels look similar, separate figures cannot form one continuous timeline. They also repeat labels and titles, wasting horizontal space and reducing visual comparability.

Compose one figure with a GridSpec layout and shared axes

The fix is to build a single, master figure, create a three-column grid, and route each regime’s drawing commands to its corresponding axis. GridSpec removes spacing between panels, sharey keeps the price scale consistent, a suptitle handles the common title, and minor titles label each regime. Call plt.show() only once at the end, after all three panels are drawn.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

payload = {
    'Date' : ['2025-08-22 16:00:00','2025-08-22 16:01:00','2025-08-22 16:02:00','2025-08-22 16:03:00','2025-08-22 16:04:00','2025-08-22 16:05:00','2025-08-22 16:06:00','2025-08-22 16:07:00','2025-08-22 16:08:00','2025-08-22 16:09:00','2025-08-22 16:10:00','2025-08-22 16:11:00','2025-08-22 16:12:00','2025-08-22 16:13:00','2025-08-22 16:14:00','2025-08-22 16:15:00','2025-08-22 16:16:00','2025-08-22 16:17:00','2025-08-22 16:18:00','2025-08-22 16:19:00','2025-08-22 16:20:00','2025-08-22 16:21:00','2025-08-22 16:22:00','2025-08-22 16:23:00','2025-08-22 16:24:00'],
    'Open': [11717.9,11717.95,11716.6,11717.4,11719.5,11727.25,11725.55,11724.35,11725.45,11724.15,11728.2,11726.6,11727.6,11729.1,11724.1,11722.8,11721.8,11720.8,11718.8,11716.7,11716.9,11722.5,11721.6,11727.8,11728.1],
    'Low': [11715.9,11716,11715.35,11716.45,11719.5,11724.3,11723.55,11723.15,11723.85,11724.15,11725.2,11726.6,11727.6,11724.2,11722.6,11721.6,11719.7,11715.8,11716.5,11716,11716.9,11721.3,11721.4,11726.35,11727],
    'High': [11718.1,11718.1,11717.9,11719.4,11727.15,11727.45,11726,11725.65,11727.2,11727.85,11728.2,11728.7,11729.5,11729.1,11725.5,11723.9,11722,11720.8,11719.8,11717.7,11722.9,11724.3,11727.8,11728.3,11728.8],
    'Close' : [11718.05,11716.5,11717,11719.3,11727.15,11725.65,11724.15,11725.35,11724.05,11727.65,11726.7,11727.8,11729.2,11724.2,11722.6,11721.7,11721.2,11718.7,11716.6,11716.8,11722.6,11721.5,11727.6,11728,11727.2],
    'Volume': [130,88,125,93,154,102,118,92,105,116,84,88,108,99,82,109,98,130,71,86,96,83,80,93,73],
    'Regime': [1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,3,3,3,3,3,3,3],
}

frame = pd.DataFrame(data=payload)
frame['Date'] = pd.to_datetime(frame['Date'])
frame.set_index('Date', inplace=True)

five_min = frame.resample('5T').agg({'Open': 'first','High': 'max','Low': 'min','Close': 'last','Volume': 'sum','Regime': 'last'})
five_min = five_min.dropna()

five_min['date'] = five_min.index.date
frame['date'] = frame.index.date

canvas = plt.figure(figsize=(30, 6))
layout = canvas.add_gridspec(nrows=1, ncols=3, hspace=0, wspace=0)
axarr = layout.subplots(sharey=True)
canvas.suptitle('30-min Candlestick with Volume Profile')

for idx_reg, regime_val in enumerate(frame['Regime'].unique()):
    slice_5 = five_min[five_min['Regime'] == regime_val]
    slice_1 = frame[frame['Regime'] == regime_val]
    if slice_5.empty:
        continue

    slice_5_reset = slice_5.reset_index()
    x_idx = np.arange(len(slice_5_reset))

    p_min = slice_1['Low'].min()
    p_max = slice_1['High'].max()
    bin_count = 200
    price_bins = np.linspace(p_min, p_max, bin_count + 1)
    step = price_bins[1] - price_bins[0]
    centers = (price_bins[:-1] + price_bins[1:]) / 2
    vp = np.zeros(bin_count)

    for _, r in slice_1.iterrows():
        lo = r['Low']
        hi = r['High']
        vol = r['Volume']
        if hi == lo:
            bidx = np.digitize(lo, price_bins) - 1
            if 0 <= bidx < bin_count:
                vp[bidx] += vol
        else:
            vpu = vol / (hi - lo)
            sb = np.digitize(lo, price_bins)
            eb = np.digitize(hi, price_bins)
            for b in range(sb, eb + 1):
                if b > 0 and b <= bin_count:
                    b_start = price_bins[b - 1]
                    b_end = price_bins[b]
                    seg_start = max(lo, b_start)
                    seg_end = min(hi, b_end)
                    part = (seg_end - seg_start) * vpu
                    vp[b - 1] += part

    span = len(slice_5_reset)
    if vp.max() > 0:
        vp_scaled = (vp / vp.max()) * span
    else:
        vp_scaled = vp

    poc_i = np.argmax(vp)
    poc_val = centers[poc_i]

    axarr[idx_reg].fill_betweenx(centers, 0, vp_scaled, color='blue', alpha=0.3, step='mid')
    axarr[idx_reg].axhline(poc_val, color='red', linestyle='-', linewidth=1)

    body_w = 0.6
    for i in range(len(slice_5_reset)):
        o = slice_5_reset['Open'][i]
        h = slice_5_reset['High'][i]
        l = slice_5_reset['Low'][i]
        c = slice_5_reset['Close'][i]
        if c > o:
            clr = 'green'
            base = o
            hgt = c - o
        else:
            clr = 'red'
            base = c
            hgt = o - c
        axarr[idx_reg].vlines(x_idx[i], l, h, color='black', linewidth=0.5)
        axarr[idx_reg].bar(x_idx[i], hgt, body_w, base, color=clr, edgecolor='black')

    axarr[idx_reg].set_xlim(-1, span + 1)
    axarr[idx_reg].set_ylim(p_min - step, p_max + step)
    axarr[idx_reg].set_xticks(x_idx)
    axarr[idx_reg].set_xticklabels(slice_5_reset['Date'].dt.strftime('%H:%M'), rotation=45)
    axarr[idx_reg].set_title(f'Regime: {regime_val}')
    axarr[idx_reg].set_xlabel('Time')
    axarr[idx_reg].set_ylabel('Price')

for a in axarr:
    a.label_outer()

plt.tight_layout()
plt.show()

Why this is worth doing

A single figure with a three-panel layout makes the regimes feel like segments of one sequence, not unrelated charts. A shared y-axis enforces a consistent price scale, the empty space between panels disappears, and common chart elements move to the suptitle and outer labels, improving readability. Most importantly, the order along the x-axis now reflects the intended progression: Regime 2 follows Regime 1, and Regime 3 follows Regime 2.

Takeaways

When you need a continuous view across categorical slices such as regimes, don’t create separate figures. Build one parent figure, partition it with GridSpec, share the y-axis for quantitative comparability, and draw each slice into its assigned axis. Keep the plotting call until the end to render everything at once. This retains the original computation of the volume profile while producing a single, coherent layout that reads naturally from left to right.

The article is based on a question from StackOverflow by Giampaolo Levorato and an answer by Aadvik.