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.

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:

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:

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:

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:

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:

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:

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:

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:

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

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

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:

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()

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.
Newsletter