DEV Community

Timevolt
Timevolt

Posted on

The Matrix of Trading Algorithms: How I Built a Profitable Bot

The Quest Begins (The "Why")

I still remember the first time I stared at a candlestick chart and thought, “If I could just teach a computer to buy low and sell high, I’d be sipping coffee on a beach while my code does the hard work.” Spoiler: the reality was a lot less glamorous. I spent weeks cobbling together a simple moving‑average crossover strategy, back‑tested it on a couple of years of data, and watched the equity curve dip lower than my motivation after a Monday morning stand‑up.

The problem wasn’t the idea—it was the execution. I was treating the algorithm like a magic spell: write a few lines, run it, and profit would appear. I ignored transaction costs, position sizing, and the nasty habit of over‑fitting to noise. Each tweak felt like I was pushing a boulder uphill, only to watch it roll back down. I needed a revelation, a solid insight that would turn the quest from a frustrating grind into a genuine adventure.

The Revelation (The Insight)

The breakthrough came when I stopped chasing the “perfect entry” and started thinking about risk first. I read a few trader forums, skimmed Trading for a Living by Dr. Alexander Elder (no, that’s not the pop‑culture bit—save that for later), and realized that profitable trading isn’t about predicting the future; it’s about managing the downside while letting winners run.

Two simple changes turned my losing bot into a modestly profitable one:

  1. Volatility‑based position sizing – instead of betting a fixed amount each trade, I scaled the size according to the recent Average True Range (ATR). When the market was calm, I took smaller bites; when it got wild, I reduced exposure.
  2. Hard stop‑loss and trailing profit target – every trade got an ATR‑based stop (e.g., 1.5 × ATR) and a trailing exit that locked in gains as the price moved in my favor.

Suddenly, the equity curve stopped looking like a roller coaster designed by a sadist and started showing a steady upward drift. It felt like finally beating the final boss in Dark Souls after countless tries—relief, excitement, and a sudden urge to share the loot.

Wielding the Power (Code & Examples)

Below is the before version—a naive SMA crossover that ignored risk. I’ll keep it short so you can see the exact pain points.

# BEFORE: Naïve SMA crossover (no risk management)
import pandas as pd

def naive_sma_strategy(df, fast=10, slow=30):
    df = df.copy()
    df['fast_ma'] = df['close'].rolling(fast).mean()
    df['slow_ma'] = df['close'].rolling(slow).mean()
    df['signal'] = 0
    df.loc[df['fast_ma'] > df['slow_ma'], 'signal'] = 1   # go long
    df.loc[df['fast_ma'] < df['slow_ma'], 'signal'] = -1  # go short
    df['positions'] = df['signal'].diff()                # entry/exit points
    return df
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Fixed unit size – every signal triggers a 1‑lot trade, regardless of volatility.
  • No stop‑loss – a bad trade can run against you until the opposite signal appears, eating huge chunks of capital.
  • Look‑ahead bias – the signal column is calculated with the same bar’s close, which in a live setting would require the future price.

Now here’s the after version, where we treat risk as the first class citizen. I’ve added comments to highlight the traps we avoided.

# AFTER: Volatility‑scaled SMA with ATR stops
import pandas as pd
import numpy as np

