LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Building a Paper-Trading Bot with the Alpaca API

tradingpythonalpacahomelabquantitative-finance

Every automated trading tutorial ends the same way: a Jupyter notebook full of backtested equity curves, a disclaimer about paper money, and silence about what happens when you actually try to run the thing continuously on real market data. Deployment is the gap. This post closes it — or at least closes enough of it to get a paper-trading bot running reliably on a homelab server, staying up through weekends, surviving restarts, and alerting you when something goes wrong. The technology stack is deliberately modest: Python, SQLite or Postgres, Docker Compose, and Alpaca’s free paper trading API. Nothing here requires a brokerage account, a Bloomberg terminal, or a co-location rack.


Why Alpaca, and What “Paper Trading” Actually Means

Alpaca is a commission-free brokerage that exposes its entire trading infrastructure through a REST and WebSocket API. The paper trading environment is not a simulator bolted on as an afterthought — it is the same matching engine as live trading, seeded with $100,000 of virtual cash, with fills driven by actual IEX market data. You can create a paper-only account with just an email address; no funding, no identity verification, no account minimum. The paper trading base URL is https://paper-api.alpaca.markets; switching to live later requires changing exactly one environment variable.

The free Basic plan gives you IEX real-time data for equities and an indicative options feed. IEX covers the National Best Bid and Offer (NBBO) for most liquid tickers, which is good enough for strategy development. If you need SIP-consolidated data across all exchanges, Alpaca sells a paid subscription — but for a paper system running on a homelab, IEX is the right starting point.

The alpaca-py SDK (currently version 0.43.4, released April 2026) is the official Python client. The older alpaca-trade-api package is deprecated; if you find forum posts referencing alpaca_trade_api.REST, they are outdated.

pip install alpaca-py==0.43.4

System Architecture

Before writing any code, it helps to draw the boundary between concerns. A trading bot is not a single program — it is a pipeline of loosely coupled stages that each have distinct failure modes.

                        ┌─────────────────────────────────────────────┐
                        │              trading-bot container           │
                        │                                              │
  Alpaca Market Data    │  ┌──────────────┐    ┌───────────────────┐  │
  WebSocket (IEX)  ────►│  │  Data Ingest │───►│ Signal Generator  │  │
                        │  │  (async)     │    │ (strategy logic)  │  │
                        │  └──────────────┘    └────────┬──────────┘  │
                        │                               │              │
                        │                               ▼              │
                        │                    ┌──────────────────────┐  │
                        │                    │  Order State Machine │  │
                        │                    │  IDLE → PENDING →    │  │
                        │                    │  FILLED → HOLD →     │  │
                        │                    │  EXIT_PENDING → FLAT │  │
                        │                    └──────────┬───────────┘  │
                        │                               │              │
  Alpaca Trading API    │  ┌────────────────┐           │              │
  WebSocket (fills) ◄──►│  │ TradingStream  │◄──────────┘              │
                        │  │ (order events) │                          │
                        │  └────────────────┘                          │
                        │                                              │
                        │  ┌──────────────────────────────────────┐   │
                        │  │   Persistence Layer (SQLite/Postgres) │   │
                        │  │   positions | orders | bar_cache      │   │
                        │  └──────────────────────────────────────┘   │
                        │                                              │
                        │  ┌──────────────┐    ┌──────────────────┐   │
                        │  │  Kill Switch │    │  Alert Emitter   │   │
                        │  │  (file/env)  │    │  (fills/errors)  │   │
                        │  └──────────────┘    └──────────────────┘   │
                        └─────────────────────────────────────────────┘

Each box maps to a Python module. They communicate through an asyncio queue rather than direct calls, which keeps the data ingest layer decoupled from strategy execution. If the signal generator hangs, the ingest queue fills up and the system degrades gracefully rather than missing fills.


Data Ingest: REST vs WebSocket

The first architectural decision is how to get market data into the bot. Both options are available through alpaca-py; neither is universally correct.

