Market Microstructure·Trade Flow Analysis·Advanced

Aggressor Side Detection

Determine the aggressor side for each executed trade using tick-level trade price relative to contemporaneous best bid and offer quotes, distinguishing market buy orders lifting offers from market sell orders hitting bids for real-time trade flow classification.

market-microstructurepattern-recognitiontrade-flow

Detecting Aggressor Side Per Trade

Introduction to Aggressor Side Detection

In financial markets, every trade involves a buyer and a seller. However, not all market participants are equal in their 'aggressiveness'. One side, the aggressor, initiates the trade by accepting the prevailing price on the opposite side of the order book (e.g., a buyer hitting the ask or a seller hitting the bid).

Aggressor side detection is the process of identifying whether a trade was initiated by a buyer (buyer-initiated or 'buy aggressor') or a seller (seller-initiated or 'sell aggressor'). This information is crucial for understanding market sentiment, order flow dynamics, and for developing trading strategies, as it reveals the immediate pressure in the market.

Importance:

  • Market Microstructure Analysis: Provides insights into the immediate supply and demand dynamics.
  • Algorithmic Trading: Used in strategies that react to order flow, such as high-frequency trading.
  • Sentiment Analysis: A large imbalance of buy aggressors can indicate bullish sentiment, while sell aggressors suggest bearish sentiment.
  • Liquidity Assessment: Helps in understanding how aggressively liquidity is being consumed in the market.

Types of Aggressor Side Detection

Identifying the aggressor side typically relies on the trade price relative to the prevailing bid and ask prices, or relative to previous trade prices. Here, we'll focus on two common methods:

  1. Price-Based Aggressor Detection: Infers aggressor side by comparing the trade price to the last traded price or a reference price.
  2. Tick-Rule Aggressor Detection: A classic method based on whether the trade occurs on an 'uptick' or 'downtick'.

Mock Data Generation

To demonstrate aggressor side detection, we'll generate some synthetic trade data. This data will include timestamp, price, and quantity for each trade.

[ ]
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# Set random seed for reproducibility
np.random.seed(42)

def generate_mock_trade_data(num_trades=100, initial_price=100.0, price_volatility=0.1, max_quantity=100):
    """
    Generates mock trade data for demonstration.

    Args:
        num_trades (int): Number of trade records to generate.
        initial_price (float): Starting price for the trades.
        price_volatility (float): Standard deviation for price changes.
        max_quantity (int): Maximum quantity per trade.

    Returns:
        pd.DataFrame: A DataFrame containing mock trade data with columns:
                      'timestamp', 'price', 'quantity'.
    """
    timestamps = pd.to_datetime(pd.date_range(start='2023-01-01', periods=num_trades, freq='1S'))
    prices = [initial_price]
    quantities = np.random.randint(10, max_quantity, num_trades).tolist()

    for i in range(1, num_trades):
        # Simulate price movement with some randomness
        price_change = np.random.normal(0, price_volatility)
        new_price = prices[-1] + price_change
        prices.append(max(new_price, initial_price * 0.9)) # Keep price from going too low

    data = pd.DataFrame({
        'timestamp': timestamps,
        'price': prices,
        'quantity': quantities
    })
    return data

# Generate 200 mock trades
trade_data = generate_mock_trade_data(num_trades=200)

print("Mock Trade Data Head:")
display(trade_data.head())

print("\nMock Trade Data Info:")
display(trade_data.info())
Mock Trade Data Head:
/tmp/ipykernel_5566/3117352234.py:22: FutureWarning: 'S' is deprecated and will be removed in a future version, please use 's' instead.
  timestamps = pd.to_datetime(pd.date_range(start='2023-01-01', periods=num_trades, freq='1S'))
timestamp price quantity
0 2023-01-01 00:00:00 100.000000 61
1 2023-01-01 00:00:01 99.808123 24
2 2023-01-01 00:00:02 99.805471 81
3 2023-01-01 00:00:03 99.811495 70
4 2023-01-01 00:00:04 100.057819 30

Mock Trade Data Info:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 200 entries, 0 to 199
Data columns (total 3 columns):
 #   Column     Non-Null Count  Dtype         
