VWAP (Volume Weighted Average Price) — средневзвешенная по объему цена за период. В отличие от простой скользящей средней, VWAP учитывает не только динамику цены, но и количество контрактов или акций, торгуемых на каждом ценовом уровне. Это делает его значительно более информативным ориентиром для оценки справедливой стоимости актива в течение сессии.
Институциональные участники используют VWAP как бенчмарк исполнения: алгоритмы крупных фондов и брокеров дробят заявки так, чтобы средняя цена сделки не отклонялась от VWAP. Именно поэтому индикатор обладает самосбывающейся природой — вокруг него концентрируется реальный институциональный поток.
Математика VWAP
Базовая формула VWAP за период из N баров выглядит так:
VWAP = Σ(Pᵢ × Vᵢ) / ΣVᵢ
где:
- Pᵢ — типичная цена i-го бара: (High + Low + Close) / 3;
- Vᵢ — объем i-го бара;
- i — индекс бара от 1 до N.
VWAP — кумулятивный индикатор: каждый новый бар обновляет числитель и знаменатель накопленным итогом с начала периода. Это принципиально отличает его от скользящего среднего, которое использует фиксированное окно.
Типичная цена (High + Low + Close) / 3 — стандартная аппроксимация, но на практике можно использовать только Close или (Open + Close) / 2. Для ликвидных инструментов с узким bid-ask спредом разница минимальна. На менее ликвидных рынках выбор формулы типичной цены влияет на результат ощутимее.
Стандартные полосы отклонения строятся на основе взвешенного стандартного отклонения:
σ = √(Σ(Vᵢ × (Pᵢ − VWAP)²) / ΣVᵢ)
Полосы:
VWAP ± k×σ
где k обычно равен 1, 2 или 3.
Первая полоса (±1σ) охватывает около 68% объема торгов при нормальном распределении — на практике это ориентир для краткосрочного mean reversion. Вторая полоса (±2σ) соответствует экстремальным отклонениям, которые чаще наблюдаются при направленном институциональном потоке или новостных движениях.
Внутридневной vs. якорный VWAP: когда использовать каждый
Стандартный внутридневной VWAP сбрасывается в начале каждой торговой сессии. Это его ключевое ограничение и одновременно преимущество: он актуален именно для текущего дня, отражает баланс сил между покупателями и продавцами в рамках одной сессии. На старших таймфреймах (дневных и выше) стандартный VWAP теряет смысл — кумуляция с начала дня на дневном баре дает тривиальный результат.
Якорный VWAP (Anchored VWAP, AVWAP) решает эту проблему: расчет начинается не с открытия сессии, а с произвольной точки. Точками привязки тут могут быть:
- начало недели, месяца, квартала — для оценки среднесрочного баланса;
- значимые экстремумы (максимум/минимум периода) — для анализа структуры рынка;
- дата публикации отчетности или иного события — для оценки реакции рынка;
- начало трендового движения — для определения уровней возврата к среднему.
AVWAP позволяет строить иерархию уровней: квартальный AVWAP задает макроуровень, недельный — среднесрочный, внутридневной — тактический. Совпадение нескольких AVWAP с разными точками привязки в одной ценовой зоне усиливает значимость этого уровня.
Связь объема и цены: почему VWAP отражает реальную стоимость
Рыночная микроструктура объясняет информационную ценность VWAP через концепцию price discovery. Крупные участники не могут исполнить заявку по одной цене — они вынуждены дробить ордера, распределяя объем во времени. Алгоритмы VWAP execution (VWAP algo) делают это явно: разбивают суммарный объем пропорционально историческому профилю объема за день и исполняют части в течение сессии.
Следствие для трейдера: цена устойчиво возвращается к VWAP, пока нет нового информационного импульса. Когда появляется значимый поток (earnings surprise, macro data), цена уходит от VWAP и формирует новый баланс. Это разделение — mean reversion вокруг VWAP в спокойных условиях против trend following при разрыве — составляет основу большинства VWAP-стратегий.
Volume Profile дополняет VWAP: точки наибольшего объема (Point of Control, POC) нередко совпадают с VWAP или AVWAP за аналогичный период. Их расхождение сигнализирует об асимметричном распределении объема — например, когда крупный участник агрессивно накапливал позицию в одном ценовом диапазоне, сдвигая POC, но не меняя VWAP существенно.
Построение VWAP-стратегий
VWAP используется в трех принципиально разных режимах:
- как уровень для стратегий возврата к среднему mean reversion;
- как фильтр направления при моментум-стратегиях;
- как бенчмарк исполнения для алгоритмических ордеров.
Смешение этих режимов в одной стратегии без явного разграничения условий — распространенная ошибка, которая ведет к противоречивым сигналам.
Стандартные отклонения от VWAP как уровни входа
Mean reversion стратегия на основе полос VWAP предполагает покупку при отклонении цены ниже VWAP − k×σ и продажу при отклонении выше VWAP + k×σ. Выход — возврат к VWAP или к противоположной полосе.
Параметр k выбирается в зависимости от инструмента и таймфрейма:
- k=1 — частые сигналы, высокий процент прибыльных сделок, малый средний профит;
- k=2 — баланс между частотой и качеством сигналов, стандартный выбор для ликвидных акций и фьючерсов;
- k=3 — редкие экстремальные отклонения, высокий средний профит, низкая частота.
Стратегии Mean reversion на VWAP работают лучше в диапазонных условиях с высокой внутридневной ликвидностью. Фильтр режима рынка обязателен: в трендовые дни отклонение от VWAP не возвращается к среднему, а продолжает расти. Простой фильтр — соотношение дневного диапазона (High − Low) к среднему диапазону за последние 20 дней. Если текущий диапазон превышает среднее в 1.5+ раза — mean reversion стратегию отключаем.
VWAP как фильтр направления тренда
В моментум-стратегиях VWAP выступает не источником сигнала, а фильтром: длинные позиции открываются только когда цена выше VWAP, короткие — только когда ниже. Это снижает количество сделок против доминирующего внутридневного потока.
Комбинирование VWAP-фильтра с сигналами на основе order flow или ценовой структуры дает более устойчивые результаты, чем изолированное применение любого из этих инструментов. Например: сигнал на вход — пробой уровня накопления объема (high volume node из Volume Profile), фильтр — цена выше дневного VWAP.
Якорный VWAP от квартального минимума или максимума используется как структурный уровень для позиционных стратегий. Цена выше квартального AVWAP при растущем объеме — бычья структура. Пересечение квартального AVWAP снизу вверх при объеме выше среднего — потенциальная точка входа в лонг с целью у предыдущего максимума.
Причины популярности VWAP среди институционалов
Эффективность VWAP как торгового инструмента напрямую связана с тем, что его используют институциональные алгоритмы. По данным консалтинговых компаний в области торговых технологий, VWAP execution остается одним из наиболее распространенных алгоритмических ордер-типов среди институциональных участников — наряду с TWAP и Implementation Shortfall.
Это создает наблюдаемые паттерны в данных. Профиль объема внутри дня типично U-образный: высокий объем на открытии, спад в середине сессии, рост к закрытию. Торговые алгоритмы на основе VWAP учитывают этот профиль, поэтому крупный объем концентрируется вблизи VWAP именно в периоды высокой ликвидности. Трейдер, понимающий эту механику, может использовать аномалии профиля объема (неожиданный рост объема в середине сессии) как ранний сигнал институционального накопления или распределения.
Важный нюанс: торговые алгоритмы VWAP не всегда стремятся купить ниже VWAP. Алгоритм Implementation Shortfall (IS), например, агрессивно торгует на открытии, чтобы минимизировать рыночный риск — даже по цене выше VWAP. Понимание типа алгоритма контрагента меняет интерпретацию ценового поведения вблизи VWAP.
Ограничения VWAP: когда индикатор теряет смысл
VWAP деградирует в нескольких ситуациях:
- Низкая ликвидность: на инструментах с редкими сделками объем распределен неравномерно, и кумулятивный VWAP стабилизируется после нескольких крупных сделок, переставая отражать текущий баланс;
- Первые минуты сессии: на открытии объем аномально высокий, и VWAP в первые 5–15 минут сильно зависит от гэпа. Сигналы в этот период ненадежны;
- Конец сессии: кумулятивный VWAP становится инертным — большой накопленный объем делает его нечувствительным к движениям последних баров;
- Инструменты без реального объема: спот рынок Forex не имеет централизованного объема. Тиковый объем — приближение, качество которого варьируется в зависимости от брокера и платформы.
На форексе VWAP применяется через тиковый объем (количество ценовых изменений за период) или через данные фьючерсов на валютные пары с CME. Второй вариант точнее, поскольку дает реальный биржевой объем, но требует подписки на соответствующий фид.
Расчет VWAP на Python
Для работы с VWAP на Python потребуются 5-минутные или более низкие таймфреймы — дневной OHLCV не подходит для внутридневного VWAP. Якорный VWAP можно строить на дневных данных, выбирая точку привязки в несколько недель или месяцев назад.
Загрузка и подготовка OHLCV-данных
Для получения минутных данных используем yfinance с интервалом 1m или 5m. Данные доступны примерно за 7 дней при интервале 1m и за 60 дней при 5m.
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
pd.set_option('display.expand_frame_repr', False)
# Загрузка минутных данных
ticker = "GLD" # ETF на золото
df = yf.download(ticker, period="5d", interval="5m", progress=False)
# Обработка MultiIndex если присутствует
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
df = df[["Open", "High", "Low", "Close", "Volume"]].copy()
df.dropna(inplace=True)
# Типичная цена
df["typical_price"] = (df["High"] + df["Low"] + df["Close"]) / 3
# Дата без времени для группировки по сессиям
df["date"] = df.index.date
print(df.tail())
print(f"Загружено строк: {len(df)}")
Price Open High Low Close Volume typical_price date
Datetime
2026-03-16 19:35:00+00:00 460.170013 460.609985 460.119995 460.510101 66181 460.413361 2026-03-16
2026-03-16 19:40:00+00:00 460.480011 460.653809 460.200012 460.239990 66388 460.364604 2026-03-16
2026-03-16 19:45:00+00:00 460.260010 460.380005 460.079987 460.299988 97140 460.253326 2026-03-16
2026-03-16 19:50:00+00:00 460.309998 460.829987 460.200012 460.550598 132042 460.526866 2026-03-16
2026-03-16 19:55:00+00:00 460.579987 460.750000 460.370087 460.450012 221534 460.523366 2026-03-16
Загружено строк: 390
После загрузки проверяем: нет ли пропусков в объеме (нулевые значения Volume в середине сессии — признак проблемы с данными или торговой паузы), корректен ли временной индекс.
Реализация стандартного и якорного VWAP
Ниже реализация обоих вариантов с полосами стандартного отклонения и визуализацией.
def calculate_vwap(df, anchor_date=None):
"""
Расчет VWAP и полос стандартного отклонения.
anchor_date: str или None. Если None — сброс по сессиям.
Возвращает DataFrame с добавленными колонками.
"""
result = df.copy()
if anchor_date is None:
# Внутридневной VWAP — сброс каждую сессию
result["cum_tp_vol"] = (
result.groupby("date")
.apply(lambda g: (g["typical_price"] * g["Volume"]).cumsum())
.reset_index(level=0, drop=True)
)
result["cum_vol"] = (
result.groupby("date")["Volume"]
.cumsum()
)
else:
# Якорный VWAP — расчет с указанной даты
anchor_idx = result.index >= pd.Timestamp(anchor_date, tz=result.index.tz)
result = result[anchor_idx].copy()
result["cum_tp_vol"] = (result["typical_price"] * result["Volume"]).cumsum()
result["cum_vol"] = result["Volume"].cumsum()
result["vwap"] = result["cum_tp_vol"] / result["cum_vol"]
# Взвешенное стандартное отклонение
if anchor_date is None:
result["cum_tp2_vol"] = (
result.groupby("date")
.apply(lambda g: (g["typical_price"] ** 2 * g["Volume"]).cumsum())
.reset_index(level=0, drop=True)
)
else:
result["cum_tp2_vol"] = (result["typical_price"] ** 2 * result["Volume"]).cumsum()
result["vwap_std"] = np.sqrt(
result["cum_tp2_vol"] / result["cum_vol"] - result["vwap"] ** 2
).clip(lower=0)
for k in [1, 2]:
result[f"vwap_upper_{k}"] = result["vwap"] + k * result["vwap_std"]
result[f"vwap_lower_{k}"] = result["vwap"] - k * result["vwap_std"]
return result
# Расчет внутридневного VWAP
df_vwap = calculate_vwap(df)
# Визуализация последней торговой сессии
last_date = df_vwap["date"].max()
day_data = df_vwap[df_vwap["date"] == last_date]
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8),
gridspec_kw={"height_ratios": [3, 1]},
sharex=True)
ax1.set_facecolor("#0d0d0d")
ax2.set_facecolor("#0d0d0d")
fig.patch.set_facecolor("#0d0d0d")
ax1.plot(day_data.index, day_data["Close"], color="#cccccc", linewidth=1.2, label="Close")
ax1.plot(day_data.index, day_data["vwap"], color="#f48040", linewidth=1.5, label="VWAP")
ax1.fill_between(day_data.index,
day_data["vwap_upper_1"], day_data["vwap_lower_1"],
alpha=0.15, color="#f48040", label="±1σ")
ax1.fill_between(day_data.index,
day_data["vwap_upper_2"], day_data["vwap_lower_2"],
alpha=0.07, color="#f48040", label="±2σ")
ax1.plot(day_data.index, day_data["vwap_upper_2"],
color="#f48040", linewidth=0.6, linestyle="--", alpha=0.6)
ax1.plot(day_data.index, day_data["vwap_lower_2"],
color="#f48040", linewidth=0.6, linestyle="--", alpha=0.6)
ax1.set_ylabel("Цена", color="#aaaaaa")
ax1.tick_params(colors="#aaaaaa")
ax1.legend(facecolor="#1a1a1a", labelcolor="#cccccc", fontsize=9)
ax1.set_title(f"{ticker} | Внутридневной VWAP | {last_date}", color="#cccccc")
for spine in ax1.spines.values():
spine.set_edgecolor("#333333")
ax2.bar(day_data.index, day_data["Volume"], color="#555555", width=0.003)
ax2.set_ylabel("Объем", color="#aaaaaa")
ax2.tick_params(colors="#aaaaaa")
for spine in ax2.spines.values():
spine.set_edgecolor("#333333")
ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
plt.xticks(color="#aaaaaa")
plt.tight_layout()
plt.savefig("vwap_intraday.png", dpi=150, bbox_inches="tight",
facecolor="#0d0d0d")
plt.show()

