Skip to content
GetProfitable
Search

Paper trading and the broker adapter

Lesson 25 · about 13 min

A live trading program is a backtest whose next bar has not happened yet. The strategy code should be identical; only the source of bars and the destination of orders change. That is achieved with a broker adapter: one small interface that the strategy talks to, with a paper implementation for testing and a real implementation for the broker. This lesson defines the interface, builds the paper version, sketches an Alpaca-style real one, and shows the once-a-day run loop that uses either.

The interface

# src/broker.py
from dataclasses import dataclass
from typing import Protocol
import pandas as pd


@dataclass(frozen=True)
class Position:
    symbol: str
    qty: float
    avg_price: float


@dataclass(frozen=True)
class OrderRequest:
    symbol: str
    side: str                 # "buy" or "sell"
    qty: float
    client_order_id: str      # unique per intended order; see lesson 3
    order_type: str = "market"


@dataclass(frozen=True)
class OrderAck:
    client_order_id: str
    broker_order_id: str
    status: str               # "accepted", "filled", "rejected", "duplicate"


class Broker(Protocol):
    def get_cash(self) -> float: ...
    def get_positions(self) -> dict[str, Position]: ...
    def get_bars(self, symbol: str, n: int) -> pd.DataFrame: ...
    def submit(self, order: OrderRequest) -> OrderAck: ...

Four methods. A Protocol means any class with those methods satisfies it; there is no inheritance to manage. Everything the strategy needs is here: how much cash, what is held, recent bars, and a way to send an order. Deliberately absent: anything about API keys, HTTP, rate limits or timestamps formats. Those live inside each implementation.

The paper broker

A paper broker is a broker that fills orders against a price you give it. It is the single most valuable test fixture you will write, because every part of the live program can be exercised against it in a unit test with no network and no account.

# src/broker.py (continued)
from dataclasses import replace


class PaperBroker:
    """In-memory broker for tests. Fills market orders at the latest close plus slippage."""

    def __init__(self, bars_by_symbol: dict[str, pd.DataFrame], cash: float = 100_000.0,
                 slippage_bps: float = 2.0, cost_bps: float = 5.0):
        self._bars = bars_by_symbol
        self._cash = cash
        self._positions: dict[str, Position] = {}
        self._seen_ids: set[str] = set()
        self._slip, self._cost = slippage_bps / 10_000, cost_bps / 10_000
        self.log: list[OrderAck] = []

    def get_cash(self) -> float:
        return self._cash

    def get_positions(self) -> dict[str, Position]:
        return dict(self._positions)

    def get_bars(self, symbol: str, n: int) -> pd.DataFrame:
        return self._bars[symbol].tail(n)

    def submit(self, order: OrderRequest) -> OrderAck:
        if order.client_order_id in self._seen_ids:
            return self._ack(order, "duplicate")
        self._seen_ids.add(order.client_order_id)
        price = float(self._bars[order.symbol]["close"].iloc[-1])
        sign = 1 if order.side == "buy" else -1
        fill = price * (1 + sign * self._slip)
        notional = order.qty * fill
        if sign > 0 and notional * (1 + self._cost) > self._cash:
            return self._ack(order, "rejected")
        self._cash -= sign * notional + notional * self._cost
        held = self._positions.get(order.symbol, Position(order.symbol, 0.0, 0.0))
        new_qty = held.qty + sign * order.qty
        if new_qty == 0:
            self._positions.pop(order.symbol, None)
        else:
            avg = fill if held.qty == 0 else held.avg_price
            self._positions[order.symbol] = replace(held, qty=new_qty, avg_price=avg)
        return self._ack(order, "filled")

    def _ack(self, order: OrderRequest, status: str) -> OrderAck:
        ack = OrderAck(order.client_order_id, f"paper-{len(self.log) + 1}", status)
        self.log.append(ack)
        return ack

It rejects buys it cannot afford, it refuses duplicate client order ids (the reason for that field is lesson 3), and it keeps a log of acknowledgements you can assert against in tests.

An Alpaca-style adapter, shape only

The real adapter is the same four methods around HTTP calls. This sketch shows the shape; the exact endpoints and field names come from the broker's documentation and are not something to memorise.

# src/broker.py (continued)
import os