---  ------     --------------  -----         
 0   timestamp  200 non-null    datetime64[ns]
 1   price      200 non-null    float64       
 2   quantity   200 non-null    int64         
dtypes: datetime64[ns](1), float64(1), int64(1)
memory usage: 4.8 KB
None

1. Price-Based Aggressor Detection

Definition and Formula

This method infers the aggressor side by comparing the current trade price to a reference price, typically the last traded price. The logic is as follows:

  • Buy Aggressor: If Current Trade Price > Last Traded Price, it's likely a buy aggressor (buyer lifted the ask).
  • Sell Aggressor: If Current Trade Price < Last Traded Price, it's likely a sell aggressor (seller hit the bid).
  • Undetermined/Neutral: If Current Trade Price == Last Traded Price, the aggressor side cannot be determined solely by this rule. This often occurs when trades happen within the spread, or at the mid-price, or if a passive order is filled.

For practical implementation, we often use the previous trade's price as the Last Traded Price.

Python Function for Price-Based Detection

[ ]
def detect_aggressor_price_based(trades: pd.DataFrame) -> pd.DataFrame:
    """
    Detects aggressor side based on price movement relative to the previous trade.

    Args:
        trades (pd.DataFrame): DataFrame with 'price' column.

    Returns:
        pd.DataFrame: Original DataFrame with an added 'aggressor_side_price_based' column.
                      'BUY' for buy aggressor, 'SELL' for sell aggressor, 'NEUTRAL' otherwise.
    """
    df = trades.copy()
    # Calculate the change in price from the previous trade
    df['prev_price'] = df['price'].shift(1)

    # Assign aggressor side based on price comparison
    df['aggressor_side_price_based'] = 'NEUTRAL'
    df.loc[df['price'] > df['prev_price'], 'aggressor_side_price_based'] = 'BUY'
    df.loc[df['price'] < df['prev_price'], 'aggressor_side_price_based'] = 'SELL'

    # The first trade has no previous price, so it's neutral by this method
    df.loc[df['prev_price'].isna(), 'aggressor_side_price_based'] = 'NEUTRAL'

    return df.drop(columns=['prev_price'])

# Perform detection on mock data
trade_data_with_aggressor_pb = detect_aggressor_price_based(trade_data)

print("Aggressor Side Detection (Price-Based) Head:")
display(trade_data_with_aggressor_pb.head(10))
Aggressor Side Detection (Price-Based) Head:
timestamp price quantity aggressor_side_price_based
0 2023-01-01 00:00:00 100.000000 61 NEUTRAL
1 2023-01-01 00:00:01 99.808123 24 SELL
2 2023-01-01 00:00:02 99.805471 81 SELL
3 2023-01-01 00:00:03 99.811495 70 BUY
4 2023-01-01 00:00:04 100.057819 30 BUY
5 2023-01-01 00:00:05 100.038583 92 SELL
6 2023-01-01 00:00:06 100.068737 96 BUY
7 2023-01-01 00:00:07 100.065266 84 SELL
8 2023-01-01 00:00:08 99.948398 84 SELL
9 2023-01-01 00:00:09 100.062681 97 BUY

Interpretation

  • When the aggressor_side_price_based is 'BUY', it means the current trade price is higher than the previous one, suggesting a buyer was willing to pay a higher price to get their order filled immediately.
  • When it's 'SELL', the current trade price is lower, indicating a seller was willing to accept a lower price to execute their trade immediately.
  • 'NEUTRAL' means the price did not change, or it's the first trade, so this method cannot definitively assign an aggressor.

2. Tick-Rule Aggressor Detection

Definition and Formula

The tick rule is a classic method that determines the aggressor side based on the direction of price movement (uptick or downtick).

  • Uptick Trade: A trade occurring at a price higher than the immediately preceding trade. Formula: Current Price > Last Trade Price. This indicates a Buy Aggressor.
  • Downtick Trade: A trade occurring at a price lower than the immediately preceding trade. Formula: Current Price < Last Trade Price. This indicates a Sell Aggressor.
  • Zero-Tick Trade: A trade occurring at the same price as the immediately preceding trade. Formula: Current Price == Last Trade Price. For zero-tick trades, a modified tick rule is often applied:
    • Zero-Plus Tick: A zero-tick trade preceded by an uptick. This is also considered a Buy Aggressor.
    • Zero-Minus Tick: A zero-tick trade preceded by a downtick. This is also considered a Sell Aggressor.

