LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Decoding Market Indicators with Code: OBV and the Golden Cross from Scratch

pythonquantitative-financepandastechnical-analysisalgorithmic-tradingdata-analysis

To an engineer, a stock chart covered in moving averages and oscillators can look like astrology with extra steps — a pile of lines whose names (“On-Balance Volume,” “Golden Cross,” “Stochastic RSI”) suggest more meaning than math. But every technical indicator is, underneath, a small and completely deterministic function over a time series of price and volume. There is no magic: given the same input bars, an indicator produces the same output, every time, and you can write it in a dozen lines of Python. Understanding indicators as code does two things at once. It strips away the mystique — you see exactly what is being computed and therefore exactly what it can and cannot tell you — and it exposes the real traps, which are almost never in the formula and almost always in how you wire the data up (look-ahead bias, off-by-one signal timing, survivorship in your backtest). This post implements two canonical indicators from scratch: On-Balance Volume (OBV), a running sum that uses volume to confirm price, and the Golden Cross, a moving-average crossover you can detect precisely. We will write them vectorized in pandas, interpret them honestly, and be clear-eyed about what a lagging indicator actually is: a compressed description of the past, not a forecast of the future.


The data model: it’s all OHLCV

Before any indicator, there is the data. Market data for a given instrument and timeframe (daily, hourly, one-minute) arrives as OHLCV bars: for each period, the Open, High, Low, Close prices and the Volume traded. In pandas this is a DataFrame indexed by timestamp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import pandas as pd
import numpy as np

# A toy OHLCV frame; in practice you'd pull this from a data provider.
df = pd.DataFrame({
    "open":  [100, 101, 102, 101, 103, 104],
    "high":  [101, 103, 103, 102, 105, 105],
    "low":   [ 99, 100, 101, 100, 102, 103],
    "close": [101, 102, 101, 102, 104, 103],
    "volume":[1000, 1500,  900, 1200, 2000, 1100],
}, index=pd.date_range("2026-01-01", periods=6, freq="D"))

Every indicator below is a pure function f(df) -> Series. That framing matters: because the functions are pure, the only way to get a wrong answer is to feed them data they would not have had at the time — which is exactly the look-ahead trap we will return to.


On-Balance Volume: turning volume into a running signal

The premise behind OBV, introduced by Joe Granville in the 1960s, is that volume precedes price — that the conviction behind a move shows up in how much is traded, and that a price rise on heavy volume is more “real” than the same rise on thin volume. OBV operationalizes this with a brutally simple rule. Maintain a running total. Each period:

  • If today’s close is higher than yesterday’s, add today’s volume to the total.
  • If today’s close is lower than yesterday’s, subtract today’s volume.
  • If the close is unchanged, leave the total alone.

Formally, with OBV_0 = 0:

            ⎧ OBV(t-1) + V(t)   if Close(t) > Close(t-1)
OBV(t)  =   ⎨ OBV(t-1) - V(t)   if Close(t) < Close(t-1)
            ⎩ OBV(t-1)          if Close(t) = Close(t-1)

The absolute value of OBV is meaningless — it depends entirely on where you started the cumulative sum. What matters is its direction and slope: a rising OBV says volume is accumulating on up-days (buyers in control), a falling OBV says volume is distributing on down-days. The headline use is divergence: if price makes a new high but OBV does not, the rally is happening on weakening volume, which technicians read as a warning that the move lacks conviction.

Vectorized implementation

The naive implementation is a Python loop over rows — correct but slow and un-pandas. The vectorized version computes the per-period signed volume first, then takes a cumulative sum:

1
2
3
4
5
6
7
8
9
def on_balance_volume(df: pd.DataFrame) -> pd.Series:
    # +1 if close rose vs prior bar, -1 if fell, 0 if unchanged.
    direction = np.sign(df["close"].diff())
    # The first bar has no prior close; treat it as 0 (no change).
    direction = direction.fillna(0)
    signed_volume = direction * df["volume"]
    return signed_volume.cumsum().rename("obv")

df["obv"] = on_balance_volume(df)

Three details carry the whole thing. close.diff() gives today minus yesterday; np.sign(...) collapses that to {-1, 0, +1}, which is the OBV rule expressed as a number. Multiplying by volume gives signed volume, and cumsum() is the running total. The fillna(0) on the first bar matters: without it the first diff() is NaN, which would poison the cumulative sum into all-NaN. This is the single most common bug in hand-rolled indicators — an unhandled boundary at the start of the series.

Reading OBV honestly

OBV is a confirmation tool, not a trigger. It does not tell you to buy or sell; it tells you whether volume agrees with price. Its weaknesses are real and worth stating: a single enormous-volume day can dominate the running sum and skew it for a long time; it treats a 0.01% up-close and a 5% up-close identically (it only looks at the sign of the change, never the magnitude); and on instruments with unreliable volume data (some FX and crypto venues), the input itself is suspect. Treat OBV as one corroborating input, never as a standalone signal.


Moving averages and the Golden Cross

A moving average smooths a noisy price series into a trend by averaging the last N closes. The two common flavors:

  • Simple Moving Average (SMA): the unweighted mean of the last N closes. Easy, but every bar in the window counts equally and a value “drops off” abruptly when it exits the window.
  • Exponential Moving Average (EMA): a weighted mean that decays older bars geometrically, so recent prices count more and the average reacts faster. EMA never fully “forgets” old data; it just discounts it.
