Skip to Content
HyperQuote is live on HyperEVM — Start trading →
MakersAuto-Quoting Bot

Auto-Quoting Bot

This tutorial walks you through building an automated quoting bot that connects to the HyperQuote relay, prices incoming RFQs, enforces risk limits, and submits signed quotes — all without manual intervention. By the end, you will have a production-ready bot architecture that you can extend with your own pricing model and market data feeds.

Running an automated quoting bot involves real financial risk. Incorrectly priced quotes can result in adverse fills. Always test thoroughly on a local Anvil instance before deploying to a live network. Start with conservative risk limits and small position sizes.

Architecture Overview

The bot processes each incoming RFQ through a sequential pipeline:

Connect to Relay WebSocket | Listen for RFQ_BROADCAST | 1. Filter (underlying, collateral, expiry validity) | 2. Price (Black-Scholes + vol surface + market data) | 3. Min Premium Check (rfq.minPremium vs computed premium) | 4. Risk Check (tenor, strike, notional, delta limits) | 5. Build Quote (assemble Quote struct) | 6. Sign (EIP-712 typed data) | 7. Record Risk (update exposure state) | 8. Submit (QUOTE_SUBMIT to relay)

If any stage fails, the RFQ is skipped and the bot moves on to the next broadcast. The pipeline is intentionally linear — each stage has a clear responsibility and can be customized independently.

Running the Bundled Bot

The SDK ships with makerRelay.ts, a fully functional bot that implements the pipeline above:

npx tsx src/makerRelay.ts

Output:

=== HyperQuote Maker Bot (Relay Mode) === Maker: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 Chain ID: 31337 Engine: 0x5FbDB2315678afecb367f032d93F642f64180aa3 Relay: ws://127.0.0.1:8080 Spot: $25 [CONNECTED] Relay WebSocket open

All configuration is loaded from environment variables at startup. See SDK Quickstart for the full variable reference.

Pricing Logic

The heart of any maker bot is its pricing engine. The SDK provides a PricingEngine interface and a stub Black-Scholes implementation. Production makers typically replace this with a proprietary model.

Fetching Market Prices

The default bot reads spot price from an environment variable (HYPE_SPOT_USD). For a production bot, you need real-time prices from one or more sources.

// Fetch spot from HyperCore's REST API async function fetchHyperCoreSpot(): Promise<number> { const response = await fetch("https://api.hyperliquid.xyz/info", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "allMids" }), }); const data = await response.json(); return parseFloat(data["HYPE"]); }

For latency-sensitive quoting, a WebSocket price feed is preferred over REST polling. Stale prices lead to mispriced quotes that can be adversely selected by informed takers.

Building the MarketData Snapshot

Regardless of your data source, construct a MarketData object before each pricing call:

