Do Volume Indicators Predict Anything?
Do volume indicators predict anything? We code OBV and VPT as published, define a falsifiable divergence rule, and measure forward returns against a baseline.
Do volume indicators predict anything? On Balance Volume (OBV) and Volume Price Trend (VPT) both keep a running tally of daily volume signed by the direction of the close, and nearly every page that defines them hands over the formula and a list of divergence rules while testing neither. This post implements both exactly as published, states the standard bearish divergence precisely enough to be falsifiable, and measures forward returns against a matched baseline over ten years of daily sessions in ten household names.
Here is the headline figure, read off the panel further down. Over the 20 sessions after a divergence day, the divergence group averaged 1.53% against 0.51% for the matched group that did not diverge, a gap of 1.02 percentage points on 516 divergence observations and 3923 comparison observations.
What OBV and VPT actually compute
OBV takes the whole day's volume and adds it to a running total when the close finishes above the previous close, subtracts it when the close finishes below, and adds nothing when the close is unchanged. VPT weights rather than signs: each day it adds volume multiplied by that day's percentage price change, so a 2% move on average volume pushes the line twice as far as a 1% move on the same volume.
Both are cumulative sums with no natural starting point, and that has one consequence worth holding onto. The level of either line is an artifact of the date the calculation was started. Only the shape can carry information, and every rule published for these indicators is a shape rule.
The panel below rebuilds both lines for Apple over a fixed past quarter. Volume and price are in different units, so each indicator is divided by that quarter's average daily volume: one unit on the indicator scale is one full day of average volume. The price series is plotted as percentage change from the first close in the window.
| date | price_change_pct | obv_ratio | vpt_ratio |
|---|---|---|---|
| 2026-04-01 | 0 | 0 | 0 |
| 2026-04-02 | 0.11 | 0.59 | 0.07 |
| 2026-04-06 | 1.26 | 1.14 | 0.7 |
| 2026-04-07 | -0.83 | -0.03 | -1.72 |
| 2026-04-08 | 1.28 | 0.74 | -0.08 |
| 2026-04-09 | 1.9 | 1.27 | 0.25 |
| 2026-04-10 | 1.9 | 0.68 | 0.25 |
| 2026-04-13 | 1.4 | 0 | -0.09 |
| 2026-04-14 | 1.25 | -0.91 | -0.22 |
| 2026-04-15 | 4.22 | 0.03 | 2.54 |
| 2026-04-16 | 3.04 | -0.79 | 1.61 |
| 2026-04-17 | 5.71 | 0.37 | 4.62 |
| 2026-04-20 | 6.81 | 1.06 | 5.33 |
| 2026-04-21 | 4.12 | 0.12 | 2.95 |
| 2026-04-22 | 6.86 | 0.93 | 5.09 |
| 2026-04-23 | 6.96 | 1.56 | 5.15 |
| 2026-04-24 | 6.04 | 0.84 | 4.53 |
| 2026-04-27 | 4.69 | 0.06 | 3.54 |
| 2026-04-28 | 5.9 | 0.82 | 4.42 |
| 2026-04-29 | 5.69 | 0.26 | 4.3 |
The exact SQL behind every number
WITH
bars AS
(
SELECT
date,
max(toFloat64(close)) AS close,
max(toFloat64(volume)) AS volume
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'AAPL'
AND date >= '2026-04-01'
AND date <= '2026-06-30'
GROUP BY date
),
stepped AS
(
SELECT
date,
close,
volume,
lagInFrame(close, 1) OVER (ORDER BY date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
FROM bars
),
running AS
(
SELECT
date,
close,
first_value(close) OVER (ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS first_close,
avg(volume) OVER (ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS avg_volume,
sum(if(prev_close = 0, 0, if(close > prev_close, volume, if(close < prev_close, -volume, 0))))
OVER (ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS obv_shares,
sum(if(prev_close = 0, 0, 100 * volume * (close / prev_close - 1)))
OVER (ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS vpt_shares
FROM stepped
)
SELECT
toString(date) AS date,
round(100 * (close / first_close - 1), 2) AS price_change_pct,
round(obv_shares / avg_volume, 2) AS obv_ratio,
round(vpt_shares / avg_volume, 2) AS vpt_ratio
FROM running
ORDER BY date ASCThe window opens at 0% by construction and runs 62 sessions to 2026-06-30, where the close sits 13.19% from where it started. Rebuilt OBV finishes the quarter at 7.71 days of average volume, VPT at 17.51. Look at the three lines together: the indicators turn where price turns, and their drift across the quarter follows the price drift. That similarity is the thing to keep measuring.
Do volume indicators predict anything? The divergence test
A rule can only be wrong if it is stated without wiggle room. Here is the one measured below.
- The sample is every session, in each of the ten names, where the close is the highest close of the trailing 20 sessions.
- A session counts as a divergence when OBV that day sits below its own value 20 sessions earlier. Price at a new local high with the volume line lower than it was is the textbook bearish divergence.
- The matched comparison group is every other session in the same sample, where price made the same kind of new high and OBV sat at or above its level 20 sessions earlier.
- The outcome is the plain close-to-close percentage return 5, 10, and 20 sessions later.
Matching on the new-high condition is the step most writeups skip. Compare divergence days against all days instead and the measurement captures the new high rather than the divergence.
| label | divergence_avg_pct | confirmed_avg_pct | gap_pct | welch_t | divergence_count | confirmed_count |
|---|---|---|---|---|---|---|
| 5 sessions | 0.46 | 0.12 | 0.34 | 2.59 | 517 | 3949 |
| 10 sessions | 0.79 | 0.28 | 0.52 | 3.04 | 517 | 3943 |
| 20 sessions | 1.53 | 0.51 | 1.02 | 4.12 | 516 | 3923 |
The exact SQL behind every number
WITH
bars AS
(
SELECT
ticker,
date,
max(toFloat64(close)) AS close,
max(toFloat64(volume)) AS volume
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('MSFT', 'SPY', 'KO', 'JNJ', 'JPM', 'XOM', 'PG', 'PEP', 'MCD', 'HD')
AND date >= '2016-01-04'
AND date <= '2026-06-30'
GROUP BY ticker, date
),
stepped AS
(
SELECT
ticker,
date,
close,
volume,
lagInFrame(close, 1) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
FROM bars
),
cumulative AS
(
SELECT
ticker,
date,
close,
sum(if(prev_close = 0, 0, if(close > prev_close, volume, if(close < prev_close, -volume, 0))))
OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS obv,
row_number() OVER (PARTITION BY ticker ORDER BY date ASC) AS bar_no
FROM stepped
),
marked AS
(
SELECT
close,
obv,
bar_no,
max(close) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS high_20,
lagInFrame(obv, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS obv_20_back,
leadInFrame(close, 5) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN CURRENT ROW AND 5 FOLLOWING) AS close_fwd_5,
leadInFrame(close, 10) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN CURRENT ROW AND 10 FOLLOWING) AS close_fwd_10,
leadInFrame(close, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN CURRENT ROW AND 20 FOLLOWING) AS close_fwd_20
FROM cumulative
),
events AS
(
SELECT
obv < obv_20_back AS is_divergent,
if(close_fwd_5 > 0, 100 * (close_fwd_5 / close - 1), NULL) AS ret_5,
if(close_fwd_10 > 0, 100 * (close_fwd_10 / close - 1), NULL) AS ret_10,
if(close_fwd_20 > 0, 100 * (close_fwd_20 / close - 1), NULL) AS ret_20
FROM marked
WHERE bar_no > 21
AND close >= high_20
),
long_form AS
(
SELECT
is_divergent,
horizon.1 AS label,
horizon.2 AS ret
FROM
(
SELECT
is_divergent,
arrayJoin([('5 sessions', ret_5), ('10 sessions', ret_10), ('20 sessions', ret_20)]) AS horizon
FROM events
)
WHERE isNotNull(horizon.2)
)
SELECT
label,
round(avgIf(ret, is_divergent), 2) AS divergence_avg_pct,
round(avgIf(ret, NOT is_divergent), 2) AS confirmed_avg_pct,
round(avgIf(ret, is_divergent) - avgIf(ret, NOT is_divergent), 2) AS gap_pct,
round((avgIf(ret, is_divergent) - avgIf(ret, NOT is_divergent))
/ sqrt(varSampIf(ret, is_divergent) / countIf(is_divergent)
+ varSampIf(ret, NOT is_divergent) / countIf(NOT is_divergent)), 2) AS welch_t,
countIf(is_divergent) AS divergence_count,
countIf(NOT is_divergent) AS confirmed_count
FROM long_form
GROUP BY label
HAVING divergence_count > 50 AND confirmed_count > 50
ORDER BY toUInt16OrZero(splitByChar(' ', label)[1]) ASCThe column to read is gap_pct, then welch_t beside it. A t statistic restates a gap in units of its own sampling noise, pooling the spread of outcomes in both groups with their sample sizes: the closer to zero, the less distinguishable the two averages are, and roughly plus or minus 2 is the conventional line for calling a difference worth a second look. Five sessions out, the gap measures 0.34 points at a t of 2.59, on 517 divergence days against 3949 matched days. Ten sessions out it is 0.52 points at a t of 3.04. At 20 sessions, 1.02 points at 4.12.
Two things hold whichever way those numbers came out. The forward windows overlap heavily, since a 20-session return is measured from almost every qualifying day, so the observation count overstates how much independent evidence sits behind each average and inflates any t statistic computed from it. And nothing here deducts commissions, spread, or the borrow cost of the short side a bearish rule implies. Before trusting a result of this shape, read look-ahead bias in backtesting and how to backtest a trading strategy.
Why OBV mostly restates returns you already watched
If the 20-session change in OBV is close to a volume-weighted restatement of the 20-session price change, then a divergence is a statement about the recent past wearing the costume of a forecast. That is measurable. The panel pairs each name's 20-session OBV change with the 20-session return behind it, and with the 20-session return ahead of it.
| ticker | trailing_corr | forward_corr | session_count |
|---|---|---|---|
| SPY | 0.84 | -0.16 | 2596 |
| HD | 0.8 | 0 | 2596 |
| JPM | 0.8 | -0.1 | 2596 |
| PEP | 0.78 | -0.14 | 2596 |
| XOM | 0.78 | -0.01 | 2596 |
| MCD | 0.76 | 0.01 | 2596 |
| KO | 0.74 | -0.15 | 2596 |
| MSFT | 0.74 | -0.14 | 2596 |
| PG | 0.69 | -0.09 | 2596 |
| JNJ | 0.59 | -0.04 | 2596 |
The exact SQL behind every number
WITH
bars AS
(
SELECT
ticker,
date,
max(toFloat64(close)) AS close,
max(toFloat64(volume)) AS volume
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('MSFT', 'SPY', 'KO', 'JNJ', 'JPM', 'XOM', 'PG', 'PEP', 'MCD', 'HD')
AND date >= '2016-01-04'
AND date <= '2026-06-30'
GROUP BY ticker, date
),
stepped AS
(
SELECT
ticker,
date,
close,
volume,
lagInFrame(close, 1) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
FROM bars
),
cumulative AS
(
SELECT
ticker,
date,
close,
sum(if(prev_close = 0, 0, if(close > prev_close, volume, if(close < prev_close, -volume, 0))))
OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS obv,
row_number() OVER (PARTITION BY ticker ORDER BY date ASC) AS bar_no
FROM stepped
),
deltas AS
(
SELECT
ticker,
close,
bar_no,
obv - lagInFrame(obv, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS obv_change,
lagInFrame(close, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS close_back,
leadInFrame(close, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN CURRENT ROW AND 20 FOLLOWING) AS close_fwd
FROM cumulative
)
SELECT
ticker,
round(corr(obv_change, 100 * (close / close_back - 1)), 2) AS trailing_corr,
round(corr(obv_change, 100 * (close_fwd / close - 1)), 2) AS forward_corr,
count() AS session_count
FROM deltas
WHERE bar_no > 21
AND close_back > 0
AND close_fwd > 0
GROUP BY ticker
ORDER BY trailing_corr DESCSPY tops the panel at 0.84 correlation between its OBV change and the return already in the books, and the weakest of the ten, JNJ, still measures 0.59. Set against the return that follows, no name in the panel reaches 0.35 in absolute terms: SPY comes out at -0.16 and JNJ at -0.04, with 2596 sessions behind the first of those two figures alone. A cumulative volume line that tracks the move it just finished measuring is doing the work of a price chart with extra steps.
Does any lookback work?
The 20-session setting is one choice among many, and a rule that comes out flat at 20 might come out differently at 40. Testing more settings is honest only when all of them get reported. The panel below runs the same divergence test at four lookbacks for both indicators, holding the forward horizon at 20 sessions, and prints the divergence-minus-confirmation gap for each.
| label | obv_gap_pct | vpt_gap_pct | obv_signal_count | vpt_signal_count |
|---|---|---|---|---|
| 10-session lookback | 0.23 | -0.18 | 734 | 497 |
| 20-session lookback | 1.04 | 0.18 | 512 | 338 |
| 40-session lookback | 1.63 | 1.17 | 407 | 305 |
| 60-session lookback | 0.91 | 0.14 | 309 | 305 |
The exact SQL behind every number
WITH
bars AS
(
SELECT
ticker,
date,
max(toFloat64(close)) AS close,
max(toFloat64(volume)) AS volume
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('MSFT', 'SPY', 'KO', 'JNJ', 'JPM', 'XOM', 'PG', 'PEP', 'MCD', 'HD')
AND date >= '2016-01-04'
AND date <= '2026-06-30'
GROUP BY ticker, date
),
stepped AS
(
SELECT
ticker,
date,
close,
volume,
lagInFrame(close, 1) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
FROM bars
),
cumulative AS
(
SELECT
ticker,
date,
close,
sum(if(prev_close = 0, 0, if(close > prev_close, volume, if(close < prev_close, -volume, 0))))
OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS obv,
sum(if(prev_close = 0, 0, volume * (close / prev_close - 1)))
OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS vpt,
row_number() OVER (PARTITION BY ticker ORDER BY date ASC) AS bar_no
FROM stepped
),
marked AS
(
SELECT
close,
obv,
vpt,
bar_no,
max(close) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 9 PRECEDING AND CURRENT ROW) AS high_10,
max(close) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS high_20,
max(close) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 39 PRECEDING AND CURRENT ROW) AS high_40,
max(close) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS high_60,
lagInFrame(obv, 10) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 10 PRECEDING AND CURRENT ROW) AS obv_10_back,
lagInFrame(obv, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS obv_20_back,
lagInFrame(obv, 40) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 40 PRECEDING AND CURRENT ROW) AS obv_40_back,
lagInFrame(obv, 60) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 60 PRECEDING AND CURRENT ROW) AS obv_60_back,
lagInFrame(vpt, 10) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 10 PRECEDING AND CURRENT ROW) AS vpt_10_back,
lagInFrame(vpt, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS vpt_20_back,
lagInFrame(vpt, 40) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 40 PRECEDING AND CURRENT ROW) AS vpt_40_back,
lagInFrame(vpt, 60) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN 60 PRECEDING AND CURRENT ROW) AS vpt_60_back,
leadInFrame(close, 20) OVER (PARTITION BY ticker ORDER BY date ASC ROWS BETWEEN CURRENT ROW AND 20 FOLLOWING) AS close_fwd_20
FROM cumulative
),
specs AS
(
SELECT
ret,
spec.1 AS label,
spec.2 AS at_new_high,
spec.3 AS obv_divergent,
spec.4 AS vpt_divergent
FROM
(
SELECT
100 * (close_fwd_20 / close - 1) AS ret,
arrayJoin([
('10-session lookback', close >= high_10, obv < obv_10_back, vpt < vpt_10_back),
('20-session lookback', close >= high_20, obv < obv_20_back, vpt < vpt_20_back),
('40-session lookback', close >= high_40, obv < obv_40_back, vpt < vpt_40_back),
('60-session lookback', close >= high_60, obv < obv_60_back, vpt < vpt_60_back)
]) AS spec
FROM marked
WHERE bar_no > 61
AND close_fwd_20 > 0
)
)
SELECT
label,
round(avgIf(ret, at_new_high AND obv_divergent) - avgIf(ret, at_new_high AND NOT obv_divergent), 2) AS obv_gap_pct,
round(avgIf(ret, at_new_high AND vpt_divergent) - avgIf(ret, at_new_high AND NOT vpt_divergent), 2) AS vpt_gap_pct,
countIf(at_new_high AND obv_divergent) AS obv_signal_count,
countIf(at_new_high AND vpt_divergent) AS vpt_signal_count
FROM specs
GROUP BY label
HAVING obv_signal_count > 50
AND vpt_signal_count > 50
AND countIf(at_new_high AND NOT obv_divergent) > 50
AND countIf(at_new_high AND NOT vpt_divergent) > 50
ORDER BY toUInt16OrZero(splitByChar('-', label)[1]) ASCAt the 10-session lookback the OBV gap comes out at 0.23 points and the VPT gap at -0.18, on 734 OBV divergence days. The 20-session lookback prints 1.04 and 0.18. The 40-session lookback prints 1.63 and 1.17, and the 60-session lookback prints 0.91 and 0.14.
That is eight tests on one dataset. Picking the friendliest of the eight and publishing it alone is how indicator rules acquire their reputations, and it is why a single flattering backtest of a divergence rule carries almost no information. With eight variants in hand, the chance that at least one lands well away from zero on luck alone is several times the chance for any one of them on its own. Report all eight or report none.
What volume is genuinely good for
One use survives all of this. Volume measures participation: how many shares changed hands while a price move was happening. A move on four times the usual volume involved far more of the shareholder base than the same move on a quiet Tuesday, and that is a fact about the move itself rather than a forecast of the next one. Relative volume states it directly as a ratio of today's volume to a recent average, with no cumulative series to seed and no divergence rule to tune. VWAP goes one step further and gives the volume-weighted average price at which the day's shares actually traded, and average daily volume sets the denominator both of those rest on.
Reconstruction is the other reason to prefer the direct measures. Two platforms seeding a cumulative series on different start dates, or adjusting their price history differently for splits and dividends, classify some small days up where the other classifies them down, and then show two different OBV lines for the same stock on the same day. Why RSI differs between platforms walks through this class of mismatch on a simpler indicator, and split-adjusted price history covers the adjustment side of it.
FAQ
Does OBV divergence predict a reversal?
In the test above, price at a 20-session high with OBV below its level 20 sessions earlier was followed by an average return of 1.53% over the next 20 sessions, against 0.51% for matched days where OBV confirmed the high. The gap carries a t statistic of 4.12, and the forward windows overlap, so weigh the size of that gap against its noise before treating the rule as information.
What is the difference between OBV and VPT?
OBV adds or subtracts the full day's volume on the sign of the close-to-close change alone. VPT adds volume multiplied by the size of that change, so a big percentage day contributes more than a small one. Both are running totals built from the same two inputs, and both move with the price series they are built from.
Why does OBV look different on two charting platforms?
Both indicators are cumulative, so the level of the line depends on the date the calculation starts, which platforms choose differently. Platforms also differ in how they adjust past prices for splits and dividends, which changes the up or down classification on some small days and shifts the line from that point onward.
How many observations does a test like this need?
Count independent observations rather than rows. Overlapping forward windows mean thousands of rows can carry only dozens of genuinely separate episodes, so a sample size, a measure of spread, and the number of settings tested belong next to every result of this kind.
Full data notes and limitations
The universe is ten large, liquid US listings with no stock split between January 2016 and June 2026, which keeps the up-day and down-day classification independent of split-adjustment choices. Daily rows are deduplicated with a GROUP BY on ticker and date before any running total begins, and every panel is pinned to fixed dates, so the figures above do not move.
Both indicators are rebuilt inside each panel and seeded at zero on the first session of the window. A platform seeding on another date reports different levels for the same stock, which is why only shape rules are tested here.
Forward returns are close to close, with no commissions, no spread, and no borrow cost for the short side a bearish rule implies. Consecutive qualifying days share most of their forward window, so the row counts far exceed the number of independent episodes and every t statistic on this page is optimistic on that account.
This is educational material about measurement. It is not advice about trading.
Every panel ships with the SQL that produced it, window functions and all. Change the lookback, the forward horizon, or the ticker list and run the test again on the Strasmore terminal.