Skip to content
Course contents
From analysis to automation

Never test with real money

The rule that keeps you solvent while learning is simple. Build and test everything against a simulated broker first, never a live one. This chapter sets up the workbench, states the sandbox-first rule, and introduces the small simulated broker that every runnable example in the course drives, so you learn order handling and live risk with real code and zero rupees at stake.

10 min readChapter 4 of 28
What you will learn
  • Explain the sandbox-first rule and why it is not negotiable
  • Understand the simulated broker the whole course uses and what it models
  • Distinguish a sandbox from both a backtest and live trading

No airline lets a trainee pilot learn on a full flight. They spend hundreds of hours in a simulator first, crashing harmlessly, until the controls are second nature. Every serious field that mixes skill with danger does the same. Surgeons rehearse, drivers start off the road. Trading is the strange exception, where beginners routinely skip the simulator and go straight to live money, and it is one reason so many crash. This course does not let you do that. From here to nearly the last page, every line of trading code you write runs against a simulated broker, not a real one.

The sandbox-first rule

The sandbox-first rule: run the identical strategy code against a paper broker that simulates fills, with no real money, before ever going live.
The sandbox-first rule: run the identical strategy code against a paper broker that simulates fills, with no real money, before ever going live.

A sandbox is a safe, walled-off copy of the real thing, where actions have no real consequences. A simulated broker is a sandbox for trading: a program that behaves like a broker, accepting your orders, filling them, and tracking your positions and funds, but with make-believe money. You can place a thousand orders, make every mistake once, and lose nothing.

The rule for this course, and for your own trading long after it, is this. Test against the sandbox until the system has genuinely earned your trust, and only then, if ever, connect it to real money. This is not timidity. It is how you find the bug where your loop places an order every second instead of every day, or the one where a crash leaves a position open, while those bugs are still free to find. A trading program has more ways to go wrong than any code you have written, and every one of them costs money when it goes wrong live. The sandbox is where they cost nothing.

The simulated broker this course uses

Throughout this course you will drive a small simulated broker, a module we will simply call the simulated broker (its class is named PaperBroker in the code). Here it is in full. You do not need to understand every line before you use it. You need to trust that it behaves like a broker and that it cannot touch a real rupee.

ExampleThe simulated broker that every example in this course drivesch04/paper_broker.py
# paper_broker.py
# A simulated broker for learning. It accepts orders, fills them, and tracks
# positions and funds. It touches NO real money and needs NO login. Every
# runnable example in this course drives this paper broker, so you can practise
# order handling and live-risk code with real output and zero risk.
#
# This is a teaching simulator, and it is deliberately simplified. A real broker
# models a full order book, margins, and network latency. Here a market order
# fills at the last known price and a limit order fills when the price reaches
# it. No specific broker is named or endorsed; the method names are the common
# shape a broker API tends to have (funds, positions, quotes, place an order).
#
# You do not need to understand every line before using it. You need to trust
# that it behaves roughly like a broker and that it cannot spend a rupee.

from dataclasses import dataclass
from itertools import count
from typing import Optional, List, Dict


@dataclass
class Order:
    order_id: int
    client_order_id: str
    symbol: str
    side: str                 # "BUY" or "SELL"
    quantity: int
    order_type: str           # "MARKET" or "LIMIT"
    price: Optional[float]    # limit price, or None for a market order
    status: str = "PENDING"   # PENDING, OPEN, PARTIALLY_FILLED, FILLED, REJECTED, CANCELLED
    filled_quantity: int = 0
    average_price: float = 0.0
    message: str = ""         # reason for a rejection, and so on

    @property
    def remaining(self) -> int:
        return self.quantity - self.filled_quantity


class PaperBroker:
    def __init__(self, cash: float = 1_000_000, prices: Optional[Dict[str, float]] = None,
                 cost_bps: float = 0.0, slippage_bps: float = 0.0,
                 liquidity: Optional[int] = None):
        # cash: starting funds in rupees.
        # prices: {symbol: last_price} the simulator knows about.
        # cost_bps, slippage_bps: illustrative costs in basis points (1 bp = 0.01%).
        #   Both default to 0 so the early examples are exact; later chapters
        #   turn them on to study slippage (chapter 19) and cost (chapter 21).
        # liquidity: shares fillable per price event (None means fill fully);
        #   later chapters use it to study partial fills (chapter 11).
        self.cash = float(cash)
        self.prices = dict(prices or {})
        self.cost_bps = cost_bps
        self.slippage_bps = slippage_bps
        self.liquidity = liquidity
        self.positions: Dict[str, Dict[str, float]] = {}  # symbol -> {quantity, avg_price}
        self.orders: Dict[int, Order] = {}
        self._ids = count(1)
        self._client_ids = count(1)

    # ----- reads (always safe to call) -----
    def get_funds(self) -> Dict[str, float]:
        return {"available_cash": round(self.cash, 2)}

    def get_positions(self) -> List[Dict]:
        return [{"symbol": s, "quantity": int(p["quantity"]),
                 "avg_price": round(p["avg_price"], 2)}
                for s, p in self.positions.items() if p["quantity"] != 0]

    def get_holdings(self) -> List[Dict]:
        # In this simulator, holdings mirror positions. A real broker separates
        # today's intraday positions from settled, delivered holdings.
        return self.get_positions()

    def get_quote(self, symbol: str) -> Optional[Dict]:
        last = self.prices.get(symbol)
        if last is None:
            return None
        half_spread = last * self.slippage_bps / 10_000 / 2  # a tiny illustrative spread
        return {"symbol": symbol, "last_price": round(last, 2),
                "bid": round(last - half_spread, 2), "ask": round(last + half_spread, 2)}

    def get_order(self, order_id: int) -> Optional[Order]:
        return self.orders.get(order_id)

    def get_orders(self) -> List[Order]:
        return list(self.orders.values())

    # ----- writes -----
    def place_order(self, symbol: str, side: str, quantity: int,
                    order_type: str = "MARKET", price: Optional[float] = None,
                    client_order_id: Optional[str] = None) -> Order:
        oid = next(self._ids)
        coid = client_order_id or f"c{next(self._client_ids)}"
        order = Order(oid, coid, symbol, side.upper(), int(quantity),
                      order_type.upper(), price)
        self.orders[oid] = order

        # validate the order before it can do anything
        if order.quantity <= 0:
            return self._reject(order, "quantity must be positive")
        if order.order_type == "LIMIT" and order.price is None:
            return self._reject(order, "a limit order needs a price")
        last = self.prices.get(symbol)
        if order.order_type == "MARKET":
            if last is None:
                return self._reject(order, "no price known for symbol")
            if order.side == "BUY" and last * order.quantity > self.cash:
                return self._reject(order, "insufficient funds")
            order.status = "OPEN"
            self._fill(order, last)
        else:  # LIMIT: rest until the price reaches it, or fill now if already there
            order.status = "OPEN"
            if last is not None and self._crosses(order, last):
                self._fill(order, order.price)
        return order

    def modify_order(self, order_id: int, price: Optional[float] = None,
                     quantity: Optional[int] = None) -> Optional[Order]:
        order = self.orders.get(order_id)
        if order is None or order.status in ("FILLED", "CANCELLED", "REJECTED"):
            return order
        if price is not None:
            order.price = price
        if quantity is not None:
            order.quantity = int(quantity)
        return order

    def cancel_order(self, order_id: int) -> Optional[Order]:
        order = self.orders.get(order_id)
        if order and order.status in ("OPEN", "PARTIALLY_FILLED"):
            order.status = "CANCELLED"
        return order

    def feed_price(self, symbol: str, price: float) -> List[Order]:
        # Move the market and try to fill any resting limit orders on this symbol.
        # Returns the orders that got a fill from this price.
        self.prices[symbol] = float(price)
        filled = []
        for order in self.orders.values():
            if (order.symbol == symbol and order.order_type == "LIMIT"
                    and order.status in ("OPEN", "PARTIALLY_FILLED")
                    and self._crosses(order, price)):
                if self._fill(order, order.price) > 0:
                    filled.append(order)
        return filled

    # ----- internals -----
    def _reject(self, order: Order, reason: str) -> Order:
        order.status = "REJECTED"
        order.message = reason
        return order

    def _crosses(self, order: Order, price: float) -> bool:
        # A buy limit fills when the price falls to it; a sell limit when it rises.
        return price <= order.price if order.side == "BUY" else price >= order.price

    def _fill(self, order: Order, price: float) -> int:
        qty = order.remaining
        # Limited liquidity fills a resting limit order a slice at a time; a
        # market order takes what it needs at once (this simulator has no book).
        if self.liquidity is not None and order.order_type == "LIMIT":
            qty = min(qty, self.liquidity)
        if qty <= 0:
            return 0
        # slippage moves the fill against whoever is taking liquidity
        slip = price * self.slippage_bps / 10_000
        fill_price = price + slip if order.side == "BUY" else price - slip

        # blend this fill into the order's running average fill price
        prev = order.filled_quantity
        order.filled_quantity += qty
        order.average_price = (order.average_price * prev + fill_price * qty) / order.filled_quantity
        order.status = "FILLED" if order.remaining == 0 else "PARTIALLY_FILLED"

        # update cash and the position
        signed = qty if order.side == "BUY" else -qty
        self.cash -= fill_price * signed                       # BUY spends cash, SELL adds
        self.cash -= abs(fill_price * qty) * self.cost_bps / 10_000
        self._apply_position(order.symbol, signed, fill_price)
        return qty

    def _apply_position(self, symbol: str, signed_qty: int, price: float):
        pos = self.positions.setdefault(symbol, {"quantity": 0, "avg_price": 0.0})
        old = pos["quantity"]
        new = old + signed_qty
        if old == 0 or (old > 0) == (signed_qty > 0):
            # opening, or adding in the same direction: blend the average price
            if new != 0:
                pos["avg_price"] = (pos["avg_price"] * old + price * signed_qty) / new
        elif abs(signed_qty) > abs(old):
            # sold or bought through flat into the other direction
            pos["avg_price"] = price
        # simply reducing toward flat keeps the existing average price
        if new == 0:
            pos["avg_price"] = 0.0
        pos["quantity"] = new

It offers the handful of things a real broker's programming interface offers, under names you will meet again. You read your funds with get_funds, your positions with get_positions, and a price with get_quote. You place, change, or cancel an order with place_order, modify_order, and cancel_order. When you place a market order, an order to trade now at the best available price, it fills at the last known price. When you place a limit order, an order to trade only at a set price or better, it waits until the price reaches your level. It keeps track of your cash and your holdings as the fills happen. A few of its settings, small costs and slippage and limited liquidity, are switched off for now and turned on in later chapters when you study those effects. It is deliberately simple. A real broker models a full order book, margins, and the delays of the network, none of which this simulator pretends to. But it is enough to learn every idea in this course safely.

A first run

Watch it work. This short program checks your funds, buys 100 shares of an illustrative Reliance at 1,400 rupees, and looks at what changed.

ExampleA first order against the simulated brokerch04/sandbox_demo.py
# A first run against the paper broker: check funds, buy, see the fill,
# see the position and the funds change. No login, no real money.
from paper_broker import PaperBroker

# Start with 10 lakh rupees and one price the simulator knows.
broker = PaperBroker(cash=1_000_000, prices={"RELIANCE": 1400.0})

print("Funds before:", broker.get_funds())

order = broker.place_order("RELIANCE", "BUY", 100, order_type="MARKET")
print(f"Order {order.order_id} [{order.client_order_id}]: {order.status}, "
      f"filled {order.filled_quantity} at avg {order.average_price:.2f}")

print("Positions:  ", broker.get_positions())
print("Funds after:", broker.get_funds())

# Read-only calls are always safe: ask the simulator for a quote.
print("Quote:      ", broker.get_quote("RELIANCE"))
Output
Funds before: {'available_cash': 1000000.0}
Order 1 [c1]: FILLED, filled 100 at avg 1400.00
Positions:   [{'symbol': 'RELIANCE', 'quantity': 100, 'avg_price': 1400.0}]
Funds after: {'available_cash': 860000.0}
Quote:       {'symbol': 'RELIANCE', 'last_price': 1400.0, 'bid': 1400.0, 'ask': 1400.0}

The output tells the whole story. You start with 10,00,000 rupees. The order comes back FILLED, 100 shares at an average price of 1,400. Your position is now 100 shares of Reliance, and your cash has fallen by 100 times 1,400, to 8,60,000. No login, no credentials, no risk, and yet every number behaves exactly as a real account would. This is where you will build things, and break them, for the rest of the course.

Sandbox, backtest, and live are three different things

It is worth separating three things that beginners blur together. A backtest, which you met in Python for Trading, runs a strategy over old, finished data all at once, to ask how it would have done. The sandbox here runs your actual live code, order by order, against a simulated broker in the present, to ask whether the machinery works. Live trading runs that same code against a real broker with real money.

They answer different questions. A backtest tests the idea. The sandbox tests the plumbing. Live trading tests your nerve and your luck. A strategy must pass the first two convincingly before the third is even a conversation, and this course keeps you in the first two the entire way.

What to carry forward

You now have the workbench and the one habit that protects you while you learn: everything runs against a simulated broker, a sandbox with make-believe money, never a live account. The simulated broker gives you the same handful of actions a real one does, read funds and positions, get a quote, place and cancel orders, and you saw a first order fill and move your cash exactly as a real account would. Keep the three ideas separate: a backtest tests the idea, the sandbox tests the machinery, live trading risks real money, and nothing skips the first two. With the workbench ready, the next part connects to the broker in earnest, starting with what a broker's programming interface actually is.