Skip to main content

How to Build a Solana Trading Bot with the Tokens API and Metis Swap API

Updated on
Jul 23, 2026

21 min read

Overview

Most trading bots start with a hardcoded token and a price threshold, so they only decide when to trade, not what. Letting a bot pick its own tokens raises two harder problems: choosing which tokens are worth trading right now, and finding the right onchain variant of each one, since a single asset like Bitcoin can exist as several competing mints with different liquidity and risk.

This guide walks through a headless TypeScript bot that pulls trending Solana assets from the Tokens API, screens them against rules defined in a config file, and executes the resulting buys and sells through Quicknode's Metis Jupiter Swap API, explaining the code that makes each of those steps work.

Note: This guide is for educational purposes only. Quicknode does not provide financial advice or endorse any trading strategies. Always do your own research before making any investment decisions.


TL;DR
  • Build a TypeScript bot that discovers trending tokens with the Tokens API, screens them with a pure rules engine, and swaps through Quicknode's Metis add-on
  • Keep discovery, decision, and execution as three separate layers, with the decision layer deterministic and network-free so it is unit-testable
  • The bot runs in dry-run mode by default: it quotes and logs intended trades but signs nothing until you explicitly opt in to live trading

What You Will Do


  • Clone and configure the sample project from the qn-guide-examples repo
  • Follow how it pulls and de-duplicates a universe of trending assets across several categories
  • Screen each asset against a liquidity, risk, volume, and momentum ruleset with pure functions
  • Map a Tokens API venue name to a Metis dexes allowlist so swaps pin to the intended pool
  • Reach Metis through a Solana Kit custom transport and execute quote/swap flows
  • Run a dry-run cycle and read the trade log before ever going live

What You Will Need

This guide assumes a working knowledge of TypeScript, REST APIs, SPL tokens, and decentralized exchange (DEX) liquidity pools.

You will also need:


This guide uses the following dependencies:

DependencyVersion
@solana/kit^7.0.0
@solana/kit-plugin-signer^0.13.0
tsx^4.23.1
typescript^5.9.3

Enhanced Performance with Quicknode

Reliable, low-latency infrastructure matters for any bot that quotes and swaps on a schedule. Quicknode provides fast Solana RPC endpoints, and the Metis add-on hosts Jupiter's onchain routing engine as a private, managed endpoint so you never maintain a routing server yourself. Create an account to get started.

How the Bot Is Built

The bot runs on an interval. Each cycle moves through three distinct layers, and keeping them separate is the core design principle:


  1. Discovery (Tokens API): Pull trending assets, resolve each to its canonical asset, pick the best onchain variant, and read its risk and market data.
  2. Decision (pure rules engine): Screen each candidate against your rules, then diff the passing set against what the bot already holds to produce buy, sell, and hold sets.
  3. Execution (Metis): For each trade, ask the Tokens API which pool is deepest, map that venue to a Metis dexes allowlist, quote, simulate, and swap.

Here is the project layout:

src/
index.ts entrypoint and poll loop
config.ts env + rules.json validation, fail fast
types.ts shared types
clients/
tokens.ts Tokens API (discovery, risk, variants, markets)
metis.ts Metis quote/swap wrappers
rpc.ts @solana/kit RPC + custom transport + wallet
engine/
screen.ts pure: universe -> passing candidates
diff.ts pure: passing + held -> buy/sell/hold
dexMap.ts pure: venue label -> Metis dexes label
exits.ts pure: take-profit / stop-loss
exec/
executor.ts selects market, sizes, quotes, swaps, updates state

Clone the Project

The complete bot lives in Quicknode's qn-guide-examples monorepo. Clone it, move into the sample folder, and install dependencies:

git clone https://github.com/quiknode-labs/qn-guide-examples.git
cd qn-guide-examples/solana/tokens-metis-bot
npm install

Create a dedicated dev wallet keypair file (do not use a wallet with real mainnet funds):

solana-keygen new --outfile ./dev-wallet.json

Copy .env.example to env.local and fill it in:

cp env.example env.local

Add your API keys and endpoints:

env.local
# Solana RPC (your Quicknode Solana mainnet endpoint; used by @solana/kit)
SOLANA_RPC_URL=