import type { MarketData } from "@hyperquote/sdk-maker"; function getMarketData(): MarketData { const spotUsd = getSpotPrice(); // from your price feed return { spotPrice: BigInt(Math.round(spotUsd * 1e18)), ivBps: getCurrentIvBps(), // e.g., 8000 = 80% riskFreeRateBps: getRiskFreeRate(), // e.g., 500 = 5% }; }

Spread Calculation

The spread is the maker’s edge over fair value — the primary source of revenue. The StubPricingEngine applies spread as a fixed percentage of notional:

premium = fairValue + (spreadBps / 10000) * (strike * quantity)

For a more sophisticated approach, consider dynamic spreads:

function computeSpreadBps(rfq: RFQ, market: MarketData, riskState: RiskState): number { let spread = 200; // base: 2% // Widen spread for larger sizes (liquidity premium) const qtyUnits = Number(rfq.quantity) / 1e18; if (qtyUnits > 10) spread += 50; if (qtyUnits > 100) spread += 100; // Widen spread in high-volatility regimes if (market.ivBps > 10000) spread += 100; // > 100% IV // Widen spread when approaching risk limits const collateralKey = rfq.collateral.toLowerCase(); const currentNotional = riskState.notionalByCollateral.get(collateralKey) ?? 0n; const maxNotional = 1_000_000n * 10n ** 6n; const utilization = Number(currentNotional) / Number(maxNotional); if (utilization > 0.7) spread += Math.floor(utilization * 200); return spread; }

Takers see competing quotes from all makers and select the most competitive one. Setting spreads too wide means you win fewer fills. Setting them too tight risks adverse selection. Calibrate based on fill rate data and market conditions.

Risk Limits

The risk module prevents runaway exposure. Five checks are enforced before every quote.

Configuring Risk Limits

import type { RiskConfig } from "@hyperquote/sdk-maker"; const riskConfig: RiskConfig = { maxNotionalPerCollateral: { [USDC.toLowerCase()]: 1_000_000n * 10n ** 6n, // $1M max in USDC [USDH.toLowerCase()]: 500_000n * 10n ** 6n, // $500K max in USDH }, maxTenorSecs: 30 * 24 * 3600, // 30 days (tighter than the 90-day protocol max) maxStrikeDeviationPct: 0.3, // strikes must be within 30% of spot maxDeltaPerExpiry: 50, // max absolute delta per expiry bucket minPremium: { [USDC.toLowerCase()]: 10_000n, // $0.01 USDC minimum [USDH.toLowerCase()]: 10_000n, }, };

Risk Check Details

CheckWhat It PreventsFailure Reason
Max TenorLong-dated exposure with high uncertaintyTenor Xs exceeds max Ys
Strike DeviationDeep OTM/ITM strikes that are hard to priceStrike deviation X% exceeds max Y%
Notional LimitUnbounded total exposure per collateral tokenNotional would exceed max for <token>
Delta per ExpiryDirectional risk concentration at a single expiryDelta exposure X would exceed max Y
Min PremiumDust quotes that are not worth the gasSoft check (deferred to post-pricing)

Max Position Size

The per-collateral notional limit acts as an effective max position size. For example, with a $25 spot and a $1M notional limit in USDC, the maximum position is approximately 40,000 WHYPE options (at $25 strike).

To limit exposure per individual trade, add a per-quote notional check:

const MAX_SINGLE_QUOTE_NOTIONAL = 100_000n * 10n ** 6n; // $100K per quote const notional = computeNotional(rfq.strike, rfq.quantity, 18, cDec); if (notional > MAX_SINGLE_QUOTE_NOTIONAL) { console.log("[SKIP] Single quote notional too large"); return; }

Token Exposure Tracking

The RiskState class tracks exposure along two axes:

  • Per-collateral notional — Total USD-equivalent exposure per stablecoin.
  • Per-expiry delta — Net directional exposure per expiry date. Calls contribute positive delta, puts contribute negative delta, allowing them to partially offset.
import { RiskState, computeNotional } from "@hyperquote/sdk-maker"; const riskState = new RiskState(); // After each successful quote submission: const notional = computeNotional(rfq.strike, rfq.quantity, 18, cDec); riskState.recordQuote( rfq.collateral, rfq.expiry, notional, pricing.delta, rfq.isCall, );

Implementing the Pricing Callback

Replace the stub engine by implementing the PricingEngine interface:

import type { PricingEngine, PricingResult, MarketData } from "@hyperquote/sdk-maker"; import type { RFQ } from "@hyperquote/sdk-maker"; class ProductionPricingEngine implements PricingEngine { private volSurface: Map<string, number>; // strike -> IV mapping constructor() { this.volSurface = new Map(); // Initialize with vol surface data from Deribit, etc. } price(rfq: RFQ, market: MarketData, cDec: number): PricingResult { const spot = Number(market.spotPrice) / 1e18; const strike = Number(rfq.strike) / 1e18; const now = Math.floor(Date.now() / 1000); const T = (Number(rfq.expiry) - now) / (365.25 * 24 * 3600); // 1. Look up implied vol from your calibrated surface const iv = this.getImpliedVol(strike, T, rfq.isCall); // 2. Compute fair value using your BSM or proprietary model const { price: fairPricePerUnit, delta } = this.blackScholes( spot, strike, T, iv, market.riskFreeRateBps / 10000, rfq.isCall, ); // 3. Apply dynamic spread const qtyUnits = Number(rfq.quantity) / 1e18; const spreadBps = this.computeSpread(rfq, market); const spreadAdj = (spreadBps / 10000) * (strike * qtyUnits); const premiumUsd = fairPricePerUnit * qtyUnits + spreadAdj; // 4. Convert to collateral units const multiplier = 10 ** cDec; const premium = BigInt(Math.ceil(premiumUsd * multiplier)); const fairValue = BigInt(Math.ceil(fairPricePerUnit * qtyUnits * multiplier)); return { premium: premium > 0n ? premium : 1n, delta, fairValue, ivUsed: iv, }; } private getImpliedVol(strike: number, T: number, isCall: boolean): number { // Your vol surface lookup logic return 0.80; // placeholder } private blackScholes( S: number, K: number, T: number, sigma: number, r: number, isCall: boolean, ): { price: number; delta: number } { // Standard BSM implementation // ... (see SDK source for reference) return { price: 0, delta: 0 }; // placeholder } private computeSpread(rfq: RFQ, market: MarketData): number { return 200; // placeholder: 2% } }

Then plug it into your bot:

const pricingEngine = new ProductionPricingEngine(); // In the RFQ handler: const pricing = pricingEngine.price(rfq, market, cDec);

Handling Multiple Token Pairs

V1 supports only WHYPE as the underlying, but multiple collateral tokens. Your bot should handle all configured collaterals:

const config: MakerConfig = { // ... allowedUnderlying: [WHYPE], collateralTokens: { [USDC.toLowerCase()]: { decimals: 6, symbol: "USDC" }, [USDH.toLowerCase()]: { decimals: 6, symbol: "USDH" }, [USDT0.toLowerCase()]: { decimals: 6, symbol: "USDT0" }, }, risk: { maxNotionalPerCollateral: { [USDC.toLowerCase()]: 1_000_000n * 10n ** 6n, [USDH.toLowerCase()]: 500_000n * 10n ** 6n, [USDT0.toLowerCase()]: 250_000n * 10n ** 6n, // lower limit for USDT0 }, // ... }, };

The filter stage automatically rejects RFQs for unsupported underlyings or collateral tokens:

function isRfqAcceptable(rfq: RFQ, config: MakerConfig): boolean { // 1. Underlying must be in the allowlist if (!config.allowedUnderlying.some( (u) => u.toLowerCase() === rfq.underlying.toLowerCase() )) return false; // 2. Collateral must be recognized const collateralKey = rfq.collateral.toLowerCase(); if (!config.collateralTokens[collateralKey]) return false; // 3. Expiry must be at 08:00 UTC and in the future if (Number(rfq.expiry) % 86400 !== 28800) return false; if (Number(rfq.expiry) <= Math.floor(Date.now() / 1000)) return false; return true; }

Complete Bot Example

Here is a production-ready bot skeleton combining all the concepts above:

import { Wallet } from "ethers"; import WebSocket from "ws"; import { Quote, MakerConfig, RFQ, MarketData, RFQBroadcastMessage, QuoteSubmitMessage, RelayMessage, quoteToJson, rfqFromJson, signQuote, StubPricingEngine, RiskState, checkRisk, computeNotional, } from "@hyperquote/sdk-maker"; // ---- Configuration ---- const WHYPE = process.env.WHYPE_ADDRESS ?? "0x0000000000000000000000000000000000000001"; const USDC = process.env.USDC_ADDRESS ?? "0x0000000000000000000000000000000000000002"; const USDH = process.env.USDH_ADDRESS ?? "0x0000000000000000000000000000000000000003"; const config: MakerConfig = { privateKey: process.env.MAKER_PRIVATE_KEY!, chainId: parseInt(process.env.CHAIN_ID ?? "31337"), engineAddress: process.env.ENGINE_ADDRESS ?? "0x5FbDB2315678afecb367f032d93F642f64180aa3", relayWsUrl: process.env.RELAY_WS_URL ?? "ws://127.0.0.1:8080", allowedUnderlying: [WHYPE], collateralTokens: { [USDC.toLowerCase()]: { decimals: 6, symbol: "USDC" }, [USDH.toLowerCase()]: { decimals: 6, symbol: "USDH" }, }, risk: { maxNotionalPerCollateral: { [USDC.toLowerCase()]: 1_000_000n * 10n ** 6n, [USDH.toLowerCase()]: 1_000_000n * 10n ** 6n, }, maxTenorSecs: 90 * 24 * 3600, maxStrikeDeviationPct: 0.5, maxDeltaPerExpiry: 100, minPremium: { [USDC.toLowerCase()]: 1000n, [USDH.toLowerCase()]: 1000n, }, }, quoteDeadlineSecs: 120, }; // ---- State ---- const wallet = new Wallet(config.privateKey); const pricingEngine = new StubPricingEngine(); const riskState = new RiskState(); let nonce = 0n; function getMarketData(): MarketData { const spotUsd = parseFloat(process.env.HYPE_SPOT_USD ?? "25"); return { spotPrice: BigInt(Math.round(spotUsd * 1e18)), ivBps: parseInt(process.env.HYPE_IV_BPS ?? "8000"), riskFreeRateBps: parseInt(process.env.RISK_FREE_RATE_BPS ?? "500"), }; } function isRfqAcceptable(rfq: RFQ): boolean { if (!config.allowedUnderlying.some(u => u.toLowerCase() === rfq.underlying.toLowerCase())) return false; if (!config.collateralTokens[rfq.collateral.toLowerCase()]) return false; if (Number(rfq.expiry) % 86400 !== 28800) return false; if (Number(rfq.expiry) <= Math.floor(Date.now() / 1000)) return false; return true; } // ---- Pipeline ---- async function processRfq(rfqId: string, rfq: RFQ, ws: WebSocket) { // 1. Filter if (!isRfqAcceptable(rfq)) return; // 2. Price const market = getMarketData(); const collateralKey = rfq.collateral.toLowerCase(); const cDec = config.collateralTokens[collateralKey]?.decimals ?? 6; const pricing = pricingEngine.price(rfq, market, cDec); // 3. Min premium check if (rfq.minPremium > 0n && pricing.premium < rfq.minPremium) return; // 4. Risk check const riskResult = checkRisk(rfq, market, config.risk, riskState, cDec, pricing.delta); if (!riskResult.passed) { console.log(` [SKIP] Risk: ${riskResult.reason}`); return; } // 5. Build quote const now = BigInt(Math.floor(Date.now() / 1000)); const quote: Quote = { maker: wallet.address, taker: "0x0000000000000000000000000000000000000000", underlying: rfq.underlying, collateral: rfq.collateral, isCall: rfq.isCall, isMakerSeller: false, strike: rfq.strike, quantity: rfq.quantity, premium: pricing.premium, expiry: rfq.expiry, deadline: now + BigInt(config.quoteDeadlineSecs), nonce, }; // 6. Sign const signature = await signQuote(wallet, quote, config.chainId, config.engineAddress); // 7. Record risk const notional = computeNotional(rfq.strike, rfq.quantity, 18, cDec); riskState.recordQuote(rfq.collateral, rfq.expiry, notional, pricing.delta, rfq.isCall); nonce += 1n; // 8. Submit const submitMsg: QuoteSubmitMessage = { type: "QUOTE_SUBMIT", data: { rfqId, quote: quoteToJson(quote), makerSig: signature }, }; ws.send(JSON.stringify(submitMsg)); console.log(`[QUOTE] ${rfqId.slice(0, 14)}... premium=${pricing.premium} delta=${pricing.delta.toFixed(4)}`); } // ---- WebSocket Lifecycle ---- function connect() { const ws = new WebSocket(config.relayWsUrl!); ws.on("open", () => console.log("[CONNECTED] Relay WebSocket open")); ws.on("message", async (data) => { try { const msg: RelayMessage = JSON.parse(data.toString()); if (msg.type === "PING") { ws.send(JSON.stringify({ type: "PONG", data: {} })); return; } if (msg.type === "ERROR") { console.error("[ERROR]", (msg.data as any).message); return; } if (msg.type === "RFQ_BROADCAST") { const broadcast = msg as unknown as RFQBroadcastMessage; await processRfq(broadcast.data.rfqId, rfqFromJson(broadcast.data.rfq), ws); } } catch (err) { console.error("[ERROR] Processing message:", err); } }); ws.on("close", () => { console.log("[DISCONNECTED] Reconnecting in 3s..."); setTimeout(connect, 3000); }); ws.on("error", (err) => console.error("[WS ERROR]", err.message)); // Keepalive const pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "PING", data: {} })); } }, 30_000); ws.on("close", () => clearInterval(pingInterval)); } // ---- Startup ---- console.log(`=== HyperQuote Maker Bot ===`); console.log(` Maker: ${wallet.address}`); console.log(` Relay: ${config.relayWsUrl}`); connect();

Pipeline Customization Points

StageWhat to CustomizeHow
FilterAdd token pair restrictions, blocklists, time-of-day filtersModify isRfqAcceptable()
PricingReplace stub BSM with a proprietary modelImplement PricingEngine interface
Market DataAdd real-time price feeds (WebSocket, REST, oracle)Replace getMarketData()
RiskAdjust limits, add per-trade caps, position-aware checksModify RiskConfig values and extend checkRisk
Quote BuildingTarget specific takers, adjust deadlines dynamicallyModify the quote construction
ReconnectAdd exponential backoff, alerting, health monitoringModify the ws.on("close") handler

The bot also ships with an offline mock mode (main.ts) that processes a built-in set of RFQs without a relay connection. This is useful for testing pricing and risk logic in isolation: npx tsx src/main.ts

Monitoring and Observability

For production deployments, consider adding:

  • Structured logging — Log each pipeline stage with RFQ ID, timing, and outcome.
  • Metrics — Track quotes submitted, quotes skipped (by reason), fill rate, average premium.
  • Alerting — Alert on relay disconnects, risk limit breaches, and pricing engine errors.
  • Local reliability tracking — Monitor your cancel rate to maintain a high reliability score.

Next Steps

Last updated on