All articles
Strategies·April 10, 2026·6 min read

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 blocks
  • ofi_30b: fee-weighted OFI over 30 blocks
  • ofi_ratio: ofi_10b / (ofi_30b + ε), acceleration signal
  • fee_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 urgency
  • pool_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_10b drops 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 project
#Solana#Machine Learning#Feature Engineering#Order Flow#Trading Bots#Strategies