# Metis (Jupiter Swap) endpoint.
# Private: https://jupiter-swap-api.quiknode.pro/YOUR_ENDPOINT/
# Public fallback for testing only: https://public.jupiterapi.com/
METIS_ENDPOINT=

# Tokens API
TOKENS_API_BASE_URL=https://api.tokens.xyz/v1
TOKENS_API_KEY=

# Wallet: path to a Solana CLI keypair file (a JSON array of 64 bytes)
WALLET_KEYPAIR_PATH=./dev-wallet.json

# Safety
DRY_RUN=true
KILL_SWITCH_FILE=./STOP

# Loop
POLL_INTERVAL_SECONDS=300
RUN_ONCE=false

Make sure env.local and dev-wallet.json are listed in .gitignore so neither is ever committed.

With the project cloned and configured, the rest of this guide walks through the parts of the code that matter most: how the Tokens API drives discovery, how the pure rules engine decides, and how Metis executes, without having to write it all from scratch.

Define the Rules

Every decision the bot makes is driven by a single rules.json file, validated strictly at startup.

rules.json
{
"universe": {
"limit": 50,
"categories": ["crypto", "rwa", "commodity", "equity", "etf"]
},
"screen": {
"minLiquidityTier": "tier2",
"maxRiskFlags": 0,
"minVolume24hUSD": 500000,
"momentum": { "window": "24h", "minChangePct": 5 }
},
"portfolio": {
"quoteMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"maxPositions": 50,
"targetPositionSizeUSD": 0.1,
"maxPositionSizeUSD": 0.15
},
"exit": {
"sellWhenScreenFails": true,
"takeProfitPct": 25,
"stopLossPct": 15,
"reentryCooldownMinutes": 60
},
"execution": {
"slippageBps": 100,
"onlyDirectRoutes": false,
"restrictIntermediateTokens": true,
"maxAccounts": 64
}
}

The file has five sections, each governing a different part of the cycle.

universe controls what the bot pulls from the Tokens API each cycle:


  • limit: how many trending assets to consider, capped at 50 (the /trending endpoint maximum).
  • categories: which Tokens API categories to scope the universe to. The bot queries each configured category and merges the results. Valid values are crypto, stablecoin, lst, rwa, commodity, equity, etf, and index.

screen is the ruleset every candidate must clear to be eligible for a buy. All four gates must pass:


  • minLiquidityTier: the floor for the Tokens API derived liquidity tier of the chosen variant (tier1 is deepest, down to tier3). This is separate from any raw dollar figure.
  • maxRiskFlags: a hard gate on the number of risk flags. A candidate is rejected if its risk summary reports more than this many, no matter how good everything else looks. 0 means zero tolerance.
  • minVolume24hUSD: the minimum 24h trading volume, in US dollars, to consider an asset liquid enough to trade.
  • momentum: the price-move filter. window is the lookback (1h or 24h) and minChangePct is the minimum percentage change over that window required to buy.

portfolio sets position sizing and how many trades the bot holds at once:


  • quoteMint: the mint the bot buys and sells against (USDC above). All position sizes are denominated in this mint's units.
  • maxPositions: the maximum number of open positions. Once reached, the bot stops buying until a position exits.
  • targetPositionSizeUSD: the dollar amount the bot aims to spend per buy.
  • maxPositionSizeUSD: a hard ceiling on any single position. Before each buy, the bot sizes the trade to targetPositionSizeUSD, then caps it at this value and again at the actual quote-mint balance in the wallet, spending whichever is smallest.

exit governs when the bot sells a position it holds:


  • sellWhenScreenFails: when true, sell a held position the moment its asset stops passing the screen.
  • takeProfitPct: sell when the position is up this percentage from its entry cost. 0 disables it.
  • stopLossPct: sell when the position is down this percentage from its entry cost. 0 disables it.
  • reentryCooldownMinutes: after selling an asset, block re-buying it for this many minutes, so a whipsawing price does not churn the same position.

execution is passed straight through to the Metis quote and swap calls:


  • slippageBps: the maximum slippage tolerance in basis points (100 = 1%).
  • onlyDirectRoutes: when true, restrict routing to single-hop swaps only.
  • restrictIntermediateTokens: when true, limit multi-hop routes to a set of high-liquidity intermediate tokens, which reduces the odds of routing through a thin pool.
  • maxAccounts: the cap on how many accounts a route may touch, which keeps the resulting transaction within size limits.

