Understanding Market Microstructure in Indian Markets

Deep dive into order books, bid-ask spreads, market impact, and liquidity dynamics specific to NSE and BSE trading.

You place a market order for 1000 shares of Reliance. The quote shows ₹2,450 bid and ₹2,451 ask. But your order fills at ₹2,452.50. What happened? Welcome to market microstructure—the hidden mechanics of how orders actually execute.

Understanding market microstructure is crucial for algorithmic traders in India. It’s the difference between profitable strategies and those killed by execution costs.

Part 1: The Order Book

Anatomy of NSE Order Book

import pandas as pd
from datetime import datetime

class OrderBook:
    """
    Simulate NSE order book structure
    """
    
    def __init__(self, symbol: str):
        self.symbol = symbol
        self.bids = []  # Buy orders (price, quantity, timestamp)
        self.asks = []  # Sell orders (price, quantity, timestamp)
        self.trades = []
    
    def add_order(self, side: str, price: float, quantity: int):
        """Add order to book"""
        timestamp = datetime.now()
        order = {'price': price, 'quantity': quantity, 'timestamp': timestamp}
        
        if side == 'BUY':
            self.bids.append(order)
            # Sort bids: highest price first
            self.bids.sort(key=lambda x: x['price'], reverse=True)
        else:
            self.asks.append(order)
            # Sort asks: lowest price first
            self.asks.sort(key=lambda x: x['price'])
        
        # Try to match orders
        self.match_orders()
    
    def match_orders(self):
        """Match buy and sell orders"""
        while self.bids and self.asks:
            best_bid = self.bids[0]
            best_ask = self.asks[0]
            
            # If bid price >= ask price, we have a match
            if best_bid['price'] >= best_ask['price']:
                # Execute at ask price (price-time priority)
                trade_price = best_ask['price']
                trade_qty = min(best_bid['quantity'], best_ask['quantity'])
                
                # Record trade
                self.trades.append({
                    'price': trade_price,
                    'quantity': trade_qty,
                    'timestamp': datetime.now()
                })
                
                # Update quantities
                best_bid['quantity'] -= trade_qty
                best_ask['quantity'] -= trade_qty
                
                # Remove filled orders
                if best_bid['quantity'] == 0:
                    self.bids.pop(0)
                if best_ask['quantity'] == 0:
                    self.asks.pop(0)
            else:
                break
    
    def get_market_depth(self, levels: int = 5) -> dict:
        """
        Get market depth (L2 data)
        Similar to NSE Market Depth view
        """
        return {
            'bids': self.bids[:levels],
            'asks': self.asks[:levels],
            'bid_price': self.bids[0]['price'] if self.bids else None,
            'ask_price': self.asks[0]['price'] if self.asks else None,
            'spread': (self.asks[0]['price'] - self.bids[0]['price']) 
                     if self.bids and self.asks else None
        }
    
    def calculate_vwap(self, quantity: int, side: str) -> float:
        """
        Calculate VWAP for given quantity
        Useful for estimating execution price
        """
        orders = self.asks if side == 'BUY' else self.bids
        
        total_cost = 0
        total_qty = 0
        
        for order in orders:
            qty_from_level = min(quantity - total_qty, order['quantity'])
            total_cost += qty_from_level * order['price']
            total_qty += qty_from_level
            
            if total_qty >= quantity:
                break
        
        if total_qty < quantity:
            return None  # Insufficient liquidity
        
        return total_cost / total_qty

# Example: Build order book
book = OrderBook('RELIANCE')

# Add some orders
book.add_order('BUY', 2450, 100)
book.add_order('BUY', 2449, 200)
book.add_order('BUY', 2448, 150)

book.add_order('SELL', 2451, 100)
book.add_order('SELL', 2452, 200)
book.add_order('SELL', 2453, 150)

# Check market depth
depth = book.get_market_depth()
print(f"Best Bid: ₹{depth['bid_price']}")
print(f"Best Ask: ₹{depth['ask_price']}")
print(f"Spread: ₹{depth['spread']}")

# Estimate execution price for 250 shares
vwap = book.calculate_vwap(250, 'BUY')
print(f"VWAP for 250 shares: ₹{vwap:.2f}")

Market Depth Visualization

import matplotlib.pyplot as plt