This method is generally more robust than simple price-based detection for zero-tick trades.

[ ]
def detect_aggressor_tick_rule(trades: pd.DataFrame) -> pd.DataFrame:
    """
    Detects aggressor side using the tick rule (including zero-tick logic).

    Args:
        trades (pd.DataFrame): DataFrame with 'price' column.

    Returns:
        pd.DataFrame: Original DataFrame with an added 'aggressor_side_tick_rule' column.
                      'BUY' for buy aggressor, 'SELL' for sell aggressor, 'NEUTRAL' otherwise.
    """
    df = trades.copy()
    df['prev_price'] = df['price'].shift(1)
    df['prev_prev_price'] = df['price'].shift(2)

    # Initialize aggressor side as NEUTRAL
    df['aggressor_side_tick_rule'] = 'NEUTRAL'

    # Uptick Rule: Current price > previous price
    df.loc[df['price'] > df['prev_price'], 'aggressor_side_tick_rule'] = 'BUY'

    # Downtick Rule: Current price < previous price
    df.loc[df['price'] < df['prev_price'], 'aggressor_side_tick_rule'] = 'SELL'

    # Zero-Tick Rule:
    # If current price == previous price, apply modified tick rule
    zero_tick_mask = (df['price'] == df['prev_price'])

    # Zero-Plus Tick: current_price == prev_price AND (prev_price > prev_prev_price)
    zero_plus_mask = zero_tick_mask & (df['prev_price'] > df['prev_prev_price'])
    df.loc[zero_plus_mask, 'aggressor_side_tick_rule'] = 'BUY'

    # Zero-Minus Tick: current_price == prev_price AND (prev_price < prev_prev_price)
    zero_minus_mask = zero_tick_mask & (df['prev_price'] < df['prev_prev_price'])
    df.loc[zero_minus_mask, 'aggressor_side_tick_rule'] = 'SELL'

    # The first two trades might remain NEUTRAL due to lack of previous data
    df.loc[df['prev_price'].isna(), 'aggressor_side_tick_rule'] = 'NEUTRAL'
    df.loc[df['prev_prev_price'].isna() & (df['aggressor_side_tick_rule'] == 'NEUTRAL'), 'aggressor_side_tick_rule'] = 'NEUTRAL'

    return df.drop(columns=['prev_price', 'prev_prev_price'])

# Perform detection on mock data
trade_data_with_aggressor_tr = detect_aggressor_tick_rule(trade_data_with_aggressor_pb) # Use previously processed data

print("Aggressor Side Detection (Tick-Rule) Head:")
display(trade_data_with_aggressor_tr.head(10))
Aggressor Side Detection (Tick-Rule) Head:
timestamp price quantity aggressor_side_price_based aggressor_side_tick_rule
0 2023-01-01 00:00:00 100.000000 61 NEUTRAL NEUTRAL
1 2023-01-01 00:00:01 99.808123 24 SELL SELL
2 2023-01-01 00:00:02 99.805471 81 SELL SELL
3 2023-01-01 00:00:03 99.811495 70 BUY BUY
4 2023-01-01 00:00:04 100.057819 30 BUY BUY
5 2023-01-01 00:00:05 100.038583 92 SELL SELL
6 2023-01-01 00:00:06 100.068737 96 BUY BUY
7 2023-01-01 00:00:07 100.065266 84 SELL SELL
8 2023-01-01 00:00:08 99.948398 84 SELL SELL
9 2023-01-01 00:00:09 100.062681 97 BUY BUY

Interpretation

  • The tick rule provides a more nuanced view for trades that occur at the same price as the previous trade. By looking at the direction of the price movement before the zero-tick, it can still infer the underlying pressure.
  • 'BUY' implies an upward price pressure or a zero-tick following an uptick.
  • 'SELL' implies a downward price pressure or a zero-tick following a downtick.
  • 'NEUTRAL' typically means there wasn't enough preceding price information to apply the rule (e.g., the first few trades).

