JIT-only MM
JIT-only market making means you do not keep a standing book. Instead of resting limit orders on the DLOB, you compete in JIT auctions (see Matching Engine for liquidity sources) by reacting to incoming taker orders in real-time.
Why JIT-only?
- No adverse selection from stale quotes: you only commit capital when you choose to fill
- Selective flow: you inspect each taker order and decide if it’s profitable to fill
- 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: You need lower-latency infrastructure than DLOB MM (to react within the auction window), and you may miss fills in fast markets if your bot is slow.
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 you’re willing to offer based on current market and inventory conditions.
Fill
Fill atomically via placeAndMakePerpOrder so your maker order is placed and matched in one transaction.
Subscribe to auctions / orders
The AuctionSubscriber gives you a stream of active JIT auctions. Use commitment: "processed" for lowest latency.
import { AuctionSubscriber } from "@velocity-exchange/sdk";Class AuctionSubscriberReference ↗
Class AuctionSubscriberReference ↗AuctionSubscriber — websocket program-account subscription scoped to `User` accounts that currently have at least one order in its auction window (`getUserWithAuctionFilter`). Used by keepers/fillers to react to auction-eligible orders (JIT-fillable or approaching the end of a Dutch auction) without scanning every user account.
| Property | Type | Required |
|---|---|---|
velocityClient | any | Yes |
opts | any | Yes |
resubOpts | any | No |
eventEmitter | StrictEventEmitter<EventEmitter, AuctionSubscriberEvents> | Yes |
subscriber | any | No |
subscribe | () => Promise<void>Establishes the filtered program-account subscription (idempotent — reuses the existing subscriber if already created) and emits `onAccountUpdate` for each matching account update. | Yes |
unsubscribe | () => Promise<void>Tears down the subscription. No-op if never subscribed. | Yes |
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-500ms before they land onchain.
OrderSubscriber is a lower-level alternative to AuctionSubscriber. It streams all user order state changes rather than only active auction events. Most JIT bots use AuctionSubscriber because it surfaces only the orders that are currently in an open auction window. Use OrderSubscriber if you need visibility into the full lifecycle of orders (e.g., tracking when orders are placed, partially filled, or cancelled) rather than just reacting to active auctions.
import { OrderSubscriber } from "@velocity-exchange/sdk";Class OrderSubscriberReference ↗
Class OrderSubscriberReference ↗OrderSubscriber — maintains a live, in-memory map of every `User` account (and therefore every open order) on the program, refreshed via polling, websocket program-account subscription, or gRPC/Laserstream, depending on `config.subscriptionConfig.type`. Used by keeper bots and `DLOBSubscriber` to build a full DLOB without individually subscribing to each trader's `User` account. Emits `orderCreated`/`userUpdated`/`updateReceived` on `eventEmitter` as updates arrive; see `OrderSubscriberEvents`.
| Property | Type | Required |
|---|---|---|
velocityClient | VelocityClient | Yes |
usersAccounts | Map<string, { slot: number; userAccount: UserAccount; }> | Yes |
subscription | PollingSubscription | WebsocketSubscription | grpcSubscription | Yes |
commitment | Commitment | Yes |
eventEmitter | StrictEventEmitter<EventEmitter, OrderSubscriberEvents> | Yes |
fetchPromise | Promise<void> | No |
fetchPromiseResolver | any | Yes |
mostRecentSlot | number | Yes |
decodeFn | (name: string, data: Buffer) => UserAccount | Yes |
decodeData | boolean | No |
fetchAllNonIdleUsers | boolean | No |
subscribe | () => Promise<void>Starts the underlying transport (polling interval, websocket program-account subscription, or gRPC stream) chosen at construction time. | Yes |
fetch | () => Promise<void>One-shot full refresh: fetches every `User` account matching the
configured filters via `getProgramAccounts` and feeds each through
`tryUpdateUserAccount`. Concurrent calls share the in-flight promise
rather than issuing a second `getProgramAccounts` request. Errors are
caught and logged, not thrown — the promise still resolves. | Yes |
tryUpdateUserAccount | (key: string, dataType: "raw" | "decoded" | "buffer", data: UserAccount | Buffer | string[], slot: number) => voidApplies an incoming update for the `User` account `key`, called by
`fetch`, `addPubkey`, and each subscription transport
(`PollingSubscription`/`WebsocketSubscription`/`grpcSubscription`).
Advances `mostRecentSlot` and always emits `updateReceived`. The stored
account is only replaced if `slot` is >= the currently cached slot for
that key; for `'raw'`/`'buffer'` inputs, before decoding it also cheaply
peeks the account's `lastActiveSlot` field at a fixed byte offset and
discards the update if that's older than what's cached, without paying
the cost of a full decode. On acceptance, emits `userUpdated`, and
`orderCreated` for any orders in the account whose `order.slot` falls in
`(previousSlot, slot]`. | Yes |
createDLOB | () => DLOBCreates a new DLOB for the order subscriber to fill. This will allow a
caller to extend the DLOB Subscriber with a custom DLOB type. | Yes |
getDLOB | (slot: number) => Promise<DLOB>Builds a fresh `DLOB` from the currently cached `User` accounts. For
reduce-only orders, resolves the base asset amount against the trader's
existing perp position (via `calculateOrderBaseAssetAmount`, `BASE_PRECISION`,
1e9) rather than trusting the order's own `baseAssetAmount` field, since a
reduce-only order's fillable size is capped by the position it reduces. | Yes |
getSlot | () => number | Yes |
addPubkey | (userAccountPublicKey: PublicKey) => Promise<void>Fetches a single `User` account directly (bypassing the subscription's filters) and applies it via `tryUpdateUserAccount`. Useful to backfill an account this subscriber's filters would otherwise exclude (e.g. an idle user with no orders). No-ops if the account doesn't exist. | Yes |
mustGetUserAccount | (key: string) => Promise<UserAccount>Returns the cached `UserAccount` for `key`, fetching it on demand via
`addPubkey` if not already cached. | Yes |
unsubscribe | () => Promise<void>Stops the underlying transport and clears the entire cached `User` account map. | Yes |
Compute auction prices (helpers)
Use getAuctionPrice to compute the current interpolated auction price at any slot. This tells you the worst price the taker would accept right now, so you need to offer a price at least this 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)}`);Function getAuctionPriceReference ↗
Function getAuctionPriceReference ↗Dispatches to the correct in-progress auction price for `order` based on its order type: fixed-price auction (`getAuctionPriceForFixedAuction`) for market/triggerLimit/plain-limit orders, or oracle-offset auction (`getAuctionPriceForOracleOffsetAuction`) for oracle-pegged limit/oracle/oracle-triggered-market orders. The result is always standardized to `tickSize`.
| Parameter | Type | Required |
|---|---|---|
order | OrderOrder whose auction price to compute. | Yes |
slot | numberCurrent slot. | Yes |
oraclePrice | anyUse `MMOraclePriceData` source for perp orders, `OraclePriceData` for spot; PRICE_PRECISION (1e6). | Yes |
tickSize | anyMarket's order tick size, PRICE_PRECISION (1e6). Defaults to `ONE` (no effective standardization). | No |
Auction price at the current slot, PRICE_PRECISION (1e6).
| Returns |
|---|
BN |
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 your maker order and fills against the taker in one transaction. You earn maker rebates and the taker gets filled, all atomic.
import {
OrderType,
PositionDirection,
PostOnlyParams,
} from "@velocity-exchange/sdk";
// Build your 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,
};
// 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);Method VelocityClient.placeAndMakePerpOrderReference ↗
Method VelocityClient.placeAndMakePerpOrderReference ↗| Parameter | Type | Required |
|---|---|---|
orderParams | OptionalOrderParamsMaker order to place; `baseAssetAmount` is BASE_PRECISION (1e9), `price`
is PRICE_PRECISION (1e6). Must have `orderType: LIMIT`, `postOnly` set, and be IOC. | Yes |
takerInfo | TakerInfoThe taker account/order to fill against (`takerInfo.order.orderId` must be open). | Yes |
txParams | TxParamsOptional compute-unit/priority-fee overrides. | No |
subAccountId | numberSub-account placing the maker order; defaults to the active sub-account. | No |
takerEscrow | RevenueShareEscrowAccountThe taker's decoded `RevenueShareEscrow`. Required to attach the escrow
when the taker is referred but their order carries no builder code — the builder case is
detected automatically from `takerInfo.order`. | No |
| Returns |
|---|
Promise<string> |
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,
} 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);
// 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,
},
{
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 (if you use both feeds) skip Swift-origin orders via isSignedMsgOrder() so you don’t double-handle. See Bot Architecture - Risk and filtering for shared patterns and code.
import { isSignedMsgOrder } from "@velocity-exchange/sdk";
// In your AuctionSubscriber callback, skip orders that came from SWIFT
// so your 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);
}
});Function isSignedMsgOrderReference ↗
Function isSignedMsgOrderReference ↗True if the order was submitted via the signed-message (swift/off-chain relay) path (`OrderBitFlag.SignedMessage`).
| Parameter | Type | Required |
|---|---|---|
order | Order | Yes |
| Returns |
|---|
boolean |
getMMOracleDataForPerpMarket is the recommended oracle getter for market makers: it returns the dedicated MM oracle price when it’s active, fresh, and close enough to the exchange oracle, and transparently falls back to the exchange oracle otherwise. It has no isValid field — that’s not part of MMOraclePriceData. It does carry isMMOracleActive (whether the market has an MM oracle configured at all — not a full validity signal) and confidence. For an actual go/no-go validity check, use isOracleValid(market, oracleData, oracleGuardRails, slot) from math/oracles, which is the same AMM-fill-oriented gate the program itself uses 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 own)oracle.confidence: price confidence interval,BNinPRICE_PRECISION(1e6) units
import { convertToNumber, isOracleValid, PRICE_PRECISION } from "@velocity-exchange/sdk";
const perpMarket = velocityClient.getPerpMarketAccount(marketIndex);
const oracle = velocityClient.getMMOracleDataForPerpMarket(marketIndex);
const slotSubscriberSlot = slotSubscriber.getSlot(); // don't poll connection.getSlot() per fill
// 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 your spread when confidence is low
const minSpread = Math.max(0.05, confidence * 2);Method VelocityClient.getMMOracleDataForPerpMarketReference ↗
Method VelocityClient.getMMOracleDataForPerpMarketReference ↗| Parameter | Type | Required |
|---|---|---|
marketIndex | numberPerp market index. | Yes |
| Returns |
|---|
MMOraclePriceData |
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. Not yet published to npm — build it from packages/jit-proxy in the monorepo until it is.
import { JitterSniper, PriceType } from "@velocity-exchange/jit-proxy";
// The constructor field is still `driftClient` (typed as `VelocityClient`) --
// @velocity-exchange/jit-proxy hasn't renamed it yet. `jitProxyClient` (a
// `JitProxyClient` wrapping the JIT proxy program) is also required; omitted here
// for brevity.
const jitter = new JitterSniper({
auctionSubscriber,
driftClient: velocityClient,
slotSubscriber,
jitProxyClient,
});
await jitter.subscribe();
// The jitter handles auction timing automatically
// You just need to configure pricing and filtersSee 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: if you also subscribe to SWIFT, onchain auctions for SWIFT orders will appear inAuctionSubscribertoo. UseisSignedMsgOrder(order)to skip them in your onchain loop (handle them in 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 your fill success rate per market. If it drops below ~20%, your pricing or latency may need adjustment.
Related
- JIT Auctions - Auction mechanics, pricing formula, and timeline
- SWIFT API - Receive orders 100-500ms 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 with
JitterSniperandJitterShotgun