Signals·TA Strategy Implementations·Intermediate

VWAP Reversion Strategy

Build a VWAP reversion strategy that trades price deviations from the volume-weighted average price, a key institutional intraday reference level that anchor traders and algorithms monitor for mean reversion opportunities.

trading-signalstrading-strategies

Strategy — VWAP Reversion


1–2. Installation and Imports

[ ]
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

!pip install pandas numpy plotly
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

3. Strategy Overview

VWAP (Volume Weighted Average Price) is the ratio of the cumulative sum of (Typical Price × Volume) to cumulative Volume from the start of the session:

VWAP = Σ(Typical Price × Volume) / Σ(Volume)

where Typical Price = (High + Low + Close) / 3.

Why VWAP matters: VWAP represents the average price at which all trades in the session have occurred, weighted by their size. Institutional traders (funds, market makers) use VWAP as an execution benchmark — they aim to buy below VWAP and sell above it. This institutional behavior creates a persistent mean-reversion tendency around VWAP.

Signal logic (VWAP bands):

  • Close falls below the lower VWAP band (VWAP − K × deviation) → Buy (+1): price is unusually far below the volume-weighted fair value; institutional buyers are likely to step in.
  • Close rises above the upper VWAP band (VWAP + K × deviation) → Sell (−1): price is unusually far above fair value; institutional sellers are likely to emerge.
  • Close between the bands → No signal (0).

Limitation: VWAP is a cumulative metric — it resets at session open and drifts further from the current price as the session ages. It is most meaningful for intraday strategies on the same session's data.


4. Data Generation