Visualizations

Let's visualize the results of our aggressor side detection methods. We'll use matplotlib and numpy to create insightful plots.

Price Series with Aggressor Trades and Aggressor Volume

This multi-panel plot will show the price series over time, marking buy and sell aggressor trades, and a cumulative plot of aggressor volume for both methods.

[ ]
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec

# Prepare data for plotting
plot_df = trade_data_with_aggressor_tr.copy()
plot_df['buy_volume_pb'] = plot_df.apply(lambda row: row['quantity'] if row['aggressor_side_price_based'] == 'BUY' else 0, axis=1)
plot_df['sell_volume_pb'] = plot_df.apply(lambda row: row['quantity'] if row['aggressor_side_price_based'] == 'SELL' else 0, axis=1)
plot_df['buy_volume_tr'] = plot_df.apply(lambda row: row['quantity'] if row['aggressor_side_tick_rule'] == 'BUY' else 0, axis=1)
plot_df['sell_volume_tr'] = plot_df.apply(lambda row: row['quantity'] if row['aggressor_side_tick_rule'] == 'SELL' else 0, axis=1)

# Cumulative volumes
plot_df['cumulative_buy_volume_pb'] = plot_df['buy_volume_pb'].cumsum()
plot_df['cumulative_sell_volume_pb'] = plot_df['sell_volume_pb'].cumsum()
plot_df['cumulative_buy_volume_tr'] = plot_df['buy_volume_tr'].cumsum()
plot_df['cumulative_sell_volume_tr'] = plot_df['sell_volume_tr'].cumsum()

fig = plt.figure(figsize=(15, 12))
gs = gridspec.GridSpec(3, 1, height_ratios=[3, 1, 1]) # 3 rows, 1 column

# Panel 1: Price Series with Aggressor Marks
ax0 = fig.add_subplot(gs[0])
ax0.plot(plot_df['timestamp'], plot_df['price'], label='Price', color='gray', alpha=0.7)

buy_aggressors_pb = plot_df[plot_df['aggressor_side_price_based'] == 'BUY']
sell_aggressors_pb = plot_df[plot_df['aggressor_side_price_based'] == 'SELL']

ax0.scatter(buy_aggressors_pb['timestamp'], buy_aggressors_pb['price'],
            marker='^', color='green', s=50, label='Buy Aggressor (Price-Based)')
ax0.scatter(sell_aggressors_pb['timestamp'], sell_aggressors_pb['price'],
            marker='v', color='red', s=50, label='Sell Aggressor (Price-Based)')

ax0.set_title('Price Series with Price-Based Aggressor Trades')
ax0.set_ylabel('Price')
ax0.legend()
ax0.grid(True, linestyle='--', alpha=0.6)

# Panel 2: Cumulative Aggressor Volume (Price-Based)
ax1 = fig.add_subplot(gs[1], sharex=ax0)
ax1.plot(plot_df['timestamp'], plot_df['cumulative_buy_volume_pb'], label='Cumulative Buy Volume (PB)', color='darkgreen')
ax1.plot(plot_df['timestamp'], plot_df['cumulative_sell_volume_pb'], label='Cumulative Sell Volume (PB)', color='darkred')
ax1.fill_between(plot_df['timestamp'], plot_df['cumulative_buy_volume_pb'], plot_df['cumulative_sell_volume_pb'],
                 where=plot_df['cumulative_buy_volume_pb'] > plot_df['cumulative_sell_volume_pb'],
                 facecolor='green', alpha=0.1, interpolate=True)
ax1.fill_between(plot_df['timestamp'], plot_df['cumulative_buy_volume_pb'], plot_df['cumulative_sell_volume_pb'],
                 where=plot_df['cumulative_buy_volume_pb'] < plot_df['cumulative_sell_volume_pb'],
                 facecolor='red', alpha=0.1, interpolate=True)
ax1.set_title('Cumulative Aggressor Volume (Price-Based)')
ax1.set_ylabel('Volume')
ax1.legend()
ax1.grid(True, linestyle='--', alpha=0.6)

