Adaptive Trend-Following Strategy in Crypto: From Classic Signals to Real-Time Execution
Blending SMA, RSI, ATR, and dynamic sizing to build a live-executed BTC/USDT strategy with WebSockets, buffer logic, and real-time performance metrics.
TL;DR: This post continues our journey into a crypto world, now with trend-following strategy, blending classic principles with modern dynamic sizing. We’ll design a strategy that adapts to signal strength, volatility, and momentum, implemented in Python with real-time WebSocket data. Running on 1-second updates over a 5–10 minute simulation, it computes portfolio value, Sharpe Ratio, and Maximum Drawdown. Code included!
Trend-following is a popular trading strategy across equities, forex, commodities, and cryptocurrencies, rooted in the principle of “let winners run.” Cryptocurrency markets, with their volatility and strong trends (e.g., Bitcoin’s parabolic runs or sharp corrections), are ideal for trend-following. But how do you balance simplicity with adaptability to market conditions?
In this post, we’ll design a crypto trend-following strategy for BTC/USDT, blending old-school discipline with modern position sizing. We’ll implement it in Python using real-time WebSocket data, simulate it over 5–10 minutes with 1-second updates, and provide code to visualize and analyse performance.
Why Crypto?
Crypto’s 24/7 trading, high volatility, and strong trends make it a natural fit for trend-following. However, flash crashes and rapid price swings demand robust risk controls. Common trend-following indicators in crypto include:
Moving Averages (Simple, Exponential, Weighted).
Breakout Systems (e.g., Donchian Channels).
Momentum Indicators (e.g., RSI, MACD).
We’ll focus on BTC/USDT, using real-time data to mimic live trading conditions.
Trend Rules: Classic SMA + RSI Logic with Modern Enhancements
Core Philosophy
Classic Trend Following: Enter when a trend emerges, exit when it reverses, and let winners grow without overthinking.
Dynamic Position Sizing: Adjust positions based on:
Signal Strength: Stronger trends (e.g., high RSI) justify larger positions.
Volatility: Higher volatility reduces position size to manage risk.
Market Momentum: Align with the broader market to avoid outlier trades.
Simplicity: Use a 10% buffer to limit frequent rebalancing reducing costs and overtrading.
Explanation: Dynamic sizing makes the strategy adaptive, taking bigger bets when the edge is high and scaling back when risk rises. The 10% buffer (rebalance only if size deviates by ±10%) minimizes transaction fees and noise, preserving the core goal: ride trends with minimal intervention.
Full Trading Logic: Entry, Sizing, Exit, and Risk
Entry:
Enter a long position when the 20-period Simple Moving Average (SMA) crosses above the 50-period SMA, and the 14-period Relative Strength Index (RSI) is above 50 to confirm momentum.
Explanation: This rule combines two classic trend-following indicators to filter for high-probability entries. The SMA crossover (20 > 50) signals a potential shift toward a bullish trend, capturing medium-term price direction. However, to avoid false breakouts or weak trends, the strategy requires confirmation from the RSI. When the RSI is above 50, it suggests positive momentum - indicating that buying pressure outweighs selling pressure. This dual condition reduces noise and helps avoid whipsaws, ensuring that entries align with both trend and momentum.
Position Sizing:
Start with a base position size of 50% of the portfolio (base_size = 0.5).
Adjust based on:
Signal Strength: Increase by 20% if RSI > 70 (signal_factor = 1.2).\
Volatility: Decrease by 20% if the 14-period ATR doubles from its 10-period average (vol_factor = 0.8).
Market Momentum: Increase by 20% if the 20-period ROC (Rate of Change) of BTC > 5% (market_factor = 1.2); decrease by 20% if ROC < 2% (market_factor = 0.8).
Resulting position size: 38.4%–72% of the portfolio.
Only adjust if the change exceeds 10% of the current position (buffer_threshold = 0.1).
Explanation: This adaptive sizing scales exposure based on market conditions. Strong trends increase positions, while high volatility or weak momentum reduces them. The 10% buffer prevents overtrading, ensuring stability. A 50% base position size is aggressive, requiring tight stop-losses (e.g., 2% price drop) to aim for 1% risk, which may lead to frequent exits in crypto’s volatility. For more typical risk alignment, consider reducing base_size to 0.1 (10%) to allow wider stop-losses (e.g., 5%–10%) while maintaining 1% risk.
3. Exit:
Exit when the 20-period SMA crosses below the 50-period SMA or a trailing stop (2x ATR below the highest price) is hit.
Explanation: The SMA crossover signals a trend reversal, while the trailing stop locks in profits during volatile moves, balancing trend capture with risk control.
4. Risk Management:
Aim to cap risk at 1% of the portfolio per trade (risk_per_trade = 0.01, or $1,000 for a $100,000 portfolio), though not currently enforced in the code.\
Position size is capped at 72% to avoid overexposure.
Maintain a single position, adjusting its size dynamically (no stacking or pyramiding).
Explanation: A 1% risk per trade is standard in crypto. The 50%–72% position size requires tight stop-losses (e.g., 2% price drop), which may cause frequent exits. Maintaining a single position (no stacking) simplifies risk management and reduces overexposure, as additional positions could amplify losses in volatile markets. However, even a single position needs risk_per_trade = 0.01 enforcement to cap losses at $1,000, given the large allocation.
Use this code to enforce 1% risk:
max_position = risk_per_trade / ((entry_price - stop_loss) / entry_price)
position = min(base_size * signal_factor * vol_factor * market_factor, max_position)
Simulation Preview
The strategy is simulated over 10 minutes with 1-second BTC/USDT data, showcasing real-time execution. Visuals include:
Price Plot: BTC/USDT price with SMA20/SMA50 and buy/sell signals (green/red markers).
Position Size: Shows allocation (0% when out, 38.4%-72% when in).
Portfolio Value: Tracks gains/losses, starting at $100,000.
Note: The 5–10 minute timeframe is for demonstration, as trend-following typically uses hourly or daily candles. For production, extend to 1-minute or longer intervals.
Implementation: Real-Time Trading with WebSockets
We’ll use Python to connect to Binance’s WebSocket API, stream 1-second BTC/USDT data, and execute the strategy. Metrics include:
Portfolio Value: Final value after trades.
Sharpe Ratio: Return per unit of risk (>1 is good for short-term).
Maximum Drawdown: Largest peak-to-trough loss.
Step 1: Setup
Install dependencies:
pip install ccxt websocket-client pandas numpy ta
Or try our QuantJourney Technical Indicators library:
pip install quantjourney-ti
Source: GitHub
Step 2: Code
Below are two scripts: one for real-time trading and one for metrics analysis.
Script 1: Real-Time Strategy with WebSocket
import websocket
import json
import pandas as pd
import numpy as np
from ta.trend import SMAIndicator
from ta.momentum import RSIIndicator
from ta.volatility import AverageTrueRange
import time
import logging
# Setup logging
logging.basicConfig(filename='trade_log.txt', level=logging.INFO, format='%(asctime)s - %(message)s')
# Strategy parameters
initial_capital = 100000
base_size = 0.5 # 50% of portfolio
risk_per_trade = 0.01 # 1% risk
simulation_duration = 600 # 10 minutes in seconds
buffer_threshold = 0.1 # 10% buffer for position changes
# Data storage
data = []
position = 0
entry_price = 0
stop_loss = 0
highest_price = 0
portfolio_value = initial_capital
trade_log = []
# WebSocket callback functions
def on_message(ws, message):
global data, position, entry_price, stop_loss, highest_price, portfolio_value
# Parse WebSocket message
msg = json.loads(message)
if 'k' in msg:
kline = msg['k']
if kline['x']: # Closed candle
timestamp = pd.to_datetime(kline['t'], unit='ms')
close = float(kline['c'])
high = float(kline['h'])
low = float(kline['l'])
# Append to data
data.append({'timestamp': timestamp, 'close': close, 'high': high, 'low': low})
if len(data) > 50: # Enough data for indicators
df = pd.DataFrame(data)
# Calculate indicators
df['sma20'] = SMAIndicator(df['close'], window=20).sma_indicator()
df['sma50'] = SMAIndicator(df['close'], window=50).sma_indicator()
df['rsi'] = RSIIndicator(df['close'], window=14).rsi()
df['atr'] = AverageTrueRange(df['high'], df['low'], df['close'], window=14).average_true_range()
df['roc'] = df['close'].pct_change(20) * 100 # Asset ROC
df['market_roc'] = df['close'].pct_change(20) * 100 # Proxy for market (BTC)
# Current values
current = df.iloc[-1]
prev = df.iloc[-2] if len(df) > 1 else None
# Volatility check
atr_avg = df['atr'].rolling(10).mean().iloc[-1]
vol_factor = 0.8 if current['atr'] > 2 * atr_avg else 1.0
# Signal and market factors
signal_factor = 1.2 if current['rsi'] > 70 else 1.0
market_factor = 1.2 if current['market_roc'] > 5 else (0.8 if current['market_roc'] < 2 else 1.0)
# Entry
if position == 0 and prev is not None:
if (current['sma20'] > current['sma50'] and prev['sma20'] <= prev['sma50'] and
current['rsi'] > 50):
position = base_size * signal_factor * vol_factor * market_factor
entry_price = current['close']
stop_loss = entry_price - 2 * current['atr']
highest_price = entry_price
logging.info(f"Enter Long: Price={entry_price}, Size={position}, Stop={stop_loss}")
# Position sizing
if position > 0:
new_size = base_size * signal_factor * vol_factor * market_factor
if abs(new_size - position) > buffer_threshold * position:
position = new_size
logging.info(f"Adjust Position: New Size={position}")
# Update trailing stop
highest_price = max(highest_price, current['high'])
trailing_stop = highest_price - 2 * current['atr']
stop_loss = max(stop_loss, trailing_stop)
# Exit
if position > 0:
if (current['sma20'] < current['sma50'] or current['close'] < stop_loss):
profit = (current['close'] - entry_price) * position * initial_capital
portfolio_value += profit
logging.info(f"Exit: Price={current['close']}, Profit={profit}, Portfolio={portfolio_value}")
trade_log.append({'timestamp': timestamp, 'portfolio_value': portfolio_value})
position = 0
entry_price = 0
stop_loss = 0
highest_price = 0
# Update portfolio value
if position > 0:
current_value = portfolio_value + (current['close'] - entry_price) * position * initial_capital
trade_log.append({'timestamp': timestamp, 'portfolio_value': current_value})
# Print every second
print(f"Time: {timestamp}, Price: {current['close']}, Portfolio: {portfolio_value:.2f}")
def on_error(ws, error):
print(f"Error: {error}")
def on_close(ws, close_status_code, close_msg):
print("WebSocket closed")
# Save trade log
pd.DataFrame(trade_log).to_csv('trade_log.csv', index=False)
def on_open(ws):
print("WebSocket opened")
# Run WebSocket
if __name__ == "__main__":
ws_url = "wss://stream.binance.com:9443/ws/btcusdt@kline_1s"
ws = websocket.WebSocketApp(ws_url, on_message=on_message, on_error=on_error,
on_close=on_close, on_open=on_open)
# Run for 10 minutes
ws.run_forever()
time.sleep(simulation_duration)
ws.close()
Script 2: Performance Metrics
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# Load trade log
data = pd.read_csv('trade_log.csv')
data['timestamp'] = pd.to_datetime(data['timestamp'])
# Calculate returns
data['returns'] = data['portfolio_value'].pct_change().fillna(0)
# Portfolio Value
final_value = data['portfolio_value'].iloc[-1]
print(f"Final Portfolio Value: ${final_value:.2f}")
# Sharpe Ratio (unannualized for short-term)
mean_return = data['returns'].mean()
std_return = data['returns'].std()
sharpe_ratio = mean_return / std_return if std_return != 0 else 0
print(f"Unannualized Sharpe Ratio: {sharpe_ratio:.2f}")
# Maximum Drawdown
data['peak'] = data['portfolio_value'].cummax()
data['drawdown'] = (data['portfolio_value'] - data['peak']) / data['peak']
max_drawdown = data['drawdown'].min()
print(f"Maximum Drawdown: {max_drawdown * 100:.2f}%")
# Plot portfolio value
plt.figure(figsize=(10, 6))
plt.plot(data['timestamp'], data['portfolio_value'], label='Portfolio Value')
plt.title('Portfolio Value Over Time')
plt.xlabel('Time')
plt.ylabel('Portfolio Value ($)')
plt.legend()
plt.grid()
plt.show()
How It Works
Strategy Execution: Every second, the script calculates SMAs, RSI, ATR, and ROC to check for entry/exit signals and adjust position sizes. It logs trades and portfolio value.
Simulation: The script runs for 10 minutes (600 seconds), printing the current price and portfolio value every second.
The initial weight is set in the Entry condition when the strategy decides to enter a long position. This occurs in the following code block within the on_message function:
if position == 0 and prev is not None:
if (current['sma20'] > current['sma50'] and prev['sma20'] <= prev['sma50'] and
current['rsi'] > 50):
position = base_size * signal_factor * vol_factor * market_factor
entry_price = current['close']
stop_loss = entry_price - 2 * current['atr']
highest_price = entry_price
logging.info(f"Enter Long: Price={entry_price}, Size={position}, Stop={stop_loss}")
The 10% buffer (buffer_threshold = 0.1) comes into play later, in the Position Sizing logic, to decide whether to adjust an existing position. It ensures that the position size is only updated if the new calculated size deviates significantly (by more than 10%) from the current position.
if position > 0:
new_size = base_size * signal_factor * vol_factor * market_factor
if abs(new_size - position) > buffer_threshold * position:
position = new_size
logging.info(f"Adjust Position: New Size={position}")
The code doesn’t allow for “stacking” multiple positions or increasing the position size in response to new buy signals. Once a trade is entered, new buy signals (e.g., another SMA crossover while in a trade) are ignored unless they affect the dynamic sizing of the current position.
If a new buy signal occurs while position > 0, it only influences the position size through the Position Sizing logic, potentially increasing or decreasing the allocation (e.g., from 1% to 1.2% of the portfolio) if the deviation exceeds the 10% buffer.
If the trade exits (e.g., sma20 < sma50 or price hits stop-loss), position is reset to 0.
The risk_per_trade is not used in the current code in the logic for position sizing, stop-loss calculation, or trade execution. That’s because the strategy doesn’t stack positions (i.e., it doesn’t add new positions on additional buy signals; it only adjusts the existing position size within a 10% buffer). We may adjust the position size to ensure the risk is 1% given a fixed stop-loss distance (e.g., 2 * ATR) with code:
position = risk_per_trade / ((entry_price - stop_loss) / entry_price)
As BTC/USDT can move 5%–10% in minutes. A fixed stop-loss (2 * ATR) without risk calibration could lead to inconsistent losses (0.5%–3% of portfolio), making risk_per_trade essential to standardize risk at 1%.
Results and Insights
Running this strategy over a 10-minute window will give you a snapshot of its behavior in live conditions. Here’s what to expect:
Portfolio Value: Total account value, reflecting gains/losses from trades, updated in real time.
Sharpe Ratio: Return per unit of risk; measures risk-adjusted returns. A value > 1 is good for short-term strategies. This is unannualized due to the 10-minute simulation, unlike annualized ratios (e.g., scaled for yearly data) in longer-term strategies.
Maximum Drawdown: Largest loss from peak to trough, showing the worst-case loss, critical for crypto’s volatile markets.
Some of the logs:
2025-06-23 17:41:10,302 - Websocket connected
2025-06-23 17:42:40,119 - Enter Long: Price=101610.0, Size=0.48, Stop=101605.61766690477
2025-06-23 17:42:50,106 - Adjust Position: New Size=0.4
2025-06-23 17:42:50,106 - Exit: Price=101619.89, Profit=395599.9999999767, Portfolio=495599.9999999767
2025-06-23 17:44:32,110 - Enter Long: Price=101572.46, Size=0.4, Stop=101566.9898761822
2025-06-23 17:44:35,119 - Exit: Price=101564.0, Profit=-338400.0000002561, Portfolio=157199.9999997206
2025-06-23 17:46:13,126 - Enter Long: Price=101519.99, Size=0.4, Stop=101515.23307116277
2025-06-23 17:46:30,105 - Adjust Position: New Size=0.48
2025-06-23 17:46:31,111 - Adjust Position: New Size=0.384
2025-06-23 17:46:32,176 - Adjust Position: New Size=0.48
2025-06-23 17:46:33,108 - Adjust Position: New Size=0.384
2025-06-23 17:46:35,137 - Adjust Position: New Size=0.48
2025-06-23 17:46:39,114 - Exit: Price=101576.04, Profit=2690399.999999441, Portfolio=2847599.999999162
2025-06-23 17:49:27,122 - Enter Long: Price=101422.8, Size=0.48, Stop=101418.67259393701
2025-06-23 17:49:32,108 - Adjust Position: New Size=0.4
2025-06-23 17:49:32,108 - Exit: Price=101418.43, Profit=-174800.0000003958, Portfolio=2672799.999998766
2025-06-23 17:50:11,218 - tearing down on exception
2025-06-23 17:57:52,506 - Websocket connected
2025-06-23 17:59:17,111 - Enter Long: Price=101457.02, Size=0.4, Stop=101455.31808376711, Risk=0.67
2025-06-23 17:59:21,102 - Exit: Price=101450.73, Profit=-2.52, Portfolio=99997.48
2025-06-23 18:00:15,105 - Enter Long: Price=101442.88, Size=0.48, Stop=101440.40851270621, Risk=1.17
2025-06-23 18:00:21,108 - Adjust Position: New Size=0.4, Stop=101429.70265776098, Risk=1.20
2025-06-23 18:00:21,108 - Exit: Price=101432.74, Profit=-4.06, Portfolio=99993.43
2025-06-23 18:00:44,107 - Enter Long: Price=101446.53, Size=0.4, Stop=101441.95769614993, Risk=1.80
2025-06-23 18:00:45,107 - Exit: Price=101436.37, Profit=-4.06, Portfolio=99989.36
2025-06-23 18:02:55,106 - Enter Long: Price=101375.99, Size=0.48, Stop=101367.85973435896, Risk=3.85
2025-06-23 18:03:02,103 - Exit: Price=101368.95, Profit=-3.38, Portfolio=99985.98
2025-06-23 18:04:25,111 - Enter Long: Price=101385.48, Size=0.48, Stop=101379.66504310611, Risk=2.75
2025-06-23 18:04:37,118 - Adjust Position: New Size=0.4, Stop=101377.17249564851, Risk=1.24
2025-06-23 18:04:37,118 - Exit: Price=101380.31, Profit=-2.07, Portfolio=99983.92
Crypto-Specific Considerations
Volatility: Crypto prices can swing 5-10% in minutes. The ATR-based stop and volatility adjustments help manage this.
Liquidity: BTC/USDT is highly liquid, but for altcoins, adjust position sizes to avoid slippage.
Exchange Risks: Use trusted exchanges and secure API keys. Consider rate limits (Binance allows ~1200 requests/minute).
Can It Make Money?
Potentially, but success requires:
Backtesting: Test across bull, bear, and sideways markets (e.g., 2020–2025).
Execution: Minimize latency and slippage.
Risk Control: Enforce 1% risk and cap positions.
Discipline: Avoid emotional overrides.
Next Steps
Backtest: Validate on historical data (2020–2025).
Optimize: Tweak SMA periods or ATR multipliers.
Scale: Add assets (e.g., ETH, SOL) for diversification.
Join the Discussion
Tried this strategy? Share results or ask questions in the comments!
Check our Crypto channels on Discord for more.
Coming Soon: Benchmarking this strategy (2020–2025) with slippage and adaptive sizing.
Happy trading!
Jakub