The Quest Begins (The "Why")
I still remember the first time I watched my algo blow up a simulated account in under an hour. I had spent weeks polishing a mean‑reversion signal, tuned the entry logic until it sang, and then — boom — the position size I’d hard‑coded at 10 % of equity turned a modest drawdown into a margin call. The chart looked like a roller‑coaster that had lost its brakes, and I felt like I’d just missed the final boss in a game because I forgot to bring a health pack.
That moment forced me to ask: If my edge is real, why am I still losing? The answer wasn’t in a better indicator; it was in how much I risked on each trade and how I protected myself when the market decided to surprise me. In other words, I needed a proper risk‑management layer — position sizing and stops — before I could trust any signal at all.
The Revelation (The Insight)
The breakthrough came when I stopped thinking about “how many shares to buy” and started thinking about “how much of my equity am I willing to lose on this idea.” Two simple ideas changed everything:
- Position sizing as a fraction of equity – Instead of a fixed number of contracts, I size each trade so that a stop‑loss hit would remove only a small, predefined slice of my capital (often 1 %–2 %). This makes the strategy survive streaks of losers without blowing up.
- Stops that adapt to volatility – A static stop (e.g., always 50 pips) works in calm markets but gets whipsawed when volatility spikes. Using an ATR‑based stop (Average True Range) lets the stop breathe with the market, giving the trade room to move while still cutting losses quickly when the price moves against me.
Together, these ideas turned my trading from a gamble into a repeatable process. It felt like discovering a cheat code from Contra — suddenly everything made sense, and I could focus on refining the edge instead of watching the account bleed.
Wielding the Power (Code & Examples)
The Naïve Approach (the struggle)
# naive_fixed_size.py
import pandas as pd
def generate_signals(df):
# placeholder: 1 = long, -1 = short, 0 = flat
df['signal'] = df['close'].rolling(20).mean() > df['close'].rolling(50).mean()
df['signal'] = df['signal'].astype(int) - df['signal'].shift().fillna(0).astype(int)
return df
def execute_trades(df, equity=1, equity_start=100_000):
position = 0 # number of contracts/shares
cash = equity_start
equity_curve = []
for i, row in df.iterrows():
# enter on signal
if row['signal'] == 1 and position == 0:
# fixed 10% of equity, ignore volatility
notional = equity_start * 0.10
price = row['close']
position = notional / price
cash -= notional # simplify: ignore commissions
# exit on opposite signal
elif row['signal'] == -1 and position > 0:
cash += position * row['close']
position = 0
equity_curve.append(cash + position * row['close'])
df['equity'] = equity_curve
return df
The problems?
- Fixed fraction of initial equity ignores that equity changes; after a few losses you’re still risking the same absolute amount.
- No stop‑loss means a single adverse move can erase weeks of profit.
- Static size doesn’t respect volatility — during a high‑vol regime you might be over‑leveraged, during low‑vol you’re under‑utilizing capital.
The Upgraded Approach (the victory)
# risk_managed.py
import pandas as pd
import numpy as np
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()
def generate_signals(df):
df['signal'] = np.where(df['close'].rolling(20).mean() > df['close'].rolling(50).mean(), 1, 0)
df['signal'] = df['signal'].diff().fillna(0) # 1 on entry, -1 on exit
return df
def position_size(equity, risk_per_trade, stop_distance):
"""
equity : current account equity
risk_per_trade: fraction of equity we are willing to lose (e.g., 0.01 for 1%)
stop_distance: price difference between entry and stop (in same units as price)
returns number of shares/contracts to trade
"""
dollar_risk = equity * risk_per_trade
if stop_distance == 0:
return 0
return dollar_risk / stop_distance
def execute_trades(df, equity_start=100_000, risk_per_trade=0.01, atr_mult=1.5):
df = generate_signals(df)
df['atr'] = atr(df)
equity = equity_start
position = 0
entry_price = 0
stop_price = 0
equity_curve = []
for i, row in df.iterrows():
price = row['close']
# --- ENTRY LOGIC ---
if row['signal'] == 1 and position == 0:
# volatility‑based stop: ATR * multiplier below entry for longs
stop_distance = row['atr'] * atr_mult
size = position_size(equity, risk_per_trade, stop_distance)
if size > 0:
position = size
entry_price = price
stop_price = entry_price - stop_distance # long stop below entry
# --- EXIT LOGIC ---
elif position > 0:
# hit stop?
if price <= stop_price:
equity += position * stop_price # realize loss at stop
position = 0
# or signal reversal?
elif row['signal'] == -1:
equity += position * price
position = 0
equity_curve.append(equity + position * price)
df['equity'] = equity_curve
return df
What changed?
-
Dynamic position sizing: each trade risks only
risk_per_trade(1 % here) of the current equity. The size shrinks after losses and grows after wins, keeping the risk profile stable. -
ATR‑based stop: the stop distance scales with recent volatility (
atr_mult * ATR). In choppy markets the stop widens, preventing premature exits; in quiet markets it tightens, protecting capital. - Clear entry/exit: we only enter when we have a signal and no open position; we exit either on stop or on an opposite signal.
Common traps to avoid (the “boss level” moments):
- Using the initial equity for sizing – always recompute size with the latest equity, otherwise you’ll over‑risk after a drawdown.
-
Setting
atr_multtoo low – a stop that’s too tight will get hit by normal noise, turning your strategy into a high‑frequency loss machine. Test a range (1.0‑2.0) on your instrument. - Forgetting to update the stop after entry – if you move the stop to break‑even or trail it, make sure the new stop distance still reflects the current ATR or a trailing‑ATR logic.
Run both scripts on the same historical data and you’ll see the equity curve of the naïve version swing wildly, sometimes dipping below zero, while the risk‑managed version stays smooth, with draw‑downs limited to roughly the chosen risk_per_trade per trade.
Why This New Power Matters
With these two tools in my belt, I stopped chasing “holy grail” indicators and started focusing on what truly drives survival: controlled risk. The strategy now feels less like a gamble and more like a disciplined business — each trade is a calculated investment with a known worst‑case loss.
That confidence lets me:
- Allocate capital across multiple strategies without fear that one blow‑up will take down the whole portfolio.
- Sleep at night knowing that a single bad day won’t erase weeks of work.
- Iterate faster on the signal side because the risk layer is stable; I can experiment with entries, exits, or filters and instantly see the impact on profitability rather than being masked by massive, random swings.
In short, position sizing and volatility‑adjusted stops transformed my algo from a fragile prototype into a robust trading system.
Your Turn – The Challenge
Grab a dataset (forex, futures, or even crypto) and implement the risk‑managed version above. Try varying risk_per_trade (0.5 %, 1 %, 2 %) and atr_mult (1.0, 2.0) and observe how the equity curve changes.
Question for you: What’s the biggest draw‑down you’ve ever seen in a strategy, and how do you think a proper position‑sizing rule would have changed that outcome?
Share your results or thoughts in the comments — let’s learn from each other’s quests! 🚀
Top comments (0)