Engineering whale-grade TWAP and VWAP on Solana
How Moby Market's execution-algorithm crate shreds a nine-figure parent order into hundreds of timing-randomised child orders without becoming a pattern for the rest of the market to read.
Every institutional execution problem starts with the same observation: the market will move against you if it knows you are coming. A $100 million SOL unwind through a single AMM call moves the reference price by enough to attract the rest of the market into the trade against you. The solution — well-known in traditional finance — is to split the parent order into many small child orders, time them against expected market volume, and randomise enough that no observer can reconstruct the parent.
That is the textbook. The interesting work is in the Rust.
This post walks through how Moby Market’s moby-trading crate implements TWAP and VWAP for whale-sized flow on Solana. The goal is not to teach time-weighted execution from scratch but to share the design choices that mattered when the parent order is large enough to be tracked across multiple venues and the adversary is actively pattern-matching for you.
The structs that drive everything
The TWAP path is anchored by a single struct in moby-trading/src/twap.rs:
pub struct TWAPOrder {
pub trader: Pubkey,
pub token_in: Pubkey,
pub token_out: Pubkey,
pub total_amount: u64,
pub time_window: i64,
pub num_splits: u32,
pub randomness_factor: u8,
pub price_deviation_limit: u16,
pub min_output_per_interval: u64,
}
Each field carries a design decision. num_splits defines the parent order’s coarseness — 288 splits over a 48-hour window means a child every ten minutes on average. randomness_factor is a 0–100 value that controls how much each child’s actual execution time can drift from its nominal slot. price_deviation_limit is in basis points and bounds how far each child can stray from a reference price (a TWAP of the last hour, in practice) before the child is skipped and rescheduled.
The VWAP path is shaped similarly but trades the explicit split count for participation tracking:
pub struct VWAPOrder {
pub trader: Pubkey,
pub token_pair: TokenPair,
pub target_volume_participation: u16,
pub max_spread_tolerance: u16,
pub volume_curve: VolumeCurve,
pub adaptive_parameters: AdaptiveParams,
}
target_volume_participation is a basis-point target — “participate in 5% of every minute’s market volume”. volume_curve captures the expected intraday shape (typically a smile or inverted bathtub) so the algorithm front-loads or back-loads relative to natural market volume. AdaptiveParams lets the algorithm react in real time when realised volume diverges from the curve.
Why randomness is the whole game
A naive TWAP fires its child orders on a perfectly regular schedule — every ten minutes, on the minute, for forty-eight hours. That schedule is itself a signal. Any actor monitoring the chain can spot a 288-pulse cadence and trade against the remaining children. Within an hour or two the parent order is publicly modelled and the rest of the children fill at adversarial prices.
The fix is straightforward in principle and subtle in practice. Each child’s nominal execution time is perturbed by a value derived from on-chain randomness, bounded by randomness_factor. With factor 0 the schedule is deterministic; with factor 100 the child can fire anywhere in a window of width equal to the inter-child interval. In practice we run with factor 30–50 by default. That hides the pattern enough that statistical detection requires hundreds of samples — much longer than the parent order lives.
There is a small but important wrinkle. The randomness source has to be unpredictable to outside observers but verifiable to the trader. We use a commit-reveal scheme rooted in a recent blockhash combined with a slot-bound VRF output; the trader can later verify that each child fired at a time consistent with the committed seed.
Mapping children to venues
A timing-randomised schedule is useless if every child hits the same Raydium pool. The router has to spread children across venues in a way that does not introduce a second observable pattern.
Inside the moby-trading crate, child orders are routed through a LiquiditySource aggregation:
pub struct LiquiditySource {
pub protocol: DexProtocol,
pub pool_address: Pubkey,
pub last_update_slot: u64,
pub liquidity_depth: LiquidityDepth,
pub fee_tier: u16,
}
For each child the router computes a feasible set of venues based on observed depth at the child’s size, then samples from that set weighted by inverse expected market-impact. A child is also allowed to split itself across multiple venues if the size is large relative to any single pool. The default cap is four hops per child; the splitter respects per-venue minimum fills so that we do not accidentally signal by submitting tiny orders to large pools.
Reference-price gating
A TWAP that fires when the market is in distress is a TWAP that loses money. Each child queries the oracle aggregation (Pyth + Switchboard + Chainlink) for a current reference price and computes the deviation from the trailing hour’s TWAP. If the deviation exceeds price_deviation_limit, the child is deferred to the next slot.
In practice this gating saves more on volatile days than the algorithm itself contributes on quiet ones. The default deviation limit is 25 basis points; high-conviction unwinds typically tighten it to 10 basis points and accept that the algorithm may stretch beyond its nominal time window.
Why VWAP is harder than TWAP
TWAP is mechanical: chop the parent into equal child sizes over time. VWAP requires a model of expected market volume and then continuous reaction to the gap between expected and realised. The model lives in VolumeCurve; the reaction lives in AdaptiveParams::rebalance_frequency.
Two design decisions matter. First, the volume curve has to be tradeable, not just historical. We compute it from a rolling thirty-day window of per-minute volume normalised by daily total, then smooth with a kernel. Second, the rebalance loop has to be cheap. Each rebalance step recomputes the residual schedule based on cumulative filled volume and target participation. On Solana that step is a single program call costing well under a cent in priority fees, which means we can rebalance every minute without burning the algorithm’s edge in transaction costs. On Ethereum the same loop would be unaffordable.
Compute-budget discipline
Solana programs operate inside a compute-unit budget. The TWAP child path was originally too expensive — roughly 900,000 compute units per child including oracle reads, deviation checks, and routing. That left almost no headroom for the actual swap. We reworked the inner loop to lift constant computations out of the per-child path, cached oracle prices for the duration of a slot, and packed the routing decision into a precomputed table indexed by venue ID. The current per-child cost runs at around 350,000 compute units, comfortably below the 1.4 million unit limit.
This is one of those engineering details that looks boring until you realise it determines whether the algorithm is economical at scale.
Failure modes we explicitly model
A few things can go wrong, and the protocol models each:
Insufficient depth at a child’s scheduled time. The router returns no feasible split; the child defers to the next slot and the algorithm logs the miss. If misses accumulate, the algorithm widens its venue set automatically and lowers per-venue size targets.
Oracle staleness. If the aggregated oracle reading is older than staleness_threshold, the child refuses to execute. The parent order pauses until the oracle catches up. We default the threshold to thirty seconds, which is generous enough to survive minor outages but tight enough that we never execute against stale prices.
Adversarial sandwich attempt. Commit-reveal child timing makes prediction hard. If a sandwich does appear, the child’s deviation check catches it and the child reverts. The router blacklists the venue for the remainder of the parent order’s lifetime.
Trader cancellation mid-flight. The TWAP is interruptible. A cancellation closes out the remaining schedule and refunds any pre-staged capital. Partial fills already executed are settled normally.
What we would change if we started over
Two things.
First, we would model the parent order’s shape explicitly from the start rather than letting it emerge from the per-child fields. The current API forces the trader to think in terms of splits and randomness factors; a higher-level “I want this volume profile” API would be cleaner. We will likely add it in v0.2 without breaking the existing struct surface.
Second, we would put VWAP’s volume curve estimation behind a trait so that desks can plug their own models in. Many institutions have proprietary volume models that beat our generic kernel; today they would have to fork the crate to use them. A trait boundary would solve that with no protocol change.
What the numbers actually look like
A $100M SOL unwind run through the algorithm against devnet liquidity (with realistic depth modelling) lands at roughly 0.08% realised slippage versus the pre-execution reference price, with child orders distributed across twelve Solana venues, no detectable pattern in the inter-child timing, and full settlement inside the nominal time window. The same parent order routed through a single AMM swap simulates at 11–15% slippage and would attract MEV bots before the first block confirmed.
That gap is the entire reason the algorithm exists. The numbers will move with market conditions and with the depth of solver participation; what matters is the structure of the win, not the exact basis points.
The protocol is MIT and self-hostable. If you want the team to scope an engagement around your size, or write to hello@mobymarket.cryptuon.com.