Рис. 1: Внутридневной VWAP с полосами отклонения. Верхняя панель — цена закрытия (серая линия) и VWAP (оранжевая) с зонами ±1σ и ±2σ. Нижняя панель — объем по барам. Зоны ±1σ охватывают большую часть ценового движения в спокойные периоды сессии; выходы за ±2σ при росте объема — потенциальные точки разворота или продолжения в зависимости от контекста
Логика функции: кумулятивные суммы (числитель и знаменатель VWAP) группируются по дате для внутридневного режима или считаются накопленным итогом с точки привязки для якорного. Стандартное отклонение вычисляется через разность второго момента и квадрата среднего — это векторизованный вариант взвешенной дисперсии без цикла по барам.
# Якорный VWAP от начала текущего месяца
anchor = df.index[df.index.date >= df.index.date.min()][0]
anchor_str = anchor.strftime("%Y-%m-%d")
df_avwap = calculate_vwap(df, anchor_date=anchor_str)
fig, ax = plt.subplots(figsize=(14, 5))
ax.set_facecolor("#0d0d0d")
fig.patch.set_facecolor("#0d0d0d")
ax.plot(df_avwap.index, df_avwap["Close"], color="#cccccc", linewidth=1.0, label="Close")
ax.plot(df_avwap.index, df_avwap["vwap"], color="#f48040", linewidth=1.5, label=f"AVWAP от {anchor_str}")
ax.fill_between(df_avwap.index,
df_avwap["vwap_upper_1"], df_avwap["vwap_lower_1"],
alpha=0.12, color="#f48040", label="±1σ")
ax.fill_between(df_avwap.index,
df_avwap["vwap_upper_2"], df_avwap["vwap_lower_2"],
alpha=0.06, color="#f48040")
ax.plot(df_avwap.index, df_avwap["vwap_upper_2"],
color="#f48040", linewidth=0.6, linestyle="--", alpha=0.5)
ax.plot(df_avwap.index, df_avwap["vwap_lower_2"],
color="#f48040", linewidth=0.6, linestyle="--", alpha=0.5)
ax.set_ylabel("Цена", color="#aaaaaa")
ax.tick_params(colors="#aaaaaa")
ax.legend(facecolor="#1a1a1a", labelcolor="#cccccc", fontsize=9)
ax.set_title(f"{ticker} | Якорный VWAP | точка привязки: {anchor_str}", color="#cccccc")
for spine in ax.spines.values():
spine.set_edgecolor("#333333")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
plt.xticks(rotation=30, color="#aaaaaa")
plt.tight_layout()
plt.savefig("vwap_anchored.png", dpi=150, bbox_inches="tight", facecolor="#0d0d0d")
plt.show()

