ML Feature Engineering: Order-Flow Imbalance on Solana
Shows how to derive predictive order-flow imbalance features from Solana's transaction stream — accounting for priority fees as a proxy for aggressor intent — and plug them into a gradient-boosted short-term price model.
Order-flow imbalance on Solana is one of the few features that remains predictive in production once you correct for the noise injected by validator scheduling and fee markets. Most implementations I see treat every swap identically — buy or sell, same weight — and then wonder why their model degrades two weeks after training. The edge is in the weighting, and on Solana, priority fees are the cleanest proxy for aggressor intent you have.
Why Order-Flow Imbalance Works on Solana
Classic OFI is signed trade volume: buys minus sells over a rolling window, normalized by total volume. The intuition is that an excess of buys means aggressors are lifting offers — they're willing to pay up — which typically precedes a short-term price increase. On a traditional LOB market you get this from the tape. On Solana you reconstruct it from confirmed transactions.
The problem is Solana's block structure. Transactions can be re-ordered by validators within a block, and jito bundles can land multiple transactions atomically. Naive tick-by-tick aggregation conflates the response (arbitrage bots cleaning up a price discrepancy) with the signal (an organic aggressor). If you don't filter this, your feature is measuring noise, not intent.
Priority Fees as an Aggressor Proxy
Here is where it gets useful. A transaction paying a priority fee above the block median is, statistically, willing to pay for inclusion speed. That's the definition of an aggressor. A bot arbing a stale quote cares about being first; a market-maker refreshing a quote does not.
For each transaction in a block, I compute a fee-adjusted buy/sell signal:
- Direction: classify the swap as buy or sell against the pool's base token. On Raydium and Orca, the instruction accounts tell you which side of the pool is receiving SOL/USDC.
- Raw weight: the notional value in USDC of the swap.
- Fee multiplier:
log(1 + priority_fee_lamports / median_fee_lamports). Log-transform keeps outlier bundles from dominating. - Signed contribution:
direction × raw_weight × fee_multiplier.
Sum these across a window — I use 10 blocks (~4 seconds) and 30 blocks (~12 seconds) in parallel — and you get two fee-weighted OFI features per token. The ratio between the two windows is a third feature: it captures whether imbalance is accelerating or fading.
Cleaning the Signal: Bundle Detection and Wash Filtering
Jito bundles execute atomically and frequently contain a buy and a sell from the same searcher in the same block. If you count both legs, they cancel to zero net imbalance — correct. But bundles can also include a searcher leg followed by an arb leg from a second party. In that case you want the first leg, not both.
In practice, I fingerprint bundles by looking for transactions from the same fee payer within the same slot. When I detect a bundle, I take only the first transaction (or the largest by notional if ordering is ambiguous) and discard the rest. This is imperfect but reduces contamination by about 60% in my testing on Raydium pairs.
Wash trading is a harder problem. On low-liquidity tokens, the same entity routes through multiple wallets to inflate volume. A simple heuristic: if a wallet's buy and sell in the same token appear within 5 blocks, discard both. This kills a lot of legitimate high-frequency behavior too, so I only apply it to tokens below a liquidity threshold (~$100k in pool depth).
Building the Feature Matrix
For each token at each 5-second interval, my feature vector looks like this:
ofi_10b: fee-weighted OFI over 10 blocksofi_30b: fee-weighted OFI over 30 blocksofi_ratio:ofi_10b / (ofi_30b + ε), acceleration signalfee_spike: whether the max priority fee in the window exceeds the 95th percentile of the trailing 1,000 blocks — a coarse event flag for sudden urgencypool_depth_imbalance: bid-side depth minus ask-side depth in the AMM, normalized by TVL (available from on-chain state)slot_utilization: compute units consumed in the block divided by block CU limit — proxy for network congestion that affects fill quality
The target is 5-second forward return, sign-only (1 = up, 0 = flat, -1 = down), clipped at ±0.3% to remove outlier events that swamp the model.
Gradient Boosting over OFI Features
I use XGBoost with a multi-class softmax objective rather than a regression target. Classification is more stable here: the 5-second price move is noisy enough that a regression target rewards overfit models chasing tiny differences. Predicting direction and calibrating confidence is more actionable.
Key training decisions:
- Walk-forward validation: train on rolling 7-day windows, validate on day 8, then advance. Never use future data to compute OFI — the rolling window features must be computed strictly in-sample.
- Feature importance monitoring: if
ofi_10bdrops out of the top 3 features in a walk-forward fold, that fold's data is probably dominated by arb activity and I increase the bundle filter aggression temporarily. - Class imbalance: flat 5-second returns dominate. I oversample the directional classes during training and apply class weights. Without this, the model predicts flat on >80% of samples and still posts decent accuracy — useless for trading.
In production on a Solana MEV and arbitrage setup, this feature set drives signal for entries when the model confidence on a directional class exceeds 0.65. Below that threshold, no trade. The threshold was tuned on out-of-sample data, not the training set.
Production Gotchas
RPC rate limits: computing these features in real time requires streaming block data, not polling. Use a Geyser plugin or a dedicated websocket endpoint. Rate-limited RPC will introduce 200–500ms of latency per block update, which destroys the predictive window.
Feature drift: Solana's fee market changes when network load spikes. The median_fee_lamports baseline must be updated on a short rolling window (I use 200 blocks), not a fixed constant. If you hard-code the baseline from a quiet period, fee spikes during high congestion will inflate every aggressor signal and your model will overtrade.
Recompute latency: the feature pipeline must complete before the next block arrives (~400ms budget). In Python this is tight. I process the slot in a Rust binary that emits a feature vector over a local socket; the Python model scores it. The Rust side runs in under 20ms on typical block sizes.
The full pipeline — streaming, feature computation, model scoring and execution — is the kind of infra and data work that separates a research prototype from something you can trust to run 24/7 with real capital. The features above are the interesting part, but the plumbing underneath is what keeps them predictive in production. A related example of ML signals wired into live execution is the Solana ML Signal Bot we shipped for a quant client, which uses a similar feature pipeline for a different target.
If you want to build a production-grade OFI model or wire ML signals into a Solana execution engine, reach out — this is exactly the kind of system we design and run.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article