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.

Next: 111 — Pivot points and Fibonacci levels