VWAP стратегия: расчет, бэктестинг и оценка эффективности

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) делают это явно: разбивают суммарный объем пропорционально историческому профилю объема за день и исполняют части в течение сессии.

👉🏻  Греки опционов (Delta, Gamma, Theta, Vega, Rho). Расчет и интерпретация чувствительности опционов к различным факторам

Следствие для трейдера: цена устойчиво возвращается к 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 используется в трех принципиально разных режимах:

  1. как уровень для стратегий возврата к среднему mean reversion;
  2. как фильтр направления при моментум-стратегиях;
  3. как бенчмарк исполнения для алгоритмических ордеров.

Смешение этих режимов в одной стратегии без явного разграничения условий — распространенная ошибка, которая ведет к противоречивым сигналам.

Стандартные отклонения от 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 снизу вверх при объеме выше среднего — потенциальная точка входа в лонг с целью у предыдущего максимума.

👉🏻  Изучаем опционы на Netflix: комплексный анализ и стратегии

Причины популярности 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 в середине сессии — признак проблемы с данными или торговой паузы), корректен ли временной индекс.

👉🏻  Скользящие оконные функции в Pandas

Реализация стандартного и якорного 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()

Внутридневной VWAP с полосами отклонения. Верхняя панель — цена закрытия (серая линия) и VWAP (оранжевая) с зонами ±1σ и ±2σ. Нижняя панель — объем по барам. Зоны ±1σ охватывают большую часть ценового движения в спокойные периоды сессии; выходы за ±2σ при росте объема — потенциальные точки разворота или продолжения в зависимости от контекста

Рис. 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()

Якорный VWAP за весь доступный период данных. В отличие от внутридневного, AVWAP не сбрасывается между сессиями — полосы расширяются по мере накопления данных в начале периода и стабилизируются на более длинном горизонте. Уровень AVWAP на старших периодах работает как структурный ориентир: ценовые зоны вблизи него нередко становятся зонами консолидации перед следующим движением

Рис. 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
=============================================

Результаты бэктеста стратегии VWAP mean reversion. Левая панель — equity curve по сделкам: зеленая заливка выше стартового капитала, красная — ниже. Правая панель — гистограмма распределения PnL по сделкам с вертикальными маркерами нуля (оранжевый) и среднего PnL (серый). Смещение распределения вправо от нуля при высоком win rate указывает на работоспособность mean reversion логики; левый хвост показывает характер потерь при трендовых движениях

Рис. 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)

Перестановочный тест для profit factor. Гистограмма показывает распределение PF при случайном порядке сделок; оранжевая вертикаль — наблюдаемый PF стратегии. Чем правее оранжевая линия относительно распределения, тем ниже p-value и тем меньше вероятность случайного результата. При малом числе сделок (менее 30–40) даже хороший PF не дает низкого p-value — это аргумент для сбора большей выборки перед выводами

Рис. 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 валидацию и честный учет транзакционных издержек.