The Quest Begins (The "Why")
It all started on a rainy Tuesday night. I was staring at my Robinhood app, watching a stock dip 2% while I was busy fixing a bug in a side‑project. I thought, “If only I had a little robot that could buy the dip for me while I’m asleep.” The idea felt like grabbing a DeLorean and jumping to a future where my portfolio grew while I binge‑watched the latest season of Stranger Things (okay, I promised myself no more pop‑culture in the body — just the title!).
I’d dabbled in algo trading tutorials before, but they always felt like they were missing the “real‑world” grit: handling API limits, dealing with messy data, and not blowing up my account with a single bad tick. So I set out on a quest: build a bot that’s simple enough to understand, robust enough to run for weeks, and fun enough to keep me motivated.
The Revelation (The Insight)
The breakthrough came when I stopped trying to predict the future and started focusing on a clear, rule‑based signal: the moving‑average crossover. When a short‑term MA crosses above a long‑term MA, it’s a classic bullish hint; the opposite is bearish. It’s not magic, but it’s a filter that removes a lot of noise.
I paired that with two practical tools:
- yfinance – pulls free historical data from Yahoo Finance. No API key, no rate‑limit headaches for backtesting.
- Alpaca – offers a paper‑trading endpoint that mimics real orders with zero risk. Perfect for testing the bot live before you ever touch real money.
The insight? Separate the research phase (backtesting) from the execution phase (live trading). Once you have a signal that works on historical data, you can wrap it in a thin execution layer that talks to Alpaca. This keeps the code tidy and makes it easy to swap strategies later.
Wielding the Power (Code & Examples)
🚧 The Struggle (Before)
My first attempt looked like this:
# 🚫 DON'T DO THIS – naive loop, no error handling, look‑ahead bias
import yfinance as yf
import time
ticker = "AAPL"
while True:
data = yf.download(ticker, period="1d", interval="1m")
short_ma = data['Close'].rolling(window=5).mean().iloc[-1]
long_ma = data['Close'].rolling(window=20).mean().iloc[-1]
if short_ma > long_ma:
print("BUY!")
elif short_ma < long_ma:
print("SELL!")
time.sleep(60) # hammer the API every minute
What’s wrong?
- Look‑ahead bias – I used the entire day’s data to compute the moving average, which in reality wouldn’t be available until the market closed.
- API hammering – yfinance isn’t meant for high‑frequency calls; I got blocked after a few hours.
- No order execution – just prints, no actual trades.
- No risk management – I’d go all‑in on every signal.
✅ The Victory (After)
Here’s the cleaned‑up version that you can actually run (paper‑trade safe). I’ve split it into three logical blocks: data fetching, signal generation, and order execution.
# -------------------------------------------------
# Simple MA‑crossover bot – paper trading on Alpaca
# -------------------------------------------------
import yfinance as yf
import pandas as pd
import alpaca_trade_api as tradeapi
from datetime import datetime, timedelta
# ----- CONFIG -----
API_KEY = "YOUR_ALPACA_API_KEY"
API_SECRET = "YOUR_ALPACA_API_SECRET"
BASE_URL = "https://paper-api.alpaca.markets" # switch to live when ready
SYMBOL = "AAPL"
SHORT_WINDOW = 5
LONG_WINDOW = 20
ORDER_QTY = 10 # shares per trade
# ----- Alpaca client -----
api = tradeapi.REST(API_KEY, API_SECRET, BASE_URL, api_version='v2')
def fetch_recent_data(symbol, days=5):
"""
Pull daily OHLCV data and make sure we only use *completed* bars.
"""
end = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
start = end - timedelta(days=days)
df = yf.download(symbol, start=start, end=end, progress=False)
# Keep only rows where the market is closed (daily bar)
df = df.dropna()
return df
def generate_signal(df):
"""
Return 'buy', 'sell', or None based on MA crossover.
Uses only past data – no look‑ahead.
"""
df = df.copy()
df['short_ma'] = df['Close'].rolling(window=SHORT_WINDOW).mean()
df['long_ma'] = df['Close'].rolling(window=LONG_WINDOW).mean()
df.dropna(inplace=True)
if len(df) < 2:
return None
prev, cur = df.iloc[-2], df.iloc[-1]
# Bullish crossover
if prev['short_ma'] <= prev['long_ma'] and cur['short_ma'] > cur['long_ma']:
return 'buy'
# Bearish crossover
if prev['short_ma'] >= prev['long_ma'] and cur['short_ma'] < cur['long_ma']:
return 'sell'
return None
def submit_order(side, qty):
"""
Place a market order via Alpaca (paper trading).
"""
try:
api.submit_order(
symbol=SYMBOL,
qty=qty,
side=side,
type='market',
time_in_force='day'
)
print(f"[{datetime.now()}] {side.upper()} {qty} {SYMBOL} submitted.")
except Exception as e:
print(f"Order failed: {e}")
def run_bot():
"""
Main loop – runs once per day after market close.
"""
while True:
df = fetch_recent_data(SYMBOL)
signal = generate_signal(df)
if signal:
submit_order(signal, ORDER_QTY)
else:
print(f"[{datetime.now()}] No signal today.")
# Sleep until next trading day (simple approach)
now = datetime.now()
next_market_open = (now + timedelta(days=1)).replace(hour=9, minute=30, second=0, microsecond=0)
sleep_seconds = (next_market_open - now).total_seconds()
print(f"Sleeping for {sleep_seconds/3600:.1f} hours...")
time.sleep(sleep_seconds)
if __name__ == "__main__":
run_bot()
Why this works:
-
No look‑ahead – we only use completed daily bars (
fetch_recent_datastops at midnight). - Respectful API usage – one call per day; yfinance is happy.
-
Clear separation – signal logic lives in
generate_signal, making it easy to swap in RSI, MACD, or a machine‑learning model later. - Safety net – any exception in order submission is caught and logged, so the bot won’t crash silently.
🪤 Traps to Avoid (The “Bosses” on Our Quest)
- Over‑fitting to historical data – If you keep tweaking windows until the backtest looks perfect, you’ll likely fail live. Keep your parameters simple and validate on an out‑of‑sample period.
- Ignoring slippage & fees – A market order can fill a few ticks away from the last price, especially on low‑volume stocks. In a real account, subtract Alpaca’s commission (free for stocks) and estimate slippage in your backtest.
- Using intraday data without proper handling – Minute‑level bars need careful alignment with market open/close; otherwise you’ll sneak in future data. Stick to daily bars until you’re comfortable with the intraday plumbing.
Why This New Power Matters
Running this bot felt like finally getting the hoverboard from Back to the Future — except instead of dodging bullies, I’m dodging emotional trading decisions. The biggest win? Discipline. The bot executes the plan without fear, greed, or that pesky “I’ll just wait for a better price” hesitation.
You now have a scaffold:
- Replace the MA crossover with any indicator you like.
- Hook up a risk‑management layer (position sizing, stop‑loss, max daily loss).
- Deploy to a cheap VPS or a GitHub Actions workflow that runs after market close.
The world of algorithmic trading isn’t reserved for quant firms with PhDs; it’s open to anyone willing to learn, test, and respect the market’s rhythm.
Your Turn – The Next Quest
Pick a ticker, paper‑trade the bot for a week, and watch how it behaves. Then, ask yourself:
- What happens if I add a volatility filter (e.g., only trade when ATR is above a threshold)?
- How does the bot react during a flash crash or a sudden news spike?
- Can I make the signal adaptive by re‑optimizing the MA windows monthly using a walk‑forward framework?
Go tweak, break, fix, and share what you learn. The market’s always changing — so should your bot. Happy coding, and may your profits be ever in your favor! 🚀
Top comments (0)