Realistic thresholds

The default /trending feed is dominated by cross-chain majors, and most non-crypto assets sit at tier2 or tier3. A tier1 floor plus a 10% hourly momentum threshold screens out nearly everything. For a sample that actually surfaces candidates, a tier2 floor with a 24h momentum window and a low single-digit threshold is the realistic setting.

Now to the first layer. The Tokens API is a third-party, read-only service (it is not a Quicknode product), so this is the one client that uses native fetch. The key travels in the x-api-key header, never in a query string and never in a log line.

The universe comes from the /assets/trending endpoint. To scope it to the categories in rules.json, the bot queries /assets/trending once per configured category and merges the results. Scoping this way also keeps the stablecoin category out of the universe, so the bot never tries to trade one stablecoin against another. An asset can appear under multiple mints (wBTC and cbBTC both resolve to the canonical asset "bitcoin"), so the merge de-duplicates by canonical assetId, keeps the highest trending score, and caps to the limit.

src/clients/tokens.ts
// Merge trending results from multiple category queries into one universe,
// deduped by canonical assetId (keep the highest trending score), capped.
export function dedupeTrending(entries: TrendingEntry[], limit: number): TrendingEntry[] {
const byAsset = new Map<string, TrendingEntry>();
for (const e of entries) {
const existing = byAsset.get(e.assetId);
if (!existing || (e.trending?.score ?? 0) > (existing.trending?.score ?? 0)) {
byAsset.set(e.assetId, e);
}
}
return [...byAsset.values()]
.sort((a, b) => (b.trending?.score ?? 0) - (a.trending?.score ?? 0))
.slice(0, limit);
}

async getTrending({ limit, categories }): Promise<TrendingEntry[]> {
if (!categories || categories.length === 0) {
const data = await get<{ trending: TrendingEntry[] }>("/assets/trending", { limit });
return data.trending.slice(0, limit);
}
const perCategory = await Promise.all(
categories.map((category) =>
get<{ trending: TrendingEntry[] }>("/assets/trending", { limit, category })
.then((d) => d.trending),
),
);
return dedupeTrending(perCategory.flat(), limit);
}

Trending gives you a list of assets, but not everything the screen needs. For each entry the bot makes three more calls, and the order matters:


  1. resolve({ mint }) confirms the trending mint actually maps to the canonical asset it claims.
  2. getVariants(assetId, { minLiquidityTier }) returns every onchain variant sorted by liquidity, filtered to the tier floor. The top entry becomes the chosenMint, the specific mint the bot would trade.
  3. getRiskSummary(mint) reads the risk flags, assessed on the chosen variant's mint, not the trending mint.

The API does not return a numeric count of risk flags. It returns a caps[] array of named factors, each with a tone. The bot counts factors toned warning or danger as flags, and treats "insufficient data" as one additional flag so an unratable asset cannot slip through a zero-flag gate.

src/clients/tokens.ts
// The screen's risk gate counts named risk factors with a negative tone.
// Insufficient data counts as one flag: an unratable asset should not pass
// a zero-flag gate.
export function countRiskFlags(summary: RiskSummary): number {
const caps = summary.caps ?? [];
const negative = caps.filter((c) => c.tone === "warning" || c.tone === "danger").length;
return negative + (summary.hasInsufficientData ? 1 : 0);
}

Momentum has a similar catch. Trending snapshots only carry price change for the 1h and 24h windows. When the rules ask for a window the snapshot cannot provide, the bot falls back to comparing the current 1h volume pace against the 24h baseline and records which basis it used, so the log always explains the number.

src/clients/tokens.ts
export function momentumFromSnapshot(
market: MarketSnapshot,
window: string,
): { momentumPct: number; momentumBasis: string } {
if (window === "1h" && typeof market.priceChange1hPercent === "number") {
return { momentumPct: market.priceChange1hPercent, momentumBasis: "1h" };
}
if (window === "24h" && typeof market.priceChange24hPercent === "number") {
return { momentumPct: market.priceChange24hPercent, momentumBasis: "24h" };
}
// Fallback: 1h volume pace vs the 24h baseline, as a percentage above (+)
// or below (-) that baseline.
const v1h = market.volume1hUSD;
const v24h = market.volume24hUSD;
if (typeof v1h === "number" && typeof v24h === "number" && v24h > 0) {
const pace = (v1h * 24) / v24h;
return { momentumPct: (pace - 1) * 100, momentumBasis: "1h-vs-24h-pace" };
}
return { momentumPct: 0, momentumBasis: "unavailable" };
}

