2025, Sep 26 17:20

Единый график режимов: свечи и профиль объёма в Matplotlib

Как объединить три режима в один непрерывный график в Matplotlib: общий холст с GridSpec, общая ось Y, свечной график 5‑мин и профиль объёма по минутам.

Когда вы делите временной ряд на режимы и строите профиль объёма для каждого отрезка, отдельные графики скрывают последовательный характер рынка. На самом деле нужен единый холст, где каждый режим идёт вплотную за предыдущим вдоль оси X, а шкала цен у них общая. Ниже — сжатый разбор, как перейти от трёх изолированных графиков к единому непрерывному расположению «бок о бок», не меняя вычислительную логику.

Постановка задачи и отрисовка по режимам, из‑за которой появляется фрагментация

Набор данных содержит бары OHLCV и столбец Regime со значениями 1, 2 или 3. Приведённый ниже код группирует данные в 5‑минутные бары для свечей, строит профиль объёма по исходным минутным данным для каждого режима и рисует каждый режим в собственной фигуре. В результате получаются три раздельных графика вместо единого непрерывного вида вдоль оси X.

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()

Почему графики не выстраиваются в один общий вид

Каждый режим отрисовывается в своей фигуре, поэтому Matplotlib не получает указаний поставить их рядом или поделиться общей осью Y. Даже если отдельные панели выглядят схожими, раздельные фигуры не образуют единую временную шкалу. К тому же дублируются подписи и заголовки, что тратит горизонтальное пространство и ухудшает визуальную сопоставимость.

Соберите один рисунок с макетом GridSpec и общими осями

Решение — создать единую «мастер‑фигуру», задать сетку из трёх столбцов и направить команды отрисовки каждого режима в соответствующую ось. GridSpec убирает зазоры между панелями, sharey обеспечивает единую шкалу цен, suptitle задаёт общий заголовок, а локальные заголовки помечают отдельные режимы. Вызов plt.show() делайте один раз — в самом конце, после отрисовки всех трёх панелей.

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()

Почему это того стоит

Одна фигура с трёхпанельной раскладкой делает режимы частями одной последовательности, а не несвязанными графиками. Общая ось Y обеспечивает единую шкалу цен, исчезают пустые зазоры между панелями, а общие элементы диаграммы переносятся в suptitle и внешние подписи — читаемость растёт. Главное — порядок вдоль оси X теперь отражает задуманное развитие: Regime 2 следует за Regime 1, а Regime 3 — за Regime 2.

Итоги

Когда нужен непрерывный обзор по категориальным срезам (например, по режимам), не создавайте отдельные фигуры. Сделайте один родительский рисунок, разделите его GridSpec, поделитесь осью Y для количественной сопоставимости и отрисовывайте каждый срез в своей оси. Вызов отрисовки оставьте на конец, чтобы вывести всё разом. Так сохраняется исходная логика расчёта профиля объёма, а результат — единый, цельный макет, который естественно читается слева направо.

Статья основана на вопросе с StackOverflow от Giampaolo Levorato и ответе Aadvik.