DEV Community

Timevolt
Timevolt

Posted on

Building a Profitable Trading Algorithm: Lessons I Learned the Hard Way

Building a Profitable Trading Algorithm: Lessons I Learned the Hard Way

Quick context (why you're writing this)

I still remember the first time I watched a backtest curve climb steadily, only to see my live account bleed red the next day. I spent three days tweaking parameters, convinced the model was “just missing a little edge.” Turns out the problem wasn’t the strategy at all—it was the way I treated data, execution, and risk as after‑thoughts. If you’ve ever felt that gut‑punch when a promising algo fails in reality, you’re not alone.

The Insight

Profitability in trading isn’t about finding the “holy grail” signal; it’s about building a system that survives the noise, slippage, and latency that inevitably show up when you go live. The biggest leverage point is robustness: realistic transaction costs, proper position sizing, and a clear exit plan that doesn’t rely on hindsight. When you bake those into the research loop, the edge you discover actually translates to P&L.

How (with code)

Below is a simplified but realistic framework I use for testing a mean‑reversion idea on equities. I’ll walk through the core pieces, point out the common pitfalls I’ve seen (and made), and show how to fix them.

1. Load data with realistic fees and slippage

A classic mistake is to assume you can trade at the close price with zero cost. In reality, you pay commissions, exchange fees, and you’ll rarely get the exact price you hoped for—especially on illiquid stocks.

import pandas as pd
import numpy as np

def load_price_data(symbol: str, start: str, end: str) -> pd.DataFrame:
    # pretend we pulled daily OHLCV from a vendor
    raw = pd.read_csv(f'data/{symbol}.csv', parse_dates=['date'])
    raw = raw.set_index('date').loc[start:end]
    return raw[['open', 'high', 'low', 'close', 'volume']]

def apply_costs(df: pd.DataFrame, commission_per_share: float = 0.005,
                slippage_bps: float = 5) -> pd.DataFrame:
    """
    Adjust close price to reflect realistic entry/exit costs.
    slippage_bps is basis points (e.g., 5 bps = 0.05%).
    """
    # Assume we enter at the next open after a signal and exit at the next open.
    # For simplicity we model slippage as a fraction of the price we trade.
    df = df.copy()
    df['entry_price'] = df['open'].shift(-1)          # signal at t, trade at t+1 open
    df['exit_price']  = df['open'].shift(-2)          # hold one day, exit t+2 open

    # Apply commission per share (both sides)
    df['commission'] = commission_per_share * 2

    # Apply slippage as a percentage of the traded price
    df['slippage'] = (df['entry_price'] + df['exit_price']) / 2 * (slippage_bps / 10_000) * 2

    df['total_cost'] = df['commission'] + df['slippage']
    return df
Enter fullscreen mode Exit fullscreen mode

What most people do wrong: They skip apply_costs entirely, or they subtract a flat commission after the fact, ignoring that slippage scales with price and trade size. The result? Inflated Sharpe ratios that vanish once you hit the market.

2. Generate signals and size positions sensibly

I like to keep the signal logic separate from the sizing logic. This makes it easier to swap in a new idea without rewriting the risk engine.

def generate_signal(df: pd.DataFrame, lookback: int = 20) -> pd.DataFrame:
    """
    Simple mean‑reversion: go long when price is below its N‑day SMA,
    short when above. We'll keep it long‑only for clarity.
    """
    df = df.copy()
    df['sma'] = df['close'].rolling(lookback).mean()
    df['signal'] = np.where(df['close'] < df['sma'], 1, 0)   # 1 = long, 0 = flat
    return df

def size_position(df: pd.DataFrame, capital: float = 100_000,
                  max_risk_per_trade: float = 0.01) -> pd.DataFrame:
    """
    Fixed fractional position sizing based on ATR‑based stop.
    Risk per trade = max_risk_per_trade * capital.
    """
    df = df.copy()
    # ATR as a proxy for volatility
    df['tr'] = np.maximum(df['high'] - df['low'],
                          np.maximum(abs(df['high'] - df['close'].shift()),
                                     abs(df['low'] - df['close'].shift())))
    df['atr'] = df['tr'].rolling(14).mean()

    # Dollar risk per share = ATR * multiplier (commonly 1.5)
    risk_per_share = df['atr'] * 1.5

    # Number of shares we can buy while risking max_risk_per_trade of capital
    df['shares'] = (capital * max_risk_per_trade) / risk_per_share
    df['shares'] = df['shares'].fillna(0).astype(int)   # can't trade fractional shares

    # Ensure we don't exceed available capital (price * shares)
    df['shares'] = np.where(df['shares'] * df['entry_price'] > capital,
                            0, df['shares'])
    return df
Enter fullscreen mode Exit fullscreen mode

Common mistake: Using a fixed number of shares (e.g., 100 shares) regardless of volatility. During a high‑vol regime you’ll overleverage; during low‑vol you’ll sit on cash. The ATR‑based method adapts size to market conditions, keeping risk roughly constant.

3. Compute P&L with costs and evaluate

Now we stitch everything together, making sure to apply costs before we calculate returns.

def backtest(df: pd.DataFrame, capital: float = 100_000) -> pd.DataFrame:
    df = df.copy()
    df['market_return'] = df['exit_price'] / df['entry_price'] - 1

    # Gross P&L per share
    df['gross_pnl'] = df['market_return'] * df['shares']

    # Subtract costs (already in dollars per share)
    df['net_pnl'] = df['gross_pnl'] - df['total_cost'] * df['shares']

    # Cumulative equity curve
    df['cum_pnl'] = df['net_pnl'].cumsum()
    df['equity'] = capital + df['cum_pnl']
    return df

# Example run
raw = load_price_data('AAPL', '2020-01-01', '2023-12-31')
raw = apply_costs(raw)
raw = generate_signal(raw)
raw = size_position(raw)
results = backtest(raw)

print(f"Final equity: ${results['equity'].iloc[-1]:,.2f}")
print(f"Total return: {(results['equity'].iloc[-1]/capital - 1)*100:.2f}%")
Enter fullscreen mode Exit fullscreen mode

What you’ll often see: People calculate market_return on the close price, then multiply by shares, and after that they subtract a flat commission. That double‑counts slippage and ignores the fact that costs reduce the number of shares you can actually afford. The code above avoids that by scaling shares down before any trade is imagined.

Why This Matters

When you embed realistic costs, volatility‑aware sizing, and a clear exit rule into your research loop, the edge you discover stops being a mirage. I’ve seen strategies that looked like 2.5 Sharpe in a naïve backtest drop to 0.6 once you add proper slippage and position limits—still profitable, but far less glamorous. The real win is consistency: you’ll stop chasing phantom improvements and start focusing on what actually moves the needle—better execution, smarter risk controls, and a disciplined review process.

If you take one thing away from this, let it be: profitability is a property of the whole system, not just the signal. Treat every component (data, cost model, sizing, execution) as a hypothesis you must test, not as an afterthought you can gloss over.


Challenge for you

Pick a strategy you’ve been tinkering with (or find a simple one online). Run it through the apply_costs and size_position functions above, then compare the equity curve to your original, cost‑free backtest. Post the difference in the comments—what surprised you? Was the strategy still worth trading, or did the costs kill the edge? Let’s learn from each other’s real‑world numbers.

Top comments (0)