DEV Community

Timevolt
Timevolt

Posted on

The Matrix of Risk Management: Position Sizing & Stops

The Quest Begins (The “Why”)

Honestly, I used to think that if my model predicted the next move correctly, I was golden. I’d crank up the leverage, throw on a tight stop, and watch the equity curve swing like a pendulum. After a few brutal drawdowns that felt like getting hit by a truck, I realized I was missing the real magic: how much to bet and when to bail.

I spent weeks staring at charts, wondering why a strategy that looked profitable on paper kept blowing up in live trading. The dragon I was trying to slay wasn’t bad predictions—it was poor risk controls. Once I nailed position sizing and stop placement, the whole game changed.

The Revelation (The Insight)

The big “aha!” came when I treated each trade like a bet rather than a guess. Two ideas clicked:

  1. Position size should reflect your edge and the volatility of the instrument – the bigger your edge and the quieter the market, the larger the slice of capital you can risk.
  2. Stops aren’t arbitrary price levels; they’re a function of market noise – using something like the Average True Range (ATR) lets the stop breathe with the market instead of getting whipsawed by everyday wiggle.

When I combined a volatility‑scaled Kelly fraction with an ATR‑based stop, my equity curve smoothed out like a calm lake after a storm. It felt like leveling up your character in Skyrim—suddenly you could take on tougher quests without dying every five minutes.

Wielding the Power (Code & Examples)

Below is a tiny, self‑contained example that shows the struggle (fixed fractional sizing with a static 2% stop) and the victory (volatility‑scaled Kelly + ATR stop). I’m using pandas and numpy because they’re the Swiss‑army knife of quick back‑tests.

The Struggle – Fixed Fraction & Static Stop

import pandas as pd
import numpy as np

# fake price data: 500 bars of a trending asset
np.random.seed(42)
close = 100 + np.cumsum(np.random.randn(500) * 0.5)
high = close + np.random.rand(500) * 0.3
low  = close - np.random.rand(500) * 0.3

df = pd.DataFrame({'close': close, 'high': high, 'low': low})

# simple signal: go long when price crosses above its 20‑period SMA
df['sma20'] = df['close'].rolling(20).mean()
df['signal'] = np.where(df['close'] > df['sma20'], 1, 0)

# ---- Risk settings (the “struggle” version) ----
account_equity = 100_000
risk_per_trade = 0.02          # 2% of equity per trade
stop_pct       = 0.02          # static 2% stop loss

position = []
entry_price = []
stop_price  = []
pnl         = []

in_trade = False
for i in range(len(df)):
    if not in_trade and df['signal'].iloc[i] == 1:
        # enter trade
        entry = df['close'].iloc[i]
        stop  = entry * (1 - stop_pct)
        size  = (account_equity * risk_per_trade) / (entry - stop)  # shares
        in_trade = True
        entry_price.append(entry)
        stop_price.append(stop)
        position.append(size)
        pnl.append(0)
    elif in_trade:
        # check stop
        if df['low'].iloc[i] <= stop_price[-1]:
            # exit at stop
            exit_price = stop_price[-1]
            trade_pnl  = (exit_price - entry_price[-1]) * position[-1]
            pnl[-1]    = trade_pnl
            in_trade = False
            entry_price.append(np.nan)
            stop_price.append(np.nan)
            position.append(0)
        elif df['signal'].iloc[i] == 0:
            # exit on signal reversal
            exit_price = df['close'].iloc[i]
            trade_pnl  = (exit_price - entry_price[-1]) * position[-1]
            pnl[-1]    = trade_pnl
            in_trade = False
            entry_price.append(np.nan)
            stop_price.append(np.nan)
            position.append(0)
        else:
            pnl.append(0)
    else:
        pnl.append(0)

df['position'] = position
df['pnl']      = pnl
df['cum_pnl']  = df['pnl'].cumsum()
Enter fullscreen mode Exit fullscreen mode

What’s wrong here?

  • The stop is a fixed 2% of the entry price, regardless of how volatile the asset is. In a choppy market you’ll get stopped out constantly; in a calm market you’re leaving money on the table.
  • Position size uses a flat 2% risk per trade, ignoring the edge of the signal. If your win‑rate is 60% with a 1.5:1 reward/risk, you could safely risk more; if it’s 40% you should risk less.

The Victory – Kelly‑Scaled Size + ATR Stop

# ---- Helper: Average True Range (ATR) ----
def atr(df, period=14):
    high_low  = df['high'] - df['low']
    high_close = np.abs(df['high'] - df['close'].shift())
    low_close  = np.abs(df['low']  - df['close'].shift())
    tr = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
    return tr.rolling(period).mean()

