DEV Community

Timevolt
Timevolt

Posted on

The Matrix of Risk: Position Sizing and Stops in Algo Trading

The Quest Begins (The "Why")

Honestly, I still remember the first time I watched my algo blow up a simulated account in under five minutes. I had spent weeks polishing a mean‑reversion strategy, back‑tested it to death, and felt like Neo dodging bullets—until the market decided to throw a curveball that looked nothing like the training data. My position size was a flat 10% of equity on every trade, and my stop loss? A vague “let it run until it hurts” mindset. The result? A cascade of margin calls that left me staring at red numbers and wondering if I’d just invented a new way to lose money faster than a speedrun in Dark Souls.

That painful episode forced me to ask: What if I could control the damage before it even starts? Not by predicting the future (spoiler: we can’t), but by sizing each bet intelligently and placing a hard stop that respects both my capital and my sanity. The answer wasn’t some secret sauce; it was the humble, yet powerful, duo of position sizing and stop‑loss rules.

The Revelation (The Insight)

The breakthrough came when I treated each trade like a small investment in a portfolio, not a gamble. Two ideas clicked:

  1. Risk per trade – Decide beforehand how much of my equity I’m willing to lose on any single position. A common rule of thumb is 1%–2% per trade.
  2. Stop‑loss distance – Use the market’s volatility (e.g., ATR) to set a stop that gives the trade room to breathe, yet caps the loss if things go south.

When you combine them, the position size becomes a simple fraction:

position_size = (risk_per_trade * equity) / stop_distance
Enter fullscreen mode Exit fullscreen mode

If the stop is wider (high volatility), you automatically shrink the size; if the market is calm, you can afford a larger stake. This dynamic sizing keeps your risk constant in dollar terms, regardless of how wild the price action gets.

I also learned to avoid the trap of moving stops after entry—the “let’s see if it comes back”—because that turns a predefined risk into an open‑ended gamble. A stop is a contract with yourself; honor it.

Wielding the Power (Code & Examples)

Below is a before‑and‑after snippet in Python (using pandas and ta for ATR). The “before” version uses a fixed fraction and a static stop; the “after” version applies the volatility‑aware formula.

Before: Fixed size, static stop

import pandas as pd

def generate_signals(df):
    # Simple mean‑reversion signal: price < SMA(20) → long
    df['signal'] = 0
    df.loc[df['close'] < df['sma_20'], 'signal'] = 1
    return df

def backtest_fixed(df, equity=100_000, fixed_frac=0.10, stop_pct=0.02):
    df = generate_signals(df)
    df['position'] = 0.0
    df['equity']   = equity
    df['peak']     = equity

    for i in range(1, len(df)):
        # entry
        if df.loc[i-1, 'signal'] == 1 and df.loc[i-1, 'position'] == 0:
            df.loc[i, 'position'] = fixed_frac * equity / df.loc[i, 'close']
            entry_price = df.loc[i, 'close']
            stop_price  = entry_price * (1 - stop_pct)

        # exit on stop or signal reversal
        if df.loc[i, 'position'] != 0:
            # stop hit?
            if df.loc[i, 'low'] <= stop_price:
                df.loc[i, 'position'] = 0
                pnl = (stop_price - entry_price) * df.loc[i-1, 'position']
            # signal flips?
            elif df.loc[i, 'signal'] == 0:
                df.loc[i, 'position'] = 0
                pnl = (df.loc[i, 'close'] - entry_price) * df.loc[i-1, 'position']
            else:
                pnl = 0

            df.loc[i, 'equity'] = df.loc[i-1, 'equity'] + pnl
            df.loc[i, 'peak']   = max(df.loc[i-1, 'peak'], df.loc[i, 'equity'])
        else:
            df.loc[i, 'equity'] = df.loc[i-1, 'equity']
            df.loc[i, 'peak']   = df.loc[i-1, 'peak']

    return df
Enter fullscreen mode Exit fullscreen mode

