Wash Trade Detection
Detect potential wash trading patterns by algorithmically analyzing trade sequences for self-trading, circular trading between controlled accounts, and matched orders that could indicate market manipulation attempts or compliance policy violations requiring investigation and remediation.
Compliance & Audit: Detect Potential Wash Trades
Wash trading is a form of market manipulation where an investor simultaneously buys and sells the same financial instruments to create artificial, misleading activity in the marketplace. This notebook implements a modular system to detect such patterns using trade data analysis.
Key Concepts
| Concept | Description |
|---|---|
| Self-Trading | When the buyer and seller are the same entity or controlled by the same entity. |
| Circular Trading | A group of accounts trading among themselves to inflate volume. |
| Round-Trip Trading | Rapidly buying and selling a position with no change in beneficial ownership. |
| Volume Inflation | The primary goal of wash trading to attract genuine investors. |
!pip install pandas matplotlib seaborn numpyRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: matplotlib in /usr/local/lib/python3.12/dist-packages (3.10.0) Requirement already satisfied: seaborn in /usr/local/lib/python3.12/dist-packages (0.13.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.2) Requirement already satisfied: contourpy>=1.0.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (1.3.3) Requirement already satisfied: cycler>=0.10 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (0.12.1) Requirement already satisfied: fonttools>=4.22.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (4.63.0) Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (1.5.0) Requirement already satisfied: packaging>=20.0 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (26.2) Requirement already satisfied: pillow>=8 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (11.3.0) Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/lib/python3.12/dist-packages (from matplotlib) (3.3.2) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
import logging
import random
import time
from collections import deque
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)Function Name: create_detector_state
Initializes the state dictionary for the wash trade detection system.
Parameters:
lookback_window(int): Number of trades to keep in the rolling buffer.
Returns:
dict: The initialized state.
def create_detector_state(lookback_window: int = 1000) -> dict:
"""
Initializes the state for wash trade detection.
Parameters
----------
lookback_window : int, optional
Size of the rolling window for trade history, defaults to 1000.
Returns
-------
dict
A dictionary containing buffers and tracking metrics.
"""
logger.info(f"Initializing detector state with lookback window of {lookback_window}")
return {
'trade_history': deque(maxlen=lookback_window),
'flagged_trades': [],
'metrics': {
'total_processed': 0,
'total_flagged': 0,
'suspicious_volume': 0.0
}
}Function Name: detect_self_trades
Checks for immediate self-trades where the buyer and seller IDs match.
Parameters:
state(dict): Current detector state.trade(dict): Single trade record.
Returns:
dict: Updated state.
def detect_self_trades(state: dict, trade: dict) -> dict:
"""
Identifies trades where the buyer and seller are the same.
Parameters
----------
state : dict
Current state dictionary.
trade : dict
Trade data containing 'buyer_id', 'seller_id', 'price', and 'quantity'.
Returns
-------
dict
Updated state with flagged trades if self-trade detected.
"""
state['metrics']['total_processed'] += 1
if trade['buyer_id'] == trade['seller_id']:
logger.warning(f"Self-trade detected! Asset: {trade['asset']}, Entity: {trade['buyer_id']}")
flag = trade.copy()
flag['reason'] = 'Self-Trade'
state['flagged_trades'].append(flag)
state['metrics']['total_flagged'] += 1
state['metrics']['suspicious_volume'] += (trade['price'] * trade['quantity'])
state['trade_history'].append(trade)
return stateFunction Name: detect_round_trips
Analyzes the window of history to find round-trip trades (A -> B -> A) within a short timeframe.
Parameters:
state(dict): Current detector state.time_threshold_seconds(int): Max time allowed for a round-trip.
Returns:
dict: Updated state.
def detect_round_trips(state: dict, time_threshold_seconds: int = 60) -> dict:
"""
Detects A-B-A trading patterns in the current trade window.
Parameters
----------
state : dict
Current state.
time_threshold_seconds : int, optional
Seconds within which the reversal must occur, defaults to 60.
Returns
-------
dict
State with round-trip violations added to flagged_trades.
"""
history = list(state['trade_history'])
if len(history) < 2:
return state
latest = history[-1]
for prev in reversed(history[:-1]):
time_diff = (latest['timestamp'] - prev['timestamp']).total_seconds()
if time_diff > time_threshold_seconds:
break
# Check if A sold to B and then B sold back to A for similar price/qty
if (latest['buyer_id'] == prev['seller_id'] and
latest['seller_id'] == prev['buyer_id'] and
abs(latest['price'] - prev['price']) / prev['price'] < 0.01):
logger.info("Round-trip trade pattern detected.")
flag = latest.copy()
flag['reason'] = 'Round-Trip'
state['flagged_trades'].append(flag)
state['metrics']['total_flagged'] += 1
break
return stateDemonstration: Simulation and Visualization
We will simulate a series of trades including normal behavior and injected wash trade patterns.
def simulate_market_data(n_trades: int = 100):
data = []
start_time = datetime.now()
for i in range(n_trades):
# Random jitter for timing
start_time += timedelta(seconds=random.randint(1, 5)) + timedelta(milliseconds=random.random() * 100)
# Normal trade
trade = {
'timestamp': start_time,
'asset': 'BTC/USD',
'buyer_id': f"user_{random.randint(1, 20)}",
'seller_id': f"user_{random.randint(1, 20)}",
'price': 50000 + random.uniform(-100, 100),
'quantity': random.uniform(0.1, 2.0)
}
# Inject Wash Trade at index 50 (Self trade)
if i == 50:
trade['buyer_id'] = "malicious_actor"
trade['seller_id'] = "malicious_actor"
# Inject Round Trip at index 80-81
if i == 81:
prev = data[-1]
trade['buyer_id'] = prev['seller_id']
trade['seller_id'] = prev['buyer_id']
trade['price'] = prev['price']
data.append(trade)
return data
# Run simulation
trades = simulate_market_data(200)
detector = create_detector_state()
for t in trades:
detector = detect_self_trades(detector, t)
detector = detect_round_trips(detector, time_threshold_seconds=30)
# Visualization
df_all = pd.DataFrame(trades)
df_flagged = pd.DataFrame(detector['flagged_trades'])
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10))
sns.lineplot(data=df_all, x='timestamp', y='price', ax=ax1, label='Market Price')
if not df_flagged.empty:
ax1.scatter(df_flagged['timestamp'], df_flagged['price'], color='red', zorder=5, label='Flagged Wash Trade')
ax1.set_title('Market Activity with Detected Wash Trades')
ax1.legend()
sns.barplot(x=['Total Trades', 'Flagged Trades'], y=[len(df_all), len(df_flagged)], ax=ax2, palette='muted')
ax2.set_title('Detection Statistics Summary')
plt.tight_layout()
plt.show()
display(pd.DataFrame([detector['metrics']]))WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_7 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: malicious_actor WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_15 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_1 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_19 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_5 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_20 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_10 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_14 WARNING:__main__:Self-trade detected! Asset: BTC/USD, Entity: user_4 /tmp/ipykernel_1646/4054621549.py:53: FutureWarning: Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect. sns.barplot(x=['Total Trades', 'Flagged Trades'], y=[len(df_all), len(df_flagged)], ax=ax2, palette='muted')
| total_processed | total_flagged | suspicious_volume | |
|---|---|---|---|
| 0 | 200 | 17 | 555968.190627 |
Production Considerations
When deploying these detection algorithms in a live audit environment, consider the following:
| Practice | Implementation Detail |
|---|---|
| Latency | Use high-performance streaming (e.g., Kafka) instead of batch processing for real-time flags. |
| False Positives | Implement a scoring system rather than binary flags to reduce noise from market makers. |
| Scalability | Distributed state management (Redis) if monitoring thousands of assets simultaneously. |
| Data Integrity | Use cryptographic hashes to ensure trade logs haven't been tampered with post-audit. |
Conclusion
In this notebook, we implemented a modular compliance tool for detecting wash trades.
- Created a state-based architecture to track trade history.
- Implemented detection logic for Self-Trading and Round-Trip patterns.
- Demonstrated the effectiveness of these checks using simulated market data and visual analysis.
- Provided guidelines for transition into a production audit pipeline.