class AlpacaLikeBroker:
    """Shape of a REST broker adapter. Network calls are stubbed out; fill in from the API docs."""

    def __init__(self, base_url: str, session):
        self.base_url = base_url                       # paper and live are DIFFERENT hosts
        self.session = session                         # e.g. requests.Session with auth headers
        key, secret = os.environ["BROKER_API_KEY"], os.environ["BROKER_API_SECRET"]
        self.session.headers.update({"APCA-API-KEY-ID": key, "APCA-API-SECRET-KEY": secret})

    def _get(self, path: str, **params) -> dict:
        resp = self.session.get(f"{self.base_url}{path}", params=params, timeout=10)
        resp.raise_for_status()
        return resp.json()

    def get_cash(self) -> float:
        return float(self._get("/v2/account")["cash"])

    def get_positions(self) -> dict[str, Position]:
        return {p["symbol"]: Position(p["symbol"], float(p["qty"]), float(p["avg_entry_price"]))
                for p in self._get("/v2/positions")}

    def get_bars(self, symbol: str, n: int) -> pd.DataFrame:
        raw = self._get(f"/v2/stocks/{symbol}/bars", timeframe="1Day", limit=n)["bars"]
        df = pd.DataFrame(raw).rename(columns={"t": "date", "o": "open", "h": "high",
                                               "l": "low", "c": "close", "v": "volume"})
        df["date"] = pd.to_datetime(df["date"], utc=True)
        return df.set_index("date")[["open", "high", "low", "close", "volume"]].astype(float)

    def submit(self, order: OrderRequest) -> OrderAck:
        payload = {"symbol": order.symbol, "side": order.side, "qty": str(order.qty),
                   "type": order.order_type, "time_in_force": "day",
                   "client_order_id": order.client_order_id}
        resp = self.session.post(f"{self.base_url}/v2/orders", json=payload, timeout=10)
        if resp.status_code == 422 and "client_order_id" in resp.text:
            return OrderAck(order.client_order_id, "", "duplicate")
        resp.raise_for_status()
        body = resp.json()
        return OrderAck(order.client_order_id, body["id"], body["status"])

Every call has a timeout. Every non-2xx status raises, except the one that means "you already sent this", which is returned as a duplicate acknowledgement because the program needs to treat that as success. The get_bars method ends by producing the standard DataFrame from Module 2, so the strategy cannot tell it from the paper broker.

Key idea: The strategy talks to a four-method broker interface. The paper implementation runs in tests with no network; the real one wraps HTTP calls and returns the same types. Nothing above the interface knows which one it is talking to.

The daily run

# src/broker.py (continued)
import numpy as np
from src.indicators import sma


def desired_qty(bars: pd.DataFrame, cash: float, held: float, fast=20, slow=50, fraction=0.95) -> float:
    close = bars["close"]
    want_long = bool(sma(close, fast).iloc[-1] > sma(close, slow).iloc[-1])
    if want_long and held == 0:
        return float(np.floor(fraction * cash / close.iloc[-1]))
    if not want_long and held > 0:
        return -held
    return 0.0


def run_once(broker: Broker, symbol: str, run_date: str) -> OrderAck | None:
    bars = broker.get_bars(symbol, n=60)
    positions = broker.get_positions()
    held = positions[symbol].qty if symbol in positions else 0.0
    delta = desired_qty(bars, broker.get_cash(), held)
    if delta == 0:
        return None
    order = OrderRequest(symbol, "buy" if delta > 0 else "sell", abs(delta),
                         client_order_id=f"{run_date}-{symbol}-crossover")
    return broker.submit(order)

run_once is the whole live program's logic: fetch, decide, maybe submit. Test it against the paper broker:

from src.data import synthetic_ohlcv

paper = PaperBroker({"SYNTH": synthetic_ohlcv(300, seed=42)})
ack = run_once(paper, "SYNTH", "2024-06-03")
print(ack, paper.get_positions(), round(paper.get_cash(), 2))
again = run_once(paper, "SYNTH", "2024-06-03")
print(again)                                   # None or "duplicate": never a second fill for the same day

Notice that the signal here is computed from the last bar the broker returned. In a live run at 16:05 New York time that bar is today's completed session; at 15:00 it is a partial bar, and acting on it is a different strategy from the one you tested. Lesson 2 is about running at the right time.

Try it: Write a pytest that runs run_once against the paper broker for each of the last 50 days of synthetic bars (slicing the bars so the broker only "knows" up to each day), then compares the sequence of fills with the trades from the vectorized backtest of the same rule. They should match in direction and date; any difference is a bug in one of the two.

Recap

  • One Broker protocol with four methods: cash, positions, bars, submit.
  • PaperBroker fills in memory, rejects unaffordable orders, refuses duplicate ids, and logs acknowledgements for tests.
  • The real adapter wraps HTTP with timeouts, raises on errors, and maps "already submitted" to a duplicate acknowledgement.
  • run_once is fetch, decide, maybe submit; it is the same code for paper and live.
  • Test the live path against the paper broker and against the backtest before any real key is involved.

See it drawn

Original diagrams for the ideas on this page. Illustrative, not real market data.

Slippage on a market orderA buy order clears four price levels, so the average price paid is worse than the price first quoted.Buy 1,000 shares at marketpricesell orders resting (bar length = size)20.04300 shares20.03200 shares20.01200 shares20.00300 sharesnothing resting at 20.02order sweeps up the bookaverage fill 20.02SLIPPAGE0.02 a share$20.00 in totalintended 20.00Each level fills at its own price; the average is what you really paid.
Slippage on a market order. You click at 20.00, but only 300 shares are resting there, so the rest of the order fills at 20.01, 20.03 and 20.04. The average price paid is 20.02, and that two-cent gap is slippage.
How a call option's delta changes with the underlying priceAn S-shaped curve rising from zero, passing through about a half at the strike, and flattening near one.Delta of a call option1.000.5008090110120Out of the moneyAt the moneyIn the money1.00 means it moves one-for-one with the stockdelta ≈ 0.50 at the strikeStrike 100Underlying price
Delta across the range of prices. Delta says how much a call's price moves for a one-point move in the stock. Far below the strike it is near 0 and the option barely reacts; at the strike it is about 0.50; far above it approaches 1 and tracks the stock.