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_onceagainst 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
Brokerprotocol with four methods: cash, positions, bars, submit. PaperBrokerfills 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
duplicateacknowledgement. run_onceis 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.