JIT-only MM
JIT-only market making keeps no standing book. Instead of resting limit orders on the DLOB, a JIT-only maker competes in JIT auctions by reacting to incoming taker orders in real time. See Matching Engine for how the liquidity sources compete once an auction ends.
Why JIT-only?
- No adverse selection from stale quotes: capital is committed only on a fill the bot chooses to take
- Selective flow: each taker order is inspected first, and filled only when it prices profitably
- Capital efficiency: no capital locked in resting orders that may never fill
- Dynamic pricing: price each fill based on current oracle, inventory, and market conditions
Tradeoff: JIT-only demands lower-latency infrastructure than DLOB MM, to react inside the auction window, and a slow bot misses fills in fast markets.
Architecture overview
A JIT-only bot follows this loop:
Subscribe
Subscribe to auction and order feeds (onchain via AuctionSubscriber, or offchain via SWIFT).
Filter
Filter incoming auctions by oracle checks, position limits, toxic flow, and profitability.
Price
Compute the best price the bot is willing to offer, based on current market and inventory conditions.
Fill
Fill atomically via placeAndMakePerpOrder so the maker order is placed and matched in one transaction.
Subscribe to auctions / orders
The AuctionSubscriber provides a stream of active JIT auctions. Use commitment: "processed" for lowest latency.
import { AuctionSubscriber } from "@velocity-exchange/sdk";
const auctionSubscriber = new AuctionSubscriber({
velocityClient,
opts: { commitment: "processed" },
});
await auctionSubscriber.subscribe();For even lower latency, subscribe to SWIFT to receive signed taker orders 100 to 500 ms before they land onchain.
OrderSubscriber is the lower-level alternative. It streams every user order state change rather than only active auction events. Most JIT bots use AuctionSubscriber, which surfaces only the orders currently inside an open auction window. Reach for OrderSubscriber when the full order lifecycle is needed, such as tracking placements, partial fills, and cancellations, rather than only reacting to live auctions.
Compute auction prices (helpers)
Use getAuctionPrice to compute the current interpolated auction price at any slot. That price is the worst the taker would accept at that moment, so a competing maker quote has to be at least that good.
import { getAuctionPrice, convertToNumber, PRICE_PRECISION } from "@velocity-exchange/sdk";
const currentSlot = await connection.getSlot();
const oracle = velocityClient.getOracleDataForPerpMarket(marketIndex);
const perpMarket = velocityClient.getPerpMarketAccount(marketIndex);
// Get the current auction price at this slot. Always pass the market's tick size
// (orderTickSize) -- it defaults to no rounding, which disagrees with the program's
// own price standardization on any market with tick_size > 1.
const auctionPriceBN = getAuctionPrice(takerOrder, currentSlot, oracle.price, perpMarket.orderTickSize);
const auctionPrice = convertToNumber(auctionPriceBN, PRICE_PRECISION);
console.log(`Auction price at slot ${currentSlot}: $${auctionPrice.toFixed(4)}`);See JIT Auctions: auction pricing for the full interpolation formula, and Orderbook & Matching: tick size for why the tick size argument matters.
Fill as maker (atomic place-and-make)
This pattern places the maker order and fills against the taker in one transaction. The maker earns rebates and the taker gets filled, atomically.
import {
OrderType,
PositionDirection,
PostOnlyParams,
OrderParamsBitFlag,
} from "@velocity-exchange/sdk";
// Build the maker order (opposite direction of taker)
const makerOrderParams = {
orderType: OrderType.LIMIT,
marketIndex: takerOrder.marketIndex,
direction: PositionDirection.SHORT, // if taker is LONG
baseAssetAmount: takerOrder.baseAssetAmount,
price: velocityClient.convertToPricePrecision(myFillPrice),
postOnly: PostOnlyParams.MUST_POST_ONLY,
// Required: the program rejects any place-and-make maker order that
// isn't IOC + post-only + limit with InvalidOrderIOCPostOnly.
bitFlags: OrderParamsBitFlag.ImmediateOrCancel,
};
// takerInfo: includes taker's public keys, user account, and the order to fill
const takerInfo = {
taker: takerPubkey, // PublicKey of taker's user account PDA
takerStats: takerStatsPubkey, // PublicKey of taker's UserStats PDA
takerUserAccount: takerUserAccount, // decoded UserAccount data
order: takerOrder, // the specific Order to fill against
};
await velocityClient.placeAndMakePerpOrder(makerOrderParams, takerInfo);Complete fill loop
Here's a more complete example that ties the pieces together:
import {
AuctionSubscriber,
getAuctionPrice,
getUserStatsAccountPublicKey,
isSignedMsgOrder,
isOracleValid,
isVariant,
convertToNumber,
PRICE_PRECISION,
BASE_PRECISION,
OrderType,
PositionDirection,
PostOnlyParams,
OrderParamsBitFlag,
} from "@velocity-exchange/sdk";
const MAX_POSITION = 100; // max 100 SOL position
const MIN_SPREAD = 0.02; // minimum $0.02 edge required
const auctionSubscriber = new AuctionSubscriber({
velocityClient,
opts: { commitment: "processed" },
});
await auctionSubscriber.subscribe();
// Listen for auction events instead of polling
auctionSubscriber.eventEmitter.on("onAccountUpdate", async (takerUserAccount, pubkey, slot) => {
for (const order of takerUserAccount.orders) {
if (order.baseAssetAmount.isZero() || order.baseAssetAmount.eq(order.baseAssetAmountFilled)) continue;
const userAccount = takerUserAccount;
// Skip SWIFT orders if handling them via SwiftOrderSubscriber
if (isSignedMsgOrder(order)) continue;
const marketIndex = order.marketIndex;
const perpMarket = velocityClient.getPerpMarketAccount(marketIndex);
const oracle = velocityClient.getMMOracleDataForPerpMarket(marketIndex, slot);
// Check oracle validity. Note: MMOraclePriceData has no `isValid` field -- use the
// `isOracleValid` AMM-fill-oriented validity gate against the market's guard rails.
const oracleIsValid = isOracleValid(
perpMarket,
oracle,
velocityClient.getStateAccount().oracleGuardRails,
slot
);
if (!oracleIsValid) continue;
// Check position limits
const user = velocityClient.getUser();
const position = user.getPerpPosition(marketIndex);
const currentSize = position
? Math.abs(convertToNumber(position.baseAssetAmount, BASE_PRECISION))
: 0;
const fillSize = convertToNumber(order.baseAssetAmount, BASE_PRECISION);
if (currentSize + fillSize > MAX_POSITION) continue;
// Get current auction price (use slot from the event, not an RPC call).
// Pass the market's tick size so this matches the program's own rounding.
const auctionPriceBN = getAuctionPrice(order, slot, oracle.price, perpMarket.orderTickSize);
const auctionPrice = convertToNumber(auctionPriceBN, PRICE_PRECISION);
const oraclePrice = convertToNumber(oracle.price, PRICE_PRECISION);
// Calculate our fill price (oracle + small edge)
const takerIsLong = isVariant(order.direction, "long");
const edge = MIN_SPREAD;
const myFillPrice = takerIsLong
? oraclePrice + edge // sell to long taker above oracle
: oraclePrice - edge; // buy from short taker below oracle
// Check if our price is within the auction range
const isCompetitive = takerIsLong
? myFillPrice <= auctionPrice
: myFillPrice >= auctionPrice;
if (!isCompetitive) continue;
// Fill!
try {
await velocityClient.placeAndMakePerpOrder(
{
orderType: OrderType.LIMIT,
marketIndex,
direction: takerIsLong ? PositionDirection.SHORT : PositionDirection.LONG,
baseAssetAmount: order.baseAssetAmount.sub(order.baseAssetAmountFilled),
price: velocityClient.convertToPricePrecision(myFillPrice),
postOnly: PostOnlyParams.MUST_POST_ONLY,
bitFlags: OrderParamsBitFlag.ImmediateOrCancel,
},
{
taker: pubkey,
takerStats: getUserStatsAccountPublicKey(velocityClient.program.programId, userAccount.authority),
takerUserAccount: userAccount,
order,
}
);
console.log(`Filled ${fillSize} @ $${myFillPrice.toFixed(4)}`);
} catch (err) {
console.error("Fill failed:", err);
}
}
});Practical filters
Apply risk and filtering checks before filling: oracle validity, position limits, toxic-flow detection, and, when both feeds are subscribed, skip Swift-origin orders via isSignedMsgOrder() so the same order is not handled twice. See Bot Architecture: risk and filtering for shared patterns and code.
import { isSignedMsgOrder } from "@velocity-exchange/sdk";
// In the AuctionSubscriber callback, skip orders that came from SWIFT
// so the onchain handler and SWIFT handler don't both try to fill the same order.
auctionSubscriber.eventEmitter.on("onAccountUpdate", async (userAccount, pubkey, slot) => {
for (const order of userAccount.orders) {
if (order.baseAssetAmount.isZero()) continue;
if (isSignedMsgOrder(order)) {
// Already handled via SwiftOrderSubscriber callback -- skip here
continue;
}
// Handle regular onchain auction
await handleAuction(order, userAccount, pubkey, slot);
}
});getMMOracleDataForPerpMarket is the oracle getter to use for market making. It returns the dedicated MM oracle price when that feed is active, fresh, and close enough to the exchange oracle, and falls back to the exchange oracle otherwise.
It has no isValid field, and that is the part integrations get wrong. MMOraclePriceData carries no validity flag at all. For a go/no-go check, call isOracleValid(market, oracleData, oracleGuardRails, slot), which is the same AMM-fill-oriented gate the program itself applies for confidence, staleness, and volatility.
Key fields:
oracle.price: current oracle price as aBNinPRICE_PRECISION(1e6) unitsoracle.isMMOracleActive: whether this market has a live MM oracle feed. Not a validity signal on its ownoracle.confidence: price confidence interval,BNinPRICE_PRECISION(1e6) units
import { convertToNumber, isOracleValid, PRICE_PRECISION } from "@velocity-exchange/sdk";
const perpMarket = velocityClient.getPerpMarketAccount(marketIndex);
const slotSubscriberSlot = slotSubscriber.getSlot(); // don't poll connection.getSlot() per fill
const oracle = velocityClient.getMMOracleDataForPerpMarket(marketIndex, slotSubscriberSlot);
// Always guard against stale or unhealthy oracle data before quoting off it
const oracleIsValid = isOracleValid(
perpMarket,
oracle,
velocityClient.getStateAccount().oracleGuardRails,
slotSubscriberSlot
);
if (!oracleIsValid) {
console.warn("Oracle invalid for market", marketIndex, "-- skipping");
return;
}
const oraclePrice = convertToNumber(oracle.price, PRICE_PRECISION);
const confidence = convertToNumber(oracle.confidence, PRICE_PRECISION);
console.log(`Oracle price: $${oraclePrice.toFixed(4)}, confidence: ±$${confidence.toFixed(4)}`);
// Optionally widen the spread when confidence is low
const minSpread = Math.max(0.05, confidence * 2);Using JIT Proxy (JitterSniper / JitterShotgun)
Instead of building fill logic from scratch, use the @velocity-exchange/jit-proxy library which handles auction timing, transaction building, and retry logic. It is on npm, and its two peer dependencies have to be installed alongside it. Anchor goes in under the @coral-xyz/anchor alias, as it does for the SDK:
bun add @velocity-exchange/jit-proxy
bun add @coral-xyz/anchor@npm:@anchor-lang/core@1.0.1 @solana/web3.js@1.98.0See JIT Auctions for why the alias matters.
import { JitterSniper, PriceType } from "@velocity-exchange/jit-proxy";
// `jitProxyClient` (a `JitProxyClient` wrapping the JIT proxy program) is also
// required; omitted here for brevity.
const jitter = new JitterSniper({
auctionSubscriber,
velocityClient,
slotSubscriber,
jitProxyClient,
});
await jitter.subscribe();
// The jitter handles auction timing automatically
// Only pricing and filters are left to configureSee the JitMaker bot for a complete production example using JitterSniper/JitterShotgun with:
- Per-market subaccount isolation (1 subaccount per market)
- Volatility-based fill rejection (
isMarketVolatile) - DLOB-aware pricing (excludes own orders from best bid/ask calculation)
- Configurable target leverage and aggressiveness
Gotchas
- Don't poll
getSlot()per auction: the example above callsgetSlot()for each auction, which is expensive at scale. Instead, use aSlotSubscriberto cache the current slot and read from it synchronously. isSignedMsgOrderfiltering: when SWIFT is subscribed as well, onchain auctions for SWIFT orders appear inAuctionSubscribertoo. UseisSignedMsgOrder(order)to skip them in the onchain loop and handle them in the SWIFT callback instead. See SWIFT API.- One subaccount per market: JIT fills can conflict if two markets try to use the same subaccount simultaneously. The
JitMakerenforces 1:1 subaccount-to-market mapping. - Fill rate tracking: track fill success rate per market. A rate that drops below roughly 20% points at pricing or latency as the cause.
Related
- JIT Auctions: Auction mechanics, pricing formula, and timeline
- SWIFT API: Receive orders 100 to 500 ms faster via offchain WebSocket
- Bot Architecture: Priority fees, health monitoring, graceful shutdown
- DLOB MM: Resting order approach (can be combined with JIT)
@velocity-exchange/jit-proxy: JIT proxy SDK withJitterSniperandJitterShotgun, on npm