[ ]
def generate_data(periods: int) -> pd.DataFrame:
    start_date     = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
    price_data = []; last_close = 42000
    for i in range(periods):
        open_price  = last_close + np.random.normal(0, last_close * 0.0005)
        close_price = open_price + np.random.normal(0, last_close * 0.005)
        body_high   = max(open_price, close_price)
        body_low    = min(open_price, close_price)
        high_price  = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
        low_price   = min(body_low  - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
        if high_price < low_price: high_price, low_price = low_price, high_price
        price_data.append({"open": max(1,int(open_price)), "high": max(1,int(high_price)),
                            "low":  max(1,int(low_price)),  "close": max(1,int(close_price))})
        last_close = close_price
    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"] = np.random.uniform(100.0, 500.0, periods)
    df["datetime"] = df.index.to_series()
    return df.reset_index(drop=True)

df = generate_data(500)
display(df.head())
open high low close volume datetime
0 41984 42154 41751 41840 490.024249 2024-01-01 00:00:00+00:00
1 41852 41898 41634 41690 332.375785 2024-01-01 00:01:00+00:00
2 41707 41771 41490 41609 178.294316 2024-01-01 00:02:00+00:00
3 41612 41889 41535 41872 363.258009 2024-01-01 00:03:00+00:00
4 41866 42003 41773 41908 413.551014 2024-01-01 00:04:00+00:00

5. Strategy Function

[ ]
def vwap_reversion_strategy(
    df:         pd.DataFrame,
    band_window:int   = 20,
    std_bands:  float = 1.5,
) -> pd.DataFrame:
    """Calculates VWAP, VWAP bands, and trading signals based on a reversion strategy.

    Args:
        df (pd.DataFrame): Input DataFrame containing 'high', 'low', 'close', 'volume', and 'datetime' columns.
        band_window (int): The rolling window for calculating the standard deviation of typical price from VWAP.
        std_bands (float): Multiplier for the standard deviation to set the width of the VWAP bands.

    Returns:
        pd.DataFrame: The original DataFrame with added 'vwap', 'vwap_upper', 'vwap_lower',
                      'vwap_distance_pct', and 'signal' columns.
    """
    # Create a copy to avoid modifying the original DataFrame and sort by datetime
    df = df.copy().sort_values("datetime", ignore_index=True)

    # Calculate Typical Price (TP)
    tp = (df["high"] + df["low"] + df["close"]) / 3

    # Calculate Cumulative VWAP (session-style)
    # VWAP = Sum(Typical Price * Volume) / Sum(Volume)
    df["vwap"] = (tp * df["volume"]).cumsum() / df["volume"].cumsum()

    # Calculate the rolling standard deviation of the typical price from VWAP
    # This is used to dynamically adjust the width of the VWAP bands based on recent volatility
    deviation = (tp - df["vwap"]).rolling(band_window).std()

    # Calculate the upper and lower VWAP bands
    # Upper band = VWAP + (std_bands * deviation)
    # Lower band = VWAP - (std_bands * deviation)
    df["vwap_upper"] = df["vwap"] + std_bands * deviation
    df["vwap_lower"] = df["vwap"] - std_bands * deviation

    # Calculate the percentage distance of the close price from VWAP
    # Useful for understanding how far price has diverged from the average
    df["vwap_distance_pct"] = (df["close"] - df["vwap"]) / df["vwap"] * 100

    # Generate trading signals:
    #   1: Buy signal (close price falls below the lower VWAP band)
    #  -1: Sell signal (close price rises above the upper VWAP band)
    #   0: No signal (close price is between the bands)
    df["signal"] = np.where(df["close"] < df["vwap_lower"],  1,
                   np.where(df["close"] > df["vwap_upper"], -1, 0))

    return df

df_signals = vwap_reversion_strategy(df, band_window=20, std_bands=1.5)

print("--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
print("\n--- VWAP Distance Statistics ---")
print(df_signals["vwap_distance_pct"].describe().round(4))
--- Signal Distribution ---
signal
 1    244
 0    210
-1     46
Name: count, dtype: int64

--- VWAP Distance Statistics ---
count    500.0000
mean      -1.2664
std        1.9903
min       -7.8591
25%       -2.6850
50%       -1.0039
75%        0.1072
max        3.6983
Name: vwap_distance_pct, dtype: float64

Explanation:

  • (tp × volume).cumsum() / volume.cumsum(): The standard cumulative VWAP formula. As more volume accumulates, each new trade's weight in the average is proportional to its size — high-volume candles move VWAP more than low-volume ones.
  • deviation: The rolling standard deviation of the typical price's divergence from VWAP, used to normalize the band width to current conditions.
  • vwap_distance_pct: A normalized measure of how far the close has strayed from VWAP — useful for position sizing and risk management.

6. Visualization

[ ]
buy_signals  = df_signals[df_signals["signal"] ==  1]
sell_signals = df_signals[df_signals["signal"] == -1]

fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
    subplot_titles=["Price + VWAP Bands + Signals", "VWAP Distance (%)"],
    row_heights=[0.65, 0.35])

fig.add_trace(go.Candlestick(
    x=df_signals["datetime"],
    open=df_signals["open"], high=df_signals["high"],
    low=df_signals["low"],   close=df_signals["close"],
    name="Price"), row=1, col=1)

fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["vwap"],
    mode="lines", name="VWAP", line=dict(color="blue", width=1.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["vwap_upper"],
    mode="lines", name="VWAP Upper", line=dict(color="orange", width=1, dash="dash")), row=1, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["vwap_lower"],
    mode="lines", name="VWAP Lower", line=dict(color="orange", width=1, dash="dash")), row=1, col=1)

fig.add_trace(go.Scatter(
    x=buy_signals["datetime"],  y=buy_signals["low"]  * 0.999,
    mode="markers", marker=dict(symbol="triangle-up",   size=10, color="green"), name="Buy (+1)"),
    row=1, col=1)
fig.add_trace(go.Scatter(
    x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
    mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"),   name="Sell (−1)"),
    row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["vwap_distance_pct"],
    mode="lines", name="VWAP Distance %", line=dict(color="purple", width=1)), row=2, col=1)
fig.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1)

fig.update_layout(
    title_text="VWAP Reversion Strategy",
    xaxis_rangeslider_visible=False,
    height=700, yaxis=dict(autorange=True),
    xaxis2_title="Datetime",
)
fig.show()

7. Conclusion

This notebook demonstrates a VWAP reversion strategy. The vwap_reversion_strategy function calculates VWAP and its bands, and generates buy/sell signals based on price deviation from these bands. The visualization helps in understanding the strategy's mechanics and signal generation.

Key takeaways:

  • VWAP is a volume-weighted average price, often used by institutions as a benchmark.
  • The strategy aims to capitalize on mean-reversion tendencies around VWAP.
  • Bands are dynamically adjusted using a rolling standard deviation to account for volatility.
  • The vwap_distance_pct provides a normalized view of price divergence.