Back To Top

April 1, 2025

Measuring Price Deviations from Its Volume-Weighted Mean

Building a Dynamic Volume Profile Oscillator to Quantify Price Dislocation Relative to VWAP

We present a way to measure how far price moves from where most volume actually traded and whether that move was normal or extreme. 

Not just price versus moving average. Not just volume spikes. Something adaptive. Something that reacts when the market stretches too far, too fast.

The oscillator presented here blends volume, price, and volatility into a single signal. 

It builds a rolling volume-weighted average price, then measures how far price pulls away from that anchor, scaled by recent volatility. 

When price accelerates away from its volume-weighted anchor, the oscillator responds. When it snaps back, the signal softens.

Dynamic Volume Profile Oscillator (OPTIMIZED)

In this guide, we’ll discuss:

  • A dynamic volume-weighted average
  • A rolling measure of price deviation
  • The adaptive oscillator with signal zones and smoothing
  • Full chart visualizations 

1. Dynamic Volume Profile Oscillator

This oscillator measures how far price moves from its recent volume-weighted average — and whether that move is significant, based on recent behavior.

At the center is VWAP: the volume-weighted average price. It’s not just a simple moving average. It anchors price to volume to show where most trading activity occurred. The formula looks like this:

VWAP Formula

This gives more weight to prices that traded on higher volume. So, if most of the day’s volume clustered around a certain price, that level pulls the VWAP closer to it.

In this oscillator, we calculate VWAP using a rolling window. But instead of updating it every bar, we refresh it every few bars — for example, every 10. This gives the profile time to form while staying responsive to change.

Next, we measure how much price typically deviates from this VWAP during the lookback period. This isn’t a standard deviation. It’s a volume-weighted average of absolute deviations, calculated like this:

Dev Formula

This tells us how “normal” it is for price to stray from the VWAP, based on both recent volatility and volume distribution. That deviation acts as a scale for the oscillator.

Now we compute the raw oscillator:

Dynamic Volume Profile Oscillator rAW oSCILLATOR fORMULA

The idea is simple:

  • If price is near VWAP, the oscillator stays near 50.
  • If price moves far above or below it, the signal moves toward 75 or 25, depending on direction.
  • The deviation term prevents false signals by scaling the response to recent market activity.

Finally, we smooth this signal with an exponential moving average. This makes it readable without killing the reactivity.

2. Python Implementation

2.1 Data Download & Preprocessing

We start by loading daily price and volume data for a specific ticker. In this case, it’s ASML.AS, but you can swap in any symbol supported by Yahoo Finance.

The dataset spans from January 2022 to December 2025. Once downloaded, we standardize the column names to keep things consistent across the analysis. Specifically:

Dynamic Volume Profile Oscillator Price Column Formula

This keeps the calculations clean later on, especially when referencing columns inside rolling windows or visualizations.

Here’s the setup code:

				
					import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")

plt.style.use("dark_background")

# Step 1: Data Download & Preprocessing
TICKER     = "ASML.AS"  # Stock symbol
START_DATE = "2022-01-01"
END_DATE   = "2025-12-31"

# Download daily data
df = yf.download(TICKER, start=START_DATE, end=END_DATE, interval="1d")
if isinstance(df.columns, pd.MultiIndex):
    df.columns = df.columns.get_level_values(0)
df.dropna(inplace=True)

# Create standard column names for ease of use
df["Price"] = df["Close"]
df["Vol"]   = df["Volume"]
df["Date"]  = df.index
				
			

2.2 Compute Dynamic Volume Profile 

We start with a rolling lookback window — for example, the last 50 bars. Within that window, we compute the VWAP.

Next, we calculate a volume-weighted deviation from that VWAP. This tells us how far, on average, price tends to move away from the VWAP during the lookback period. 

It’s calculated as:

Dev Formula 2

Unlike standard deviation, this method weights each absolute price deviation by its volume share. That means larger trades have a bigger influence on the deviation scale.

				
					# Compute Dynamic Volume Profile (VWAP & Deviation)
