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.
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:
- Price-Based Aggressor Detection: Infers aggressor side by comparing the trade price to the last traded price or a reference price.
- 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_basedis '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()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.")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.