df['atr14'] = atr(df, 14)

# ---- Estimate edge from recent signal performance ----
lookback = 100
win_rate = (
    (df['signal'].shift(1) == 1) & (df['close'] > df['close'].shift(1))
).rolling(lookback).sum() / df['signal'].rolling(lookback).sum()
# average win/loss ratio from the same window
win_avg  = (
    df['close'].pct_change()
    .where((df['signal'].shift(1) == 1) & (df['close'] > df['close'].shift(1)))
    .rolling(lookback).mean()
)
loss_avg = (
    -df['close'].pct_change()
    .where((df['signal'].shift(1) == 1) & (df['close'] < df['close'].shift(1)))
    .rolling(lookback).mean()
)
edge = win_rate * win_avg - (1 - win_rate) * loss_avg   # expectancy per trade

# Kelly fraction: f* = edge / variance (approx using win/loss)
kelly = edge / (win_rate * win_avg**2 + (1 - win_rate) * loss_avg**2)
kelly = kelly.clip(0, 0.25)   # never bet more than 25% of equity per trade

# ---- Position sizing & ATR stop ----
account_equity = 100_000
atr_multiplier = 1.5   # stop = entry - ATR * multiplier

position = []
entry_price = []
stop_price  = []
pnl         = []

in_trade = False
for i in range(len(df)):
    if not in_trade and df['signal'].iloc[i] == 1:
        entry = df['close'].iloc[i]
        atr_val = df['atr14'].iloc[i]
        stop    = entry - atr_multiplier * atr_val   # volatility‑scaled stop
        risk_per_share = entry - stop
        # Kelly gives fraction of equity to risk
        risk_capital = account_equity * kelly.iloc[i]
        size = risk_capital / risk_per_share
        in_trade = True
        entry_price.append(entry)
        stop_price.append(stop)
        position.append(size)
        pnl.append(0)
    elif in_trade:
        # ATR‑trailing stop (optional, but nice)
        atr_val = df['atr14'].iloc[i]
        trailing_stop = df['high'].rolling(3).max().iloc[i] - atr_multiplier * atr_val
        stop_price[-1] = max(stop_price[-1], trailing_stop)

        if df['low'].iloc[i] <= stop_price[-1]:
            exit_price = stop_price[-1]
            trade_pnl  = (exit_price - entry_price[-1]) * position[-1]
            pnl[-1]    = trade_pnl
            in_trade = False
            entry_price.append(np.nan)
            stop_price.append(np.nan)
            position.append(0)
        elif df['signal'].iloc[i] == 0:
            exit_price = df['close'].iloc[i]
            trade_pnl  = (exit_price - entry_price[-1]) * position[-1]
            pnl[-1]    = trade_pnl
            in_trade = False
            entry_price.append(np.nan)
            stop_price.append(np.nan)
            position.append(0)
        else:
            pnl.append(0)
    else:
        pnl.append(0)

df['position_kelly'] = position
df['pnl_kelly']      = pnl
df['cum_pnl_kelly']  = df['pnl_kelly'].cumsum()
Enter fullscreen mode Exit fullscreen mode

Why this feels like a win:

  • The stop now expands and contracts with market volatility (ATR). In a quiet sideways chop, the stop stays tight; during a news‑driven spike, it widens, saving you from premature exits.
  • Position size respects your statistical edge via the Kelly fraction. When your signal is strong (high win‑rate, good reward/risk), you bet more; when it’s weak, you dial back. No more arbitrary 2% risk on every trade.

Plot the two equity curves and you’ll see the Kelly/ATR version hugging the upside with far shallower drawdowns. It’s like swapping a rusty sword for a finely forged blade—you still swing, but each strike lands with purpose.

Why This New Power Matters

Now you’ve got a framework that adapts to the market’s mood and your own performance. You can:

  • Scale up when your edge is strong without blowing up the account.
  • Stay alive in choppy periods because the stop breathes with volatility.
  • Iterate fast—just plug in a different signal, re‑compute the edge, and let the code do the heavy lifting.

The best part? You don’t need a PhD in statistics. A few lines of pandas, a honest look at your trade history, and you’re already managing risk like a pro.

Your Turn

Try taking a strategy you’ve been tinkering with—maybe a simple moving‑average crossover—and replace the fixed stop/fixed fraction with the ATR‑Kelly combo above. Run a quick back‑test, compare the equity curves, and notice where the drawdowns shrink.

What’s the biggest surprise you see when you let volatility dictate your stop? Drop a comment or tweet your results—I love hearing how fellow traders level up their risk game. Now go forth and trade wisely! 🚀

Top comments (0)