Resolving an asset to its best onchain variant and ranking pools by execution quality is the same workflow covered in How to Research Solana Token Trades with the Tokens API.

Screen Candidates

This is the heart of the decision layer. Given fully mapped candidates, it applies four gates in a fixed order (liquidity tier, risk flags, volume, then momentum) and returns only what passes. Every failing gate is collected, so the log can show all the reasons at once rather than just the first:

src/engine/screen.ts
export function screenWithReasons(universe: Candidate[], rules: Rules): ScreenResult {
const passing: Candidate[] = [];
const rejected: ScreenRejection[] = [];

for (const c of universe) {
const reasons: string[] = [];

if (tierRank(c.liquidityTier) > tierRank(rules.screen.minLiquidityTier)) {
reasons.push(`liquidity tier ${c.liquidityTier} below floor ${rules.screen.minLiquidityTier}`);
}
if (c.riskFlagCount > rules.screen.maxRiskFlags) {
reasons.push(`${c.riskFlagCount} risk flags exceeds max ${rules.screen.maxRiskFlags}`);
}
if (c.volume24hUSD < rules.screen.minVolume24hUSD) {
reasons.push(`24h volume $${c.volume24hUSD.toFixed(0)} below floor $${rules.screen.minVolume24hUSD}`);
}
if (c.momentumPct < rules.screen.momentum.minChangePct) {
reasons.push(`momentum ${c.momentumPct.toFixed(2)}% (${c.momentumBasis}) below ${rules.screen.momentum.minChangePct}%`);
}

if (reasons.length === 0) passing.push(c);
else rejected.push({ candidate: c, reasons });
}

return { passing, rejected };
}

Once you know what passes, the diff function turns the passing set plus current holdings into concrete actions:


  • Buy: a candidate that passes and is not already held, as long as adding it stays within maxPositions. When capacity is limited, higher-momentum candidates win the open slots.
  • Sell: a held position whose asset no longer passes the screen (when exit.sellWhenScreenFails is true).
  • Hold: passing and already held, so no action.

Every decision carries a human-readable reason built from the numbers that drove it, which becomes the audit trail in the trade log. A reentryCooldownMinutes window also blocks re-buying an asset immediately after selling it.

Map to Metis DEX Labels

The two APIs do not always agree on venue names. The Tokens API says "Raydium Clamm" and "Orca" while Metis calls the same venues "Raydium CLMM" and routes Orca pools as "Whirlpool". If you pass the Tokens API name straight into a Metis quote, at best it is ignored and at worst you route to the wrong pool.

The dexMap module is the translator. It normalizes both names, checks for a direct match against the live Metis label set, then falls back to an explicit alias table. An unmapped venue throws instead of silently sending an empty dexes list, because an empty allowlist means "route anywhere," which defeats the whole point of pinning to the pool the Tokens API identified.

src/engine/dexMap.ts
const VENUE_ALIASES: Record<string, string[]> = {
// Orca pools quote through Whirlpool on Jupiter; keep legacy labels as fallbacks.
orca: ["Whirlpool", "Orca V2", "Orca V1"],
raydium: ["Raydium"],
"raydium clamm": ["Raydium CLMM"],
"raydium clmm": ["Raydium CLMM"],
meteora: ["Meteora", "Meteora DLMM"],
"pump fun": ["Pump.fun Amm", "Pump.fun"],
// ...extend as new venues appear in Tokens API market data
};

// labelMap is the raw GET /program-id-to-label response: { programId: label }.
export function toMetisDexes(venueLabel: string, labelMap: Record<string, string>): string[] {
const wanted = normalize(venueLabel);
const canonicalByNormalized = new Map<string, string>();
for (const label of Object.values(labelMap)) {
if (!canonicalByNormalized.has(normalize(label))) {
canonicalByNormalized.set(normalize(label), label);
}
}

const candidates: string[] = [];
const direct = canonicalByNormalized.get(wanted); // exact match wins outright
if (direct) candidates.push(direct);
for (const alias of VENUE_ALIASES[wanted] ?? []) {
const canonical = canonicalByNormalized.get(normalize(alias));
if (canonical && !candidates.includes(canonical)) candidates.push(canonical);
}

if (candidates.length === 0) throw new UnmappedVenueError(venueLabel);
return candidates;
}

