Trailing Stop Loss in Python: Code for Crypto Traders
Learn how to code a trailing stop loss in Python for crypto trading. Includes working examples, formulas, and risk management rules for Binance and Bybit.
Learn how to code a trailing stop loss in Python for crypto trading. Includes working examples, formulas, and risk management rules for Binance and Bybit.
A trailing stop loss is one of the most practical tools in a crypto trader's arsenal — it lets your winners run while automatically cutting losses if the market reverses. Unlike a fixed stop, it moves with price. When BTC climbs from $60,000 to $70,000, your stop climbs too. If it then drops 5%, you're out — with profit locked in. Coding this yourself in Python gives you full control over the logic, the exchange API, and how it fits into your broader trading system.
The honest answer is: it depends on the market regime. In trending markets — bull runs, breakout moves, momentum plays — a trailing stop loss is excellent. It captures the bulk of a move without requiring you to manually exit. In choppy, sideways markets, it gets whipsawed constantly, triggering exits on noise rather than real reversals.
For crypto specifically, volatility is the defining characteristic. Bitcoin can swing 5-8% intraday. Set your trail too tight and you'll be stopped out on normal price breathing. Set it too loose and you give back too much. The key is calibrating your trailing percentage to the asset's Average True Range (ATR) — not picking a round number like 2% because it sounds reasonable.
Platforms like Bybit and OKX have native trailing stop functionality built into their order types. But native exchange trailing stops often have limitations — minimum trail distances, no ATR support, and no custom logic. Coding your own gives you full flexibility.
The math behind a trailing stop is straightforward. For a long position, the stop level is calculated from the highest price observed since entry. For a short position, it tracks the lowest price.
# Core trailing stop formulas
# For LONG positions:
# stop_price = highest_price_since_entry * (1 - trail_percent / 100)
# For SHORT positions:
# stop_price = lowest_price_since_entry * (1 + trail_percent / 100)
# Example: BTC long at $60,000 with 5% trail
entry_price = 60_000
trail_percent = 5.0
highest_price = 70_000 # price has moved up
stop_price = highest_price * (1 - trail_percent / 100)
print(f"Current trailing stop: ${stop_price:,.2f}") # $66,500
# ATR-based trail (more robust)
# stop_price = highest_price - (atr * multiplier)
atr = 1_500 # 14-period ATR
multiplier = 2.0
stop_atr = highest_price - (atr * multiplier)
print(f"ATR-based stop: ${stop_atr:,.2f}") # $67,000
The ATR-based approach is generally more robust for crypto. It automatically widens stops during volatile periods and tightens them during calm periods — adapting to the market rather than using a fixed percentage that might be too tight on a volatile Tuesday and too loose on a quiet weekend.
Below is a production-ready trailing stop class you can integrate with any exchange. It handles both percentage-based and ATR-based modes, tracks the high-water mark, and returns a signal when the stop is breached.
class TrailingStop:
"""
Trailing stop loss for long crypto positions.
Supports percentage-based and ATR-based trailing.
"""
def __init__(self, trail_pct: float = None, atr_multiplier: float = None):
self.trail_pct = trail_pct # e.g. 5.0 for 5%
self.atr_multiplier = atr_multiplier # e.g. 2.0
self.peak_price = None
self.stop_price = None
self.triggered = False
def update(self, current_price: float, atr: float = None) -> dict:
"""
Call this on every new price tick or candle close.
Returns dict with stop_price and triggered flag.
"""
if self.peak_price is None:
self.peak_price = current_price
# Update peak
if current_price > self.peak_price:
self.peak_price = current_price
# Calculate stop level
if self.trail_pct is not None:
self.stop_price = self.peak_price * (1 - self.trail_pct / 100)
elif self.atr_multiplier is not None and atr is not None:
self.stop_price = self.peak_price - (atr * self.atr_multiplier)
else:
raise ValueError("Provide trail_pct or atr_multiplier + atr")
# Check if triggered
if current_price <= self.stop_price:
self.triggered = True
return {
"peak_price": self.peak_price,
"stop_price": round(self.stop_price, 2),
"current_price": current_price,
"triggered": self.triggered,
"drawdown_pct": round(
(self.peak_price - current_price) / self.peak_price * 100, 2
),
}
def reset(self):
self.peak_price = None
self.stop_price = None
self.triggered = False
# --- Usage Example ---
if __name__ == "__main__":
ts = TrailingStop(trail_pct=5.0)
price_feed = [60000, 62000, 65000, 70000, 68000, 66000, 65500]
for price in price_feed:
result = ts.update(price)
status = "STOP TRIGGERED" if result["triggered"] else "active"
print(
f"Price: ${price:} | Peak: ${result['peak_price']:} "
f"| Stop: ${result['stop_price']:} | {status}"
)
if result["triggered"]:
print(f" => Exit at ${result['stop_price']:} "
f"(drawdown from peak: {result['drawdown_pct']}%)")
break
This outputs a clean trace of your stop level as price moves. When the stop triggers, you'd feed that signal into your exchange API — whether that's placing a market order on Binance, Bybit, or Coinbase via their REST APIs.
Binance has a native trailing stop order type, but if you want custom logic (ATR-based, multi-asset, or conditional), you'll use their websocket price stream combined with your own TrailingStop class. Here's the pattern:
import asyncio
import json
import websockets
from binance.client import Client # pip install python-binance
API_KEY = "your_api_key"
API_SECRET = "your_api_secret"
client = Client(API_KEY, API_SECRET)
ts = TrailingStop(trail_pct=4.0) # 4% trail
SYMBOL = "BTCUSDT"
QUANTITY = 0.001 # position size in BTC
async def run_trailing_stop():
stream_url = f"wss://stream.binance.com:9443/ws/{SYMBOL.lower()}@trade"
async with websockets.connect(stream_url) as ws:
print(f"Connected to Binance stream for {SYMBOL}")
async for message in ws:
data = json.loads(message)
price = float(data["p"]) # trade price
result = ts.update(price)
if result["triggered"]:
print(f"Stop triggered at ${result['stop_price']}")
# Place market sell order on Binance
order = client.order_market_sell(
symbol=SYMBOL,
quantity=QUANTITY
)
print(f"Order placed: {order['orderId']}")
break
asyncio.run(run_trailing_stop())
Always test with small quantities first. On Binance, use the testnet endpoint (testnet.binance.vision) before going live. On Bybit, the testnet is at api-testnet.bybit.com. Never run untested bot code on a live account with real funds.
For OKX users, the pattern is identical — swap in the OKX SDK and adjust the websocket URL. Gate.io and KuCoin both have Python SDKs that follow the same structure. The TrailingStop class above is exchange-agnostic; only the order placement code changes.
A trailing stop loss is only as good as the position sizing that comes before it. If you size positions too large, even a well-designed trailing stop can create catastrophic drawdowns on a string of losses. Here's a practical framework:
The standard rule: risk no more than 1-2% of total portfolio capital per trade. Your position size is determined by where your initial stop is placed, not by how much you want to make.
def calculate_position_size(
portfolio_value: float,
risk_pct: float,
entry_price: float,
initial_stop_pct: float
) -> dict:
"""
Calculate position size based on fixed risk per trade.
Args:
portfolio_value: Total account value in USD
risk_pct: Max risk per trade as % of portfolio (e.g. 1.0 for 1%)
entry_price: Asset entry price
initial_stop_pct: Initial stop distance as % (e.g. 5.0 for 5%)
"""
max_loss_usd = portfolio_value * (risk_pct / 100)
stop_distance_usd = entry_price * (initial_stop_pct / 100)
position_size_units = max_loss_usd / stop_distance_usd
position_value_usd = position_size_units * entry_price
return {
"max_loss_usd": round(max_loss_usd, 2),
"position_size_units": round(position_size_units, 6),
"position_value_usd": round(position_value_usd, 2),
"portfolio_allocation_pct": round(
position_value_usd / portfolio_value * 100, 1
),
}
# Example: $50,000 portfolio, 1% risk, BTC at $70,000, 5% initial stop
result = calculate_position_size(50_000, 1.0, 70_000, 5.0)
print(result)
# {'max_loss_usd': 500.0, 'position_size_units': 0.142857,
# 'position_value_usd': 10000.0, 'portfolio_allocation_pct': 20.0}
| Asset | Entry | Trail % | Risk/Trade | Position Size | Allocation |
|---|---|---|---|---|---|
| BTC | $70,000 | 5% | 1% ($500) | 0.143 BTC | 20% |
| ETH | $3,500 | 6% | 1% ($500) | 2.38 ETH | 16.7% |
| SOL | $180 | 8% | 1% ($500) | 34.7 SOL | 12.5% |
| BTC | $70,000 | 5% | 2% ($1,000) | 0.286 BTC | 40% |
| ALT | $1.20 | 12% | 0.5% ($250) | 1,736 units | 41.7% |
Notice how high-volatility alts require wider stops, which paradoxically limits your position size even when allocating the same dollar risk. This is the math enforcing discipline that emotion often can't. Tools like VoiceOfChain can feed you real-time signal strength data, so you know whether a trade is high-conviction enough to use your full 1-2% risk or scale back to 0.5%.
| Scenario | Position Size | Trail % | Peak | Exit | P&L |
|---|---|---|---|---|---|
| BTC uptrend, clean exit | $10,000 | 5% | $70K→$85K | $80,750 | +$1,071 |
| BTC reversal, stop works | $10,000 | 5% | $70K→$75K→$65K | $71,250 | +$178 |
| No stop, same reversal | $10,000 | None | $70K→$75K→$55K | $55K (manual) | -$2,143 |
| Whipsaw in range | $10,000 | 3% | $70K→$72K→$69.8K | $69,840 | -$23 |
| Whipsaw, wider trail | $10,000 | 7% | $70K→$72K→$65K | $66,960 | -$432 |
Before running any trailing stop in live markets, backtest it against historical OHLCV data. The quickest way is to pull candle data from Binance's public API — no auth required — and simulate the stop logic across hundreds of trades.
import requests
import pandas as pd
def fetch_binance_ohlcv(symbol: str, interval: str, limit: int = 500) -> pd.DataFrame:
url = "https://api.binance.com/api/v3/klines"
params = {"symbol": symbol, "interval": interval, "limit": limit}
data = requests.get(url, params=params).json()
df = pd.DataFrame(data, columns=[
"open_time", "open", "high", "low", "close",
"volume", "close_time", "qav", "num_trades",
"taker_buy_base", "taker_buy_quote", "ignore"
])
df["close"] = df["close"].astype(float)
df["high"] = df["high"].astype(float)
df["low"] = df["low"].astype(float)
return df
def backtest_trailing_stop(df: pd.DataFrame, trail_pct: float) -> dict:
"""Simple long-only backtest: enter on first candle, trail stop from there."""
ts = TrailingStop(trail_pct=trail_pct)
entry_price = df["close"].iloc[0]
exit_price = None
exit_idx = None
for i, row in df.iterrows():
result = ts.update(row["close"])
if result["triggered"]:
exit_price = result["stop_price"]
exit_idx = i
break
if exit_price is None:
exit_price = df["close"].iloc[-1]
exit_idx = df.index[-1]
pnl_pct = (exit_price - entry_price) / entry_price * 100
return {
"entry": entry_price,
"exit": exit_price,
"candles_held": exit_idx,
"pnl_pct": round(pnl_pct, 2),
"max_peak": ts.peak_price,
"gave_back_pct": round(
(ts.peak_price - exit_price) / ts.peak_price * 100, 2
),
}
# Run it
df = fetch_binance_ohlcv("BTCUSDT", "4h", limit=200)
result = backtest_trailing_stop(df, trail_pct=5.0)
print(result)
The "gave_back_pct" metric is key — it tells you how much of your peak profit you surrendered before the stop fired. If you're consistently giving back 70-80% of a move, your trail is too loose. If you're getting stopped out with tiny gains on moves that later went much higher, it's too tight. Tune until gave_back_pct sits in the 20-40% range, which balances capture rate with letting winners breathe.
VoiceOfChain's signal platform can complement this approach — its real-time order flow signals can tell you when institutional buying pressure is building, helping you decide when to tighten your trail versus hold it loose for a larger move.
A trailing stop loss coded in Python gives you something no native exchange order type can: complete control. You decide the trail distance, whether it's percentage or ATR-based, when to activate it, and what happens when it triggers. The TrailingStop class shown here is intentionally simple — production systems will add logging, error handling, reconnection logic, and multi-asset support.
The most important thing isn't the code itself — it's the discipline of using it consistently. Trailing stop python code that runs reliably beats a perfect strategy that you override manually every time a trade goes slightly against you. Start with the backtesting framework, dial in your trail percentage against real historical data from Binance or Bybit, and only then connect it to a live account with small position sizes. Let the math work for you.
Track your trailing stop performance separately from your entry signals. You might have an excellent entry signal but a poorly tuned trail — or vice versa. Separating these metrics tells you exactly where to improve.