# Panel 3: Cumulative Aggressor Volume (Tick-Rule)
ax2 = fig.add_subplot(gs[2], sharex=ax0)
ax2.plot(plot_df['timestamp'], plot_df['cumulative_buy_volume_tr'], label='Cumulative Buy Volume (TR)', color='green')
ax2.plot(plot_df['timestamp'], plot_df['cumulative_sell_volume_tr'], label='Cumulative Sell Volume (TR)', color='red')
ax2.fill_between(plot_df['timestamp'], plot_df['cumulative_buy_volume_tr'], plot_df['cumulative_sell_volume_tr'],
                 where=plot_df['cumulative_buy_volume_tr'] > plot_df['cumulative_sell_volume_tr'],
                 facecolor='green', alpha=0.1, interpolate=True)
ax2.fill_between(plot_df['timestamp'], plot_df['cumulative_buy_volume_tr'], plot_df['cumulative_sell_volume_tr'],
                 where=plot_df['cumulative_buy_volume_tr'] < plot_df['cumulative_sell_volume_tr'],
                 facecolor='red', alpha=0.1, interpolate=True)
ax2.set_title('Cumulative Aggressor Volume (Tick-Rule)')
ax2.set_xlabel('Timestamp')
ax2.set_ylabel('Volume')
ax2.legend()
ax2.grid(True, linestyle='--', alpha=0.6)

plt.tight_layout()
plt.show()
cell output

Net Aggressor Imbalance

This plot shows the cumulative net aggressor volume over time. A positive trend indicates more buying pressure, while a negative trend indicates more selling pressure.

[ ]
# Calculate Net Aggressor Volume
plot_df['net_aggressor_volume_pb'] = plot_df['cumulative_buy_volume_pb'] - plot_df['cumulative_sell_volume_pb']
plot_df['net_aggressor_volume_tr'] = plot_df['cumulative_buy_volume_tr'] - plot_df['cumulative_sell_volume_tr']

fig, ax = plt.subplots(figsize=(15, 7))

ax.plot(plot_df['timestamp'], plot_df['net_aggressor_volume_pb'], label='Net Aggressor Volume (Price-Based)', color='blue', alpha=0.8)
ax.plot(plot_df['timestamp'], plot_df['net_aggressor_volume_tr'], label='Net Aggressor Volume (Tick-Rule)', color='purple', linestyle='--', alpha=0.8)

# Add a zero line for reference
ax.axhline(0, color='gray', linestyle=':', linewidth=0.8)

ax.set_title('Cumulative Net Aggressor Volume Over Time')
ax.set_xlabel('Timestamp')
ax.set_ylabel('Cumulative Net Aggressor Volume')
ax.legend()
ax.grid(True, linestyle='--', alpha=0.6)

plt.tight_layout()
plt.show()

print("Interpretation of Net Aggressor Imbalance:")
print("- The blue line (Price-Based) shows the running total of buy volume minus sell volume based on simple price changes.")
print("- The purple dashed line (Tick-Rule) shows the same for the more refined tick-rule method.")
print("- When the line is above zero and trending upwards, it indicates accumulating buying pressure.")
print("- When the line is below zero and trending downwards, it indicates accumulating selling pressure.")
print("- Divergences between price action and net aggressor volume can signal potential reversals or continuation of trends.")
cell output
Interpretation of Net Aggressor Imbalance:
- The blue line (Price-Based) shows the running total of buy volume minus sell volume based on simple price changes.
- The purple dashed line (Tick-Rule) shows the same for the more refined tick-rule method.
- When the line is above zero and trending upwards, it indicates accumulating buying pressure.
- When the line is below zero and trending downwards, it indicates accumulating selling pressure.
- Divergences between price action and net aggressor volume can signal potential reversals or continuation of trends.

Conclusion

Aggressor side detection is a fundamental concept in market microstructure analysis, providing valuable insights into the immediate supply and demand dynamics of a financial asset. By understanding whether trades are buyer-initiated or seller-initiated, traders and analysts can gain a deeper understanding of market sentiment and order flow.

While price-based methods offer a simple approach, more sophisticated techniques like the tick-rule provide a more granular view, especially for trades occurring at the same price level. These techniques are often combined with other indicators and order book data for robust trading strategies and market analysis.