def compute_vwap_and_dev(df, lookback=50, profile_periods=10):
    """
    For each bar, every 'profile_periods' bars we recalc:
      VWAP = sum(Price * Volume) / sum(Volume)
      Dev  = volume-weighted average deviation from VWAP
    Results are stored in df["VWAP_Level"] and df["Price_Deviation"].
    """
    n = len(df)
    vwap_vals = np.full(n, np.nan)
    dev_vals  = np.full(n, np.nan)
    for i in range(n):
        if i < lookback:
            continue
        if (i == lookback) or (i % profile_periods == 0):
            window_slice = df.iloc[i - lookback : i]
            vol_sum = window_slice["Vol"].sum()
            if vol_sum > 0:
                sum_price_vol = (window_slice["Price"] * window_slice["Vol"]).sum()
                vwap_current = sum_price_vol / vol_sum
                abs_dev = (window_slice["Price"] - vwap_current).abs()
                weights = window_slice["Vol"] / vol_sum
                dev_current = (abs_dev * weights).sum()
            else:
                vwap_current = df["Price"].iloc[i]
                dev_current  = 0
            for j in range(i, min(i + profile_periods, n)):
                vwap_vals[j] = vwap_current
                dev_vals[j]  = dev_current
    df["VWAP_Level"]      = vwap_vals
    df["Price_Deviation"] = dev_vals

# Set parameters for volume profile computation
LOOKBACK = 50           # Number of bars used to compute VWAP and deviation. Higher = smoother, slower to adapt.
PROFILE_PERIODS = 10    # How often to update VWAP/deviation. Smaller = more responsive, larger = more stable.

compute_vwap_and_dev(df, LOOKBACK, PROFILE_PERIODS)
				
			

2.3 Compute the Oscillator

The oscillator is designed to capture mean reversion relative to the dynamic volume profile.

When mean reversion mode is enabled, the raw oscillator is calculated using:

Osc Raw formula 2 Dynamic Volume Profile Oscillator

This formula adjusts a baseline of 50 based on how far the current price is from the VWAP. 

The result is normalized by the volume-weighted price deviation to account for recent volatility. The further price stretches from VWAP, the more extreme the signal.

To smooth the signal and make it easier to interpret, we apply an exponential moving average:

Oscillator Formula 2

The final oscillator reflects both directional bias and how unusual that bias is, given recent price–volume dynamics.

				
					# Compute the Oscillator
# Helper functions defined near use:

def normalize(value, min_val, max_val):
    """Scale 'value' to 0..100 based on the range [min_val, max_val]."""
    rng = max_val - min_val
    if rng <= 0:
        return 50
    return np.clip(((value - min_val) / rng) * 100, 0, 100)

def ema(series, length):
    """Exponential Moving Average."""
    return series.ewm(span=length, adjust=False).mean()

def sma(series, length):
    """Simple Moving Average."""
    return series.rolling(window=length, min_periods=1).mean()

def stdev(series, length):
    """Rolling standard deviation."""
    return series.rolling(window=length, min_periods=1).std()

# Compute raw oscillator value.
MEAN_REVERSION = True     # If True, oscillator reacts to price deviation from VWAP. If False, uses volume normalization.
SENSITIVITY = 1.0         # Affects how sharply the oscillator reacts to price deviations. Higher = more sensitive.
SMOOTHING = 5             # EMA length for smoothing the oscillator. Lower = more responsive, higher = smoother.

# Pre-calc volume SMA in case we need it (for non-mean reversion)
df["VolSMA"] = df["Vol"].rolling(window=SMOOTHING, min_periods=1).mean()
df["Osc_Raw"] = np.nan

for i in range(len(df)):
    if MEAN_REVERSION:
        vwap_val = df["VWAP_Level"].iloc[i]
        dev_val  = df["Price_Deviation"].iloc[i]
        if pd.isna(vwap_val) or dev_val == 0:
            df.at[df.index[i], "Osc_Raw"] = 50
        else:
            price_term = df["Price"].iloc[i] - vwap_val
            df.at[df.index[i], "Osc_Raw"] = 50 + (price_term / (dev_val * SENSITIVITY)) * 25
    else:
        vol_sma = df["VolSMA"].iloc[i]
        if i < LOOKBACK:
            df.at[df.index[i], "Osc_Raw"] = np.nan
        else:
            sub = df["VolSMA"].iloc[i - LOOKBACK + 1 : i + 1]
            v_min = sub.min()
            v_max = sub.max()
            df.at[df.index[i], "Osc_Raw"] = normalize(vol_sma, v_min, v_max)

# Smooth the raw oscillator
df["Oscillator"] = ema(df["Osc_Raw"], SMOOTHING)
				
			

2.4 Adaptive Midline and Zone Calculation

To help interpret the oscillator, an adaptive midline is calculated. When enabled, the midline is the simple moving average (SMA) of the oscillator over a specified period:

Midline Formula

We then measure the oscillator’s variability by computing its rolling standard deviation and scaling it:

Osc STD Formula

From this, the upper and lower zones are defined as:

Dynamic Volume Profile Oscillator Upper Zone Formula