Dimension REST polling WebSocket streaming
Latency Hundreds of ms per poll cycle Sub-100ms tick arrival
Complexity Simple request/response loop Async event loop, reconnect logic
Rate limits 200 req/min on free tier Single persistent connection
Startup cost None Handshake + subscription
Gaps on reconnect None (poll covers gap) Possible data loss during reconnect
CPU overhead Low (idle between polls) Low (event-driven)
Use case fit Minute-bar strategies Tick or second-bar strategies

For a strategy that trades on one-minute bars, REST polling is genuinely simpler and more reliable. Poll at the start of each bar, get the completed bar, compute your signal, act. The code is synchronous, easy to test, and has no reconnection logic to debug. The downside is that you cannot react within the bar — you are always one bar behind the close.

For strategies that need to act on quotes, trades, or sub-minute price action, WebSocket streaming is necessary. The StockDataStream class handles reconnection internally, but you still need to think about what happens to your state if the stream drops for 30 seconds.

A hybrid approach works well in practice: use WebSocket streaming for real-time price monitoring and circuit breaker triggers, but pull historical bars via REST at startup to seed your indicators. The code below shows both patterns.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# rest_ingest.py — polling completed 1-minute bars
from alpaca.data.historical import StockHistoricalDataClient
from alpaca.data.requests import StockBarsRequest
from alpaca.data.timeframe import TimeFrame
from datetime import datetime, timezone, timedelta

client = StockHistoricalDataClient(api_key=API_KEY, secret_key=SECRET_KEY)

def get_latest_bar(symbol: str) -> dict:
    end = datetime.now(timezone.utc)
    start = end - timedelta(minutes=5)
    req = StockBarsRequest(
        symbol_or_symbols=symbol,
        timeframe=TimeFrame.Minute,
        start=start,
        end=end,
    )
    bars = client.get_stock_bars(req)
    return bars[symbol][-1]  # most recent completed bar
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# ws_ingest.py — streaming real-time quotes
from alpaca.data.live import StockDataStream
import asyncio

async def handle_quote(data):
    await quote_queue.put(data)

def start_stream(symbols: list[str], queue: asyncio.Queue):
    global quote_queue
    quote_queue = queue
    stream = StockDataStream(api_key=API_KEY, secret_key=SECRET_KEY)
    stream.subscribe_quotes(handle_quote, *symbols)
    stream.run()

Signal Generation

Signal generation is intentionally separated from data ingest. The signal generator consumes bars or quotes from a queue and emits trading signals — nothing more. It does not know about orders, positions, or the outside world. This separation makes unit testing trivial: feed it synthetic bars, assert on the emitted signals.

A simple moving average crossover illustrates the pattern without distraction:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# signals.py
from collections import deque
from dataclasses import dataclass
from enum import Enum

class Signal(Enum):
    LONG = "long"
    SHORT = "short"
    FLAT = "flat"
    HOLD = "hold"

@dataclass
class BarData:
    symbol: str
    close: float
    volume: int
    timestamp: str

class MACrossSignal:
    def __init__(self, fast: int = 9, slow: int = 21):
        self.fast_window = deque(maxlen=fast)
        self.slow_window = deque(maxlen=slow)
        self.prev_signal = Signal.HOLD

    def update(self, bar: BarData) -> Signal:
        self.fast_window.append(bar.close)
        self.slow_window.append(bar.close)

        if len(self.slow_window) < self.slow_window.maxlen:
            return Signal.HOLD  # not enough data

        fast_ma = sum(self.fast_window) / len(self.fast_window)
        slow_ma = sum(self.slow_window) / len(self.slow_window)

        if fast_ma > slow_ma and self.prev_signal != Signal.LONG:
            self.prev_signal = Signal.LONG
            return Signal.LONG
        elif fast_ma < slow_ma and self.prev_signal != Signal.SHORT:
            self.prev_signal = Signal.SHORT
            return Signal.SHORT
        return Signal.HOLD