Рис. 2: Якорный VWAP за весь доступный период данных. В отличие от внутридневного, AVWAP не сбрасывается между сессиями — полосы расширяются по мере накопления данных в начале периода и стабилизируются на более длинном горизонте. Уровень AVWAP на старших периодах работает как структурный ориентир: ценовые зоны вблизи него нередко становятся зонами консолидации перед следующим движением
Бэктестинг VWAP-стратегии
Для бэктеста реализуем простую mean reversion стратегию: вход в лонг при пересечении цены ниже VWAP − 1.5σ, выход при возврате к VWAP. Симметрично для шорта. Бэктест строим на минутных данных без внешних библиотек — это дает полный контроль над логикой исполнения.
def backtest_vwap_reversion(df_vwap, k=1.5, slippage=0.0001, min_bars=3):
"""
Mean reversion стратегия на полосах VWAP.
Вход:
long -> Close < vwap - k * std short -> Close > vwap + k * std
Выход:
- возврат к VWAP
- конец сессии (EOD)
Параметры:
k: ширина полос
slippage: доля (например 0.0001 = 1 bps на сторону)
min_bars: сколько баров пропускать в начале сессии
"""
data = df_vwap.copy()
# Базовые проверки
required_cols = {"vwap", "vwap_std", "Close", "date"}
missing = required_cols - set(data.columns)
if missing:
raise ValueError(f"Missing columns: {missing}")
# Фичи
data["upper"] = data["vwap"] + k * data["vwap_std"]
data["lower"] = data["vwap"] - k * data["vwap_std"]
# Номер бара внутри сессии
data["bar_num"] = data.groupby("date").cumcount()
trades = []
position = 0 # 1 long, -1 short, 0 flat
direction = 0
entry_price = None
entry_time = None
prev_date = None
for ts, row in data.iterrows():
current_date = row["date"]
# EOD закрытие
if prev_date is not None and current_date != prev_date and position != 0:
exit_price = row["Close"] * (1 - slippage * direction)
pnl = (exit_price - entry_price) * direction
trades.append({
"entry_time": entry_time,
"exit_time": ts,
"direction": direction,
"entry_price": entry_price,
"exit_price": exit_price,
"pnl_pct": pnl / entry_price,
"exit_reason": "eod"
})
position = 0
direction = 0
# Вход
if position == 0:
if row["bar_num"] < min_bars: prev_date = current_date continue if row["vwap_std"] > 0:
# long
if row["Close"] < row["lower"]: position = 1 direction = 1 entry_price = row["Close"] * (1 + slippage) entry_time = ts # short elif row["Close"] > row["upper"]:
position = -1
direction = -1
entry_price = row["Close"] * (1 - slippage)
entry_time = ts
# Выход
elif position == 1:
if row["Close"] >= row["vwap"]:
exit_price = row["Close"] * (1 - slippage)
pnl = (exit_price - entry_price) * direction
trades.append({
"entry_time": entry_time,
"exit_time": ts,
"direction": direction,
"entry_price": entry_price,
"exit_price": exit_price,
"pnl_pct": pnl / entry_price,
"exit_reason": "vwap_touch"
})
position = 0
direction = 0
elif position == -1:
if row["Close"] <= row["vwap"]: exit_price = row["Close"] * (1 + slippage) pnl = (exit_price - entry_price) * direction trades.append({ "entry_time": entry_time, "exit_time": ts, "direction": direction, "entry_price": entry_price, "exit_price": exit_price, "pnl_pct": pnl / entry_price, "exit_reason": "vwap_touch" }) position = 0 direction = 0 prev_date = current_date return pd.DataFrame(trades) # Запуск бэктеста trades_df = backtest_vwap_reversion(df_vwap, k=1.5, slippage=0.0001) print(f"Всего сделок: {len(trades_df)}") if len(trades_df) > 0:
print(trades_df[["direction", "pnl_pct", "exit_reason"]].tail(10))
Всего сделок: 12
direction pnl_pct exit_reason
2 1 0.000189 eod
3 1 0.003005 vwap_touch
4 -1 0.002592 vwap_touch
5 1 0.003320 vwap_touch
6 1 0.001765 vwap_touch
7 1 -0.008607 eod
8 -1 0.001802 vwap_touch
9 1 -0.012365 eod
10 -1 0.000319 vwap_touch
11 1 -0.002843 vwap_touch
Бэктест итерируется по барам, отслеживая состояние позиции. Первые три бара каждой сессии пропускаются — в этот период VWAP нестабилен и полосы отклонения слишком узкие. Проскальзывание задается как доля от цены и применяется асимметрично: при покупке цена входа увеличивается, при продаже — уменьшается. Принудительное закрытие на конце сессии исключает перенос внутридневных позиций на следующий день.
Давайте теперь разберем результаты:
def analyze_trades(trades_df):
if len(trades_df) == 0:
print("Нет сделок для анализа")
return
pnl = trades_df["pnl_pct"]
equity = (1 + pnl).cumprod()
wins = pnl[pnl > 0]
losses = pnl[pnl <= 0] total_return = equity.iloc[-1] - 1 win_rate = len(wins) / len(pnl) avg_win = wins.mean() if len(wins) > 0 else 0
avg_loss = losses.mean() if len(losses) > 0 else 0
profit_factor = (wins.sum() / abs(losses.sum())) if len(losses) > 0 else np.inf
drawdown = (equity / equity.cummax() - 1)
max_dd = drawdown.min()
sharpe = (pnl.mean() / pnl.std()) * np.sqrt(252 * 78) if pnl.std() > 0 else 0
print("=" * 45)
print(f"{'Метрика':<28} {'Значение':>12}")
print("=" * 45)
print(f"{'Всего сделок':<28} {len(pnl):>12}")
print(f"{'Доходность':<28} {total_return:>11.2%}")
print(f"{'Win Rate':<28} {win_rate:>11.2%}")
print(f"{'Средний выигрыш':<28} {avg_win:>11.4%}")
print(f"{'Средний проигрыш':<28} {avg_loss:>11.4%}")
print(f"{'Profit Factor':<28} {profit_factor:>12.2f}")
print(f"{'Макс. просадка':<28} {max_dd:>11.2%}")
print(f"{'Sharpe (annual.)':<28} {sharpe:>12.2f}")
print("=" * 45)
# Визуализация equity curve и распределения PnL
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
fig.patch.set_facecolor("#0d0d0d")
for ax in [ax1, ax2]:
ax.set_facecolor("#0d0d0d")
for spine in ax.spines.values():
spine.set_edgecolor("#333333")
ax.tick_params(colors="#aaaaaa")
ax1.plot(equity.values, color="#cccccc", linewidth=1.2)
ax1.fill_between(range(len(equity)),
equity.values, 1,
where=equity.values >= 1,
alpha=0.15, color="#4caf50")
ax1.fill_between(range(len(equity)),
equity.values, 1,
where=equity.values < 1,
alpha=0.15, color="#e53935")
ax1.axhline(1, color="#555555", linewidth=0.8, linestyle="--")
ax1.set_title("Equity Curve", color="#cccccc")
ax1.set_xlabel("Сделка №", color="#aaaaaa")
ax1.set_ylabel("Накопленный капитал", color="#aaaaaa")
ax2.hist(pnl * 100, bins=30, color="#555555", edgecolor="#333333")
ax2.axvline(0, color="#f48040", linewidth=1.2, linestyle="--")
ax2.axvline(pnl.mean() * 100, color="#cccccc", linewidth=1.0,
linestyle=":", label=f"Среднее: {pnl.mean()*100:.3f}%")
ax2.set_title("Распределение PnL по сделкам", color="#cccccc")
ax2.set_xlabel("PnL, %", color="#aaaaaa")
ax2.set_ylabel("Количество сделок", color="#aaaaaa")
ax2.legend(facecolor="#1a1a1a", labelcolor="#cccccc", fontsize=9)
plt.suptitle("VWAP Mean Reversion | Результаты бэктеста", color="#cccccc", y=1.02)
plt.tight_layout()
plt.savefig("vwap_backtest_results.png", dpi=150, bbox_inches="tight",
facecolor="#0d0d0d")
plt.show()
analyze_trades(trades_df)
=============================================
Метрика Значение
=============================================
Всего сделок 12
Доходность -0.98%
Win Rate 66.67%
Средний выигрыш 0.1773%
Средний проигрыш -0.5983%
Profit Factor 0.59
Макс. просадка -2.16%
Sharpe (annual.) -23.34
=============================================