The labelMap it consumes comes straight from Metis's program-id-to-label endpoint, which the Metis client fetches and caches.

Call Metis API with Solana Kit

Rather than hitting Metis with raw fetch calls, the bot follows Quicknode's add-ons using Solana Kit pattern: register custom metis_* methods on the Kit RPC client and use a custom transport that routes those methods to your Metis endpoint while everything else goes to the normal Solana JSON-RPC transport. The payoff is one RPC object for the whole app: balance reads, transaction sends, and Metis calls all flow through the same client.

The transport inspects each request's method name and forks accordingly:

src/clients/rpc.ts
function createBotTransport(solanaRpcUrl: string, metisEndpoint: string): RpcTransport {
const jsonRpcTransport = createDefaultRpcTransport({ url: solanaRpcUrl });
return async <TResponse,>(...args: Parameters<RpcTransport>): Promise<TResponse> => {
const { method, params } = args[0].payload as { method: string; params: unknown };
if (method.startsWith("metis_")) {
return handleMetisRequest<TResponse>(method, params, metisEndpoint);
}
return jsonRpcTransport(...args) as Promise<TResponse>;
};
}

The handleMetisRequest helper maps each metis_* method to a REST route: GET methods (/quote, /program-id-to-label) turn their params into query strings (arrays comma-joined, which is exactly the dexes format Metis wants), and POST methods (/swap) send a JSON body. It wraps the REST response in { result } so a shared responseTransformer can unwrap both REST and JSON-RPC uniformly.

src/clients/rpc.ts
const api = createJsonRpcApi<BotRpcApi>({
// Kit 7: return a full RpcRequest, not bare params, or the method name is lost.
requestTransformer: (request: RpcRequest<unknown>): RpcRequest => {
if (request.methodName.startsWith("metis_")) {
return {
...request,
params: Array.isArray(request.params) ? request.params[0] : request.params,
};
}
return request;
},
responseTransformer: (response: unknown) => {
const envelope = response as { result?: unknown; error?: { code?: number; message?: string } };
if (envelope.error) throw new Error(`RPC error: ${envelope.error.message ?? "unknown"}`);
return envelope.result;
},
});

With the transport in place, the Metis client is just typed wrappers over those custom methods. The quote wrapper builds the request from the execution rules and only sets dexes when the allowlist is non-empty (an empty list lets Metis route anywhere, which is what you want for a sell). It returns the full parsed quote object unmodified, because /swap needs the whole thing sent back:

src/clients/metis.ts
async quote(req: QuoteRequest): Promise<MetisQuoteResponse> {
const params: MetisQuoteParams = {
inputMint: req.inputMint,
outputMint: req.outputMint,
amount: req.amountBaseUnits,
slippageBps: rules.execution.slippageBps,
swapMode: "ExactIn",
onlyDirectRoutes: rules.execution.onlyDirectRoutes,
restrictIntermediateTokens: rules.execution.restrictIntermediateTokens,
maxAccounts: rules.execution.maxAccounts,
};
if (req.dexes.length > 0) params.dexes = req.dexes.join(",");
return rpc.metis_quote(params).send();
},

async swap(quoteResponse: MetisQuoteResponse, userPublicKey: string): Promise<MetisSwapResponse> {
return rpc
.metis_swap({
quoteResponse,
userPublicKey,
wrapAndUnwrapSol: true,
dynamicComputeUnitLimit: true,
prioritizationFeeLamports: {
priorityLevelWithMaxLamports: {
priorityLevel: "high",
maxLamports: 1_000_000, // hard cap on the priority fee: 0.001 SOL
global: false,
},
},
})
.send();
},

Metis returns an unsigned base64 transaction to be signed and executed.

Sign and Send

Because Metis hands back an unsigned transaction, the bot deserializes it, signs it with the dev wallet, and sends it. The wallet is loaded once at startup via signerFromFile from the Kit signer plugin, and the code below signs with its underlying wallet.keyPair. The key safety step is that every transaction is simulated before it is sent, and a failed simulation aborts the trade rather than burning the fee on a doomed swap.

