Quant Memo

Getting Started

← All guides

From Backtest to Paper to Live

The realistic path from a clean backtest to real money, why paper trading flatters, how to place orders through a broker sandbox without hardcoding keys, modeling slippage and execution lag, reconciliation and idempotency, implementation shortfall, risk of ruin, and the kill-switches that keep a live system from destroying your account.

Suppose you have done everything in backtest traps: no look-ahead, a survived universe, deflated Sharpe, costs on turnover, a clean walk-forward. You still do not have a live P&L, you have the most optimistic number consistent with the data. The distance between that number and your brokerage statement is where most retail algos quietly die. This guide walks the real path, backtest → paper → live, and names what breaks at each step.

The broker-integration snippets are illustrative (they touch the network, so they are marked and not executed here), but every piece of logic, slippage, lag, shortfall, ruin, reconciliation, is plain Python that runs offline. Keys always come from the environment; never hardcode a secret.

Why paper trading flatters

Paper (simulated) trading is essential, but for plumbing, not for edge. A paper engine fills you at the current quote, instantly, in full, every time. Reality does none of that:

  • Perfect fills at the touch. Paper fills at the mid or the posted bid/ask; live, a market order walks the book and a limit order sits in a queue behind everyone who got there first.
  • No market impact. Your paper order does not move the price. A real order of any size does, see Market Impact and the The Square-Root Impact Law.
  • No partial fills or rejects. Paper never gives you 300 of 1,000 shares and then the price runs away. Live does, constantly.
  • No queue position or Adverse Selection. A resting limit order gets filled precisely when the market is about to move against you.
  • No latency. Paper acts on the same bar it decided on; your live loop has network and decision lag.

Paper trading answers "does my code place, cancel, and reconcile orders correctly?" It barely touches "does this strategy make money?" Treat a good paper run as necessary, never sufficient.

Talking to a broker (sandbox first)

Every serious broker offers a sandbox/paper endpoint. Point at it first; flip one URL to go live. The pattern below (Alpaca) is representative; IBKR (ib_insync) and crypto exchanges (ccxt) follow the same shape, construct a client from env credentials, submit a typed order, poll for status.

# ILLUSTRATIVE, requires the alpaca-py package and network access.
import os
from alpaca.trading.client import TradingClient
from alpaca.trading.requests import MarketOrderRequest, LimitOrderRequest
from alpaca.trading.enums import OrderSide, TimeInForce

client = TradingClient(
    api_key=os.environ["ALPACA_KEY"],          # from env, never in source
    secret_key=os.environ["ALPACA_SECRET"],
    paper=True,                                  # <-- sandbox; set False only when ready
)

def submit_market(symbol, qty, side, client_order_id):
    return client.submit_order(MarketOrderRequest(
        symbol=symbol, qty=abs(qty), side=side,
        time_in_force=TimeInForce.DAY,
        client_order_id=client_order_id,         # idempotency key (see below)
    ))
# ILLUSTRATIVE, ccxt (crypto), same sandbox-first shape
import ccxt, os
ex = ccxt.binance({"apiKey": os.environ["BINANCE_KEY"],
                   "secret": os.environ["BINANCE_SECRET"]})
ex.set_sandbox_mode(True)                        # testnet
# ex.create_order("BTC/USDT", "limit", "buy", amount=0.01, price=50000)

Order types: certain price vs certain fill

You are always trading one certainty for another. See Market vs. Limit Orders.

Order typeFill certaintyPrice certaintyYou pay / risk
Market~certainnonefull spread + impact; gaps on illiquid names
Limitnonecappedadverse selection, fills only when you're wrong
Marketable limithighcappedsmall spread cost; protected from gaps

A marketable limit, a buy limit priced a few ticks above the ask (or a sell below the bid), is the workhorse for systematic retail: it crosses the spread for a near-certain fill but caps how far you can be gapped if the book is thin. Pure market orders are fine for liquid ETFs and dangerous on anything wide.

Model slippage before you send a single order

Assume you pay the spread plus square-root impact. This one function turns an order size into a realistic fill:

import numpy as np