The signal generator emits Signal.LONG or Signal.SHORT only on transitions, not on every bar. This prevents repeated order submissions when the condition persists.


Order Management State Machine

Order management is where most trading bots go wrong. The naive approach — submit an order and assume it fills — breaks on partial fills, rejections, timeouts, and API errors. A state machine makes the transitions explicit and testable.

From State Event To State Action
FLAT Signal.LONG PENDING_ENTRY Submit market buy
FLAT Signal.SHORT PENDING_ENTRY Submit market sell
PENDING_ENTRY fill event HOLDING Record position
PENDING_ENTRY rejection event FLAT Log error, alert
PENDING_ENTRY timeout (30s) FLAT Cancel order, alert
HOLDING Signal flip PENDING_EXIT Submit closing order
HOLDING Kill switch PENDING_EXIT Submit closing order
PENDING_EXIT fill event FLAT Record PnL, reset
PENDING_EXIT timeout (30s) PENDING_EXIT Re-submit, alert
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# state_machine.py
from enum import Enum, auto
from alpaca.trading.client import TradingClient
from alpaca.trading.requests import MarketOrderRequest
from alpaca.trading.enums import OrderSide, TimeInForce
import time

class BotState(Enum):
    FLAT = auto()
    PENDING_ENTRY = auto()
    HOLDING = auto()
    PENDING_EXIT = auto()

class OrderStateMachine:
    def __init__(self, trading_client: TradingClient, symbol: str, qty: int):
        self.client = trading_client
        self.symbol = symbol
        self.qty = qty
        self.state = BotState.FLAT
        self.current_order_id: str | None = None
        self.order_submitted_at: float | None = None
        self.position_side: str | None = None

    def on_signal(self, signal) -> None:
        from signals import Signal
        if self.state == BotState.FLAT:
            if signal == Signal.LONG:
                self._submit_order(OrderSide.BUY)
            elif signal == Signal.SHORT:
                self._submit_order(OrderSide.SELL)
        elif self.state == BotState.HOLDING:
            if signal in (Signal.SHORT, Signal.FLAT):
                self._submit_closing_order()

    def on_fill(self, order_id: str, side: str) -> None:
        if order_id != self.current_order_id:
            return
        if self.state == BotState.PENDING_ENTRY:
            self.state = BotState.HOLDING
            self.position_side = side
        elif self.state == BotState.PENDING_EXIT:
            self.state = BotState.FLAT
            self.current_order_id = None
            self.position_side = None

    def on_rejection(self, order_id: str) -> None:
        if order_id == self.current_order_id:
            self.state = BotState.FLAT
            self.current_order_id = None

    def check_timeouts(self, timeout_secs: int = 30) -> None:
        if self.state in (BotState.PENDING_ENTRY, BotState.PENDING_EXIT):
            if time.time() - (self.order_submitted_at or 0) > timeout_secs:
                self._cancel_current_order()

    def _submit_order(self, side: OrderSide) -> None:
        req = MarketOrderRequest(
            symbol=self.symbol,
            qty=self.qty,
            side=side,
            time_in_force=TimeInForce.DAY,
        )
        order = self.client.submit_order(req)
        self.current_order_id = order.id
        self.order_submitted_at = time.time()
        self.state = BotState.PENDING_ENTRY

    def _submit_closing_order(self) -> None:
        close_side = OrderSide.SELL if self.position_side == "buy" else OrderSide.BUY
        req = MarketOrderRequest(
            symbol=self.symbol,
            qty=self.qty,
            side=close_side,
            time_in_force=TimeInForce.DAY,
        )
        order = self.client.submit_order(req)
        self.current_order_id = order.id
        self.order_submitted_at = time.time()
        self.state = BotState.PENDING_EXIT

    def _cancel_current_order(self) -> None:
        if self.current_order_id:
            try:
                self.client.cancel_order_by_id(self.current_order_id)
            except Exception:
                pass
            self.state = BotState.FLAT
            self.current_order_id = None