1
2
3
df["sma_50"]  = df["close"].rolling(window=50).mean()
df["sma_200"] = df["close"].rolling(window=200).mean()
df["ema_50"]  = df["close"].ewm(span=50, adjust=False).mean()

The Golden Cross is the moment the 50-period SMA crosses above the 200-period SMA — a widely watched signal that a shorter-term trend has turned up through the longer-term trend, read as bullish. Its bearish mirror, the Death Cross, is the 50 crossing below the 200. Drawn out:

price/MA
   |                         SMA50
   |                        /  ____ SMA200
   |          SMA50 ___    / /
   |   ______/       \    / /
   |  /               \  / /
   |                    \/  <-- DEATH CROSS (50 below 200)
   |                    /\
   |                   /  \____ ...
   |     GOLDEN CROSS /        (50 crosses back above 200)
   +----------------------------------------------- time

Detecting the cross exactly

A beginner’s mistake is to test sma_50 > sma_200 and call every True a buy signal — but that fires on every bar the 50 is above the 200, not on the crossing. The crossing is an edge, not a level, and the clean way to detect an edge is to look at where the sign of the difference changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def golden_cross_signals(df: pd.DataFrame,
                         short: int = 50,
                         long: int = 200) -> pd.Series:
    sma_s = df["close"].rolling(short).mean()
    sma_l = df["close"].rolling(long).mean()

    # +1 when short is above long, -1 when below.
    regime = np.sign(sma_s - sma_l)

    # The cross is where the regime flips. diff() of the sign is:
    #   +2  -> crossed up   (Golden Cross)
    #   -2  -> crossed down  (Death Cross)
    #    0  -> no cross this bar
    cross = regime.diff()

    signals = pd.Series(0, index=df.index, name="signal")
    signals[cross ==  2] =  1   # golden cross
    signals[cross == -2] = -1   # death cross
    return signals

np.sign(sma_s - sma_l) reduces the two averages to a single regime variable; .diff() is non-zero only on the bar where that regime flips. This returns +1 on exactly the Golden Cross bar and -1 on exactly the Death Cross bar — sparse, event-like, and free of the “fires every bar” bug. The same sign-plus-diff pattern detects any crossover (MACD line vs signal line, price vs a single MA), which is why it is worth internalizing once.


The look-ahead trap (where backtests go to die)

Here is the part that separates a toy from something you would trust with money. Suppose you compute the Golden Cross signal on a daily bar and want to “act” on it. The crossing is computed from the day’s close — which you do not know until the day is over. If your backtest then assumes you bought at that same day’s close (or worse, its open), you have used information from the end of the day to trade at the start of it. That is look-ahead bias, and it makes strategies look spectacular in backtests and fail in production.

The fix is to lag the signal: act on the next bar, not the bar that produced the signal.

1
2
3
4
5
6
7
8
df["signal"] = golden_cross_signals(df)
# You learn the signal at today's close; the earliest you can act is the
# NEXT bar's open. Shift the signal forward by one to model that delay.
df["position"] = df["signal"].replace(0, np.nan).ffill().shift(1).fillna(0)

# Strategy return = position held over the bar's return.
df["ret"] = df["close"].pct_change()
df["strategy_ret"] = df["position"] * df["ret"]

The .shift(1) is not cosmetic — it is the difference between a believable backtest and a lie. Three more traps live in the same neighborhood:

  • Survivorship bias. Backtesting on today’s index constituents quietly excludes every company that went bankrupt or got delisted, inflating returns. Use point-in-time universes.
  • Transaction costs and slippage. A strategy that crosses frequently can be profitable on paper and unprofitable after commissions, spread, and market impact. Subtract realistic costs per trade.
  • Overfitting. Tune the windows (50/200, or 9/21, or whatever) hard enough on historical data and you can “discover” a strategy that fit noise. Validate out-of-sample, and be suspicious of any parameter set that looks too good.

What an indicator actually is

Step back from the code and the honest summary is this: a moving-average crossover and OBV are both lagging indicators — functions of past prices and volumes. By construction they describe what already happened. A 200-day average cannot turn until the underlying prices have already moved for weeks; a Golden Cross confirms a trend that, by the time it triggers, is well underway. That is not a flaw to be engineered away — it is the nature of any smoother. The value of these tools is not prediction but disciplined description: they impose a consistent, unemotional reading on noisy data, which is genuinely useful for managing your own behavior even if they grant no edge over the market. The market is, to a first approximation, extremely hard to beat; treat any indicator that seems to print money in backtest as a bug in your methodology until proven otherwise. For the related discipline of pricing and risk-managing derivatives — where the math is far less hand-wavy — see the companion post on the engineering of options trading and the Greeks.


Verdict

Technical indicators are not mysticism; they are short, deterministic functions over an OHLCV series, and writing them yourself is the fastest way to understand both their logic and their limits. OBV is a cumulative sum of signed volume — three lines in pandas (sign(diff) * volume, then cumsum) — useful as a confirmation of whether volume agrees with price, and dangerous if you read its absolute level or trust bad volume data. The Golden Cross is a moving-average crossover detected cleanly with a sign change and a diff, not a level test, and the real engineering is not the indicator but the plumbing around it: lag your signals with .shift(1) to avoid look-ahead bias, subtract realistic costs, use point-in-time data, and validate out-of-sample. Above all, remember what you have built — a precise description of the past, not a forecast of the future. Indicators are tools for discipline and analysis; they are not a money printer, and any backtest that suggests otherwise is almost certainly lying to you. (Educational content, not financial advice.)


Sources

Comments