Your First AI Trading Bot: EMA Cross + Alpaca Paper Trading
Build your first algorithmic trading bot with Python, EMA crossover signals, and Alpaca's free paper trading API. No hype, no paid tools, just honest step-by-step code.
Every trader eventually wonders whether a machine could do the boring parts for them: watch the charts, enter on a signal, manage a stop, and close at the end of the day. That curiosity is healthy. The problem is that most "AI trading bot" tutorials skip straight to cherry-picked backtests and promises of passive income. This one will not.
In this tutorial you will build a tiny but complete algorithmic trading bot from scratch. It will use an EMA crossover strategy, pull data from Alpaca, run a realistic backtest, and then trade on Alpaca's free paper account. By the end you will have a working codebase, a realistic sense of what bots can and cannot do, and a safe path to keep experimenting.
This guide is educational only. We do not sell a strategy, signal service, or "secret algorithm." The goal is to teach you how to test ideas safely, not to convince you that bots print money.
Why Start with EMA Crossover?
A trading bot is just a program that makes decisions based on rules. The simpler the rules, the easier it is to understand why the bot wins or loses. The exponential moving average crossover is one of the simplest systematic rules in technical trading:
- A short-period EMA follows recent prices closely.
- A long-period EMA follows the bigger trend.
- When the short EMA crosses above the long EMA, the bot considers buying.
- When the short EMA crosses below the long EMA, the bot considers selling or shorting.
This logic is easy to explain, easy to code, and easy to debug. It is also easy to see where it fails: in choppy markets it whipsaws back and forth, and in strong trends it can enter late. That honesty is the point. If you can build, backtest, and improve a simple EMA bot, you can later swap in more sophisticated signals.
What You Need Before You Start
You do not need a powerful computer or a paid data subscription. Here is the minimal setup:
| Requirement | Recommended Option | Why |
|---|---|---|
| Python | Version 3.10 or newer | We use modern syntax and type hints sparingly. |
| Code editor | VS Code, Cursor, or PyCharm | Any editor works; you just need to edit text files. |
| Alpaca account | Free paper trading account | Provides market data and simulated execution. |
| Internet | Stable connection | The bot polls for data or listens to a websocket. |
| Patience | A few hours | Most of the time is spent understanding, not typing. |
You do not need to be a professional developer. If you have written a Python script that imports a package and read a CSV, you have enough background.
Setting Up Alpaca Paper Trading
Alpaca is a US brokerage that offers a commission-free API and a paper trading environment. The paper account behaves almost identically to a live account, except the money is fake. This is exactly what you want when learning.
First, create an account at Alpaca and choose the paper trading option. Once logged in, go to the API keys page and generate new keys. You will see two values: an API key ID and a secret key.
| Key Type | Example Format | Where to Store |
|---|---|---|
| API Key ID | PKABCDEF1234567890 | Environment variable ALPACA_API_KEY |
| Secret Key | A1b2C3d4... | Environment variable ALPACA_SECRET_KEY |
Never paste these keys directly into code that you might upload to GitHub. The fastest safe approach is a .env file in your project folder and a package like python-dotenv to load it.
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install alpaca-py pandas numpy python-dotenv yfinanceCreate a .env file:
ALPACA_API_KEY=your_key_here
ALPACA_SECRET_KEY=your_secret_here
And add .env to your .gitignore file so it is never committed.
Fetching Historical Bars with Python
Before the bot trades, it needs data. Alpaca's Python SDK makes this straightforward. Create a file named data.py:
import os
from datetime import datetime, timedelta
from dotenv import load_dotenv
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
import pandas as pd
load_dotenv()
client = StockHistoricalDataClient(
api_key=os.getenv("ALPACA_API_KEY"),
secret_key=os.getenv("ALPACA_SECRET_KEY"),
raw_data=False,
)
request = StockBarsRequest(
symbol_or_symbols="SPY",
timeframe=TimeFrame.Hour,
start=datetime.now() - timedelta(days=90),
end=datetime.now(),
feed="iex",
)
bars = client.get_stock_bars(request)
df = bars.df.reset_index()
df = df.rename(columns={"timestamp": "timestamp", "open": "open", "high": "high", "low": "low", "close": "close", "volume": "volume"})
print(df.tail())Run the script. If you see recent hourly bars for SPY, your keys and environment are correct. The feed="iex" flag tells Alpaca to use free IEX data, which is fine for learning. Delayed SIP data is also available but not required.
Free data feeds may have slight delays and fewer ticks than paid SIP data. For a learning bot this is acceptable, but do not assume the same fills you see in paper will repeat in live trading.
Coding the EMA Crossover Strategy
Now that data is flowing, add the logic. Create strategy.py:
import pandas as pd
def add_ema_signals(df: pd.DataFrame, fast: int = 12, slow: int = 26) -> pd.DataFrame:
df = df.copy()
df["ema_fast"] = df["close"].ewm(span=fast, adjust=False).mean()
df["ema_slow"] = df["close"].ewm(span=slow, adjust=False).mean()
df["signal"] = 0
df.loc[df["ema_fast"] > df["ema_slow"], "signal"] = 1
df.loc[df["ema_fast"] < df["ema_slow"], "signal"] = -1
df["crossover"] = df["signal"].diff()
return dfThis function does three things. First it calculates two exponential moving averages. Then it labels every bar as bullish, bearish, or neutral based on which EMA is on top. Finally it computes the difference between consecutive signal values, so a value of 2 means a bullish crossover and -2 means a bearish crossover.
You can change the fast and slow periods later. Common combinations include 12 and 26, 9 and 21, or 50 and 200. There is no magic pair. Each market and timeframe responds differently, which is why backtesting matters.
Backtesting the Strategy
A backtest simulates how the strategy would have performed on historical data. It is not a guarantee of future profit, but it teaches you whether the rules even make sense. Create backtest.py:
import pandas as pd
from data import get_bars
from strategy import add_ema_signals
INITIAL_CAPITAL = 10000
COMMISSION_PER_TRADE = 1.0
def backtest(df: pd.DataFrame, fast: int = 12, slow: int = 26):
df = add_ema_signals(df, fast, slow)
position = 0
cash = INITIAL_CAPITAL
shares = 0
trades = []
for _, row in df.iterrows():
price = row["close"]
crossover = row["crossover"]
if crossover == 2 and position <= 0:
# Bullish crossover: buy
shares = (cash - COMMISSION_PER_TRADE) // price
if shares > 0:
cash -= shares * price + COMMISSION_PER_TRADE
position = 1
trades.append({"type": "buy", "price": price, "timestamp": row["timestamp"]})
elif crossover == -2 and position >= 0:
# Bearish crossover: sell
if shares > 0:
cash += shares * price - COMMISSION_PER_TRADE
trades.append({"type": "sell", "price": price, "timestamp": row["timestamp"]})
shares = 0
position = -1
final_value = cash + shares * df.iloc[-1]["close"]
return {
"final_value": final_value,
"return_pct": (final_value - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100,
"trades": len(trades),
"trade_list": trades,
}
if __name__ == "__main__":
df = get_bars("SPY", days=180)
result = backtest(df, fast=12, slow=26)
print(f"Final value: ${result['final_value']:.2f}")
print(f"Return: {result['return_pct']:.2f}%")
print(f"Number of trades: {result['trades']}")This backtest is intentionally simple. It assumes market orders fill at the closing price, charges a flat commission, and ignores slippage and partial fills. Those simplifications are fine for a first bot, but they mean real-world results will differ.
Here is an example output you might see on 180 days of SPY hourly data:
| Metric | Example Value | Interpretation |
|---|---|---|
| Initial capital | $10,000 | Starting portfolio value. |
| Final value | $10,430 | Simulated value after the last bar. |
| Return | 4.30% | Before taxes and data fees. |
| Trades | 14 | Seven round trips from crossovers. |
| Max drawdown | Unknown | We have not computed it yet. |
A 4.3% return sounds modest, but the real question is whether the bot beat a simple buy-and-hold of SPY over the same period. If SPY returned 8% while the bot returned 4%, the strategy is underperforming. If SPY was flat and the bot returned 4%, the strategy added some value. Context matters.
Never judge a strategy by total return alone. Always compare it to a benchmark, measure drawdowns, and check how many trades it takes. A strategy that makes money slowly but crashes hard is not a good strategy.
Improving the Backtest with Risk Metrics
A real backtest report should include more than profit. Add these metrics to understand the strategy's risk:
| Metric | Formula in Plain English | Why It Matters |
|---|---|---|
| Sharpe ratio | Return per unit of volatility | Higher is better; shows if returns justify the swings. |
| Max drawdown | Largest peak-to-trough drop | Tells you how bad the worst losing streak felt. |
| Win rate | Winning trades / total trades | Useful, but can be misleading without risk/reward. |
| Profit factor | Gross profit / gross loss | Values above 1 mean the strategy makes more than it loses. |
| Benchmark return | Buy-and-hold return over same period | Shows whether the bot added value or just rode the market. |
You can compute max drawdown by tracking the running high of the equity curve and measuring the worst decline from that high. A strategy with a smooth equity curve and modest drawdowns is usually more tradable than one with huge spikes.
Add a helper function to backtest.py:
def max_drawdown(equity_curve: pd.Series) -> float:
rolling_max = equity_curve.cummax()
drawdown = (equity_curve - rolling_max) / rolling_max
return drawdown.min()Then build an equity curve from your trades and pass it to this function. The result will be a negative percentage like -0.08 for an 8% drawdown.
Going Live on Paper Trading
Once the backtest looks reasonable, the next step is paper trading. Paper trading forces you to deal with real API calls, order statuses, and runtime errors, without risking money. Create bot.py:
import os
import time
from datetime import datetime
from dotenv import load_dotenv
from alpaca.trading.client import TradingClient
from alpaca.trading.requests import MarketOrderRequest
from alpaca.trading.enums import OrderSide, TimeInForce
from data import get_bars
from strategy import add_ema_signals
load_dotenv()
SYMBOL = "SPY"
QTY = 10
FAST = 12
SLOW = 26
trading_client = TradingClient(
api_key=os.getenv("ALPACA_API_KEY"),
secret_key=os.getenv("ALPACA_SECRET_KEY"),
paper=True,
)
def get_latest_signal():
df = get_bars(SYMBOL, days=5)
df = add_ema_signals(df, fast=FAST, slow=SLOW)
return df.iloc[-1]["signal"], df.iloc[-2]["signal"]
def place_order(side: OrderSide, qty: int):
order = MarketOrderRequest(
symbol=SYMBOL,
qty=qty,
side=side,
time_in_force=TimeInForce.DAY,
)
return trading_client.submit_order(order)
if __name__ == "__main__":
current, previous = get_latest_signal()
if current == 1 and previous != 1:
print(f"{datetime.now()}: Bullish crossover. Buying {QTY} shares of {SYMBOL}.")
place_order(OrderSide.BUY, QTY)
elif current == -1 and previous != -1:
print(f"{datetime.now()}: Bearish crossover. Selling {QTY} shares of {SYMBOL}.")
place_order(OrderSide.SELL, QTY)
else:
print(f"{datetime.now()}: No crossover. No action taken.")This bot checks the latest signal once and exits. For a production-style bot you would wrap the logic in a loop with a sleep timer, or run it as a cron job every hour. For learning, a single run is safer because you can inspect each order manually.
| Component | File | Purpose |
|---|---|---|
| Data fetcher | data.py | Downloads historical bars from Alpaca. |
| Strategy | strategy.py | Computes EMAs and crossover signals. |
| Backtest | backtest.py | Simulates past performance. |
| Live bot | bot.py | Places paper trades based on signals. |
| Environment | .env | Stores API keys outside source control. |
Common Errors and How to Fix Them
Every beginner hits the same set of problems. Here is how to handle the most frequent ones.
API key rejected
Check that you copied the keys correctly and that you are using paper keys with paper=True. Live keys and paper keys are different. Also verify that python-dotenv actually loaded the file: print os.getenv("ALPACA_API_KEY") and confirm it is not None.
No data returned
Make sure the symbol is tradeable on Alpaca and that you requested data within market hours for the timeframe. For hourly bars, request at least the last few trading days. If the market was just closed for a holiday, there will naturally be no recent bars.
Crossover fires on every bar
This usually means you compared the current signal to itself instead of the previous bar. The crossover column should use .diff(), which compares each row to the row before it. If the signal is 1 for ten bars in a row, only the first bar should trigger a trade.
Orders rejected for insufficient buying power
Paper accounts still enforce buying power. A $100,000 paper account cannot buy $200,000 of stock. Reduce QTY or check your account balance in the Alpaca dashboard before running the bot.
The bot overtrades in choppy markets
EMA crossovers are notorious for whipsaws. Add a filter such as a minimum price move, a trend filter with a 200-period EMA, or a cooldown timer between trades. For example, you can refuse a new signal unless at least five bars have passed since the last trade.
Best Practices for Running Your Bot
A working bot is only the beginning. The following habits separate hobbyists from serious systematic traders.
Log everything
Every signal, order, error, and runtime state should be written to a log file. When a trade goes wrong, you need to know exactly what the bot saw and when. A simple Python logging configuration is enough:
import logging
logging.basicConfig(
filename="bot.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)Start small and stay on paper
Even after a profitable backtest, stay on paper for weeks or months. Markets change, and a strategy that worked in the past can break when volatility shifts. Paper trading reveals execution problems and slippage that backtests hide.
Separate signal generation from execution
In the example code, signal logic and order placement live in different files. Keep them that way. If one file has a bug, the other might still protect you. Better yet, run signal generation first, review the output, and only then allow the execution script to trade.
Monitor your API rate limits
Alpaca enforces rate limits on market data and trading endpoints. A polling loop that fires every second can get you throttled. For hourly or daily strategies, polling once per bar is plenty. For faster strategies, use websockets instead of repeated REST calls.
Have a kill switch
Every bot should have a way to stop instantly. This can be a keyboard interrupt, a flag file, or a maximum daily loss rule. If the market gaps against you or your code misbehaves, you must be able to halt trading without editing the script.
From EMA Cross to Better Strategies
Once you understand the full pipeline, you can experiment with more advanced ideas:
| Next Step | What to Add | Complexity |
|---|---|---|
| Trend filter | Only trade when price is above a 200 EMA | Low |
| Multiple timeframes | Confirm hourly signal on daily chart | Medium |
| Risk per trade | Risk 1% of capital instead of fixed shares | Medium |
| Stop losses and targets | Exit at ATR multiples or support levels | Medium |
| Mean reversion | Trade RSI or Bollinger Band bounces | Medium |
| Machine learning | Train a classifier on features | High |
Do not chase complexity for its own sake. A simple strategy you fully understand will usually outperform a fancy model you copied from the internet.
FAQ
The questions below are also embedded in the page's structured data so search engines can show them directly. The answers are the same as the frontmatter values.
Do I need to know Python to build this trading bot?
Basic Python is enough. If you can read loops, functions, and install packages with pip, you can follow this tutorial. We explain every line of code.
Is Alpaca paper trading really free?
Yes. Alpaca's paper trading account is free and gives you the same API as live trading, so you can test strategies with simulated money.
Will this EMA crossover bot make me money?
Probably not by itself. EMA crossover is a classic but fragile strategy. This tutorial teaches you how to build, backtest, and iterate, not how to get rich quickly.
What hardware do I need to run a trading bot?
A normal laptop is fine. A Raspberry Pi or cloud server also works. The code in this guide uses less computing power than a streaming video call.
How do I keep my Alpaca API keys safe?
Store them in environment variables or a .env file, never commit them to Git, restrict API permissions to trading only, and rotate keys regularly.
Can I run this bot on crypto?
Not directly with Alpaca, which focuses on US stocks and ETFs. The same EMA logic applies to crypto, but you would need a crypto exchange API.
What should I do after this tutorial?
Run the bot on paper for several weeks, track every trade in a spreadsheet, add risk rules, then decide whether to trade a tiny live account or continue improving the strategy.
Bottom Line
Building your first AI trading bot is less about discovering a secret strategy and more about learning the mechanics of systematic trading. EMA crossover is a modest starting point, but it forces you to solve the real problems: data access, signal generation, backtesting, risk measurement, and safe execution.
Go slowly. Test everything on paper. Track your results honestly. And remember that the best bot is the one you understand well enough to stop when something goes wrong.