Market Maker Quickstart
Get a two-sided market maker running in under 10 minutes. This guide places a bid and an ask, then refreshes them as the oracle price moves.
Prerequisites
- Node.js + TypeScript project
- Velocity SDK installed:
bun add @velocity-exchange/sdk - Funded Solana account with collateral deposited into a spot market (the accepted collateral mints are configured onchain, per spot market)
- Basic familiarity with async/await
RPC choice matters. The default https://api.mainnet-beta.solana.com is rate-limited and unsuitable for production bots: a quoting loop hits 429 errors within minutes. Use a dedicated RPC provider, and for WebSocket subscriptions one that supports accountSubscribe.
Step 1: Initialize VelocityClient
Set up your connection and subscribe to market data.
import { Connection } from "@solana/web3.js";
import { Wallet, VelocityClient, loadKeypair } from "@velocity-exchange/sdk";
const connection = new Connection("https://api.mainnet-beta.solana.com");
const wallet = new Wallet(loadKeypair("~/.config/solana/id.json"));
const velocityClient = new VelocityClient({
connection,
wallet,
env: "mainnet-beta",
});
await velocityClient.subscribe();
// Initialize the user account (if first time)
// const [txSig] = await velocityClient.initializeUserAccount(0);Step 2: Get oracle price
Read the current oracle price to calculate the bid/ask spread.
import { PRICE_PRECISION, convertToNumber } from "@velocity-exchange/sdk";
const marketIndex = 0; // SOL-PERP
const oracle = velocityClient.getOracleDataForPerpMarket(marketIndex);
const oraclePrice = convertToNumber(oracle.price, PRICE_PRECISION);
console.log(`Oracle price: $${oraclePrice}`);Step 3: Place two-sided quotes
Place a bid (buy) below oracle and an ask (sell) above oracle. PostOnlyParams.MUST_POST_ONLY stops either order from crossing, so every fill settles on the maker side.
import {
PRICE_PRECISION,
convertToNumber,
MarketType,
OrderType,
PositionDirection,
PostOnlyParams
} from "@velocity-exchange/sdk";
const marketIndex = 0; // SOL-PERP
const spread = 0.5; // $0.50 spread on each side
const size = 0.1; // 0.1 SOL per order
// Fetch oracle price for spread calculation
const oracle = velocityClient.getOracleDataForPerpMarket(marketIndex);
const oraclePrice = convertToNumber(oracle.price, PRICE_PRECISION);
const bidPrice = oraclePrice - spread;
const askPrice = oraclePrice + spread;
await velocityClient.placeOrders([
{
orderType: OrderType.LIMIT,
marketType: MarketType.PERP,
marketIndex,
direction: PositionDirection.LONG,
baseAssetAmount: velocityClient.convertToPerpPrecision(size),
price: velocityClient.convertToPricePrecision(bidPrice),
postOnly: PostOnlyParams.MUST_POST_ONLY,
},
{
orderType: OrderType.LIMIT,
marketType: MarketType.PERP,
marketIndex,
direction: PositionDirection.SHORT,
baseAssetAmount: velocityClient.convertToPerpPrecision(size),
price: velocityClient.convertToPricePrecision(askPrice),
postOnly: PostOnlyParams.MUST_POST_ONLY,
},
]);
console.log(`Placed bid @ $${bidPrice}, ask @ $${askPrice}`);Step 4: Monitor and update
Check for fills and cancel/replace orders when the oracle moves. This complete example runs a loop that refreshes quotes every 10 seconds.
import {
PRICE_PRECISION,
BASE_PRECISION,
convertToNumber,
MarketType,
OrderType,
PositionDirection,
PostOnlyParams,
} from "@velocity-exchange/sdk";
const marketIndex = 0;
const spread = 0.5;
const size = 0.1;
setInterval(async () => {
try {
// Check current position
const user = velocityClient.getUser();
const position = user.getPerpPosition(marketIndex);
if (position) {
const posSize = convertToNumber(position.baseAssetAmount, BASE_PRECISION);
console.log(`Current position: ${posSize} SOL`);
}
// Cancel all existing orders for this market
await velocityClient.cancelOrders(MarketType.PERP, marketIndex);
// Re-fetch oracle price
const oracle = velocityClient.getOracleDataForPerpMarket(marketIndex);
const oraclePrice = convertToNumber(oracle.price, PRICE_PRECISION);
const bidPrice = oraclePrice - spread;
const askPrice = oraclePrice + spread;
// Place fresh two-sided quotes
await velocityClient.placeOrders([
{
orderType: OrderType.LIMIT,
marketType: MarketType.PERP,
marketIndex,
direction: PositionDirection.LONG,
baseAssetAmount: velocityClient.convertToPerpPrecision(size),
price: velocityClient.convertToPricePrecision(bidPrice),
postOnly: PostOnlyParams.MUST_POST_ONLY,
},
{
orderType: OrderType.LIMIT,
marketType: MarketType.PERP,
marketIndex,
direction: PositionDirection.SHORT,
baseAssetAmount: velocityClient.convertToPerpPrecision(size),
price: velocityClient.convertToPricePrecision(askPrice),
postOnly: PostOnlyParams.MUST_POST_ONLY,
},
]);
console.log(`Updated quotes: bid $${bidPrice.toFixed(2)} / ask $${askPrice.toFixed(2)}`);
} catch (err) {
console.error("Error updating quotes:", err);
}
}, 10_000); // Update every 10 secondsThis cancel-and-replace loop sends two transactions every 10 seconds, roughly 17,000 per day. Production desks quote with oracle offset orders instead: they float with the oracle on their own, so the transaction count drops to about 30 per day.
Next steps
This example is a starting point. Production market makers also need:
- Oracle offset orders: orders that automatically track oracle price, drastically reducing transactions (DLOB MM)
- Inventory management: adjust spread based on position size (DLOB MM)
- Risk controls: position limits, health checks, emergency cancel (Bot Architecture)
- JIT participation: compete in auctions for better fills (JIT-only MM)
- Efficient subscriptions: WebSocket or gRPC for lower latency (Bot Architecture)
- Multiple markets: quote across markets simultaneously
Common pitfalls
- Forgetting
PostOnlyParams: without it, an order meant as a maker quote can cross the spread and execute as taker, paying the taker fee instead of earning the maker rebate - Using
PRICE_PRECISIONwrong: oracle prices are inPRICE_PRECISION(1e6), base amounts inBASE_PRECISION(1e9). Mixing them up causes orders at wildly wrong prices - Not initializing user account: first-time users must call
velocityClient.initializeUserAccount()before placing orders. The SDK will throwUser account not foundotherwise - 32-order limit: each Velocity subaccount supports a maximum of 32 open orders. Cancel stale orders or use multiple subaccounts for multi-market strategies
For production patterns and best practices, see:
- DLOB MM: comprehensive quoting strategies including oracle offset orders
- Bot Architecture: subscription loops, throttling, priority fees
FloatingPerpMakerin keeper-bots-v2: the production reference for oracle offset quoting. It lives in thevelocity-v1monorepo, which is not public yet