def fill_price(mid, side, shares, adv, half_spread_bps=1.0, impact_coef=0.02):
    """side=+1 buy, -1 sell. impact_coef ~ one daily vol (Almgren square-root law)."""
    part = shares / adv                                  # participation rate
    slip = half_spread_bps/1e4 + impact_coef*np.sqrt(part)
    return mid * (1 + side*slip), slip*1e4               # (fill price, slippage in bps)

for shares in [1_000, 10_000, 100_000, 500_000]:
    px, bps = fill_price(100.0, +1, shares, adv=1_000_000)
    print(f"{shares/1e6:5.1%} of ADV -> {bps:6.1f} bps")
# 0.1% ->   7.3 bps | 1.0% ->  21.0 | 10.0% ->  64.2 | 50.0% -> 142.4

Cost grows with the square root of participation: trading 100x the size costs only ~10x the slippage, but it still costs, and it caps how much capital the strategy can hold (Portfolio Capacity). Anything above a few percent of ADV should be sliced over time with a TWAP/VWAP/POV schedule; this is the whole subject of Optimal Execution.

Execution lag: fast alpha is fragile

Your live loop acts after the bar it decided on. For a slow signal this is harmless; for a fast one it is fatal. A mean-reverting series whose reversal edge lives entirely at a one-bar horizon:

rng = np.random.default_rng(60); import pandas as pd
m = 3000; eps = rng.normal(0, 0.01, m); rr = np.zeros(m)
for t in range(1, m):
    rr[t] = -0.15*rr[t-1] + eps[t]
mr = pd.Series(rr)
def sr(r): r=pd.Series(r).dropna(); return (r.mean()/r.std())*np.sqrt(252)
for lag in [1, 2, 3]:
    pos = (-np.sign(mr.shift(lag))).fillna(0.0)          # fade, executed `lag` bars later
    print(lag, round(sr(pos*mr), 2))
# 1 -> 1.66  |  2 -> 0.04  |  3 -> 0.07

One extra bar of delay collapses a Sharpe of 1.66 to 0.04. If your edge decays this fast, you are in a latency game (Latency & High-Frequency Trading) you will lose against professionals; retail should trade signals whose alpha survives minutes-to-days of lag.

Backtest vs live: implementation shortfall

Implementation shortfall is the gap between the price your backtest assumed (the decision-time close) and the price you actually got. It scales with turnover. Same drifting series, a slow and a fast trend rule, 8 bps of slippage per unit traded:

rng = np.random.default_rng(5)
price = pd.Series(100*np.exp(np.cumsum(rng.normal(0.0004, 0.01, 1500))))
ret = price.pct_change(fill_method=None).fillna(0.0)
ann = lambda r: (1+r).prod()**(252/len(r)) - 1
for name, fa, sl in [("slow 20/60", 20, 60), ("fast  3/10", 3, 10)]:
    pos = (price.rolling(fa).mean() > price.rolling(sl).mean()).astype(float).shift(1).fillna(0)
    turn = pos.diff().abs().fillna(0.0)
    bt = pos*ret; live = bt - turn*8/1e4
    print(name, f"backtest={ann(bt):.2%}  live={ann(live):.2%}  short={ann(bt)-ann(live):.2%}")
# slow: backtest=14.28% live=13.91% short=0.37%   fast: backtest=10.98% live=8.45% short=2.53%

The slow rule loses 0.37%/yr to shortfall; the fast rule loses 2.53%/yr and ends up below the slow rule despite a comparable backtest. Turnover, not gross return, decides how much of your paper edge survives contact with the market.

Reconciliation and idempotency

A live system crashes, restarts, and retries on network errors. Two disciplines keep that from wrecking you.

Reconcile to the broker's truth, not your assumption. On every cycle, fetch actual positions and trade the difference to target:

target = {"AAPL": 100, "MSFT": 50, "SPY": -30}      # what we intend to hold
broker = {"AAPL": 100, "MSFT": 40, "TSLA": 10}      # what the broker actually reports
orders = {s: target.get(s, 0) - broker.get(s, 0)
          for s in set(target) | set(broker)
          if target.get(s, 0) - broker.get(s, 0) != 0}
print(orders)   # {'MSFT': 10, 'SPY': -30, 'TSLA': -10}  <- note the stray TSLA gets flattened

Make every submit idempotent. Attach a deterministic client_order_id (e.g. date:symbol:purpose) so a retried request is a no-op, not a double order:

class Broker:
    def __init__(self): self.seen = set()
    def submit(self, client_order_id, symbol, qty):
        if client_order_id in self.seen:            # already accepted -> ignore
            return "duplicate-ignored"
        self.seen.add(client_order_id); return "accepted"

bk = Broker(); coid = "2016-01-05:AAPL:rebal"
print(bk.submit(coid, "AAPL", 10))   # accepted
print(bk.submit(coid, "AAPL", 10))   # duplicate-ignored  <- retry placed 0 extra orders

Without these two, a single restart can leave you double-positioned or fighting a phantom TSLA line you never meant to hold.

Risk of ruin, and why most retail algo fails

Most retail algos do not fail because the signal was fake, they fail because the sizing guaranteed eventual ruin. Model your strategy as bets of one unit each, won with probability p>0.5p>0.5; starting with UU units, the probability of ever going broke is

R=(1pp)U.R = \left(\frac{1-p}{p}\right)^{U}.

def mc_ruin(p, U, trials=20000, maxsteps=200000):
    rng = np.random.default_rng(123); ruined = 0
    for _ in range(trials):
        w = U
        for _ in range(maxsteps):
            w += 1 if rng.random() < p else -1
            if w <= 0: ruined += 1; break
            if w >= 20*U: break
    return ruined/trials
for p, U in [(0.55, 10), (0.55, 20), (0.60, 10)]:
    print(p, U, round(((1-p)/p)**U, 4), round(mc_ruin(p, U), 4))
# 0.55 10 -> 0.1344 vs MC 0.1350 | 0.55 20 -> 0.0181 vs 0.0177 | 0.60 10 -> 0.0173 vs 0.0166

Closed form and simulation agree. The lesson: thin capital (small UU) makes ruin likely even with a real edge, a 55% edge with only 10 units of buffer still busts ~13% of the time. Leverage makes it worse. Betting a multiple of the Kelly fraction (f=p(1p)/bf^* = p - (1-p)/b) turns a winning game into a losing one:

p, b = 0.53, 1.0; kelly = p - (1-p)/b            # f* = 0.06
rng = np.random.default_rng(9)
for mult in [0.5, 1.0, 2.0, 3.0]:
    f = mult*kelly; finals = []
    for _ in range(3000):
        w = 1.0
        for _ in range(1000):
            w *= (1 + f*b) if rng.random() < p else (1 - f)
            if w < 1e-9: w = 0.0; break            # bankrupt -> absorbing
        finals.append(w)
    finals = np.array(finals)
    print(f"{mult}x Kelly: median={np.median(finals):.3g}  P(down 90%)={(finals<0.1).mean():.0%}")
# 0.5x median=3.86 P=0%  | 1.0x median=6.06 P=1%  | 2.0x median=1.25 P=26%  | 3.0x median=0.004 P=70%

At 2x Kelly the median account barely grows and 26% of paths lose 90%; at 3x, 70% are all but wiped out. This is why "fractional Kelly" (¼ to ½) is the retail default, see Position Sizing. Size for survival first; returns are the reward for still being in the game.

Monitoring and kill-switches

A live algo must fail closed. Wire these before you fund the account:

  • Heartbeat & staleness, if the loop or the data feed stalls, flatten and alert.
  • Hard limits, max gross/net exposure, max position per name, max order rate; reject anything that breaches them.
  • Drawdown kill-switch, halt new orders (and optionally de-risk) past a preset daily/total loss.
  • Reconciliation alarm, any mismatch between intended and broker positions pages you immediately.
  • Manual override, a single command that cancels all and goes flat.

The progression is the whole point: a bias-free backtest is a hypothesis, paper trading tests your plumbing, and live trading, small, monitored, correctly sized, is the only thing that tests your edge. Start with the smallest size that makes the P&L real, keep costs and shortfall in view, and let the account grow only as the live curve confirms the backtest, never before.

The theory behind it

Related strategies

Further reading

  • Alpaca Trading API, Paper Trading & Orders documentation
  • Interactive Brokers, TWS API documentation
  • ccxt, Unified Crypto Exchange API documentation
  • Kissell, The Science of Algorithmic Trading and Portfolio Management (2013)
  • Marcos López de Prado, Advances in Financial Machine Learning (2018)

Code is for education, not investment advice. Test everything on paper before risking capital.