The TradingStream WebSocket connection delivers fill and rejection events asynchronously, which feed into on_fill and on_rejection. This keeps the state machine honest — no polling the orders endpoint to find out if something filled.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# trading_stream.py
from alpaca.trading.stream import TradingStream

def start_trading_stream(state_machine, api_key: str, secret_key: str):
    stream = TradingStream(api_key=api_key, secret_key=secret_key, paper=True)

    async def trade_update_handler(data):
        event = data.event
        order_id = data.order.id
        side = data.order.side.value
        if event == "fill":
            state_machine.on_fill(order_id, side)
        elif event in ("canceled", "rejected", "expired"):
            state_machine.on_rejection(order_id)

    stream.subscribe_trade_updates(trade_update_handler)
    stream.run()

State Persistence: SQLite vs Postgres

The bot’s critical state — current position, open order ID, PnL history — must survive a container restart. In-memory state is gone the moment Docker restarts the container or the host reboots.

Criterion SQLite Postgres
Setup effort Zero (file on disk) Docker service, init scripts
Concurrent writers One process only Many processes
Backup cp bot.db bot.db.bak pg_dump or WAL archiving
Crash recovery WAL mode handles most cases Full ACID guarantees
Operational cost None Moderate
Right choice Single-bot homelab Multi-strategy or team use

For a single bot running on a homelab, SQLite in WAL mode is the right answer. It requires no infrastructure, is trivially backed up with a nightly cp, and handles the single-writer pattern perfectly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# persistence.py
import sqlite3
from pathlib import Path

DB_PATH = Path("/data/bot.db")

def init_db():
    con = sqlite3.connect(DB_PATH)
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("""
        CREATE TABLE IF NOT EXISTS positions (
            id INTEGER PRIMARY KEY,
            symbol TEXT NOT NULL,
            side TEXT NOT NULL,
            qty INTEGER NOT NULL,
            entry_price REAL,
            entry_time TEXT,
            exit_price REAL,
            exit_time TEXT,
            pnl REAL
        )
    """)
    con.execute("""
        CREATE TABLE IF NOT EXISTS bot_state (
            key TEXT PRIMARY KEY,
            value TEXT NOT NULL
        )
    """)
    con.commit()
    return con

def save_state(con: sqlite3.Connection, key: str, value: str):
    con.execute(
        "INSERT OR REPLACE INTO bot_state (key, value) VALUES (?, ?)",
        (key, value),
    )
    con.commit()

def load_state(con: sqlite3.Connection, key: str, default: str = "") -> str:
    row = con.execute(
        "SELECT value FROM bot_state WHERE key = ?", (key,)
    ).fetchone()
    return row[0] if row else default

If you later want to run multiple strategies concurrently, or need a web dashboard querying the same data, switch to Postgres. The schema stays identical; only the connection string changes. See the log-management guide for patterns around persisting structured events alongside your trade records.


Deploying on the Homelab with Docker Compose

The bot runs as a single container. The database file lives on a bind-mounted host directory so it persists across container recreations. The only secret management needed is an env file that Docker Compose reads at startup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# docker-compose.yml
services:
  trading-bot:
    image: trading-bot:latest
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    env_file:
      - .env.paper
    volumes:
      - ./data:/data
      - ./kill_switch:/kill_switch
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "5"
1
2
3
4
5
6
7
8
9
# Dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

CMD ["python", "-u", "main.py"]
1
2
3
4
5
6
# .env.paper  — never commit this file
ALPACA_API_KEY=PKxxxxxxxxxxxxxxxxxxxxxxxx
ALPACA_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ALPACA_PAPER=true
SYMBOL=SPY
QTY=10

The restart: unless-stopped policy handles crashes and host reboots automatically. For a homelab, this is preferable to a systemd unit because it keeps everything in one compose file rather than split across /etc/systemd/system/. If you prefer systemd, see the homelab hardware guide for patterns around managing long-running services on bare metal. The compose approach works equally well on a Raspberry Pi, a retired workstation, or a small VPS.