def visualize_order_book(book: OrderBook):
    """
    Visualize order book depth
    """
    depth = book.get_market_depth(levels=10)
    
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
    
    # Bids (green)
    if depth['bids']:
        bid_prices = [b['price'] for b in depth['bids']]
        bid_qtys = [b['quantity'] for b in depth['bids']]
        
        ax1.barh(bid_prices, bid_qtys, color='#00ff00', alpha=0.7)
        ax1.set_xlabel('Quantity')
        ax1.set_ylabel('Price (₹)')
        ax1.set_title('Bids (Buy Orders)', fontweight='bold')
        ax1.grid(True, alpha=0.3)
    
    # Asks (red)
    if depth['asks']:
        ask_prices = [a['price'] for a in depth['asks']]
        ask_qtys = [a['quantity'] for a in depth['asks']]
        
        ax2.barh(ask_prices, ask_qtys, color='#ff0000', alpha=0.7)
        ax2.set_xlabel('Quantity')
        ax2.set_ylabel('Price (₹)')
        ax2.set_title('Asks (Sell Orders)', fontweight='bold')
        ax2.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()

visualize_order_book(book)

Part 2: Bid-Ask Spread Analysis

Spread Dynamics in Indian Markets

class SpreadAnalyzer:
    """
    Analyze bid-ask spread patterns
    """
    
    def __init__(self):
        self.spread_data = []
    
    def calculate_spread_metrics(
        self,
        bid: float,
        ask: float,
        mid_price: float = None
    ) -> dict:
        """
        Calculate various spread metrics
        """
        if mid_price is None:
            mid_price = (bid + ask) / 2
        
        # Absolute spread
        absolute_spread = ask - bid
        
        # Relative spread (%)
        relative_spread = (absolute_spread / mid_price) * 100
        
        # Effective spread (for actual trades)
        # Typically half of quoted spread
        effective_spread = absolute_spread / 2
        
        return {
            'absolute_spread': absolute_spread,
            'relative_spread_pct': relative_spread,
            'effective_spread': effective_spread,
            'mid_price': mid_price
        }
    
    def estimate_liquidity_cost(
        self,
        quantity: int,
        bid: float,
        ask: float,
        side: str
    ) -> dict:
        """
        Estimate cost of immediate execution
        """
        mid_price = (bid + ask) / 2
        
        if side == 'BUY':
            execution_price = ask
            slippage = execution_price - mid_price
        else:
            execution_price = bid
            slippage = mid_price - execution_price
        
        total_cost = quantity * slippage
        cost_pct = (slippage / mid_price) * 100
        
        return {
            'execution_price': execution_price,
            'mid_price': mid_price,
            'slippage_per_share': slippage,
            'total_cost': total_cost,
            'cost_pct': cost_pct
        }
    
    def analyze_spread_by_time(self, market_data: pd.DataFrame):
        """
        Analyze spread variation throughout trading day
        
        Spreads typically:
        - Wider at market open (9:15-9:30)
        - Tightest during peak hours (11:00-14:00)
        - Wider near close (15:15-15:30)
        """
        market_data['hour'] = market_data.index.hour
        market_data['spread'] = market_data['ask'] - market_data['bid']
        market_data['spread_pct'] = (
            market_data['spread'] / 
            ((market_data['bid'] + market_data['ask']) / 2)
        ) * 100
        
        hourly_spread = market_data.groupby('hour')['spread_pct'].agg(['mean', 'std'])
        
        return hourly_spread

# Example usage
analyzer = SpreadAnalyzer()

# Morning trade (wider spread)
morning_cost = analyzer.estimate_liquidity_cost(
    quantity=100,
    bid=2450,
    ask=2452,  # ₹2 spread
    side='BUY'
)

print("Morning Trade (9:20 AM):")
print(f"Slippage: ₹{morning_cost['slippage_per_share']:.2f} per share")
print(f"Total Cost: ₹{morning_cost['total_cost']:.2f}")
print(f"Cost: {morning_cost['cost_pct']:.4f}%")

# Midday trade (tighter spread)
midday_cost = analyzer.estimate_liquidity_cost(
    quantity=100,
    bid=2450,
    ask=2450.50,  # ₹0.50 spread
    side='BUY'
)

print("\nMidday Trade (12:00 PM):")
print(f"Slippage: ₹{midday_cost['slippage_per_share']:.2f} per share")
print(f"Total Cost: ₹{midday_cost['total_cost']:.2f}")
print(f"Cost: {midday_cost['cost_pct']:.4f}%")

Spread Classification by Stock

