A working trading bot needs more than API calls — it needs retries, balance checks, error classification, and defensive signing. This guide extracts the production patterns from the PMX reference agent.
Setup
const { Connection , Transaction , Keypair } = require ( "@solana/web3.js" );
const bs58 = require ( "bs58" );
const API = "https://api.pmx.trade" ;
const connection = new Connection ( "https://api.devnet.solana.com" , "confirmed" );
const wallet = Keypair . fromSecretKey ( bs58 . decode ( process . env . PRIVATE_KEY ));
const WALLET = wallet . publicKey . toBase58 ();
API Client with Retries
The PMX API rate-limits at 429. A production client should retry with backoff:
const MAX_RETRIES = 3 ;
async function api ( method , path , body ) {
for ( let attempt = 0 ; attempt < MAX_RETRIES ; attempt ++ ) {
const opts = {
method ,
headers: { "Content-Type" : "application/json" },
signal: AbortSignal . timeout ( 30_000 ),
};
if ( body ) opts . body = JSON . stringify ( body );
let res ;
try {
res = await fetch ( ` ${ API }${ path } ` , opts );
} catch ( err ) {
if ( err . name === "TimeoutError" || err . name === "AbortError" ) {
return { success: false , error: "Request timed out" };
}
throw err ;
}
if ( res . status === 429 && attempt < MAX_RETRIES - 1 ) {
await new Promise (( r ) => setTimeout ( r , ( attempt + 1 ) * 3000 ));
continue ;
}
return res . json ();
}
return { success: false , error: "Rate limited after all retries" };
}
Signing & Submitting Transactions
Every write endpoint returns a base64-encoded unsigned transaction. Sign it locally and submit:
async function signAndSend ( base64Tx ) {
const tx = Transaction . from ( Buffer . from ( base64Tx , "base64" ));
tx . sign ( wallet );
const sig = await connection . sendRawTransaction ( tx . serialize (), {
skipPreflight: false ,
});
await Promise . race ([
connection . confirmTransaction ( sig , "confirmed" ),
new Promise (( _ , reject ) =>
setTimeout (() => reject ( new Error ( "Confirmation timed out" )), 60_000 )
),
]);
return sig ;
}
Always race confirmation against a timeout. Solana confirmations can hang indefinitely on congested networks.
Getting a Quote
Always quote before trading. The quote returns expected tokens, price impact, and fees:
async function getQuote ( marketId , side , usdcAmount ) {
const raw = Math . floor ( usdcAmount * 1e6 );
const res = await api (
"GET" ,
`/v2/markets/ ${ marketId } /quote?side= ${ side } &amount= ${ raw } &action=buy`
);
if ( ! res . success ) return null ;
return {
tokensOut: res . data . outputAmount / 1e6 ,
effectivePrice: res . data . effectivePrice ,
priceImpactBps: res . data . priceImpactBps ,
fee: res . data . tradeFee / 1e6 ,
preOdds: res . data . preOdds ,
postOdds: res . data . postOdds ,
};
}
The humanAmount field (used in the buy/sell body) accepts human-readable amounts like 5 for $5. The quote endpoint uses raw units — multiply by 1e6.
Buying Tokens
async function buy ( marketId , side , amount ) {
const res = await api ( "POST" , `/v2/markets/ ${ marketId } /buy` , {
wallet: WALLET ,
side ,
humanAmount: amount ,
});
if ( ! res . success ) throw new Error ( res . error );
return signAndSend ( res . data . transaction );
}
Selling Tokens
Selling works the same way. The amount is the number of tokens to sell, not USDC:
async function sell ( marketId , side , tokenAmount ) {
const res = await api ( "POST" , `/v2/markets/ ${ marketId } /sell` , {
wallet: WALLET ,
side ,
humanAmount: tokenAmount ,
});
if ( ! res . success ) throw new Error ( res . error );
return signAndSend ( res . data . transaction );
}
Checking Positions
The positions endpoint returns all markets where your wallet holds tokens:
async function getPositions () {
const res = await api ( "GET" , `/v2/positions/ ${ WALLET } ` );
return res . data || [];
}
Each position includes:
Field Description marketIdMarket identifier yesBalance / noBalanceToken balances (raw) isResolvedWhether the market has been resolved winningSide"YES" or "NO" (only if resolved)needsRedemptiontrue if you hold winning tokens that can be redeemed
Error Classification
Not all errors are equal. Classify them to decide whether to retry, skip, or halt:
function classifyError ( err ) {
const msg = typeof err === "string" ? err : err ?. message || "" ;
if ( msg . includes ( "AccountNotInitialized" ) || msg . includes ( "0xbc4" ))
return "broken_market" ;
if ( msg . includes ( "insufficient funds" ) || msg . includes ( "0x1" ))
return "insufficient_funds" ;
if ( msg . includes ( "429" ) || msg . includes ( "rate limit" ))
return "rate_limited" ;
if ( msg . includes ( "ETIMEDOUT" ) || msg . includes ( "ECONNREFUSED" ))
return "network_error" ;
return "unknown" ;
}
Error Type Action broken_marketPermanently skip this market — mints are uninitialized insufficient_fundsCheck balance, wait for redemptions rate_limitedBackoff and retry network_errorRetry next cycle
Edge Detection
The reference agent uses a simple edge model: compare the “true” probability (derived from an external price feed) against the market odds, and only trade when the gap is meaningful.
function calculateEdge ( currentPrice , threshold , direction , marketOdds ) {
const priceDelta = Math . abs ( currentPrice - threshold ) / threshold ;
let trueYesProb ;
if ( direction === "above" ) {
trueYesProb =
currentPrice >= threshold
? 0.5 + Math . min ( priceDelta * 10 , 0.45 )
: 0.5 - Math . min ( priceDelta * 10 , 0.45 );
} else {
trueYesProb =
currentPrice < threshold
? 0.5 + Math . min ( priceDelta * 10 , 0.45 )
: 0.5 - Math . min ( priceDelta * 10 , 0.45 );
}
const edge = trueYesProb - marketOdds . yes ;
return { edge , trueYesProb , side: edge > 0 ? "YES" : "NO" };
}
Trade sizing based on edge:
Edge Action > 8% Trade $0.50–$2.00 > 4% Trade $0.25–$1.00 > 2% Occasionally trade $0.10–$0.50 < 2% Skip — not enough edge
Putting It Together
A minimal trading loop that runs every 60 seconds:
async function tradingLoop () {
while ( true ) {
const markets = await api ( "GET" , "/v2/markets?status=active" );
if ( ! markets . data ) continue ;
for ( const market of markets . data ) {
const quote = await getQuote ( market . id , "YES" , 1 );
if ( ! quote ) continue ;
// Your edge logic here — compare market odds to your model
const shouldBuy = quote . effectivePrice < 0.4 ; // example
if ( shouldBuy ) {
try {
const sig = await buy ( market . id , "YES" , 1 );
console . log ( `Bought YES on # ${ market . id } : ${ sig } ` );
} catch ( err ) {
const errType = classifyError ( err );
if ( errType === "broken_market" ) continue ;
console . error ( `Buy failed: ${ err . message } ` );
}
}
}
// Check for redeemable positions
const positions = await getPositions ();
for ( const pos of positions ) {
if ( pos . needsRedemption ) {
const res = await api ( "POST" , `/v2/markets/ ${ pos . marketId } /redeem` , {
wallet: WALLET ,
side: pos . winningSide ,
});
if ( res . data ?. transaction ) await signAndSend ( res . data . transaction );
}
}
await new Promise (( r ) => setTimeout ( r , 60_000 ));
}
}
Next Steps
Build a Market Maker Create, trade, resolve, and redeem — the full automated loop
API Reference Full endpoint documentation