The /data volume mount is where SQLite writes bot.db. The /kill_switch mount is discussed below.


Logging and Alerting

A trading bot that fails silently is worse than no bot at all — you may not notice that it stopped trading until you check your account a week later and find stale positions. Log everything that matters, alert on the things that require immediate attention.

What to log at each level:

  • INFO: bar received, signal emitted, order submitted, fill confirmed, PnL recorded
  • WARNING: partial fill, order timeout triggered, reconnect attempt
  • ERROR: order rejected, API error, kill switch activated, circuit breaker tripped

Use structured JSON logging so the records are parseable later. The Python structlog library makes this straightforward, but the stdlib logging module with a JSON formatter is sufficient. See the log-management guide for the full pattern.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# logger.py
import logging
import json
import sys
from datetime import datetime, timezone

class JSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "msg": record.getMessage(),
        }
        if record.exc_info:
            payload["exc"] = self.formatException(record.exc_info)
        if hasattr(record, "extra"):
            payload.update(record.extra)
        return json.dumps(payload)

def get_logger(name: str) -> logging.Logger:
    logger = logging.getLogger(name)
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JSONFormatter())
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)
    return logger

For alerting, the two events worth waking you up for are fills and errors. A fill confirms that the bot placed a real (paper) trade — useful for monitoring that the strategy is active. An error means something needs human attention.

The simplest homelab alerting mechanism is a webhook to a self-hosted ntfy.sh instance or a Telegram bot. Both require a single HTTP POST:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# alerting.py
import httpx

NTFY_URL = "https://ntfy.your-homelab.local/trading-bot"

async def send_alert(title: str, message: str, priority: str = "default"):
    async with httpx.AsyncClient() as client:
        await client.post(
            NTFY_URL,
            content=message,
            headers={
                "Title": title,
                "Priority": priority,
                "Tags": "chart_with_upwards_trend",
            },
        )

Call send_alert from the fill handler and from the top-level exception handler in main.py. That covers the two cases that matter most.


Kill Switches and Circuit Breakers

Any automated system that can lose money — even paper money — needs a manual override and an automatic circuit breaker. These are not afterthoughts; build them into the architecture from the start.

The kill switch is a file on the container’s mounted volume. The main loop checks for its existence at the start of each bar cycle. If the file exists, the bot closes any open position, cancels pending orders, and exits cleanly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# kill_switch.py
from pathlib import Path

KILL_FILE = Path("/kill_switch/STOP")

def is_active() -> bool:
    return KILL_FILE.exists()

def check_and_exit(state_machine, logger):
    if is_active():
        logger.warning("Kill switch activated. Flattening position.")
        state_machine.on_signal(Signal.FLAT)
        raise SystemExit(0)

To trigger it from the host: touch ./kill_switch/STOP. The bot will stop at the next bar cycle. To re-enable: rm ./kill_switch/STOP. This pattern is far more reliable than trying to send signals to a container process.

The circuit breaker is an automatic kill switch based on drawdown. Track the running PnL and halt trading if the loss exceeds a threshold:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# circuit_breaker.py
class CircuitBreaker:
    def __init__(self, max_loss: float = -500.0):
        self.max_loss = max_loss
        self.session_pnl = 0.0
        self.tripped = False

    def record_pnl(self, pnl: float) -> None:
        self.session_pnl += pnl
        if self.session_pnl < self.max_loss:
            self.tripped = True

    def is_tripped(self) -> bool:
        return self.tripped

Check circuit_breaker.is_tripped() in the same place you check the kill switch. When tripped, close positions and stop the strategy loop, but keep the process running and alerting — you want to know why it tripped, not just that it stopped.