def classify_liquidity(symbol: str, avg_spread_pct: float) -> str:
    """
    Classify stock by liquidity based on spread
    """
    if avg_spread_pct < 0.05:
        return "Highly Liquid"  # Nifty 50 blue chips
    elif avg_spread_pct < 0.10:
        return "Liquid"  # Nifty Next 50
    elif avg_spread_pct < 0.25:
        return "Moderately Liquid"  # Mid caps
    else:
        return "Illiquid"  # Small caps

# Typical spreads in Indian markets
spread_examples = {
    'RELIANCE': 0.02,    # 0.02% - Highly liquid
    'TCS': 0.03,         # 0.03% - Highly liquid
    'TATASTEEL': 0.08,   # 0.08% - Liquid
    'SMALLCAP': 0.30     # 0.30% - Illiquid
}

for stock, spread in spread_examples.items():
    liquidity = classify_liquidity(stock, spread)
    print(f"{stock}: {spread:.2f}% spread - {liquidity}")

Part 3: Market Impact

Price Impact Models

class MarketImpactModel:
    """
    Estimate market impact of large orders
    
    Based on Almgren-Chriss model adapted for Indian markets
    """
    
    def __init__(self):
        # Impact parameters (calibrated for NSE)
        self.permanent_impact_coeff = 0.1
        self.temporary_impact_coeff = 0.5
    
    def calculate_impact(
        self,
        order_size: int,
        adv: float,  # Average daily volume
        volatility: float,
        participation_rate: float = 0.10
    ) -> dict:
        """
        Calculate market impact
        
        Parameters:
        - order_size: Number of shares to trade
        - adv: Average daily volume
        - volatility: Daily volatility
        - participation_rate: % of volume we represent
        """
        # Participation rate
        pr = order_size / adv
        
        # Permanent impact (price moves permanently)
        permanent_impact_pct = self.permanent_impact_coeff * np.sqrt(pr) * volatility
        
        # Temporary impact (price recovers after)
        temporary_impact_pct = self.temporary_impact_coeff * pr * volatility
        
        # Total impact
        total_impact_pct = permanent_impact_pct + temporary_impact_pct
        
        return {
            'permanent_impact_pct': permanent_impact_pct * 100,
            'temporary_impact_pct': temporary_impact_pct * 100,
            'total_impact_pct': total_impact_pct * 100,
            'participation_rate': pr * 100
        }
    
    def optimal_execution_time(
        self,
        order_size: int,
        adv: float,
        risk_aversion: float = 1e-6
    ) -> dict:
        """
        Calculate optimal execution horizon
        
        Trade-off: Execute fast (more impact) vs slow (more risk)
        """
        # Simplified model
        optimal_time_days = (order_size / adv) ** (1/3)
        
        return {
            'optimal_days': optimal_time_days,
            'recommended_strategy': self._get_strategy_recommendation(optimal_time_days)
        }
    
    def _get_strategy_recommendation(self, days: float) -> str:
        """Recommend execution strategy"""
        if days < 0.1:
            return "Market order (immediate execution)"
        elif days < 1:
            return "Intraday VWAP or TWAP"
        elif days < 5:
            return "Multi-day VWAP"
        else:
            return "Patient limit orders"

# Usage
impact_model = MarketImpactModel()

# Large order example
order_impact = impact_model.calculate_impact(
    order_size=50000,        # 50,000 shares
    adv=5000000,             # 50 lakh shares daily average
    volatility=0.02,         # 2% daily volatility
    participation_rate=0.10  # 10% of volume
)

print("Market Impact Analysis:")
print(f"Participation Rate: {order_impact['participation_rate']:.2f}%")
print(f"Permanent Impact: {order_impact['permanent_impact_pct']:.4f}%")
print(f"Temporary Impact: {order_impact['temporary_impact_pct']:.4f}%")
print(f"Total Impact: {order_impact['total_impact_pct']:.4f}%")

# Optimal execution
execution_plan = impact_model.optimal_execution_time(
    order_size=50000,
    adv=5000000
)

print(f"\nRecommended Execution Time: {execution_plan['optimal_days']:.2f} days")
print(f"Strategy: {execution_plan['recommended_strategy']}")

VWAP and TWAP Execution