Рис. 3: Результаты бэктеста стратегии VWAP mean reversion. Левая панель — equity curve по сделкам: зеленая заливка выше стартового капитала, красная — ниже. Правая панель — гистограмма распределения PnL по сделкам с вертикальными маркерами нуля (оранжевый) и среднего PnL (серый). Смещение распределения вправо от нуля при высоком win rate указывает на работоспособность mean reversion логики; левый хвост показывает характер потерь при трендовых движениях
Аннуализированный Sharpe считается с множителем √(252 × 78), где 78 — количество 5-минутных баров в торговой сессии NYSE. Для других рынков этот множитель нужно скорректировать. Profit factor выше 1.3 при win rate выше 55% — минимальная планка для дальнейшей проработки стратегии; результаты ниже этих уровней на коротком периоде данных, скорее всего, не переживут тест на будущих данных.
Оценка эффективности
Для стратегий возврата к среднему, построенных на VWAP, приоритетные метрики — profit factor и win rate в связке, а не Sharpe изолированно. Mean reversion по природе дает высокий win rate с небольшим средним выигрышем и редкими крупными потерями, поэтому Sharpe может выглядеть приемлемо даже при отрицательном математическом ожидании на длинном горизонте.
Минимальный набор метрик для оценки:
- Profit Factor — отношение суммарного выигрыша к суммарному проигрышу; порог: >1.3;
- Win Rate + средний выигрыш / средний проигрыш — оцениваем совместно через expectancy = WR × avg_win − (1 − WR) × avg_loss;
- Максимальная просадка — в сопоставлении со средней доходностью за аналогичный период;
- Calmar ratio — отношение годовой доходности к максимальной просадке; порог: >0.5 для внутридневных стратегий.
Статистическая значимость результатов на коротком периоде данных (5–60 дней минутных баров) низкая по определению. Для базовой проверки используем перестановочный тест: перемешиваем PnL сделок случайным образом N=1000 раз и смотрим, в какой доле симуляций случайная стратегия показывает результат не хуже наблюдаемого. Если p-value выше 0.05 — результаты не отличаются от случайных на данном объеме выборки.
def permutation_test(trades_df, n_simulations=1000):
if len(trades_df) < 10: print("Недостаточно сделок для теста") return pnl = trades_df["pnl_pct"].values observed_pf = pnl[pnl > 0].sum() / abs(pnl[pnl <= 0].sum()) rng = np.random.default_rng(42) simulated_pf = [] for _ in range(n_simulations): shuffled = rng.permutation(pnl) pos = shuffled[shuffled > 0].sum()
neg = abs(shuffled[shuffled <= 0].sum()) simulated_pf.append(pos / neg if neg > 0 else np.nan)
simulated_pf = np.array([x for x in simulated_pf if not np.isnan(x)])
p_value = (simulated_pf >= observed_pf).mean()
print(f"Observed Profit Factor : {observed_pf:.3f}")
print(f"Median simulated PF : {np.median(simulated_pf):.3f}")
print(f"p-value : {p_value:.3f}")
fig, ax = plt.subplots(figsize=(10, 4))
ax.set_facecolor("#0d0d0d")
fig.patch.set_facecolor("#0d0d0d")
for spine in ax.spines.values():
spine.set_edgecolor("#333333")
ax.hist(simulated_pf, bins=40, color="#555555", edgecolor="#333333", label="Случайные PF")
ax.axvline(observed_pf, color="#f48040", linewidth=1.5, label=f"Observed PF={observed_pf:.3f}")
ax.axvline(np.median(simulated_pf), color="#aaaaaa", linewidth=1.0,
linestyle="--", label=f"Median sim={np.median(simulated_pf):.3f}")
ax.set_title(f"Перестановочный тест | p-value={p_value:.3f}", color="#cccccc")
ax.set_xlabel("Profit Factor", color="#aaaaaa")
ax.set_ylabel("Частота", color="#aaaaaa")
ax.tick_params(colors="#aaaaaa")
ax.legend(facecolor="#1a1a1a", labelcolor="#cccccc", fontsize=9)
plt.tight_layout()
plt.savefig("vwap_permutation_test.png", dpi=150, bbox_inches="tight",
facecolor="#0d0d0d")
plt.show()
permutation_test(trades_df)