These zones adjust as the oscillator becomes more or less volatile and make it easier to identify potential inflection points relative to recent behavior.

				
					# Adaptive Midline and Zone Calculation
USE_ADAPTIVE_MID = True    # If True, the midline adjusts over time using a moving average. If False, it's fixed at 50.
MIDLINE_PERIOD = 50        # Controls how slowly or quickly the adaptive midline updates. Higher = smoother.
ZONE_WIDTH = 1.5           # Multiplier for standard deviation bands around the midline. Wider zones mean more tolerance.

if USE_ADAPTIVE_MID:
    df["Midline"] = sma(df["Oscillator"], MIDLINE_PERIOD)
else:
    df["Midline"] = 50

df["Osc_Stdev"] = stdev(df["Oscillator"], MIDLINE_PERIOD) * ZONE_WIDTH
df["Upper_Zone"] = df["Midline"] + df["Osc_Stdev"]
df["Lower_Zone"] = df["Midline"] - df["Osc_Stdev"]
				
			

The entire end-to-end workflow can be found on the following Google Colab Notebook:

Entreprenerdly.com - Dynamic Volume Profile Oscillator.ipynb

We then measure the oscillator’s variability by computing its rolling standard deviation and scaling it:

2.5. Compute Fast & Slow Signal Lines

Fast and slow signal lines are computed to capture short-term versus long-term momentum changes:

Fast Slow Signal Formula

Bullish or bearish conditions are determined by comparing the oscillator with the midline and the fast and slow signals. 

When the oscillator is above the midline or the fast signal crosses above the slow signal, the system leans bullish. Otherwise, it leans bearish.

				
					# Compute Fast & Slow Signal Lines
df["Fast_Signal"] = ema(df["Oscillator"], 5)
df["Slow_Signal"] = ema(df["Oscillator"], 15)

# Determine bullish or bearish state based on oscillator vs. midline and signals.
df["is_bullish"] = (df["Oscillator"] > df["Midline"]) | (df["Fast_Signal"] > df["Slow_Signal"])
df["is_bearish"] = ~df["is_bullish"]
				
			

2.6. Plotting the Oscillator and Price Charts

Finally, two charts are created:

Price Chart: Displays the raw price data, optionally color-coded based on bullish or bearish states.

Oscillator Chart: Shows the smoothed oscillator, overlaid with the fast and slow signals, the adaptive midline, and gradient-filled zones between the midline and the upper/lower boundaries.

This helps see not only the current price and its volume-weighted profile but also how the market’s “memory” and volatility contribute to potential reversal signals.

				
					# Plotting the Oscillator and Price Charts
COLOR_BARS = True
BULL_COLOR = "#00FFBB"
BEAR_COLOR = "#FF009D"
SHOW_PRICE_CHART = True

if SHOW_PRICE_CHART:
    fig, axes = plt.subplots(nrows=2, figsize=(16, 8), sharex=True,
                             gridspec_kw={"height_ratios": [2, 1]})
    ax_price, ax_osc = axes
else:
    fig, ax_osc = plt.subplots(nrows=1, figsize=(12, 8))

# ----- Price Chart (Top Panel) -----
if SHOW_PRICE_CHART:
    ax_price.set_title(f"Dynamic Volume Profile Oscillator: {TICKER}", color="white")
    ax_price.set_facecolor("#1F1B1B")
    ax_price.grid(True, alpha=0.2, color="white")
    ax_price.tick_params(axis='x', colors='white')
    ax_price.tick_params(axis='y', colors='white')

    if COLOR_BARS:
        xvals = df["Date"]
        yvals = df["Price"]
        for i in range(1, len(df)):
            c = BULL_COLOR if df["is_bullish"].iloc[i] else BEAR_COLOR
            ax_price.plot([xvals[i-1], xvals[i]],
                          [yvals[i-1], yvals[i]],
                          color=c, linewidth=1.5)
    else:
        ax_price.plot(df["Date"], df["Price"], color="white", linewidth=1.5)

    # --- Add VWAP overlay ---
    ax_price.plot(df["Date"], df["VWAP_Level"], color="white", linestyle="--", linewidth=1, label="VWAP")

    ax_price.set_ylabel("Price", color="white")
    ax_price.legend(loc="best", facecolor="#1F1B1B")

# ----- Oscillator Chart (Bottom Panel) -----
ax_osc.set_facecolor("#1F1B1B")
ax_osc.grid(True, alpha=0.2, color="white")
ax_osc.tick_params(axis='x', colors='white')
ax_osc.tick_params(axis='y', colors='white')
ax_osc.set_ylabel("Oscillator", color="white")

