The Quest Begins (The "Why")
I still remember the first time I stared at a streaming ticker and thought, “What if I could let a script do the heavy lifting while I grab coffee?” I was fresh out of a quant‑finance class, armed with a Python notebook and a fierce desire to stop missing those 2 a.m. breakout moves. The dragon I wanted to slay? My own hesitation—constantly second‑guessing whether to buy, sell, or just watch the candles paint a picture I couldn’t interpret fast enough.
I started with a naïve script that pulled prices from Yahoo Finance, calculated a simple moving average, and fired off market orders whenever the short SMA crossed above the long SMA. It looked elegant on paper, but in practice it was a disaster: orders piled up, I got hit with massive slippage, and my account bled like a wounded side‑quest character. Honestly, I felt like I was debugging a boss fight with no health packs.
The Revelation (The Insight)
The turning point came when I realized the bot wasn’t the problem—my expectations were. A trading bot isn’t a crystal ball; it’s a disciplined executor of a rule‑set that must survive noisy data, latency, and the dreaded “over‑fit” trap. I started treating the strategy like a spell: define clear incantations (entry/exit rules), add protective wards (risk management), and constantly test the enchantment against historical data before unleashing it live.
Once I embraced that mindset, the pieces fell into place: clean data pipelines, vectorized calculations with pandas, proper order sizing, and a simple logging system that let me review each trade like a combat replay. The bot went from a frantic button‑masher to a calm, methodical sidekick that could watch the markets while I slept.
Wielding the Power (Code & Examples)
Below is the evolution of my script—first the “struggle” version, then the victorious upgrade. Feel free to copy, tweak, and break it on purpose; that’s how you learn.
The Struggle (What Not to Do)
import yfinance as yf
import pandas as pd
def naive_bot(ticker, short_window=20, long_window=50):
# Pull data – no adjustment for splits/dividends, no timezone awareness
data = yf.download(ticker, period="60d", interval="1h")
data['SMA_short'] = data['Close'].rolling(short_window).mean()
data['SMA_long'] = data['Close'].rolling(long_window).mean()
# Generate signals – looks fine but will fire on every bar
data['signal'] = 0
data.loc[data['SMA_short'] > data['SMA_long'], 'signal'] = 1
data.loc[data['SMA_short'] < data['SMA_long'], 'signal'] = -1
# Naive execution – market order on every signal change
data['position'] = data['signal'].diff()
return data[['Close', 'SMA_short', 'SMA_long', 'signal', 'position']].dropna()
print(naive_bot("AAPL").tail())
Traps I fell into:
- No adjustment for corporate actions → price jumps looked like signals.
- Hourly data without timezone conversion caused mismatched timestamps.
- Every signal change triggered a market order, ignoring position size and slippage.
- No risk limits – a single bad candle could wipe out the account.
The Victory (What Actually Works)
import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timezone
# ------------------- CONFIG -------------------
TICKER = "AAPL"
SHORT_WIN = 20
LONG_WIN = 50
RISK_PER_TRADE = 0.01 # 1% of equity per trade
MAX_POSITION = 0.1 # never more than 10% of equity in one stock
# -------------------------------------------
def fetch_data(ticker, days=120):
"""Fetch adjusted hourly data, ensure UTC timestamps."""
df = yf.download(ticker,
period=f"{days}d",
interval="1h",
auto_adjust=True, # adjust for splits/dividends
prepost=False)
df = df.tz_localize(None) # strip any timezone info yfinance adds
df = df.tz_localize('UTC') # force UTC for consistency
return df
def add_indicators(df):
df = df.copy()
df['SMA_short'] = df['Close'].rolling(SHORT_WIN).mean()
df['SMA_long'] = df['Close'].rolling(LONG_WIN).mean()
return df
def generate_signals(df):
df = df.copy()
df['signal'] = 0
df.loc[df['SMA_short'] > df['SMA_long'], 'signal'] = 1 # go long
df.loc[df['SMA_short'] < df['SMA_long'], 'signal'] = -1 # go flat (or short if you wish)
# Only act when signal actually changes – reduces churn
df['position'] = df['signal'].diff()
return df
def size_position(equity, price, risk_per_trade=RISK_PER_TRADE):
"""Simple fixed‑fractional position sizing."""
risk_amount = equity * risk_per_trade
# Assume a 2% stop‑loss for demonstration; adjust as you see fit
stop_loss_pct = 0.02
shares = risk_amount / (price * stop_loss_pct)
# Enforce max position size
max_shares = (equity * MAX_POSITION) / price
return min(shares, max_shares)
def run_bot():
equity = 100_000 # starting paper equity
df = fetch_data(TICKER)
df = add_indicators(df)
df = generate_signals(df)
trades = []
position = 0 # 0 = flat, >0 = long
entry_price = None
for idx, row in df.iterrows():
price = row['Close']
signal_change = row['position']
# ----- EXIT LOGIC -----
if position > 0 and signal_change == -1: # signal went from long to flat
pnl = (price - entry_price) * position
equity += pnl
trades.append({
'time': idx,
'action': 'SELL',
'price': price,
'shares': position,
'pnl': pnl,
'equity': equity
})
position = 0
entry_price = None
continue
# ----- ENTRY LOGIC -----
if position == 0 and signal_change == 1: # flat → long
shares = size_position(equity, price)
if shares < 1: # too small to trade
continue
position = shares
entry_price = price
trades.append({
'time': idx,
'action': 'BUY',
'price': price,
'shares': shares,
'pnl': 0,
'equity': equity
})
# Close any open position at the end of the data
if position > 0:
price = df.iloc[-1]['Close']
pnl = (price - entry_price) * position
equity += pnl
trades.append({
'time': df.index[-1],
'action': 'SELL',
'price': price,
'shares': position,
'pnl': pnl,
'equity': equity
})
trades_df = pd.DataFrame(trades)
print(f"Final equity: ${equity:,.2f}")
print(trades_df.tail())
return trades_df
if __name__ == "__main__":
run_bot()
Why this version feels like a win:
- Adjusted data eliminates phantom jumps from dividends/splits.
- UTC timestamps keep everything tidy when you later integrate with a broker API.
- Signal‑change detection prevents the bot from hammering the market on every bar.
- Risk‑based position sizing and a max‑exposure guard keep a single trade from blowing up the account.
- Simple trade log gives you a replay you can inspect—crucial for learning and for meeting any regulator’s “you must keep records” requirement.
Feel free to swap the SMA crossover for RSI, MACD, or a machine‑learning signal; the skeleton stays the same.
Why This New Power Matters
Now you’ve got a bot that doesn’t just chase shiny cross‑overs—it respects risk, respects the data’s quirks, and leaves a clear audit trail. You can run it against years of hourly data in minutes, see how it would have fared during the 2020 crash, or paper‑trade it live with a broker like Alpaca or Interactive Brokers. The best part? You own the logic. No black‑box subscription, no vague “AI‑powered” promises—just pure, transparent Python that you can tweak, break, and improve whenever you feel like it.
Imagine walking away from your desk, knowing your script is watching the tape, logging every move, and only acting when the odds line up just right. That’s the kind of freedom that turns a curious hobbyist into a confident trader—without sacrificing sleep or sanity.
Your Turn – The Challenge
Grab your favorite equity (or crypto pair if you dare), plug it into the script above, and run a back‑test over the last six months. Then, answer this: What’s the smallest tweak you made that turned a losing curve into a winning one? Share your result in the comments—let’s geek out over the numbers together! Happy coding, and may your spreads be tight and your fills be swift. 🚀
Top comments (0)