src/clients/rpc.ts
async function signAndSend(base64Tx: string): Promise<string> {
const txBytes = getBase64Encoder().encode(base64Tx);
const tx = getTransactionDecoder().decode(txBytes);
const signed = await partiallySignTransaction([wallet.keyPair], tx);
const wire = getBase64EncodedWireTransaction(signed);

// Simulate before sending; refuse to send a transaction that fails simulation.
const simulation = await rpc
.simulateTransaction(wire, { encoding: "base64", replaceRecentBlockhash: true, sigVerify: false })
.send();
if (simulation.value.err) {
throw new Error(`Simulation failed, refusing to send: ${JSON.stringify(simulation.value.err)}`);
}

const sig = await rpc
.sendTransaction(wire, { encoding: "base64", skipPreflight: false, maxRetries: 3n })
.send();

// Confirm by polling signature status (up to 60s).
// ...poll getSignatureStatuses until confirmed or finalized...
return sig;
}

Execute a Trade Safely

The executor is where the three layers meet, and where every remaining safety cap is enforced immediately before money moves. For a buy, it gets the deepest market from the Tokens API, maps the venue to a Metis dexes allowlist, sizes the trade, quotes, and (unless it is a dry run) swaps. The trade is clamped to the target size, the max size, and the actual wallet balance, whichever is smallest.

src/exec/executor.ts
async function sizeBuyInQuoteBaseUnits(): Promise<string | null> {
const target = Math.min(rules.portfolio.targetPositionSizeUSD, rules.portfolio.maxPositionSizeUSD);
const targetBase = BigInt(await rpc.toBaseUnits(target, rules.portfolio.quoteMint));

const balances = await rpc.getBalances(rpc.walletAddress);
const quoteBalance = balances.tokens.find((t) => t.mint === rules.portfolio.quoteMint);
const available = BigInt(quoteBalance?.amountBaseUnits ?? "0");

if (available <= 0n) {
if (dryRun) return targetBase.toString(); // hypothetical: quote and log without funds
return null;
}
// Never size above the cap or above the actual balance.
return (targetBase < available ? targetBase : available).toString();
}

Every quote is then checked against a hard price-impact ceiling. Even if your slippage tolerance would technically allow it, a quote that moves the pool more than 2.5% means the market is thinner than the screen believed, and the swap is refused:

src/exec/executor.ts
const quote = await metis.quote({ inputMint, outputMint, amountBaseUnits, dexes });
// priceImpactPct is a decimal fraction despite the name ("0.0001" means
// 0.01%), per the Jupiter Swap API reference that Metis wraps. Scale to
// percent before comparing against the percent ceiling.
const priceImpact = Number(quote.priceImpactPct) * 100;
if (!Number.isFinite(priceImpact) || priceImpact > MAX_PRICE_IMPACT_PCT) {
logger.warn("Quoted price impact exceeds ceiling, refusing swap", {
symbol: candidate.symbol,
priceImpactPct: quote.priceImpactPct,
ceilingPct: MAX_PRICE_IMPACT_PCT,
});
return;
}

On a dry run, the executor logs the intended trade with a dryRun: true flag and leaves state untouched. On a live fill, it signs and sends through signAndSend, records the new position, and appends a line to the trade log. Sells route across any DEX (empty dexes) for the best exit price, and price-based take-profit and stop-loss exits, evaluated from a live sell quote, take priority over the screen so a stop-loss fires even when the asset still passes.

Run a Dry-Run Cycle

With every layer in view, here is how index.ts ties them together. The main loop pulls the tokens, resolves and screens them, diffs against holdings, and runs each decision, printing a funnel breakdown so it is always clear where assets dropped out.

Run a single cycle with the shipped script (recommended for your first run):

npm run start:once

That script sets RUN_ONCE=true and runs tsx --env-file=env.local src/index.ts for you. To run a raw command instead, use RUN_ONCE=true npx tsx --env-file=env.local src/index.ts.

You will see output along these lines (values will differ):

