Skip to content

Smart Order Routing (SOR)

Overview

Smart Order Routing (SOR) automatically directs orders to the trading venue offering the best execution. In fragmented markets (like US equities with 16+ exchanges), SOR is essential for achieving best execution and minimizing market impact.

Difficulty advanced

Market Fragmentation

US Equity Exchanges

Exchanges (16+):
- NYSE, NYSE Arca, NYSE American
- NASDAQ, NASDAQ BX, NASDAQ PSX
- CBOE EDGX, CBOE EDGA, CBOE BYX, CBOE BZX
- IEX, MEMX, LTSE
- MIAX Pearl, FINRA ADF

Each has:
- Different fee structures (maker-taker, inverted)
- Different liquidity pools
- Different order types
- Different latency characteristics

NBBO (National Best Bid and Offer)

NBBO = Best bid across all exchanges | Best ask across all exchanges

Example:
NYSE:     Bid $150.00 × 500   Ask $150.05 × 300
NASDAQ:   Bid $150.01 × 200   Ask $150.04 × 400
CBOE:     Bid $149.99 × 800   Ask $150.06 × 100

NBBO: Bid $150.01 (NASDAQ) | Ask $150.04 (NASDAQ)

sor decision logic

Routing Priorities

1. Price: Route to venue with best price first
2. Liquidity: Route to venue with most size at best price
3. Fees: Consider maker-taker rebates
4. Fill probability: Historical fill rates at each venue
5. Speed: Expected time to fill
6. Hidden liquidity: Check for mid-point/dark pools

Fee Structure Impact

Maker-Taker Model:
- Maker (adding liquidity): Receive rebate ($0.0020-0.0030/share)
- Taker (removing liquidity): Pay fee ($0.0028-0.0032/share)

Inverted Model:
- Maker: Pay fee
- Taker: Receive rebate

Effective Cost = Displayed Spread + Fees - Rebates

Example:
Stock spread: $0.01
Taker fee: $0.0030
Effective cost: $0.0130

With rebate:
Effective cost: $0.0130 - $0.0020 = $0.0110

where: Displayed Spread quoted bid-ask difference · Fees taker fee on the venue you hit · Rebates maker rebate if you posted. does: the per-share economics of crossing vs. posting on a fragmented venue. SOR uses this to choose between sweeping at the top of book (pay taker fees), posting passive (collect rebate), or routing to a dark/midpoint pool (no fee, no rebate, midpoint price). Choose SOR when the parent order is liquidity-driven and you need real-time venue selection rather than a fixed schedule.

Python Implementation

import numpy as np
import pandas as pd
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass

@dataclass
class Venue:
    """Trading venue characteristics."""
    name: str
    bid: float
    bid_size: int
    ask: float
    ask_size: int
    maker_rebate: float  # Negative = fee
    taker_fee: float
    fill_rate: float  # Historical fill probability
    avg_fill_time_ms: float  # Average time to fill

@dataclass
class Order:
    """Order to be routed."""
    side: int  # 1=buy, 2=sell
    symbol: str
    quantity: int
    limit_price: float = None
    order_type: str = 'limit'  # 'market', 'limit'

class SmartOrderRouter:
    """Smart order routing engine."""

    def __init__(self, venues: List[Venue]):
        self.venues = venues

    def get_nbbo(self) -> Tuple[float, int, float, int]:
        """Get National Best Bid and Offer."""
        best_bid = max(self.venues, key=lambda v: v.bid)
        best_ask = min(self.venues, key=lambda v: v.ask)

        return (best_bid.bid, best_bid.bid_size,
                best_ask.ask, best_ask.ask_size)

    def calculate_effective_cost(self, venue: Venue, side: int,
                                  price: float) -> float:
        """Calculate effective cost including fees/rebates."""
        if side == 1:  # Buy
            # Taker fee when buying at ask
            fee = venue.taker_fee
            effective_spread = venue.ask - price + fee
        else:  # Sell
            # Taker fee when selling at bid
            fee = venue.taker_fee
            effective_spread = price - venue.bid + fee

        return effective_spread

    def score_venue(self, venue: Venue, order: Order,
                    priority_weights: Dict[str, float] = None) -> float:
        """Score venue for order routing."""
        if priority_weights is None:
            priority_weights = {
                'price': 0.40,
                'liquidity': 0.25,
                'fees': 0.15,
                'fill_probability': 0.10,
                'speed': 0.10
            }

        nbbo = self.get_nbbo()
        score = 0.0

        # Price score (how close to NBBO)
        if order.side == 1:  # Buy
            price_diff = venue.ask - nbbo[2]
        else:
            price_diff = nbbo[0] - venue.bid

        price_score = max(0, 1 - price_diff / 0.05)  # Normalize
        score += priority_weights['price'] * price_score

        # Liquidity score
        available = venue.bid_size if order.side == 2 else venue.ask_size
        liquidity_score = min(1.0, available / order.quantity)
        score += priority_weights['liquidity'] * liquidity_score

        # Fee score
        effective_cost = self.calculate_effective_cost(venue, order.side,
                                                        nbbo[2] if order.side == 1 else nbbo[0])
        fee_score = max(0, 1 - effective_cost / 0.05)
        score += priority_weights['fees'] * fee_score

        # Fill probability
        score += priority_weights['fill_probability'] * venue.fill_rate

        # Speed score (inverse of fill time)
        speed_score = max(0, 1 - venue.avg_fill_time_ms / 100)
        score += priority_weights['speed'] * speed_score

        return score

    def route_order(self, order: Order) -> List[Dict]:
        """Route order across venues for optimal execution."""
        remaining = order.quantity
        routes = []

        while remaining > 0:
            # Score all venues
            scores = [(v, self.score_venue(v, order)) for v in self.venues]
            scores.sort(key=lambda x: x[1], reverse=True)

            best_venue = scores[0][0]

            # Check if venue has liquidity
            available = (best_venue.bid_size if order.side == 2 
                        else best_venue.ask_size)

            if available <= 0:
                break  # No liquidity at any venue

            # Route to best venue
            route_qty = min(remaining, available)

            # Calculate effective price
            if order.side == 1:  # Buy
                exec_price = best_venue.ask
            else:
                exec_price = best_venue.bid

            routes.append({
                'venue': best_venue.name,
                'price': exec_price,
                'quantity': route_qty,
                'side': 'buy' if order.side == 1 else 'sell',
                'estimated_fee': route_qty * best_venue.taker_fee,
                'estimated_rebate': route_qty * best_venue.maker_rebate
            })

            remaining -= route_qty

            # Remove venue from consideration (simulate fill)
            if order.side == 1:
                best_venue.ask_size -= route_qty
            else:
                best_venue.bid_size -= route_qty

        return routes

    def sweep_order(self, order: Order, max_venues: int = 5) -> List[Dict]:
        """Sweep order across multiple venues simultaneously."""
        remaining = order.quantity
        routes = []

        # Sort venues by price
        if order.side == 1:  # Buy - ascending ask prices
            sorted_venues = sorted(self.venues, key=lambda v: v.ask)
        else:  # Sell - descending bid prices
            sorted_venues = sorted(self.venues, key=lambda v: v.bid, reverse=True)

        venues_used = 0
        for venue in sorted_venues:
            if remaining <= 0 or venues_used >= max_venues:
                break

            available = (venue.bid_size if order.side == 2 
                        else venue.ask_size)

            if available > 0:
                route_qty = min(remaining, available)

                exec_price = venue.ask if order.side == 1 else venue.bid

                routes.append({
                    'venue': venue.name,
                    'price': exec_price,
                    'quantity': route_qty,
                    'side': 'buy' if order.side == 1 else 'sell'
                })

                remaining -= route_qty
                venues_used += 1

        return routes