Putting It Together: main.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# main.py
import asyncio
import os
from alpaca.trading.client import TradingClient
from state_machine import OrderStateMachine
from signals import MACrossSignal, Signal
from persistence import init_db, save_state, load_state
from kill_switch import check_and_exit
from circuit_breaker import CircuitBreaker
from alerting import send_alert
from logger import get_logger
import time

logger = get_logger("main")

API_KEY = os.environ["ALPACA_API_KEY"]
SECRET_KEY = os.environ["ALPACA_SECRET_KEY"]
SYMBOL = os.environ.get("SYMBOL", "SPY")
QTY = int(os.environ.get("QTY", "10"))

async def main():
    trading_client = TradingClient(API_KEY, SECRET_KEY, paper=True)
    db = init_db()
    signal_gen = MACrossSignal(fast=9, slow=21)
    sm = OrderStateMachine(trading_client, SYMBOL, QTY)
    cb = CircuitBreaker(max_loss=-500.0)

    logger.info("Bot started", extra={"symbol": SYMBOL, "qty": QTY})
    await send_alert("Bot started", f"Trading {SYMBOL} x{QTY}")

    while True:
        check_and_exit(sm, logger)
        if cb.is_tripped():
            logger.error("Circuit breaker tripped. Halting.")
            await send_alert("Circuit breaker", "Max loss reached. Bot halted.", priority="high")
            break

        try:
            from rest_ingest import get_latest_bar
            bar = get_latest_bar(SYMBOL)
            signal = signal_gen.update(bar)
            sm.on_signal(signal)
            sm.check_timeouts()
            save_state(db, "last_bar_ts", bar.timestamp)
        except Exception as e:
            logger.error("Main loop error", extra={"error": str(e)})
            await send_alert("Bot error", str(e), priority="high")

        await asyncio.sleep(60)  # wait for next bar

if __name__ == "__main__":
    asyncio.run(main())

The TradingStream runs in a separate thread (alpaca-py handles this internally when you call .run()). The main loop is a simple 60-second polling cycle on completed bars. The two async contexts do not conflict because the fill handler only updates the state machine’s internal state without making blocking calls.


Honest Trade-offs

This architecture is simple by design, and simplicity has costs. The single-process model means that if the bar polling loop blocks — an occasional Alpaca API slowdown, a slow database write — fill events from TradingStream will still arrive but check_timeouts will not run. In practice this is rarely a problem with market orders on liquid tickers, but it is the kind of edge case that bites you on days with unusual volatility.

SQLite is fine until it is not. If you want to add a second strategy or a monitoring dashboard, you will need to switch to Postgres. Design the persistence layer with that migration in mind from the start: keep all database access behind the functions in persistence.py so there is one place to change the connection logic.

Paper trading fills are optimistic. Market orders in the paper environment fill at the current mid-price with no slippage and no partial fills on liquid names. Live trading on the same strategy will perform worse, sometimes significantly. Before treating paper PnL as predictive of live performance, read up on how backtests lie — many of the same fill-quality assumptions apply to paper environments.

Finally, the IEX data feed has a known delay of roughly 15 minutes relative to the SIP consolidated feed for some data products. For minute-bar strategies this is usually not relevant, but if you are working on strategies that are sensitive to the exact top-of-book at entry, verify what you are actually receiving by comparing IEX quotes to another data source during your testing period.


Verdict

Alpaca’s paper trading API removes every barrier to running a continuously operating trading bot: no account minimum, no subscription fee for basic data, and a WebSocket feed that works the same way as the live environment. The alpaca-py SDK (0.43.4) is mature and well-documented. The architecture described here — async ingest, explicit state machine, SQLite persistence, Docker Compose deployment, file-based kill switch — is compact enough to run on the lowest-end homelab hardware and robust enough to survive the failure modes that matter. The hard parts of trading are not the infrastructure; they are signal quality, position sizing (see kelly-criterion-position-sizing), and understanding the risk metrics that tell you whether your system is working. But you cannot get to those hard parts without a working system to experiment on. This is how you build one.


Sources

Comments