============================================================
MODE: DRY RUN (no trades will be placed)
Wallet: 7Xy...q9F
Rules: max 50 positions, $0.1 target size, 100 bps slippage
============================================================
Cycle start: 41 trending assets (categories: crypto, rwa, commodity, equity, etf)
Funnel: 41 pulled -> 2 fetch-error, 6 no-variant, 33 screened (3 passing, 30 rejected); held 0; decisions: 3
GLD: REJECT - momentum 1.20% (24h) below 5%
SPCX: REJECT - 1 risk flags exceeds max 0
WBTC: BUY (dry run) - passed screen: tier=tier1, riskFlags=0, vol24h=$88431022, momentum=6.14% (24h)
...
RUN_ONCE set, exiting after one cycle

Each executed or intended trade also appends a full record to logs/trades.ndjson, including the venue, the dexes sent, quoted amounts, price impact, and the reason. That log is your audit trail and the raw material for tuning the rules.


Stopping a running loop

When running the continuous loop (npm start), create the kill-switch file to stop the bot cleanly without interrupting a trade mid-flight: touch STOP. The bot checks for it at startup, on every loop wake, and between trades.

Going Live

When you are ready to place real trades, and only after watching several dry cycles behave, set DRY_RUN=false in env.local and fund the dev wallet with a small amount of SOL (for fees) and the quote mint (USDC by default) sized to your targetPositionSizeUSD. The startup banner will read MODE: LIVE TRADING so you always know which mode you are in.

The safety model that protects a live run, all enforced in code:


  • DRY_RUN defaults to true; live trading requires an explicit false.
  • Position count and size caps are checked before every buy, clamped to the real balance.
  • Every transaction is simulated before it is sent, and a failed simulation aborts the trade.
  • Quotes above the 2.5% price-impact ceiling are refused regardless of slippage tolerance.
  • The kill-switch file halts trading at startup, each loop wake, and between trades.
  • The API key and wallet secret are never logged, and the secret never enters the environment (only a keypair file path does).

Wrapping Up

You have built a trading bot that decides what to trade, not just when. It discovers trending tokens through the Tokens API, screens them with a deterministic rules engine you can unit-test in isolation, and routes the resulting swaps through Quicknode's Metis add-on over a single Solana Kit transport. Just as important, you kept the three concerns separate: discovery and execution fetch facts, and only the pure engine in the middle makes decisions.

You can tighten the rules, add new screens, or swap in a different execution venue without touching the logic that decides a trade, and you can prove the engine's behavior with tests before a single lamport moves.

Frequently Asked Questions

Why route Metis through a Solana Kit custom transport instead of using fetch or @jup-ag/api?

Using a @solana/kit custom transport gives you one RPC path for everything onchain: balance reads, transaction sends, and Metis quote/swap calls all flow through the same client. You register custom metis_* methods and route them to your Metis endpoint while every other method defaults to the normal Solana JSON-RPC transport. This is the pattern Quicknode's add-on docs recommend, and it keeps the code from juggling two separate HTTP clients.

Does the bot place real trades out of the box?

No. DRY_RUN defaults to true, so the bot quotes and logs the trades it would place but signs and sends nothing. Live trading requires setting DRY_RUN=false explicitly. Even then, every transaction is simulated before it is sent, position size and count caps are enforced against the real wallet balance, and any quote whose price impact exceeds 2.5% is refused.

Why do the Tokens API and Metis use different names for the same DEX?

They are independent systems with independent naming. The Tokens API reports human-readable venue names like 'Raydium Clamm' or 'Orca', while Metis uses labels from its program-id-to-label map like 'Raydium CLMM' and routes Orca pools as 'Whirlpool'. The dexMap module bridges the two with an explicit alias table and throws on an unmapped venue rather than sending an empty allowlist, which would let the swap route anywhere.

Can I run this without the Metis add-on enabled on my endpoint?

For testing, yes. Point METIS_ENDPOINT at the public fallback https://public.jupiterapi.com, which needs no key. A private jupiter-swap-api.quiknode.pro endpoint returns 404 on every Metis path until the Metis - Jupiter Swap add-on is enabled on that endpoint, so enable the add-on before pointing the bot at your private URL.

Why is the rules engine kept free of network calls?

Determinism and testability. Because screen, diff, dexMap, and the exit logic are pure functions, the same inputs always produce the same decisions, so you can unit-test every gate (a pass, a failure on each individual check, capacity-limited buys, exit-on-screen-fail) with fixtures and no network. Keeping discovery and execution outside the engine means a flaky API response can never silently change a trading decision.

Resources


We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide