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 для количественной сопоставимости и отрисовывайте каждый срез в своей оси. Вызов отрисовки оставьте на конец, чтобы вывести всё разом. Так сохраняется исходная логика расчёта профиля объёма, а результат — единый, цельный макет, который естественно читается слева направо.