ax_osc.plot(df["Date"], df["Oscillator"], color="white", label="Oscillator", linewidth=2)
ax_osc.plot(df["Date"], df["Fast_Signal"], color="yellow", alpha=0.6, label="Fast Signal")
ax_osc.plot(df["Date"], df["Slow_Signal"], color="orange", alpha=0.6, label="Slow Signal")
ax_osc.plot(df["Date"], df["Midline"], color="gray", label="Adaptive Midline", linewidth=1)

# Create gradient fills for the zones by subdividing the space into steps.
dates = df["Date"]
mid   = df["Midline"]
up    = df["Upper_Zone"]
dn    = df["Lower_Zone"]

NUM_STEPS = 10
up_diff = up - mid
dn_diff = mid - dn

for step in range(NUM_STEPS):
    f1 = step / NUM_STEPS
    f2 = (step + 1) / NUM_STEPS
    y1_up = mid + up_diff * f1
    y2_up = mid + up_diff * f2
    alpha_up = 0.05 + 0.03 * step
    ax_osc.fill_between(dates, y1_up, y2_up, color=BEAR_COLOR, alpha=alpha_up)

    y1_dn = mid - dn_diff * f1
    y2_dn = mid - dn_diff * f2
    alpha_dn = 0.05 + 0.03 * step
    ax_osc.fill_between(dates, y1_dn, y2_dn, color=BULL_COLOR, alpha=alpha_dn)

ax_osc.legend(loc="best", facecolor="#1F1B1B")

fig.autofmt_xdate()
plt.tight_layout()
plt.show()
				
			
Dynamic Volume Profile Oscillator (OPTIMIZED)

Figure 1. Price and oscillator with dynamic VWAP overlay. Colors show bullish (cyan) and bearish (magenta) momentum. Zones reflect adaptive volatility bands.

In the price chart, colors alternate between cyan (bullish) and magenta (bearish). These colors are determined based on the oscillator’s current state:

  • Cyan: The oscillator is above the midline or the fast signal is above the slow signal.
  • Magenta: The oscillator is below the midline and the fast signal is below the slow signal.

This shows periods where momentum is aligned with volume dynamics.

The white dashed line shows the dynamic VWAP that updates every few bars. 

In the oscillator panel:

  • The white line is the smoothed oscillator.
  • Yellow is the fast signal (short-term shift).
  • Orange is the slow signal (trend confirmation).
  • The gray line is the adaptive midline.
  • The shaded bands above and below reflect recent oscillator volatility.

When these bands widen, it signals increased momentum or instability. The oscillator is moving more aggressively relative to its recent range. When they contract, conditions are quieter.

Price tops often align with oscillator peaks above the upper band. Reversals may follow dips below the lower band, especially when signals start turning up.

Momentum builds when the oscillator rises above the midline, supported by both signals pointing higher.

Use Cases and Limitations

This oscillator works best when you’re looking for structure beneath price, especially in environments where volume and volatility shift dynamically.

Use Cases

  • Spotting exhaustion after extended moves, where price stretches far from its volume-weighted average.
  • Confirming entries when price reclaims the VWAP and momentum builds, supported by fast/slow signal alignment.
  • Filtering signals in choppy or sideways markets, where traditional trend indicators often misfire.
  • Tracking regime shifts from quiet to volatile or trending to ranging based on oscillator expansion and band behavior.

Limitations

  • Not built for high-frequency entries — the smoothing and rolling calculations are more suited to swing or positional context.
  • Slight lag during sharp transitions, especially if smoothing is set too high.
  • Parameter sensitivity — performance depends on how you tune the lookback window, profile update frequency, and sensitivity scaling.

Potential Improvements

  • Add volume filters or regime-based logic to dynamically adjust parameters.
  • Use session-based VWAPs for intraday signals and anchored setups.
  • Combine with secondary signals like RSI divergence or MACD crossovers for confirmation.
  • Explore alternative visual formats, such as histogram plots or zero-line oscillators, to match your workflow.

Final Thoughts

This oscillator is most useful when you want to measure price movement relative to where volume actually traded, not just where price has been.

When we combine VWAP, volume-weighted deviation, and adaptive scaling, it gives us a context-aware signal that adjusts to changing volatility. 

It works well in mean-reverting environments, transitional phases, or when price stretches away from volume consensus.

Prev Post

WhiteBIT Nova Hits 1 Million Transactions and Boosts Crypto Adoption

Next Post

Draw Trend Lines Algorithmically with Multi-Anchored Regressions

post-bars
Mail Icon

Newsletter

Get Every Weekly Update & Insights

[mc4wp_form id=]

Leave a Comment