Solana 趋势代币交易机器人实战:Tokens API + Metis 完整开发教程(TypeScript)
大多数交易机器人只决定"何时交易",这个教程让它自己决定"交易什么":QuickNode 的实战指南带你用 TypeScript 构建一个 Solana 趋势代币机器人——发现、决策、执行三层完全解耦,附完整代码、dry-run 流程与上线安全清单。
一键发币: x402兼容 | Aptos | X Layer | SUI | SOL | BNB | ETH | BASE | ARB | OP | Polygon | Avalanche
大多数交易机器人从"写死的代币 + 价格阈值"开始——它们只决定什么时候交易,不决定交易什么。让机器人自己挑选代币会带来两个更难的问题:
- 现在哪些代币值得交易?
- 同一个资产(比如比特币)在链上可能有多个竞争性 mint,流动性、风险各不相同——该买哪一个?
QuickNode 在 2026 年 10 月更新的一份实战指南(TypeScript,21 分钟阅读)给出了完整解法:一个无头机器人从 Tokens API 拉取 Solana 趋势资产,用配置文件定义的纯规则引擎筛选,再通过 Metis(Jupiter Swap API) 执行买卖。本文完整编译这篇教程的核心内容与代码。
⚠️ 原文声明:本教程仅供学习,不构成投资建议,也不代表对任何交易策略的背书。请自行研究后再做任何投资决策。
TL;DR
- 用 Tokens API 发现趋势代币、用纯规则引擎筛选、用 Metis 执行交换;
- 把发现 / 决策 / 执行拆成三层,其中决策层是确定性、无网络的纯函数——可以单元测试;
- 机器人默认 dry-run:只报价和记录,直到你显式开启实盘才会签名交易。
你将完成什么
- 克隆并配置
qn-guide-examples示例项目; - 学会它如何跨多个类别拉取趋势资产并去重;
- 用流动性、风险、成交量、动量四道规则筛选每个资产(纯函数);
- 把 Tokens API 的场所名称映射到 Metis 的
dexes白名单,让交换锁定在目标池; - 通过 Solana Kit 的自定义 transport 访问 Metis,执行报价/交换流程;
- 跑一次 dry-run 并读懂交易日志,然后再考虑上线。
需要什么
假设你熟悉 TypeScript、REST API、SPL 代币与 DEX 流动性池。此外需要:
- Node.js v22+;
- Solana CLI(用来创建开发钱包密钥对文件);
- Quicknode 账号:Solana 主网端点 + 启用 Metis(Jupiter Swap API) add-on;
- 免费的 Tokens API key;
- 少量主网 SOL(手续费)与 USDC(如果你想跑真实交易);
- 熟悉 Solana 版本化交易(versioned transactions)。
机器人是怎么搭的:三层架构
机器人按固定间隔运行,每个周期经过三个层次——把它们分开是核心设计原则:
| 层 | 做什么 |
|---|---|
| 发现(Tokens API) | 拉取趋势资产,解析到规范资产(canonical asset),选出最佳链上变体,读取风险与市场数据 |
| 决策(纯规则引擎) | 按规则筛选候选,与当前持仓做差集,产生买/卖/持有集合 |
| 执行(Metis) | 对每笔交易,问 Tokens API 哪个池最深,把场所映射到 Metis dexes 白名单,报价、模拟、交换 |
项目结构:
src/
index.ts 入口与轮询循环
config.ts 环境变量 + rules.json 校验(快速失败)
types.ts 共享类型
clients/
tokens.ts Tokens API(发现、风险、变体、市场)
metis.ts Metis 报价/交换封装
rpc.ts @solana/kit RPC + 自定义 transport + 钱包
engine/
screen.ts 纯函数:候选集 -> 通过筛选的候选
diff.ts 纯函数:通过 + 持仓 -> 买/卖/持有
dexMap.ts 纯函数:场所标签 -> Metis dexes 标签
exits.ts 纯函数:止盈 / 止损
exec/
executor.ts 选择市场、计算仓位、报价、交换、更新状态
克隆项目
git clone https://github.com/quiknode-labs/qn-guide-examples.git
cd qn-guide-examples/solana/tokens-metis-bot
npm install
创建一个专用的开发钱包(不要用有真实资金的钱包):
solana-keygen new --outfile ./dev-wallet.json
cp env.example env.local
在 env.local 中填入 API key 与端点:
# Solana RPC(你的 Quicknode 主网端点,@solana/kit 使用)
SOLANA_RPC_URL=
# Metis(Jupiter Swap)端点
METIS_ENDPOINT=
# Tokens API
TOKENS_API_BASE_URL=https://api.tokens.xyz/v1
TOKENS_API_KEY=
# 钱包:Solana CLI 密钥对文件路径(64 字节的 JSON 数组)
WALLET_KEYPAIR_PATH=./dev-wallet.json
# 安全
DRY_RUN=true
KILL_SWITCH_FILE=./STOP
# 循环
POLL_INTERVAL_SECONDS=300
RUN_ONCE=false
确保 env.local 与 dev-wallet.json 都在 .gitignore 里——永远不要提交它们。
定义规则:一个 rules.json 管所有决策
机器人的每个决策都由单一 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
}
}
- universe:每周期考虑多少个趋势资产(上限 50)以及范围(crypto / stablecoin / lst / rwa / commodity / equity / etf / index);
- screen:四道买入门槛——流动性层级下限(tier1 最深到 tier3)、风险标记上限(0 = 零容忍)、24h 最低成交量、动量窗口与最小涨幅;
- portfolio:计价 mint(默认 USDC)、最大持仓数、目标与单笔上限(下单时取"目标、上限、真实余额"的最小值);
- exit:筛选失败即卖出、止盈/止损百分比、卖出后的再入场冷却时间(防同一仓位被来回割);
- execution:直接透传给 Metis 报价/交换——滑点(bps)、是否仅直达路由、是否限制中间代币、最大账户数。
现实阈值提示:默认的 /trending 以跨链主流资产为主,多数非加密资产落在 tier2/tier3。tier1 + 10% 小时动量几乎筛不出任何东西——想真的出现候选,用 tier2 + 24h 窗口 + 个位数涨幅阈值。
第一层:发现趋势代币
Tokens API 是第三方只读服务(不是 QuickNode 产品),所以这是唯一用原生 fetch 的客户端。密钥只放在 x-api-key 请求头里,不进 query string、不进日志。
机器人对配置的每个类别调用 /assets/trending 再合并结果;这样也能把稳定币类排除在外,避免"用一个稳定币换另一个稳定币"。同一资产可能有多个 mint(wBTC 与 cbBTC 都指向规范资产 bitcoin),所以合并时按 assetId 去重、保留最高趋势分、再按上限截断:
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);
}
趋势列表本身不够,每个条目还要再调三次(顺序很重要):
resolve({ mint }):确认趋势 mint 确实映射到它声称的规范资产;getVariants(assetId, { minLiquidityTier }):返回所有链上变体,按流动性排序并过滤到层级下限——第一名成为chosenMint(真正要交易的 mint);getRiskSummary(mint):读取风险标记——评估对象是上一步选出的变体 mint,而不是趋势 mint。
API 不会直接返回"风险标记数量",而是返回 caps[] 命名因子数组(每个带 tone)。机器人把 warning/danger 计为 flag,并把"数据不足"也额外计 1 个 flag——这样无法评估的资产无法通过"零容忍"门槛:
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);
}
动量也有类似的坑:趋势快照只带 1h/24h 的价格变化。当规则要求快照无法提供的窗口时,机器人退回到"用当前 1h 成交量速度对比 24h 基准",并记录使用了哪种口径,让日志永远能解释这个数字:
export function momentumFromSnapshot(market: MarketSnapshot, window: 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" };
}
const v1h = market.volume1hUSD, 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" };
}
第二层:筛选候选(决策的核心)
决策层是纯函数:按固定顺序应用四道门槛(流动性层级 → 风险标记 → 成交量 → 动量),只返回通过者;每个失败的门槛都会被收集,日志能一次显示所有原因,而不是只看到第一个:
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 };
}
拿到通过集合后,diff 函数把它与当前持仓对照,产生具体动作:
- 买:通过且未持有(受
maxPositions限制;名额有限时动量更高者优先); - 卖:持有的资产不再通过筛选(当
sellWhenScreenFails为 true); - 持有:通过且已持有。
每个决策都带一条人类可读的理由(由驱动它的数字构成),这些理由会进入交易日志成为审计线索;reentryCooldownMinutes 则阻止卖出后立即重新买入。
第三层:映射 Metis DEX 标签
两个 API 对场所的命名并不一致:Tokens API 说 "Raydium Clamm" 和 "Orca",Metis 叫 "Raydium CLMM",Orca 的池则走 "Whirlpool"。直接把 Tokens API 的名字丢给 Metis 报价,轻则被忽略、重则路由到错误的池。
dexMap 模块负责翻译:规范化名称 → 与 Metis 实时标签集直接匹配 → 回退到显式别名表。未映射的场所会抛错,而不是静默发送空 dexes 列表——因为空白名单意味着"随便路由",彻底违背"锁定 Tokens API 识别出的池"的初衷:
const VENUE_ALIASES: Record<string, string[]> = {
orca: ["Whirlpool", "Orca V2", "Orca V1"], // Orca 池在 Jupiter 上走 Whirlpool
raydium: ["Raydium"],
"raydium clamm": ["Raydium CLMM"],
meteora: ["Meteora", "Meteora DLMM"],
"pump fun": ["Pump.fun Amm", "Pump.fun"],
};
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);
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;
}
labelMap 来自 Metis 的 program-id-to-label 端点,由客户端获取并缓存。
用 Solana Kit 调用 Metis
机器人不用裸 fetch 访问 Metis,而是遵循 QuickNode 的"用 Solana Kit 调 add-on"模式:在 Kit 的 RPC 客户端注册 metis_* 自定义方法,并用自定义 transport 把这些方法路由到 Metis 端点,其他请求走常规 Solana JSON-RPC——好处是整个应用只有一个 RPC 对象:读余额、发交易、调 Metis 全部复用同一个客户端。
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>;
};
}
handleMetisRequest 把每个 metis_* 方法映射到 REST 路由:GET(/quote、/program-id-to-label)把参数转成 query string(数组用逗号连接——正好是 Metis 的 dexes 格式),POST(/swap)发送 JSON body,并把响应包成 { result } 让统一的 responseTransformer 处理。
有了 transport,Metis 客户端就是类型化封装:quote 按执行规则构建请求,只在白名单非空时设置 dexes(卖出时留空,让 Metis 自由路由);swap 则把完整报价对象原样回传:
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) {
return rpc.metis_swap({
quoteResponse, userPublicKey,
wrapAndUnwrapSol: true,
dynamicComputeUnitLimit: true,
prioritizationFeeLamports: {
priorityLevelWithMaxLamports: {
priorityLevel: "high", maxLamports: 1_000_000, // 优先费硬上限 0.001 SOL
global: false,
},
},
}).send();
}
Metis 返回未签名的 base64 交易——接下来就是签名与发送。
签名与发送:模拟失败绝不上链
机器人反序列化交易、用开发钱包签名、发送。钱包在启动时通过 Kit signer 插件的 signerFromFile 加载一次。关键安全步骤:每笔交易发送前先模拟,模拟失败直接中止交易,而不是烧掉手续费发送一笔注定失败的交换:
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);
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();
// 之后轮询签名状态确认(最长 60 秒)
return sig;
}
安全执行:每一分钱都先过闸
执行器是三层汇合处,也是所有安全上限在资金移动前的最后一道检查。买入时:取 Tokens API 里最深的池 → 映射 Metis 白名单 → 计算仓位 → 报价 →(非 dry-run 时)交换。仓位被同时钳制在目标值、单笔上限与实际钱包余额三者的最小值:
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(); // dry-run:只报价与记录
return null;
}
return (targetBase < available ? targetBase : available).toString();
}
每个报价还要过硬性价格冲击上限:即使你的滑点容忍度允许,只要报价对池子的冲击超过 2.5%,就说明市场比筛选时以为的更薄,交换被拒绝:
const priceImpact = Number(quote.priceImpactPct) * 100; // 注意:该字段是小数("0.0001" = 0.01%)
if (!Number.isFinite(priceImpact) || priceImpact > MAX_PRICE_IMPACT_PCT) {
logger.warn("Quoted price impact exceeds ceiling, refusing swap", { /* ... */ });
return;
}
dry-run 模式下,执行器只记录 dryRun: true 的意向交易、不改变状态;真实成交时才签名发送、记录新持仓并追加到交易日志。卖出会清空 dexes 以获取最佳退出价格;基于价格的止盈/止损(用实时卖出报价评估)优先级高于筛选逻辑——即使资产仍然通过筛选,止损也会触发。
跑一次 dry-run 周期
npm run start:once
你会看到类似输出(数值会不同):
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)
每笔执行或意向交易都会向 logs/trades.ndjson 追加完整记录:场所、发送的 dexes、报价金额、价格冲击与原因——这就是你的审计线索与调参素材。
停止运行中的循环:在连续循环模式(npm start)下,创建 kill-switch 文件即可让机器人在不打断交易的情况下干净退出:
touch STOP
机器人会在启动时、每次循环唤醒时、以及每笔交易之间检查它。
上线(Going Live)
只有在观察了多个 dry 周期且行为符合预期后,才在 env.local 中设置 DRY_RUN=false,并给开发钱包充值少量 SOL(手续费)与计价代币(默认 USDC,按目标仓位大小配置)。启动横幅会显示 MODE: LIVE TRADING,让你随时知道当前模式。
保护实盘运行的安全模型——全部在代码中强制:
DRY_RUN默认为 true,实盘必须显式设为 false;- 每次买入前检查持仓数量与仓位上限,并钳制到真实余额;
- 每笔交易发送前先模拟,模拟失败即中止;
- 报价价格冲击超过 2.5% 一律拒绝,与滑点容忍度无关;
- kill-switch 文件可在启动、每次循环与交易之间叫停;
- API key 与钱包私钥永不写日志,私钥也从不进入环境变量(只传密钥对文件路径)。
收尾
你现在有了一个"决定交易什么、而不只是决定何时交易"的机器人:通过 Tokens API 发现趋势代币,用可单元测试的确定性规则引擎筛选,再通过单一 Solana Kit transport 交给 Metis 执行。同样重要的是三层解耦:发现与执行负责取事实,只有中间的纯引擎做决策。
你可以收紧规则、增加筛选条件、甚至更换执行场所,而不用碰"做交易决策"的逻辑;也可以在一 lamport 移动之前用测试证明引擎的行为。
常见问题(FAQ)
Q:为什么用 Solana Kit 自定义 transport,而不是 fetch 或 @jup-ag/api?
A:自定义 transport 让链上操作只有一条 RPC 路径:读余额、发交易、Metis 报价/交换全部走同一个客户端。你注册 metis_* 自定义方法并路由到 Metis 端点,其余方法默认走普通 Solana JSON-RPC。这是 QuickNode add-on 文档推荐的模式,也让客户端配置与日志统一。
Q:机器人开箱就会下真单吗?
A:不会。DRY_RUN 默认为 true——只报价和记录将要下的单,不签名、不发送。实盘需要显式设置 DRY_RUN=false;即便如此,每笔交易发送前仍会模拟,仓位数量与金额上限对照真实余额执行,价格冲击超过 2.5% 的报价一律拒绝。
Q:为什么 Tokens API 与 Metis 对同一个 DEX 的叫法不同?
A:它们是两套独立系统,命名也各自独立。Tokens API 用人类可读的场所名(如 'Raydium Clamm'、'Orca'),Metis 用 program-id-to-label 映射里的标签(如 'Raydium CLMM',Orca 池路由为 'Whirlpool')。dexMap 模块用显式别名表在两者间桥接;未映射的场所会抛错而不是发送空白名单(空白名单会变成“随便路由”)。
Q:为什么规则引擎不包含网络调用?
A:为了确定性与可测试性。screen、diff、dexMap 与退出逻辑都是纯函数:相同输入永远产生相同决策,所以每一个门槛(通过、单项失败、名额受限的买入、筛选失败退出)都可以用固定数据做单元测试、不需要网络。把发现与执行放在引擎之外,意味着不稳定的 API 响应永远不会悄悄改变决策。
相关阅读
原文链接
- QuickNode Guides — Swap Tokens API and Metis Trading Bot(2026-07-21 发布,2026-10-14 更新)https://www.quicknode.com/guides/solana-development/3rd-party-integrations/tokens-api-metis-trading-bot
- 示例代码仓库 — https://github.com/quiknode-labs/qn-guide-examples
DefiPlot 翻译整理,转载请标明出处。
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。