
Market Microstructure
- 244 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
market-microstructure is a Claude Code skill that analyzes Solana DEX orderflow, classifying swaps and deriving buyer/seller pressure and microstructure signals.
About
market-microstructure analyzes DEX orderflow on Solana by classifying swaps as buys or sells and deriving buyer/seller pressure, volume profiles, whale activity, and wash-trading patterns. A developer uses it to read the swap trade tape for entry/exit timing and token-quality scoring. It works from Birdeye, DexScreener, and Helius trade data.
- Analyzes DEX orderflow: trade classification, buyer/seller pressure, whale detection, wash trading
- Ships trade_flow_analysis.py and volume_profile.py plus flow-signals and wash-trading references
- Derives signals from the swap trade tape on Solana AMMs
Market Microstructure by the numbers
- 244 all-time installs (skills.sh)
- Ranked #79 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
market-microstructure capabilities & compatibility
- Capabilities
- market microstructure · trade classification · wash trading detection
- Use cases
- data analysis · research · trading
What market-microstructure says it does
There are no orderbooks on AMMs — every trade is a swap against a liquidity pool.
the sequence, size, and direction of swaps reveal accumulation, distribution, whale activity, and wash trading patterns.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill market-microstructureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 244 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Read DEX swap flow to gauge buy/sell pressure, whale activity, and wash trading.
Who is it for?
Reading the swap trade tape for pressure, whale flow, and wash-trading signals.
Skip if: Orderbook/CEX microstructure (use market-microstructure-traditional).
When should I use this skill?
You need to derive buy/sell pressure or detect wash trading from DEX swaps.
By the numbers
- 7 analysis areas listed
- 5 trade-size buckets tabulated
- volume anomaly threshold 3x rolling average
Files
Market Microstructure — DEX Orderflow Analysis
Overview
Market microstructure on Solana DEXes differs fundamentally from traditional finance. There are no orderbooks on AMMs — every trade is a swap against a liquidity pool. Yet trade flow analysis remains powerful: the sequence, size, and direction of swaps reveal accumulation, distribution, whale activity, and wash trading patterns.
This skill covers:
- Trade classification — identifying buys vs sells from swap direction
- Volume profiles — time-based and size-based breakdowns
- Buyer/seller pressure — ratio metrics, net flow, trade count asymmetry
- Trade size distribution — whale detection, retail vs institutional flow
- Flow momentum signals — acceleration, volume spikes, composite scores
- Token velocity — turnover rate as a sentiment proxy
- Wash trading detection — spotting fake volume and bot patterns
Why Microstructure Matters on DEXes
On CEXes, microstructure means orderbook depth, bid-ask spread, and queue position. On AMMs, liquidity sits in pool curves — there is no spread or queue. But the trade tape (the chronological list of swaps) contains rich signal:
1. Who is trading? — Whale wallets vs retail, smart money vs bots 2. How are they trading? — Large single swaps vs DCA-style splits 3. When are they trading? — Volume clustering around events or time zones 4. What direction? — Net buy vs sell pressure over sliding windows
These signals feed into entry/exit timing, position sizing, and token quality scoring.
Trade Classification
Buy vs Sell Identification
On Solana DEXes, every swap has an input token and output token:
| Swap Direction | Classification | Meaning |
|---|---|---|
| SOL → Token | Buy | Trader spending SOL to acquire token |
| USDC → Token | Buy | Trader spending stables to acquire token |
| Token → SOL | Sell | Trader converting token back to SOL |
| Token → USDC | Sell | Trader converting token to stables |
| Token A → Token B | Context-dependent | Classify based on which token you're analyzing |
From API Data Sources
Birdeye Trade History (/defi/txs/token):
- Returns
sidefield:"buy"or"sell" - Includes
from(input token) andto(output token) amounts
DexScreener Pair Trades:
- Returns
typefield indicating swap direction relative to the pair
Helius Parsed Transactions:
- Parse swap instructions to extract input/output mints and amounts
- Classify based on which mint matches your target token
See references/trade_classification.md for detailed classification logic and size buckets.
Volume Profiles
Time-Based Profiles
Aggregate trade volume into fixed time buckets to identify patterns:
# Hourly volume profile
hourly_volume = {}
for trade in trades:
hour = trade["timestamp"] // 3600 * 3600
hourly_volume.setdefault(hour, {"buy_vol": 0, "sell_vol": 0})
if trade["side"] == "buy":
hourly_volume[hour]["buy_vol"] += trade["volume_usd"]
else:
hourly_volume[hour]["sell_vol"] += trade["volume_usd"]Key metrics from time profiles:
- Peak hours — when is the token most actively traded?
- Volume trend — is volume increasing, decreasing, or stable?
- Volume anomalies — spikes exceeding 3x the rolling average
Size-Based Profiles
Classify trades into size buckets to separate whale activity from retail:
| Bucket | SOL Range | Typical Actor |
|---|---|---|
| Micro | < 0.1 SOL | Dust / test trades |
| Small | 0.1 – 1 SOL | Retail traders |
| Medium | 1 – 10 SOL | Active traders |
| Large | 10 – 50 SOL | Serious positions |
| Whale | 50+ SOL | Whales / institutions |
Buyer/Seller Pressure Metrics
Core Ratios
def compute_pressure(trades: list[dict], period_seconds: int = 3600) -> dict:
"""Compute buy/sell pressure metrics over a time period."""
buy_vol = sum(t["volume_usd"] for t in trades if t["side"] == "buy")
sell_vol = sum(t["volume_usd"] for t in trades if t["side"] == "sell")
total_vol = buy_vol + sell_vol
buy_trades = sum(1 for t in trades if t["side"] == "buy")
sell_trades = sum(1 for t in trades if t["side"] == "sell")
total_trades = buy_trades + sell_trades
return {
"buy_sell_ratio": buy_vol / sell_vol if sell_vol > 0 else float("inf"),
"buy_volume_pct": buy_vol / total_vol if total_vol > 0 else 0.5,
"net_flow_usd": buy_vol - sell_vol,
"trade_count_ratio": buy_trades / total_trades if total_trades > 0 else 0.5,
}Signal Interpretation
| Metric | Bullish | Neutral | Bearish |
|---|---|---|---|
| Buy Volume % | > 60% | 40–60% | < 40% |
| Net Flow | Positive, increasing | Near zero | Negative, increasing |
| Trade Count Ratio | > 0.55 | 0.45–0.55 | < 0.45 |
| Large Trade Ratio | High buy-side | Balanced | High sell-side |
See references/flow_signals.md for the full signal catalog and composite scoring.
Trade Size Distribution
Analyzing the distribution of trade sizes reveals market structure:
import statistics
def analyze_trade_sizes(trades: list[dict]) -> dict:
"""Analyze trade size distribution."""
sizes = [t["volume_usd"] for t in trades]
if not sizes:
return {}
return {
"mean": statistics.mean(sizes),
"median": statistics.median(sizes),
"stdev": statistics.stdev(sizes) if len(sizes) > 1 else 0,
"skew_indicator": statistics.mean(sizes) / statistics.median(sizes),
"max_trade": max(sizes),
"whale_pct": sum(s for s in sizes if s > 5000) / sum(sizes),
}Interpreting skew: A skew_indicator (mean/median) well above 1.0 indicates a fat-tailed distribution — a few large trades dominate. This is normal for tokens with whale interest but can also signal manipulation.
Momentum Signals from Trade Flow
Volume Acceleration
Compare current period volume to the previous period:
acceleration = current_volume / previous_volume if previous_volume > 0 else 0- acceleration > 2.0 — volume surge, potential breakout or dump
- acceleration 0.8–1.2 — stable activity
- acceleration < 0.5 — dying interest
Buy Pressure Acceleration
Track how the buy ratio changes over time:
current_buy_ratio = current_buy_vol / current_total_vol
previous_buy_ratio = prev_buy_vol / prev_total_vol
buy_momentum = current_buy_ratio - previous_buy_ratioPositive buy_momentum with increasing volume is a strong accumulation signal.
Token Velocity
Token velocity measures how frequently tokens change hands:
velocity = daily_volume / circulating_supply| Velocity | Interpretation |
|---|---|
| < 0.01 | Low activity, illiquid, or strong holders |
| 0.01–0.05 | Normal trading activity |
| 0.05–0.20 | Active trading, possible speculation |
| > 0.20 | Very high turnover, potential wash trading |
High velocity combined with low unique trader count is a wash trading red flag.
Wash Trading Detection
Wash trading inflates volume to make a token appear more active than it truly is. Key detection signals:
1. Low unique trader ratio — unique_wallets / trade_count < 0.3 2. Volume/TVL anomaly — daily_volume / tvl > 10 (volume vastly exceeds liquidity) 3. Uniform trade sizes — low entropy in trade size distribution 4. Self-trading — same wallet on both sides within short windows 5. Funded-together clusters — multiple wallets funded from the same source
See references/wash_trading.md for detailed detection methods and scoring.
Data Sources
Birdeye API
Primary source for trade history on Solana tokens:
GET /defi/txs/token— recent trades for a tokenGET /defi/ohlcv— candle data with volumeGET /defi/price/volume— aggregated volume data
Requires API key. See the birdeye-api skill for endpoint details.
DexScreener API
Free, no-auth alternative for pair-level data:
GET /latest/dex/tokens/{address}— token pairs with volumeGET /latest/dex/pairs/solana/{pairAddress}— pair details
Helius API
For wallet-level trade analysis and parsed transactions:
- Parse swap transactions to extract trade details
- Attribute trades to specific wallets
- See the
helius-apiskill for transaction parsing.
Composite Momentum Score
Combine multiple flow signals into a single score (range: -100 to +100):
def compute_momentum_score(
buy_ratio: float,
volume_accel: float,
whale_buy_pct: float,
unique_trader_trend: float,
) -> float:
"""Compute composite momentum score from flow signals.
Args:
buy_ratio: Buy volume / total volume (0 to 1).
volume_accel: Current vol / previous vol.
whale_buy_pct: Whale buy volume / total whale volume (0 to 1).
unique_trader_trend: Change in unique traders vs previous period.
Returns:
Score from -100 (strong sell pressure) to +100 (strong buy pressure).
"""
# Buy ratio component: 0.5 = neutral, maps to [-40, +40]
buy_component = (buy_ratio - 0.5) * 80
# Volume acceleration: >1 = growing, maps to [-20, +20]
vol_component = min(max((volume_accel - 1.0) * 20, -20), 20)
# Whale direction: 0.5 = neutral, maps to [-25, +25]
whale_component = (whale_buy_pct - 0.5) * 50
# Unique trader growth: positive = healthy, maps to [-15, +15]
trader_component = min(max(unique_trader_trend * 15, -15), 15)
score = buy_component + vol_component + whale_component + trader_component
return max(-100, min(100, score))| Score Range | Interpretation |
|---|---|
| +60 to +100 | Strong accumulation — heavy buy pressure |
| +20 to +60 | Moderate buying — cautious accumulation |
| -20 to +20 | Neutral / balanced flow |
| -60 to -20 | Moderate selling — distribution underway |
| -100 to -60 | Strong distribution — heavy sell pressure |
Integration with Other Skills
| Skill | How It Connects |
|---|---|
birdeye-api | Primary data source for trade history and volume |
helius-api | Wallet-attributed trade data from parsed transactions |
liquidity-analysis | Volume/TVL ratios, liquidity context for flow signals |
whale-tracking | Identify whale wallets for large trade attribution |
token-holder-analysis | Supply distribution context for velocity metrics |
position-sizing | Use flow signals to adjust entry sizing |
regime-detection | Combine flow momentum with regime classification |
Files
References
references/trade_classification.md— Buy/sell classification logic, size buckets, aggregationreferences/flow_signals.md— Complete signal catalog with formulas and interpretationreferences/wash_trading.md— Detection methods, metrics, and risk scoring
Scripts
scripts/trade_flow_analysis.py— Fetch trades, classify, compute flow signals and momentumscripts/volume_profile.py— Hourly volume profiles, trend detection, anomaly identification
Flow Signals — Orderflow Metrics and Interpretation
Signal Catalog
This reference covers every flow signal used in microstructure analysis, with formulas, computation code, interpretation ranges, and combination strategies.
1. Buy/Sell Volume Ratio
Formula:
buy_ratio = buy_volume_usd / (buy_volume_usd + sell_volume_usd)Range: 0.0 to 1.0 (0.5 = perfectly balanced)
| Value | Interpretation |
|---|---|
| > 0.70 | Strong buy pressure — aggressive accumulation |
| 0.60 – 0.70 | Moderate buy pressure |
| 0.45 – 0.60 | Neutral / balanced |
| 0.30 – 0.45 | Moderate sell pressure |
| < 0.30 | Strong sell pressure — aggressive distribution |
2. Net Flow
Formula:
net_flow = buy_volume_usd - sell_volume_usdRange: Unbounded (positive = net buying, negative = net selling)
Net flow is best used as a trend indicator rather than absolute value. Track the rolling sum over 1h, 4h, and 24h windows:
def rolling_net_flow(trades: list[dict], window_seconds: int) -> float:
"""Compute net flow over a rolling window."""
cutoff = time.time() - window_seconds
recent = [t for t in trades if t["timestamp"] >= cutoff]
buy_vol = sum(t["volume_usd"] for t in recent if t["side"] == "buy")
sell_vol = sum(t["volume_usd"] for t in recent if t["side"] == "sell")
return buy_vol - sell_vol3. Trade Count Ratio
Formula:
trade_count_ratio = buy_trades / (buy_trades + sell_trades)This differs from volume ratio because it weights each trade equally regardless of size. Divergence between volume ratio and count ratio is informative:
| Volume Ratio | Count Ratio | Interpretation |
|---|---|---|
| High | High | Broad-based buying across all sizes |
| High | Low | Few large buys dominating (whale accumulation) |
| Low | High | Many small buys but large sells (distribution) |
| Low | Low | Broad-based selling |
4. Large Trade Ratio
Formula:
large_trade_ratio = whale_volume / total_volumeWhere whale_volume includes trades > 50 SOL equivalent.
| Value | Interpretation |
|---|---|
| > 0.50 | Whale-dominated market — follow the whales |
| 0.20 – 0.50 | Mixed market with significant whale presence |
| < 0.20 | Retail-dominated — less directional conviction |
Large Trade Direction
More useful than just the ratio is the direction of large trades:
def large_trade_direction(trades: list[dict], threshold_usd: float = 5000) -> dict:
"""Analyze direction of large trades."""
large = [t for t in trades if t["volume_usd"] >= threshold_usd]
if not large:
return {"whale_buy_pct": 0.5, "whale_count": 0}
buy_vol = sum(t["volume_usd"] for t in large if t["side"] == "buy")
total_vol = sum(t["volume_usd"] for t in large)
return {
"whale_buy_pct": buy_vol / total_vol if total_vol > 0 else 0.5,
"whale_count": len(large),
"whale_volume_usd": total_vol,
}5. Volume Acceleration
Formula:
volume_acceleration = current_period_volume / previous_period_volume| Value | Interpretation |
|---|---|
| > 3.0 | Volume spike — significant event, check direction |
| 2.0 – 3.0 | Surging volume — breakout or breakdown likely |
| 1.0 – 2.0 | Growing interest |
| 0.5 – 1.0 | Declining interest |
| < 0.5 | Volume drying up — consolidation or abandonment |
Important: Always pair volume acceleration with direction. A 5x volume spike that is 90% sells means something very different than 90% buys.
6. Unique Trader Count
Formula:
unique_traders = len(set(t["wallet"] for t in trades_in_period))Track this over time to measure genuine interest growth:
def unique_trader_trend(
current_traders: int, previous_traders: int
) -> float:
"""Compute unique trader growth rate."""
if previous_traders == 0:
return 0.0
return (current_traders - previous_traders) / previous_traders| Trend | Interpretation |
|---|---|
| > +0.20 | Rapidly growing community |
| 0 to +0.20 | Stable/growing |
| -0.20 to 0 | Declining interest |
| < -0.20 | Rapid exodus |
7. Token Velocity
Formula:
velocity = daily_traded_volume / circulating_supplyWhere both are measured in token units (not USD).
| Velocity | Interpretation |
|---|---|
| < 0.01 | Very low — strong holding behavior or dead token |
| 0.01 – 0.05 | Normal for established tokens |
| 0.05 – 0.20 | Active speculation |
| 0.20 – 1.00 | Extremely high turnover — likely wash trading or hype peak |
| > 1.00 | Almost certainly artificial volume |
8. Trade Size Entropy
Formula:
entropy = -sum(p * log2(p) for p in bucket_probabilities)Where bucket_probabilities is the distribution of trades across size buckets.
| Entropy | Interpretation |
|---|---|
| > 2.0 | Diverse trade sizes — organic market |
| 1.0 – 2.0 | Moderate diversity |
| < 1.0 | Concentrated sizes — possible bot/wash activity |
import math
def trade_size_entropy(trades: list[dict], buckets: int = 10) -> float:
"""Compute Shannon entropy of trade size distribution."""
if not trades:
return 0.0
sizes = [t["volume_usd"] for t in trades]
min_s, max_s = min(sizes), max(sizes)
if min_s == max_s:
return 0.0
width = (max_s - min_s) / buckets
counts = [0] * buckets
for s in sizes:
idx = min(int((s - min_s) / width), buckets - 1)
counts[idx] += 1
total = len(sizes)
probs = [c / total for c in counts if c > 0]
return -sum(p * math.log2(p) for p in probs)Composite Momentum Score
Combine signals into a single score from -100 to +100:
Component Weights
| Component | Weight | Range Mapped |
|---|---|---|
| Buy Volume Ratio | 40% | 0–1 → -40 to +40 |
| Volume Acceleration | 20% | clamped → -20 to +20 |
| Whale Buy Direction | 25% | 0–1 → -25 to +25 |
| Unique Trader Trend | 15% | clamped → -15 to +15 |
Formula
score = (
(buy_ratio - 0.5) * 80 # [-40, +40]
+ clamp((vol_accel - 1) * 20, -20, 20) # [-20, +20]
+ (whale_buy_pct - 0.5) * 50 # [-25, +25]
+ clamp(trader_trend * 15, -15, 15) # [-15, +15]
)
score = clamp(score, -100, 100)Score Interpretation
| Score | Label | Suggested Action |
|---|---|---|
| +60 to +100 | Strong Accumulation | Consider entry if other factors align |
| +20 to +60 | Moderate Buying | Monitor for continuation |
| -20 to +20 | Neutral | No clear directional signal |
| -60 to -20 | Moderate Selling | Caution on new entries |
| -100 to -60 | Strong Distribution | Avoid entries, consider exits |
Signal Freshness
Flow signals decay rapidly. Recommended maximum signal age:
| Signal | Max Useful Age |
|---|---|
| Buy/sell ratio | 1 hour for scalping, 4 hours for swing |
| Net flow | 4 hours |
| Volume acceleration | Current period only |
| Unique traders | 24 hours |
| Token velocity | 24 hours |
Always display the timestamp of the most recent trade in the dataset so the user knows how fresh the data is.
Combining with Price Action
Flow signals are most powerful when confirmed by price:
- Bullish divergence: Increasing buy ratio + flat/declining price = accumulation
- Bearish divergence: Increasing sell ratio + flat/rising price = distribution
- Confirmation: Buy ratio and price both increasing = trend continuation
Trade Classification — Buy/Sell Identification on Solana DEXes
Core Principle
On AMM-based DEXes, every trade is a swap between two tokens through a liquidity pool. There is no explicit "buy" or "sell" order type. Classification depends on which token the trader is spending (input) and which they are receiving (output), relative to the token you are analyzing.
Classification Rules
Rule 1: Quote Token Direction
For any token pair where one side is a quote asset (SOL, USDC, USDT):
| Input Token | Output Token | Classification | Rationale |
|---|---|---|---|
| SOL | Target Token | Buy | Spending SOL to acquire token |
| USDC/USDT | Target Token | Buy | Spending stables to acquire token |
| Target Token | SOL | Sell | Liquidating token for SOL |
| Target Token | USDC/USDT | Sell | Liquidating token for stables |
Rule 2: Token-to-Token Swaps
When neither side is a standard quote asset (e.g., Token A swapped for Token B via a multi-hop route):
- If analyzing Token A: the swap is a sell of A
- If analyzing Token B: the swap is a buy of B
- The same transaction can be a buy for one token and a sell for another
Rule 3: Multi-Hop Routes
Jupiter and other aggregators often route through intermediate pools. The classification should be based on the net effect — what the user's wallet started with and ended with:
def classify_swap(input_mint: str, output_mint: str, target_mint: str) -> str:
"""Classify a swap as buy or sell for the target token.
Args:
input_mint: The token mint the trader spent.
output_mint: The token mint the trader received.
target_mint: The token we are analyzing.
Returns:
'buy', 'sell', or 'unknown'.
"""
if output_mint == target_mint:
return "buy"
elif input_mint == target_mint:
return "sell"
return "unknown"Data Source Classification
Birdeye Trade History
Endpoint: GET /defi/txs/token
Birdeye provides pre-classified trades:
{
"txHash": "5abc...",
"side": "buy",
"from": {"address": "So11...", "amount": 1.5},
"to": {"address": "EPjF...", "amount": 150000},
"volumeUsd": 225.0,
"owner": "7xKX..."
}The side field is already resolved. Use it directly.
DexScreener
DexScreener pair data includes volume breakdowns but individual trade classification requires inspecting pair-level transactions. The volume.buys and volume.sells fields on the pair object give aggregate counts.
Helius Parsed Transactions
Helius returns parsed swap instructions. Extract input/output from the instruction data:
def classify_from_helius(parsed_tx: dict, target_mint: str) -> str | None:
"""Classify a Helius parsed transaction for the target token."""
for ix in parsed_tx.get("instructions", []):
if ix.get("programId") in KNOWN_DEX_PROGRAMS:
transfers = ix.get("innerInstructions", [])
# Find token transfers to determine input/output
input_mint = extract_input_mint(transfers)
output_mint = extract_output_mint(transfers)
if output_mint == target_mint:
return "buy"
if input_mint == target_mint:
return "sell"
return None
KNOWN_DEX_PROGRAMS = [
"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", # Jupiter V6
"whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc", # Orca Whirlpool
"675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8", # Raydium AMM
"CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK", # Raydium CLMM
]Trade Size Buckets
Classify trades by size to separate whale activity from retail flow:
| Bucket | SOL Equivalent | USD Approximate | Typical Actor |
|---|---|---|---|
| Micro | < 0.1 SOL | < $15 | Dust, test txs, sniper bots |
| Small | 0.1 – 1 SOL | $15 – $150 | Retail / casual traders |
| Medium | 1 – 10 SOL | $150 – $1,500 | Active traders |
| Large | 10 – 50 SOL | $1,500 – $7,500 | Serious positions |
| Whale | 50 – 200 SOL | $7,500 – $30,000 | Whales |
| Mega | 200+ SOL | $30,000+ | Institutions / big whales |
Note: USD thresholds shift with SOL price. Use SOL-denominated buckets as primary and show USD as supplementary.
def classify_trade_size(sol_amount: float) -> str:
"""Classify a trade into a size bucket based on SOL amount."""
if sol_amount < 0.1:
return "micro"
elif sol_amount < 1.0:
return "small"
elif sol_amount < 10.0:
return "medium"
elif sol_amount < 50.0:
return "large"
elif sol_amount < 200.0:
return "whale"
else:
return "mega"Aggregation Periods
Different timeframes reveal different patterns:
| Period | Use Case |
|---|---|
| 1 minute | Scalping signals, real-time flow |
| 5 minutes | Short-term momentum, entry timing |
| 1 hour | Intraday pressure, session analysis |
| 4 hours | Swing trading signals |
| 24 hours | Daily sentiment, accumulation/distribution |
Rolling vs Fixed Windows
- Fixed windows (e.g., every hour on the hour) — simpler, good for profiles
- Rolling windows (e.g., last 60 minutes from now) — smoother, better for signals
For real-time signals, use rolling windows. For historical profiles, use fixed windows.
Volume-Weighted Classification (VWAP Method)
An alternative classification approach uses VWAP deviation:
1. Compute the VWAP over a period 2. Trades executed above VWAP lean toward buy pressure (buyer willing to pay premium) 3. Trades executed below VWAP lean toward sell pressure (seller accepting discount)
def vwap_classify(price: float, vwap: float, threshold: float = 0.001) -> str:
"""Classify trade direction based on VWAP deviation.
Args:
price: Execution price of the trade.
vwap: Volume-weighted average price over the period.
threshold: Minimum deviation to classify (default 0.1%).
Returns:
'buy_pressure', 'sell_pressure', or 'neutral'.
"""
deviation = (price - vwap) / vwap
if deviation > threshold:
return "buy_pressure"
elif deviation < -threshold:
return "sell_pressure"
return "neutral"This method supplements direct buy/sell classification and is especially useful when the side field is unavailable.
Edge Cases
1. Wrapped SOL trades — wSOL (So11...) should be treated the same as native SOL 2. Stable-to-stable — USDC→USDT swaps are not buy or sell for either; skip them 3. Self-referential — Token→Token swaps through the same pool (rare, usually arb) 4. Failed transactions — Always filter for successful transactions only 5. Partial fills — Jupiter routes may partially fill; use the actual amounts, not requested
Wash Trading Detection on Solana DEXes
What Is Wash Trading?
Wash trading is the practice of simultaneously buying and selling the same asset to create the illusion of market activity. On Solana DEXes, wash trading is common because:
- Transaction fees are very low (~$0.001 per swap)
- No KYC — anyone can create unlimited wallets
- Volume attracts attention on aggregator sites (DexScreener, Birdeye)
- High volume rankings drive organic trader interest
Why Detection Matters
- Inflated volume makes tokens appear more liquid and popular than they are
- False confidence — traders size positions based on volume that is not real
- Slippage risk — real liquidity is much lower than volume suggests
- Rug risk correlation — tokens with heavy wash trading are more likely to rug
Detection Methods
Method 1: Self-Trading (Same Wallet)
The simplest form. A single wallet buys and sells within a short window.
Detection:
def detect_self_trades(
trades: list[dict], window_seconds: int = 300
) -> list[dict]:
"""Find wallets that buy and sell within a short time window."""
from collections import defaultdict
wallet_trades = defaultdict(list)
for t in trades:
wallet_trades[t["wallet"]].append(t)
suspicious = []
for wallet, wtrades in wallet_trades.items():
buys = [t for t in wtrades if t["side"] == "buy"]
sells = [t for t in wtrades if t["side"] == "sell"]
for buy in buys:
for sell in sells:
if abs(buy["timestamp"] - sell["timestamp"]) < window_seconds:
suspicious.append({
"wallet": wallet,
"buy_time": buy["timestamp"],
"sell_time": sell["timestamp"],
"buy_amount": buy["volume_usd"],
"sell_amount": sell["volume_usd"],
})
return suspiciousThreshold: If self-trading wallets account for > 20% of volume, flag as suspicious.
Method 2: Cluster Trading (Funded-Together Wallets)
More sophisticated wash traders use multiple wallets funded from a common source.
Detection approach: 1. For each trading wallet, look up its funding history (first SOL transfer in) 2. Group wallets that received initial funding from the same parent 3. If a cluster of funded-together wallets accounts for high volume, flag it
This requires Helius or Solana RPC to trace funding. See the helius-api skill.
Method 3: Uniform Trade Sizes
Bots often execute trades of identical or near-identical sizes.
Detection:
def detect_uniform_sizes(
trades: list[dict], tolerance: float = 0.02
) -> dict:
"""Detect suspiciously uniform trade sizes.
Args:
trades: List of trade records.
tolerance: Maximum relative deviation to consider 'same size'.
Returns:
Dict with uniformity score and flagged groups.
"""
from collections import Counter
# Round to tolerance band
rounded = []
for t in trades:
band = round(t["volume_usd"] / (t["volume_usd"] * tolerance + 1))
rounded.append(band)
counts = Counter(rounded)
if not counts:
return {"uniformity_score": 0, "max_cluster_pct": 0}
max_cluster = max(counts.values())
total = len(trades)
return {
"uniformity_score": max_cluster / total,
"max_cluster_pct": max_cluster / total,
"max_cluster_size": max_cluster,
"total_trades": total,
}Threshold: If > 40% of trades fall in the same size band, flag as suspicious.
Method 4: Low Unique Trader Ratio
Organic markets have many distinct participants. Wash traded tokens have few.
Formula:
unique_trader_ratio = unique_wallets / total_trade_count| Ratio | Interpretation |
|---|---|
| > 0.60 | Healthy — many unique participants |
| 0.30 – 0.60 | Normal — some repeat traders |
| 0.10 – 0.30 | Suspicious — few wallets generating many trades |
| < 0.10 | Almost certainly wash trading |
Method 5: Volume/Liquidity Anomaly
Real volume is constrained by available liquidity. If daily volume far exceeds TVL, much of it is likely wash traded.
Formula:
volume_tvl_ratio = daily_volume_usd / pool_tvl_usd| Ratio | Interpretation |
|---|---|
| < 2.0 | Normal — volume within liquidity capacity |
| 2.0 – 5.0 | Active but plausible |
| 5.0 – 20.0 | Suspicious — check other signals |
| > 20.0 | Very likely wash traded |
Rationale: For volume to be 20x TVL, the same liquidity must turn over 20 times per day. While possible in high-frequency pools (SOL/USDC), it is implausible for small-cap tokens.
Composite Wash Trading Score
Combine detection signals into a 0–100 risk score:
def wash_trading_score(
unique_ratio: float,
volume_tvl: float,
uniformity: float,
self_trade_pct: float,
) -> float:
"""Compute wash trading risk score (0 = clean, 100 = definitely wash).
Args:
unique_ratio: unique_wallets / trade_count (0 to 1).
volume_tvl: daily_volume / pool_tvl.
uniformity: max_cluster_pct from uniform size detection.
self_trade_pct: Fraction of volume from self-trading wallets.
"""
# Low unique ratio -> higher risk (weight: 30)
ratio_score = max(0, (0.5 - unique_ratio) / 0.5) * 30
# High volume/TVL -> higher risk (weight: 25)
vtl_score = min(25, max(0, (volume_tvl - 2.0) / 18.0) * 25)
# High uniformity -> higher risk (weight: 20)
uniform_score = min(20, uniformity * 50)
# Self-trading -> higher risk (weight: 25)
self_score = min(25, self_trade_pct * 100)
return min(100, ratio_score + vtl_score + uniform_score + self_score)Score Interpretation
| Score | Risk Level | Recommended Action |
|---|---|---|
| 0 – 20 | Low | Volume appears organic |
| 20 – 40 | Moderate | Some red flags — reduce position sizing |
| 40 – 60 | High | Significant wash trading likely — use caution |
| 60 – 80 | Very High | Most volume is likely fake — avoid or size tiny |
| 80 – 100 | Extreme | Almost certainly wash traded — do not rely on volume |
Practical Adjustments
When wash trading is detected, adjust other metrics:
1. Effective volume = reported_volume (1 - wash_score/100) 2. Effective unique traders = reported_traders unique_ratio 3. Adjusted velocity = velocity * (1 - wash_score/100)
These adjusted metrics give a more realistic picture of genuine market activity.
Limitations
- Cluster detection requires on-chain funding trace data (expensive)
- Sophisticated wash traders use randomized sizes and timing
- New legitimate tokens may trigger false positives due to low unique traders
- Volume/TVL ratio depends on accurate TVL data, which can lag
- Self-trading detection requires wallet-attributed trade data (not always available)
Always use multiple detection methods together. No single signal is definitive.
#!/usr/bin/env python3
"""Trade flow analysis for Solana tokens.
Fetches recent trades for a token, classifies them by size and direction,
computes buy/sell pressure metrics, and generates a composite momentum score.
Usage:
python scripts/trade_flow_analysis.py # demo mode
python scripts/trade_flow_analysis.py --demo # explicit demo
TOKEN_MINT=EPjF... BIRDEYE_API_KEY=xxx python scripts/trade_flow_analysis.py
Dependencies:
uv pip install httpx
Environment Variables:
TOKEN_MINT: Solana token mint address to analyze
BIRDEYE_API_KEY: Birdeye API key (optional — falls back to demo data)
"""
import json
import math
import os
import random
import statistics
import sys
import time
from typing import Optional
try:
import httpx
except ImportError:
print("Install httpx: uv pip install httpx")
sys.exit(1)
# ── Configuration ───────────────────────────────────────────────────
TOKEN_MINT = os.getenv("TOKEN_MINT", "")
BIRDEYE_API_KEY = os.getenv("BIRDEYE_API_KEY", "")
DEMO_MODE = "--demo" in sys.argv or (not TOKEN_MINT and not BIRDEYE_API_KEY)
SOL_PRICE_USD = 150.0 # Approximate SOL price for demo bucketing
# Trade size buckets in SOL
SIZE_BUCKETS = {
"micro": (0, 0.1),
"small": (0.1, 1.0),
"medium": (1.0, 10.0),
"large": (10.0, 50.0),
"whale": (50.0, 200.0),
"mega": (200.0, float("inf")),
}
# ── Data Fetching ──────────────────────────────────────────────────
def fetch_trades_birdeye(
token_mint: str, api_key: str, limit: int = 200
) -> list[dict]:
"""Fetch recent trades from Birdeye API.
Args:
token_mint: Token mint address.
api_key: Birdeye API key.
limit: Maximum trades to fetch.
Returns:
List of normalized trade dicts with keys:
side, volume_usd, volume_sol, timestamp, wallet, tx_hash.
Raises:
httpx.HTTPStatusError: On non-2xx response.
"""
url = "https://public-api.birdeye.so/defi/txs/token"
headers = {"X-API-KEY": api_key, "accept": "application/json"}
params = {"address": token_mint, "limit": min(limit, 50), "tx_type": "swap"}
trades: list[dict] = []
with httpx.Client(timeout=30) as client:
resp = client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
for item in data.get("data", {}).get("items", []):
sol_amount = item.get("volumeUsd", 0) / SOL_PRICE_USD
trades.append({
"side": item.get("side", "unknown"),
"volume_usd": item.get("volumeUsd", 0),
"volume_sol": sol_amount,
"timestamp": item.get("blockUnixTime", 0),
"wallet": item.get("owner", "unknown"),
"tx_hash": item.get("txHash", ""),
})
return trades
def fetch_trades_dexscreener(token_mint: str) -> list[dict]:
"""Fetch pair data from DexScreener as volume summary fallback.
DexScreener does not provide individual trade data, but pair-level
volume stats can be used for aggregate analysis.
Args:
token_mint: Token mint address.
Returns:
List with a single summary pseudo-trade, or empty list.
"""
url = f"https://api.dexscreener.com/latest/dex/tokens/{token_mint}"
try:
with httpx.Client(timeout=15) as client:
resp = client.get(url)
resp.raise_for_status()
data = resp.json()
pairs = data.get("pairs", [])
if not pairs:
return []
pair = pairs[0]
volume_24h = pair.get("volume", {}).get("h24", 0)
buys = pair.get("txns", {}).get("h24", {}).get("buys", 0)
sells = pair.get("txns", {}).get("h24", {}).get("sells", 0)
print(f" DexScreener 24h volume: ${volume_24h:,.0f}")
print(f" DexScreener 24h buys: {buys}, sells: {sells}")
return [] # No individual trades available
except Exception as e:
print(f" DexScreener fallback failed: {e}")
return []
def generate_demo_trades(count: int = 500) -> list[dict]:
"""Generate synthetic trade data for demonstration.
Creates realistic trade data with patterns:
- Mix of buy/sell with slight buy bias
- Log-normal trade size distribution
- Some whale trades
- Some repeat wallets (simulating active traders)
- Timestamps over the last 24 hours
Args:
count: Number of synthetic trades to generate.
Returns:
List of normalized trade dicts.
"""
now = int(time.time())
wallets = [f"wallet_{i:04d}" for i in range(80)] # 80 unique wallets
active_wallets = wallets[:15] # 15 are very active
trades: list[dict] = []
for i in range(count):
# Time: spread over last 24 hours with some clustering
hours_ago = random.expovariate(0.15) # cluster toward recent
hours_ago = min(hours_ago, 24.0)
ts = now - int(hours_ago * 3600)
# Side: 55% buys (slight accumulation bias)
side = "buy" if random.random() < 0.55 else "sell"
# Size: log-normal distribution (most small, few large)
sol_amount = random.lognormvariate(mu=0.5, sigma=1.5)
sol_amount = max(0.01, min(sol_amount, 500.0))
# Wallet: active wallets trade more
if random.random() < 0.4:
wallet = random.choice(active_wallets)
else:
wallet = random.choice(wallets)
trades.append({
"side": side,
"volume_usd": sol_amount * SOL_PRICE_USD,
"volume_sol": sol_amount,
"timestamp": ts,
"wallet": wallet,
"tx_hash": f"demo_tx_{i:06d}",
})
# Add some wash-like trades (same wallet buy+sell similar size)
for _ in range(20):
wash_wallet = f"wash_{random.randint(0, 4):02d}"
wash_size = random.uniform(1.0, 5.0)
wash_time = now - random.randint(0, 86400)
trades.append({
"side": "buy",
"volume_usd": wash_size * SOL_PRICE_USD,
"volume_sol": wash_size,
"timestamp": wash_time,
"wallet": wash_wallet,
"tx_hash": f"wash_buy_{_:04d}",
})
trades.append({
"side": "sell",
"volume_usd": wash_size * SOL_PRICE_USD * random.uniform(0.98, 1.02),
"volume_sol": wash_size * random.uniform(0.98, 1.02),
"timestamp": wash_time + random.randint(10, 120),
"wallet": wash_wallet,
"tx_hash": f"wash_sell_{_:04d}",
})
trades.sort(key=lambda t: t["timestamp"])
return trades
# ── Analysis Functions ─────────────────────────────────────────────
def classify_trade_size(sol_amount: float) -> str:
"""Classify trade into size bucket.
Args:
sol_amount: Trade size in SOL.
Returns:
Bucket name: micro, small, medium, large, whale, or mega.
"""
for bucket, (lo, hi) in SIZE_BUCKETS.items():
if lo <= sol_amount < hi:
return bucket
return "mega"
def compute_pressure_metrics(trades: list[dict]) -> dict:
"""Compute buy/sell pressure metrics from trade list.
Args:
trades: List of trade dicts with 'side' and 'volume_usd'.
Returns:
Dict with buy_ratio, net_flow, trade_count_ratio, etc.
"""
buy_vol = sum(t["volume_usd"] for t in trades if t["side"] == "buy")
sell_vol = sum(t["volume_usd"] for t in trades if t["side"] == "sell")
total_vol = buy_vol + sell_vol
buy_count = sum(1 for t in trades if t["side"] == "buy")
sell_count = sum(1 for t in trades if t["side"] == "sell")
total_count = buy_count + sell_count
return {
"buy_volume_usd": buy_vol,
"sell_volume_usd": sell_vol,
"total_volume_usd": total_vol,
"buy_ratio": buy_vol / total_vol if total_vol > 0 else 0.5,
"net_flow_usd": buy_vol - sell_vol,
"buy_count": buy_count,
"sell_count": sell_count,
"trade_count_ratio": buy_count / total_count if total_count > 0 else 0.5,
}
def compute_size_distribution(trades: list[dict]) -> dict[str, dict]:
"""Compute volume and count by trade size bucket.
Args:
trades: List of trade dicts.
Returns:
Dict mapping bucket name to {count, volume_usd, buy_count, sell_count}.
"""
dist: dict[str, dict] = {}
for bucket in SIZE_BUCKETS:
dist[bucket] = {"count": 0, "volume_usd": 0, "buy_count": 0, "sell_count": 0}
for t in trades:
bucket = classify_trade_size(t["volume_sol"])
dist[bucket]["count"] += 1
dist[bucket]["volume_usd"] += t["volume_usd"]
if t["side"] == "buy":
dist[bucket]["buy_count"] += 1
else:
dist[bucket]["sell_count"] += 1
return dist
def compute_unique_traders(trades: list[dict]) -> dict:
"""Compute unique trader metrics.
Args:
trades: List of trade dicts with 'wallet' field.
Returns:
Dict with unique_count, total_trades, ratio, top_traders.
"""
from collections import Counter
wallet_counts = Counter(t["wallet"] for t in trades)
unique = len(wallet_counts)
total = len(trades)
top_5 = wallet_counts.most_common(5)
top_volume: dict[str, float] = {}
for wallet, _ in top_5:
vol = sum(t["volume_usd"] for t in trades if t["wallet"] == wallet)
top_volume[wallet] = vol
return {
"unique_count": unique,
"total_trades": total,
"unique_ratio": unique / total if total > 0 else 0,
"top_traders": [
{"wallet": w, "trades": c, "volume_usd": top_volume.get(w, 0)}
for w, c in top_5
],
}
def detect_self_trades(
trades: list[dict], window_seconds: int = 300
) -> dict:
"""Detect wallets that buy and sell within a short window.
Args:
trades: List of trade dicts.
window_seconds: Time window for matching buy/sell pairs.
Returns:
Dict with self_trade_count, self_trade_volume, self_trade_wallets.
"""
from collections import defaultdict
wallet_trades: dict[str, list[dict]] = defaultdict(list)
for t in trades:
wallet_trades[t["wallet"]].append(t)
self_trade_wallets: set[str] = set()
self_trade_volume = 0.0
self_trade_count = 0
for wallet, wtrades in wallet_trades.items():
buys = [t for t in wtrades if t["side"] == "buy"]
sells = [t for t in wtrades if t["side"] == "sell"]
for buy in buys:
for sell in sells:
if abs(buy["timestamp"] - sell["timestamp"]) < window_seconds:
self_trade_wallets.add(wallet)
self_trade_volume += buy["volume_usd"] + sell["volume_usd"]
self_trade_count += 2
break
else:
continue
break
return {
"self_trade_wallets": len(self_trade_wallets),
"self_trade_volume_usd": self_trade_volume,
"self_trade_count": self_trade_count,
}
def trade_size_entropy(trades: list[dict], buckets: int = 10) -> float:
"""Compute Shannon entropy of trade size distribution.
Args:
trades: List of trade dicts.
buckets: Number of histogram bins.
Returns:
Entropy value (higher = more diverse sizes).
"""
if len(trades) < 2:
return 0.0
sizes = [t["volume_usd"] for t in trades]
min_s, max_s = min(sizes), max(sizes)
if min_s == max_s:
return 0.0
width = (max_s - min_s) / buckets
counts = [0] * buckets
for s in sizes:
idx = min(int((s - min_s) / width), buckets - 1)
counts[idx] += 1
total = len(sizes)
probs = [c / total for c in counts if c > 0]
return -sum(p * math.log2(p) for p in probs)
def compute_momentum_score(
buy_ratio: float,
volume_accel: float,
whale_buy_pct: float,
unique_trader_trend: float,
) -> float:
"""Compute composite momentum score from flow signals.
Args:
buy_ratio: Buy volume / total volume (0 to 1).
volume_accel: Current vol / previous vol.
whale_buy_pct: Whale buy volume / total whale volume (0 to 1).
unique_trader_trend: Change rate in unique traders.
Returns:
Score from -100 (strong sell pressure) to +100 (strong buy pressure).
"""
buy_component = (buy_ratio - 0.5) * 80
vol_component = max(-20, min(20, (volume_accel - 1.0) * 20))
whale_component = (whale_buy_pct - 0.5) * 50
trader_component = max(-15, min(15, unique_trader_trend * 15))
score = buy_component + vol_component + whale_component + trader_component
return max(-100, min(100, score))
def interpret_score(score: float) -> str:
"""Return human-readable interpretation of momentum score.
Args:
score: Momentum score (-100 to +100).
Returns:
Interpretation string.
"""
if score >= 60:
return "STRONG ACCUMULATION — heavy buy pressure"
elif score >= 20:
return "MODERATE BUYING — cautious accumulation"
elif score >= -20:
return "NEUTRAL — balanced flow"
elif score >= -60:
return "MODERATE SELLING — distribution underway"
else:
return "STRONG DISTRIBUTION — heavy sell pressure"
# ── Reporting ──────────────────────────────────────────────────────
def print_report(trades: list[dict], token_label: str) -> None:
"""Print formatted trade flow analysis report.
Args:
trades: List of normalized trade dicts.
token_label: Display label for the token.
"""
print("=" * 70)
print(f" TRADE FLOW ANALYSIS — {token_label}")
print(f" Trades analyzed: {len(trades)}")
if trades:
oldest = min(t["timestamp"] for t in trades)
newest = max(t["timestamp"] for t in trades)
span_hours = (newest - oldest) / 3600
print(f" Time span: {span_hours:.1f} hours")
print("=" * 70)
# ── Pressure Metrics ───────────────────────────────────────────
pressure = compute_pressure_metrics(trades)
print("\n── Buy/Sell Pressure ──────────────────────────────────")
print(f" Buy Volume: ${pressure['buy_volume_usd']:>12,.0f} "
f"({pressure['buy_ratio']:.1%})")
print(f" Sell Volume: ${pressure['sell_volume_usd']:>12,.0f} "
f"({1 - pressure['buy_ratio']:.1%})")
print(f" Net Flow: ${pressure['net_flow_usd']:>+12,.0f}")
print(f" Buy Trades: {pressure['buy_count']:>6d} "
f"({pressure['trade_count_ratio']:.1%})")
print(f" Sell Trades: {pressure['sell_count']:>6d} "
f"({1 - pressure['trade_count_ratio']:.1%})")
# ── Size Distribution ──────────────────────────────────────────
dist = compute_size_distribution(trades)
print("\n── Trade Size Distribution ────────────────────────────")
print(f" {'Bucket':<8} {'Count':>6} {'Volume':>12} {'Buys':>6} {'Sells':>6}")
print(f" {'-'*8} {'-'*6} {'-'*12} {'-'*6} {'-'*6}")
for bucket, data in dist.items():
if data["count"] > 0:
print(f" {bucket:<8} {data['count']:>6d} "
f"${data['volume_usd']:>10,.0f} "
f"{data['buy_count']:>6d} {data['sell_count']:>6d}")
# ── Unique Traders ─────────────────────────────────────────────
trader_info = compute_unique_traders(trades)
print("\n── Unique Traders ────────────────────────────────────")
print(f" Unique wallets: {trader_info['unique_count']}")
print(f" Total trades: {trader_info['total_trades']}")
print(f" Unique ratio: {trader_info['unique_ratio']:.3f}")
print(f"\n Top 5 traders:")
for t in trader_info["top_traders"]:
w = t["wallet"]
if len(w) > 16:
w = w[:6] + "..." + w[-4:]
print(f" {w:<16} {t['trades']:>4} trades ${t['volume_usd']:>10,.0f}")
# ── Wash Trading Signals ───────────────────────────────────────
self_trades = detect_self_trades(trades)
entropy = trade_size_entropy(trades)
print("\n── Wash Trading Indicators ───────────────────────────")
print(f" Self-trading wallets: {self_trades['self_trade_wallets']}")
print(f" Self-trade volume: ${self_trades['self_trade_volume_usd']:,.0f}")
print(f" Trade size entropy: {entropy:.2f} "
f"({'diverse' if entropy > 2.0 else 'moderate' if entropy > 1.0 else 'low — suspicious'})")
print(f" Unique trader ratio: {trader_info['unique_ratio']:.3f} "
f"({'healthy' if trader_info['unique_ratio'] > 0.3 else 'suspicious'})")
# ── Momentum Score ─────────────────────────────────────────────
# Split trades into two halves for acceleration
mid = len(trades) // 2
first_half = trades[:mid]
second_half = trades[mid:]
first_vol = sum(t["volume_usd"] for t in first_half)
second_vol = sum(t["volume_usd"] for t in second_half)
vol_accel = second_vol / first_vol if first_vol > 0 else 1.0
# Whale buy percentage
whale_trades = [t for t in trades if t["volume_sol"] >= 50]
if whale_trades:
whale_buy_vol = sum(t["volume_usd"] for t in whale_trades if t["side"] == "buy")
whale_total = sum(t["volume_usd"] for t in whale_trades)
whale_buy_pct = whale_buy_vol / whale_total if whale_total > 0 else 0.5
else:
whale_buy_pct = 0.5
# Unique trader trend (first vs second half)
first_unique = len(set(t["wallet"] for t in first_half))
second_unique = len(set(t["wallet"] for t in second_half))
trader_trend = (
(second_unique - first_unique) / first_unique
if first_unique > 0
else 0.0
)
score = compute_momentum_score(
pressure["buy_ratio"], vol_accel, whale_buy_pct, trader_trend
)
print("\n── Composite Momentum Score ──────────────────────────")
print(f" Buy Ratio: {pressure['buy_ratio']:.3f}")
print(f" Volume Acceleration: {vol_accel:.2f}x")
print(f" Whale Buy %: {whale_buy_pct:.1%}")
print(f" Trader Trend: {trader_trend:+.1%}")
print(f" ─────────────────────────────")
print(f" MOMENTUM SCORE: {score:+.1f} / 100")
print(f" Signal: {interpret_score(score)}")
print("=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point for trade flow analysis."""
if DEMO_MODE:
print("[DEMO MODE] Using synthetic trade data\n")
trades = generate_demo_trades(500)
print_report(trades, "DEMO TOKEN")
return
print(f"Fetching trades for {TOKEN_MINT[:8]}...{TOKEN_MINT[-4:]}")
# Try Birdeye first
trades: list[dict] = []
if BIRDEYE_API_KEY:
try:
trades = fetch_trades_birdeye(TOKEN_MINT, BIRDEYE_API_KEY)
print(f" Birdeye: fetched {len(trades)} trades")
except Exception as e:
print(f" Birdeye fetch failed: {e}")
# Fallback to DexScreener for aggregate stats
if not trades:
print(" Trying DexScreener for aggregate data...")
fetch_trades_dexscreener(TOKEN_MINT)
print("\n No individual trade data available. Use --demo for synthetic data.")
sys.exit(0)
label = f"{TOKEN_MINT[:8]}...{TOKEN_MINT[-4:]}"
print_report(trades, label)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Volume profile analysis for Solana tokens.
Builds hourly volume profiles, detects trends and anomalies, and identifies
peak trading hours. Useful for timing entries and understanding activity patterns.
Usage:
python scripts/volume_profile.py # demo mode
python scripts/volume_profile.py --demo # explicit demo
TOKEN_MINT=EPjF... BIRDEYE_API_KEY=xxx python scripts/volume_profile.py
Dependencies:
uv pip install httpx
Environment Variables:
TOKEN_MINT: Solana token mint address to analyze
BIRDEYE_API_KEY: Birdeye API key (optional — falls back to demo data)
"""
import math
import os
import random
import statistics
import sys
import time
from datetime import datetime, timezone
from typing import Optional
try:
import httpx
except ImportError:
print("Install httpx: uv pip install httpx")
sys.exit(1)
# ── Configuration ───────────────────────────────────────────────────
TOKEN_MINT = os.getenv("TOKEN_MINT", "")
BIRDEYE_API_KEY = os.getenv("BIRDEYE_API_KEY", "")
DEMO_MODE = "--demo" in sys.argv or (not TOKEN_MINT and not BIRDEYE_API_KEY)
ANOMALY_THRESHOLD = 3.0 # Volume spike = 3x rolling average
# ── Data Fetching ──────────────────────────────────────────────────
def fetch_ohlcv_birdeye(
token_mint: str, api_key: str, interval: str = "1H", limit: int = 168
) -> list[dict]:
"""Fetch OHLCV candle data from Birdeye.
Args:
token_mint: Token mint address.
api_key: Birdeye API key.
interval: Candle interval (1H, 4H, 1D).
limit: Number of candles to fetch (168 = 7 days of hourly).
Returns:
List of candle dicts with keys: timestamp, open, high, low, close,
volume_usd, trades_count.
Raises:
httpx.HTTPStatusError: On non-2xx response.
"""
url = "https://public-api.birdeye.so/defi/ohlcv"
headers = {"X-API-KEY": api_key, "accept": "application/json"}
now = int(time.time())
time_from = now - (limit * 3600) # approximate for 1H candles
params = {
"address": token_mint,
"type": interval,
"time_from": time_from,
"time_to": now,
}
with httpx.Client(timeout=30) as client:
resp = client.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
candles: list[dict] = []
for item in data.get("data", {}).get("items", []):
candles.append({
"timestamp": item.get("unixTime", 0),
"open": item.get("o", 0),
"high": item.get("h", 0),
"low": item.get("l", 0),
"close": item.get("c", 0),
"volume_usd": item.get("v", 0),
"trades_count": item.get("trades", 0),
})
candles.sort(key=lambda c: c["timestamp"])
return candles
def generate_demo_candles(hours: int = 168) -> list[dict]:
"""Generate synthetic hourly candle data for demonstration.
Creates realistic volume patterns with:
- Daily cyclicality (higher during US/EU hours)
- Random volume spikes
- Gradual trend
- Weekend dips
Args:
hours: Number of hourly candles to generate.
Returns:
List of candle dicts.
"""
now = int(time.time())
candles: list[dict] = []
base_volume = 50000.0 # Base hourly volume in USD
price = 0.001 # Starting price
for i in range(hours):
ts = now - (hours - i) * 3600
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
hour_of_day = dt.hour
day_of_week = dt.weekday()
# Daily cycle: peak at 14-18 UTC, low at 2-6 UTC
hour_factor = 0.5 + 0.8 * math.sin(math.pi * (hour_of_day - 6) / 12) ** 2
if hour_of_day < 6 or hour_of_day > 22:
hour_factor *= 0.6
# Weekend dip
weekend_factor = 0.6 if day_of_week >= 5 else 1.0
# Random variation
noise = random.lognormvariate(0, 0.4)
# Occasional spikes
spike = 1.0
if random.random() < 0.03:
spike = random.uniform(3.0, 8.0)
# Gradual volume trend (slight increase)
trend = 1.0 + (i / hours) * 0.3
volume = base_volume * hour_factor * weekend_factor * noise * spike * trend
volume = max(100, volume)
trades = max(1, int(volume / random.uniform(100, 500)))
# Price random walk
price *= 1 + random.gauss(0.0002, 0.01)
o = price
h = price * (1 + abs(random.gauss(0, 0.02)))
l = price * (1 - abs(random.gauss(0, 0.02)))
c = price * (1 + random.gauss(0, 0.005))
price = c
candles.append({
"timestamp": ts,
"open": o,
"high": h,
"low": l,
"close": c,
"volume_usd": volume,
"trades_count": trades,
})
return candles
# ── Analysis Functions ─────────────────────────────────────────────
def build_hourly_profile(candles: list[dict]) -> dict[int, dict]:
"""Aggregate candles by hour-of-day to build a volume profile.
Args:
candles: List of hourly candle dicts.
Returns:
Dict mapping hour (0-23) to aggregated stats.
"""
profile: dict[int, dict] = {
h: {"total_volume": 0, "candle_count": 0, "total_trades": 0, "volumes": []}
for h in range(24)
}
for c in candles:
dt = datetime.fromtimestamp(c["timestamp"], tz=timezone.utc)
hour = dt.hour
vol = c["volume_usd"]
profile[hour]["total_volume"] += vol
profile[hour]["candle_count"] += 1
profile[hour]["total_trades"] += c.get("trades_count", 0)
profile[hour]["volumes"].append(vol)
for hour in range(24):
p = profile[hour]
count = p["candle_count"]
if count > 0:
p["avg_volume"] = p["total_volume"] / count
p["avg_trades"] = p["total_trades"] / count
else:
p["avg_volume"] = 0
p["avg_trades"] = 0
return profile
def detect_volume_trend(candles: list[dict], window: int = 24) -> dict:
"""Detect volume trend by comparing recent vs prior period.
Args:
candles: List of hourly candle dicts, sorted by time.
window: Hours to compare (default 24 = last day vs prior day).
Returns:
Dict with trend classification and ratio.
"""
if len(candles) < window * 2:
return {"trend": "insufficient_data", "ratio": 1.0}
recent = candles[-window:]
prior = candles[-window * 2 : -window]
recent_vol = sum(c["volume_usd"] for c in recent)
prior_vol = sum(c["volume_usd"] for c in prior)
if prior_vol == 0:
return {"trend": "no_prior_data", "ratio": 0}
ratio = recent_vol / prior_vol
if ratio > 1.5:
trend = "strongly_increasing"
elif ratio > 1.1:
trend = "increasing"
elif ratio > 0.9:
trend = "stable"
elif ratio > 0.5:
trend = "decreasing"
else:
trend = "strongly_decreasing"
return {
"trend": trend,
"ratio": ratio,
"recent_volume_usd": recent_vol,
"prior_volume_usd": prior_vol,
}
def detect_anomalies(
candles: list[dict], threshold: float = ANOMALY_THRESHOLD, lookback: int = 24
) -> list[dict]:
"""Identify volume anomalies (spikes exceeding threshold * rolling average).
Args:
candles: List of hourly candle dicts, sorted by time.
threshold: Multiple of rolling average to flag as anomaly.
lookback: Rolling window size in candles.
Returns:
List of anomaly dicts with timestamp, volume, average, and ratio.
"""
anomalies: list[dict] = []
for i in range(lookback, len(candles)):
window = candles[i - lookback : i]
avg = statistics.mean(c["volume_usd"] for c in window)
current = candles[i]["volume_usd"]
if avg > 0 and current / avg > threshold:
dt = datetime.fromtimestamp(
candles[i]["timestamp"], tz=timezone.utc
)
anomalies.append({
"timestamp": candles[i]["timestamp"],
"datetime": dt.strftime("%Y-%m-%d %H:%M UTC"),
"volume_usd": current,
"rolling_avg": avg,
"ratio": current / avg,
})
return anomalies
def compute_volume_stats(candles: list[dict]) -> dict:
"""Compute summary volume statistics.
Args:
candles: List of candle dicts.
Returns:
Dict with total, mean, median, stdev, max, min volumes.
"""
volumes = [c["volume_usd"] for c in candles]
if not volumes:
return {}
return {
"total_usd": sum(volumes),
"mean_usd": statistics.mean(volumes),
"median_usd": statistics.median(volumes),
"stdev_usd": statistics.stdev(volumes) if len(volumes) > 1 else 0,
"max_usd": max(volumes),
"min_usd": min(volumes),
"candle_count": len(volumes),
}
# ── Reporting ──────────────────────────────────────────────────────
def print_bar(value: float, max_value: float, width: int = 30) -> str:
"""Generate a text-based bar for display.
Args:
value: Current value.
max_value: Maximum value (full bar width).
width: Character width of full bar.
Returns:
Bar string made of block characters.
"""
if max_value <= 0:
return ""
filled = int((value / max_value) * width)
filled = min(filled, width)
return "\u2588" * filled
def print_report(candles: list[dict], token_label: str) -> None:
"""Print formatted volume profile report.
Args:
candles: List of hourly candle dicts.
token_label: Display label for the token.
"""
print("=" * 70)
print(f" VOLUME PROFILE — {token_label}")
print(f" Candles analyzed: {len(candles)}")
if candles:
start = datetime.fromtimestamp(candles[0]["timestamp"], tz=timezone.utc)
end = datetime.fromtimestamp(candles[-1]["timestamp"], tz=timezone.utc)
print(f" Period: {start.strftime('%Y-%m-%d %H:%M')} to "
f"{end.strftime('%Y-%m-%d %H:%M')} UTC")
print("=" * 70)
# ── Summary Stats ──────────────────────────────────────────────
stats = compute_volume_stats(candles)
if stats:
print("\n── Volume Summary ────────────────────────────────────")
print(f" Total Volume: ${stats['total_usd']:>14,.0f}")
print(f" Hourly Mean: ${stats['mean_usd']:>14,.0f}")
print(f" Hourly Median: ${stats['median_usd']:>14,.0f}")
print(f" Std Deviation: ${stats['stdev_usd']:>14,.0f}")
print(f" Max Hour: ${stats['max_usd']:>14,.0f}")
print(f" Min Hour: ${stats['min_usd']:>14,.0f}")
# ── Hourly Profile ─────────────────────────────────────────────
profile = build_hourly_profile(candles)
max_avg = max(p["avg_volume"] for p in profile.values())
print("\n── Hourly Volume Profile (UTC) ───────────────────────")
print(f" {'Hour':>4} {'Avg Volume':>12} {'Avg Trades':>10} Bar")
print(f" {'----':>4} {'----------':>12} {'----------':>10} ---")
for hour in range(24):
p = profile[hour]
bar = print_bar(p["avg_volume"], max_avg)
print(f" {hour:>4} ${p['avg_volume']:>10,.0f} {p['avg_trades']:>10.0f} {bar}")
# Peak and quiet hours
sorted_hours = sorted(
range(24), key=lambda h: profile[h]["avg_volume"], reverse=True
)
peak_hours = sorted_hours[:3]
quiet_hours = sorted_hours[-3:]
print(f"\n Peak hours (UTC): {', '.join(f'{h:02d}:00' for h in sorted(peak_hours))}")
print(f" Quiet hours (UTC): {', '.join(f'{h:02d}:00' for h in sorted(quiet_hours))}")
# ── Volume Trend ───────────────────────────────────────────────
trend = detect_volume_trend(candles)
print("\n── Volume Trend (24h vs prior 24h) ───────────────────")
print(f" Trend: {trend['trend']}")
print(f" Ratio: {trend.get('ratio', 0):.2f}x")
if "recent_volume_usd" in trend:
print(f" Recent 24h: ${trend['recent_volume_usd']:>12,.0f}")
print(f" Prior 24h: ${trend['prior_volume_usd']:>12,.0f}")
# ── Anomalies ──────────────────────────────────────────────────
anomalies = detect_anomalies(candles)
print(f"\n── Volume Anomalies (>{ANOMALY_THRESHOLD:.0f}x average) "
f"──────────────────────")
if anomalies:
print(f" Found {len(anomalies)} anomalies:")
for a in anomalies[-10:]: # Show most recent 10
print(f" {a['datetime']} "
f"${a['volume_usd']:>10,.0f} "
f"({a['ratio']:.1f}x avg)")
else:
print(" No volume anomalies detected.")
print("=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point for volume profile analysis."""
if DEMO_MODE:
print("[DEMO MODE] Using synthetic candle data\n")
candles = generate_demo_candles(168)
print_report(candles, "DEMO TOKEN")
return
print(f"Fetching OHLCV for {TOKEN_MINT[:8]}...{TOKEN_MINT[-4:]}")
if not BIRDEYE_API_KEY:
print(" BIRDEYE_API_KEY not set. Use --demo for synthetic data.")
sys.exit(1)
try:
candles = fetch_ohlcv_birdeye(TOKEN_MINT, BIRDEYE_API_KEY)
print(f" Fetched {len(candles)} hourly candles")
except Exception as e:
print(f" Fetch failed: {e}")
print(" Use --demo for synthetic data.")
sys.exit(1)
if not candles:
print(" No candle data returned. Use --demo for synthetic data.")
sys.exit(0)
label = f"{TOKEN_MINT[:8]}...{TOKEN_MINT[-4:]}"
print_report(candles, label)
if __name__ == "__main__":
main()