Рис. 4: Перестановочный тест для profit factor. Гистограмма показывает распределение PF при случайном порядке сделок; оранжевая вертикаль — наблюдаемый PF стратегии. Чем правее оранжевая линия относительно распределения, тем ниже p-value и тем меньше вероятность случайного результата. При малом числе сделок (менее 30–40) даже хороший PF не дает низкого p-value — это аргумент для сбора большей выборки перед выводами
Walk-forward валидация для внутридневных VWAP стратегий строится по неделям или двухнедельным блокам: обучение на N неделях, тест на следующей. Основная проверка — стабильность параметра k (множитель стандартного отклонения) и порога фильтра трендового дня. Если оптимальный k в разных периодах прыгает от 1.0 до 3.0 — стратегия подогнана под данные, а не эксплуатирует устойчивый паттерн.
Заключение
VWAP — один из немногих технических индикаторов, у которого есть реальное институциональное обоснование. Его эффективность держится не на визуальных паттернах, а на том, что крупные участники действительно торгуют относительно него. Это создает наблюдаемую структуру в данных, которую можно формализовать и проверить.
Вместе с тем VWAP не работает как готовый торговый сигнал в отрыве от контекста: режим рынка, профиль объема, тип сессии — все это меняет интерпретацию отклонений от индикатора. Бэктест на минутных данных дает первичную проверку гипотезы, но статистическая значимость на коротких выборках остается ограниченной. Путь от работающего бэктеста к реальной стратегии проходит через строгую out-of-sample валидацию и честный учет транзакционных издержек.