def analyze_routing_performance(routes: List[Dict],
                                 arrival_price: float) -> Dict:
    """Analyze SOR execution quality."""
    total_shares = sum(r['quantity'] for r in routes)
    total_value = sum(r['price'] * r['quantity'] for r in routes)
    avg_price = total_value / total_shares

    total_fees = sum(r.get('estimated_fee', 0) for r in routes)
    total_rebates = sum(r.get('estimated_rebate', 0) for r in routes)

    # Implementation shortfall
    side = 1 if routes[0]['side'] == 'buy' else -1
    shortfall = (avg_price - arrival_price) * side * total_shares

    # Venues used
    venue_count = len(set(r['venue'] for r in routes))

    return {
        'avg_execution_price': round(avg_price, 4),
        'arrival_price': arrival_price,
        'total_shares': total_shares,
        'implementation_shortfall': round(shortfall, 2),
        'is_bps': round((shortfall / (arrival_price * total_shares)) * 10000, 2),
        'total_fees': round(total_fees, 2),
        'total_rebates': round(total_rebates, 2),
        'net_fees': round(total_fees + total_rebates, 2),
        'venues_used': venue_count,
        'n_routes': len(routes)
    }

Mid-Point and Dark Pool Routing

class DarkPoolRouter:
    """Route to dark pools and mid-point venues."""

    def __init__(self, dark_pools: List[Venue]):
        self.dark_pools = dark_pools

    def find_midpoint_opportunity(self, nbbo_bid: float, 
                                   nbbo_ask: float) -> List[Dict]:
        """Find mid-point execution opportunities."""
        midpoint = (nbbo_bid + nbbo_ask) / 2

        opportunities = []
        for pool in self.dark_pools:
            # Dark pools often execute at midpoint
            if pool.bid <= midpoint <= pool.ask:
                opportunities.append({
                    'venue': pool.name,
                    'midpoint': midpoint,
                    'spread_savings': (nbbo_ask - nbbo_bid) / 2,
                    'available_size': min(pool.bid_size, pool.ask_size)
                })

        return opportunities

Best Execution Obligations

Regulatory Requirements

SEC Rule 606 (US):
- Brokers must disclose order routing practices
- Quarterly reports on routing destinations
- Payment for order flow disclosure

MiFID II (EU):
- Best execution obligation
- Pre-trade and post-trade transparency
- Systematic Internalizer regime

Broker duties:
1. Price (primary factor)
2. Speed of execution
3. Likelihood of execution
4. Likelihood of settlement
5. Size and nature of order
6. Transaction costs

Checklist

  • [ ] All relevant venues included in routing
  • [ ] Fee structures accurately modeled
  • [ ] NBBO continuously monitored
  • [ ] Fill probability data current
  • [ ] Dark pool/mid-point opportunities considered
  • [ ] Latency to each venue measured
  • [ ] Routing logic periodically reviewed
  • [ ] Best execution compliance documented
  • [ ] Post-trade TCA (Transaction Cost Analysis) performed
  • [ ] Conflicts of interest disclosed (PFOF)

References

  1. SEC. (2023). "Rule 606: Disclosure of Order Routing Information." Securities and Exchange Commission.
  2. Hendershott, T. & Riordan, R. (2013). "Exchange Architecture and Liquidity." Journal of Financial Markets, 16(3), 423-451.
  3. Foucault, T., Kadan, O., & Kandel, E. (2013). "Liquidity Cycles and Make/Take Fees in Electronic Markets." Journal of Finance, 68(1), 299-341.