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 Spreadquoted bid-ask difference ·Feestaker fee on the venue you hit ·Rebatesmaker 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¶
- SEC. (2023). "Rule 606: Disclosure of Order Routing Information." Securities and Exchange Commission.
- Hendershott, T. & Riordan, R. (2013). "Exchange Architecture and Liquidity." Journal of Financial Markets, 16(3), 423-451.
- Foucault, T., Kadan, O., & Kandel, E. (2013). "Liquidity Cycles and Make/Take Fees in Electronic Markets." Journal of Finance, 68(1), 299-341.