Volume indicators: OBV, volume profile
Everything in chapters 101-109 uses price alone. Volume indicators add a second dimension — how much conviction (participation) is behind a price move, not just its direction.
OBV — On-Balance Volume
def obv(df: pd.DataFrame) -> pd.Series:
direction = df["close"].diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0))
return (direction * df["volume"]).cumsum()
OBV's absolute level is meaningless (it's a running cumulative sum) — only its trend and divergence against price matter.
def obv_divergence_check(price: pd.Series, obv_series: pd.Series, lookback: int = 20) -> str:
price_trend = price.iloc[-1] - price.iloc[-lookback]
obv_trend = obv_series.iloc[-1] - obv_series.iloc[-lookback]
if price_trend > 0 and obv_trend < 0:
return "bearish divergence — price up, volume conviction fading"
elif price_trend < 0 and obv_trend > 0:
return "bullish divergence — price down, volume conviction fading on the downside"
return "no divergence"
This is the same divergence pattern chapter 103 flagged for RSI — chapter 116 generalizes it to work against any indicator, OBV included.
Volume Profile — distribution of traded volume by price level, not by time
def volume_profile(df: pd.DataFrame, num_bins: int = 50) -> pd.DataFrame:
price_range = pd.cut((df["high"] + df["low"] + df["close"]) / 3, bins=num_bins)
profile = df.groupby(price_range, observed=True)["volume"].sum().sort_index()
return profile.reset_index().rename(columns={"index": "price_bin"})
def find_poc(profile: pd.DataFrame) -> float:
"""Point of Control — the price level with the most traded volume."""
poc_row = profile.loc[profile["volume"].idxmax()]
return poc_row["price_bin"].mid
Value Area — the price range containing a target % of total volume (commonly 70%)
def value_area(profile: pd.DataFrame, target_pct: float = 0.70) -> tuple[float, float]:
total_volume = profile["volume"].sum()
sorted_profile = profile.sort_values("volume", ascending=False)
cumulative = 0
included_bins = []
for _, row in sorted_profile.iterrows():
cumulative += row["volume"]
included_bins.append(row["price_bin"])
if cumulative >= total_volume * target_pct:
break
all_edges = [b.left for b in included_bins] + [b.right for b in included_bins]
return min(all_edges), max(all_edges)
Why volume profile matters for support/resistance beyond simple price levels
The Point of Control (POC) and Value Area boundaries represent *consensus fair-value zones* where the most trading actually happened, not just arbitrary swing highs/lows — price tends to react at these levels because they represent genuine accumulated positioning, giving them a different character than a support level identified purely from price pivots (chapter 111).
Practical note on data requirements
Volume profile needs granular intraday data (chapter 28) to be meaningful — computing it from daily candles loses almost all the information that makes it useful, since you'd be distributing an entire day's volume across a wide daily range rather than seeing where within the day volume actually concentrated.