What’s wrong here?

  • The fixed_frac of 10% ignores volatility; during a choppy session the same dollar amount can swing wildly.
  • The stop is a fixed 2% below entry, which may be too tight in a high‑volatility market (getting stopped out prematurely) or too loose in a calm one (exposing more capital than needed).

After: Volatility‑adjusted size & ATR stop

import pandas as pd
import ta

def generate_signals(df):
    df['signal'] = 0
    df.loc[df['close'] < df['sma_20'], 'signal'] = 1
    return df

def add_atr(df, period=14):
    df['atr'] = ta.volatility.average_true_range(df['high'], df['low'], df['close'], window=period)
    return df

def backtest_vol(df, equity=100_000, risk_per_trade=0.01, atr_period=14, atr_multiplier=2.0):
    df = generate_signals(df)
    df = add_atr(df, atr_period)
    df['position'] = 0.0
    df['equity']   = equity
    df['peak']     = equity

    for i in range(1, len(df)):
        if df.loc[i-1, 'signal'] == 1 and df.loc[i-1, 'position'] == 0:
            entry_price = df.loc[i, 'close']
            stop_distance = atr_multiplier * df.loc[i, 'atr']   # e.g., 2 * ATR
            stop_price    = entry_price - stop_distance

            # position size so that loss = risk_per_trade * equity if stop hit
            df.loc[i, 'position'] = (risk_per_trade * equity) / stop_distance

        # exit logic
        if df.loc[i, 'position'] != 0:
            entry_price = df.loc[i-1, 'close']  # we stored entry price implicitly via position calc
            stop_distance = atr_multiplier * df.loc[i, 'atr']
            stop_price    = entry_price - stop_distance

            if df.loc[i, 'low'] <= stop_price:
                df.loc[i, 'position'] = 0
                pnl = (stop_price - entry_price) * df.loc[i-1, 'position']
            elif df.loc[i, 'signal'] == 0:
                df.loc[i, 'position'] = 0
                pnl = (df.loc[i, 'close'] - entry_price) * df.loc[i-1, 'position']
            else:
                pnl = 0

            df.loc[i, 'equity'] = df.loc[i-1, 'equity'] + pnl
            df.loc[i, 'peak']   = max(df.loc[i-1, 'peak'], df.loc[i, 'equity'])
        else:
            df.loc[i, 'equity'] = df.loc[i-1, 'equity']
            df.loc[i, 'peak']   = df.loc[i-1, 'peak']

    return df
Enter fullscreen mode Exit fullscreen mode

Why this feels like leveling up:

  • The stop now breaths with the market (ATR), so you’re not getting whipsawed by noise.
  • Position size automatically shrinks when volatility spikes, keeping your dollar risk steady at 1% of equity.
  • The code is still short enough to drop into a backtest loop, yet it captures the core principle: risk first, size second.

Why This New Power Matters

Adopting this approach transformed my trading from a rollercoaster of emotions to a disciplined process. I could now sleep at night knowing that even a string of losers would only nibble at my equity, not devour it. The equity curve smoothed out, drawdowns shrank, and the Sharpe ratio crept up—all without changing the underlying signal.

More importantly, the mindset shift spreads beyond algo trading. Whenever you’re allocating capital—whether it’s time, money, or effort—thinking in terms of pre‑defined loss and adjusting exposure gives you a safety net that lets you pursue bigger opportunities without fearing catastrophic loss.

Your Turn: Embark on Your Own Quest

Try swapping the fixed fraction in your own strategy for the volatility‑sized version above. Play with the risk_per_trade (0.5 %‑2 %) and atr_multiplier (1.5‑3) to see how the equity curve reacts.

Challenge: Run a walk‑forward analysis on a symbol of your choice, compare the fixed‑size vs. ATR‑sized versions, and share the biggest surprise you discover in the comments.

Happy hunting, and may your stops be ever in your favor!

Top comments (0)