class ExecutionAlgorithm:
    """
    VWAP and TWAP execution algorithms
    """
    
    def vwap_schedule(
        self,
        total_quantity: int,
        historical_volume_profile: pd.DataFrame
    ) -> pd.DataFrame:
        """
        Create VWAP execution schedule
        
        Distribute orders according to historical volume patterns
        """
        # Normalize volume profile
        total_volume = historical_volume_profile['volume'].sum()
        historical_volume_profile['volume_pct'] = (
            historical_volume_profile['volume'] / total_volume
        )
        
        # Distribute order quantity
        historical_volume_profile['order_qty'] = (
            total_quantity * historical_volume_profile['volume_pct']
        ).astype(int)
        
        return historical_volume_profile[['time', 'volume', 'order_qty']]
    
    def twap_schedule(
        self,
        total_quantity: int,
        start_time: str,
        end_time: str,
        interval_minutes: int = 15
    ) -> pd.DataFrame:
        """
        Create TWAP execution schedule
        
        Distribute orders evenly across time
        """
        # Calculate number of intervals
        start = pd.to_datetime(start_time)
        end = pd.to_datetime(end_time)
        
        time_range = pd.date_range(
            start=start,
            end=end,
            freq=f'{interval_minutes}min'
        )
        
        # Equal quantity per interval
        qty_per_interval = total_quantity // len(time_range)
        
        schedule = pd.DataFrame({
            'time': time_range,
            'order_qty': qty_per_interval
        })
        
        # Handle remainder
        remainder = total_quantity - (qty_per_interval * len(time_range))
        schedule.loc[0, 'order_qty'] += remainder
        
        return schedule

# Example: VWAP execution
# Historical volume profile (simplified)
volume_profile = pd.DataFrame({
    'time': ['09:15', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00'],
    'volume': [100000, 200000, 300000, 400000, 300000, 200000, 150000]
})

algo = ExecutionAlgorithm()

# Schedule 10,000 share order using VWAP
vwap_schedule = algo.vwap_schedule(10000, volume_profile)

print("\nVWAP Execution Schedule:")
print(vwap_schedule)

# Schedule using TWAP
twap_schedule = algo.twap_schedule(
    total_quantity=10000,
    start_time='09:15',
    end_time='15:30',
    interval_minutes=30
)

print("\nTWAP Execution Schedule:")
print(twap_schedule)

Part 4: Practical Implementation

Smart Order Router

class SmartOrderRouter:
    """
    Intelligent order routing for optimal execution
    """
    
    def __init__(self, impact_model: MarketImpactModel):
        self.impact_model = impact_model
    
    def route_order(
        self,
        quantity: int,
        urgency: str,  # 'immediate', 'normal', 'patient'
        current_spread: float,
        adv: float,
        volatility: float
    ) -> dict:
        """
        Determine best execution strategy
        """
        participation_rate = quantity / adv
        
        # Calculate impact
        impact = self.impact_model.calculate_impact(
            quantity, adv, volatility
        )
        
        # Decision logic
        if urgency == 'immediate':
            if impact['total_impact_pct'] > 0.5:
                return {
                    'strategy': 'Iceberg order with passive fills',
                    'reasoning': 'High impact - use hidden orders'
                }
            else:
                return {
                    'strategy': 'Market order',
                    'reasoning': 'Low impact - execute immediately'
                }
        
        elif urgency == 'normal':
            if participation_rate > 0.10:
                return {
                    'strategy': 'VWAP algorithm',
                    'reasoning': 'Large order - blend with volume'
                }
            else:
                return {
                    'strategy': 'Limit order at mid-price',
                    'reasoning': 'Normal size - seek price improvement'
                }
        
        else:  # patient
            return {
                'strategy': 'Passive limit orders over multiple days',
                'reasoning': 'Minimize impact with patient execution'
            }

# Example
router = SmartOrderRouter(impact_model)

decision = router.route_order(
    quantity=25000,
    urgency='normal',
    current_spread=0.50,
    adv=5000000,
    volatility=0.02
)

print("\nSmart Order Routing Decision:")
print(f"Strategy: {decision['strategy']}")
print(f"Reasoning: {decision['reasoning']}")

Conclusion

Market microstructure mastery is essential for algo traders:

  1. Order Book: Understand depth, price-time priority
  2. Spreads: Know when to cross spread vs wait
  3. Market Impact: Large orders need careful execution
  4. Execution Algos: VWAP, TWAP, smart routing

The difference between gross returns and net returns often lies in microstructure understanding.

Want to optimize your execution? Contact us for advanced execution consulting.