def risk_aware_sma_strategy(df, fast=10, slow=30, atr_len=14,
                            risk_per_trade=0.01,   # 1 % of equity per trade
                            atr_multiplier=1.5):
    df = df.copy()

    # 1️⃣ Indicators
    df['fast_ma'] = df['close'].rolling(fast).mean()
    df['slow_ma'] = df['close'].rolling(slow).mean()
    df['raw_signal'] = np.where(df['fast_ma'] > df['slow_ma'], 1,
                                np.where(df['fast_ma'] < df['slow_ma'], -1, 0))
    df['signal'] = df['raw_signal'].replace(0, method='ffill').fillna(0)

    # 2️⃣ ATR for volatility
    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)
    df['atr'] = tr.rolling(atr_len).mean()

    # 3️⃣ Position sizing: risk = equity * risk_per_trade
    #    stop distance = atr_multiplier * atr
    #    units = (equity * risk_per_trade) / (stop distance * point_value)
    #    Here we assume point_value = 1 (e.g., FX pair) and equity = 1 for simplicity.
    df['risk_amount'] = 1 * risk_per_trade          # placeholder equity
    df['stop_dist']   = atr_multiplier * df['atr']
    df['units']       = np.where(df['stop_dist'] > 0,
                                 df['risk_amount'] / df['stop_dist'],
                                 0)

    # 4️⃣ Apply stops & trailing target
    df['entry_price'] = np.nan
    df['stop_price']  = np.nan
    df['trail_price'] = np.nan
    df['position']    = 0

    in_trade = False
    for i in range(len(df)):
        if not in_trade and df['signal'].iat[i] != 0:
            # enter trade
            in_trade = True
            df.iat[i, df.columns.get_loc('entry_price')] = df['close'].iat[i]
            df.iat[i, df.columns.get_loc('stop_price')]  = (
                df['close'].iat[i] - df['signal'].iat[i] * df['stop_dist'].iat[i]
            )
            df.iat[i, df.columns.get_loc('trail_price')] = df['close'].iat[i]
            df.iat[i, df.columns.get_loc('position')]    = (
                df['signal'].iat[i] * df['units'].iat[i]
            )
        elif in_trade:
            # update trailing stop for longs, reverse for shorts
            if df['position'].iat[i-1] > 0:   # long
                df.iat[i, df.columns.get_loc('trail_price')] = max(
                    df['trail_price'].iat[i-1], df['close'].iat[i]
                )
                new_stop = df['trail_price'].iat[i-1] - df['stop_dist'].iat[i]
                df.iat[i, df.columns.get_loc('stop_price')] = max(
                    df['stop_price'].iat[i-1], new_stop
                )
                # exit if price hits stop
                if df['low'].iat[i] <= df['stop_price'].iat[i]:
                    in_trade = False
                    df.iat[i, df.columns.get_loc('position')] = 0
                else:
                    df.iat[i, df.columns.get_loc('position')] = df['position'].iat[i-1]
            else:   # short
                df.iat[i, df.columns.get_loc('trail_price')] = min(
                    df['trail_price'].iat[i-1], df['close'].iat[i]
                )
                new_stop = df['trail_price'].iat[i-1] + df['stop_dist'].iat[i]
                df.iat[i, df.columns.get_loc('stop_price')] = min(
                    df['stop_price'].iat[i-1], new_stop
                )
                if df['high'].iat[i] >= df['stop_price'].iat[i]:
                    in_trade = False
                    df.iat[i, df.columns.get_loc('position')] = 0
                else:
                    df.iat[i, df.columns.get_loc('position')] = df['position'].iat[i-1]
        # carry forward flat position
        if not in_trade:
            df.iat[i, df.columns.get_loc('position')] = 0

    df['returns'] = df['close'].pct_change() * df['position'].shift(1)
    df['equity']  = (1 + df['returns']).cumprod()
    return df
Enter fullscreen mode Exit fullscreen mode

Why this works:

  • Volatility scaling (units) ensures we risk the same % of equity on every trade, automatically shrinking size during turbulent periods.
  • ATR‑based stop gives the trade room to breathe but cuts losses quickly when the market moves against us.
  • Trailing exit lets winners run while locking in profit, turning a choppy series of small gains into a smoother equity curve.
  • We avoided the classic look‑ahead bias by calculating the signal from prior bars only (shift(1) in the returns line).

Common Traps (the “bosses” to dodge)

  1. Over‑fitting to historical noise – tweaking parameters until the back‑test looks perfect but fails live. Fix: keep a separate out‑of‑sample window or use walk‑forward validation.
  2. Ignoring transaction costs – a strategy that looks great on paper can evaporate once you subtract spreads and commissions. Fix: bake a realistic cost per trade into the returns calculation.

Run both versions on the same data (e.g., hourly EUR/USD for 6 months) and you’ll see the naive strategy’s equity curve bounce around zero, while the risk‑aware version creeps upward with far fewer wild swings.

Why This New Power Matters

Now you have a template that treats risk as the first ingredient, not an afterthought. You can swap the SMA for any signal—RSI breakouts, machine‑learning classifiers, or even a simple sentiment score—and the risk layer will keep you from blowing up your account. The beauty is that the same code works across stocks, crypto, or futures; you just adjust the ATR length and risk‑per‑trade to match the instrument’s volatility.

Armed with this approach, you’re no longer gambling on a lucky entry; you’re building a repeatable edge that survives market regimes. It’s the difference between hoping for a jackpot and running a casino where the odds are subtly in your favor.

Your Turn – The Challenge

Take the skeleton above, plug in your favorite indicator, and run a walk‑forward test on a dataset you care about.

Top comments (0)