Solana 趋势代币交易机器人实战:Tokens API + Metis 完整开发教程(TypeScript)

大多数交易机器人只决定"何时交易",这个教程让它自己决定"交易什么":QuickNode 的实战指南带你用 TypeScript 构建一个 Solana 趋势代币机器人——发现、决策、执行三层完全解耦,附完整代码、dry-run 流程与上线安全清单。

Solana 趋势代币交易机器人实战:Tokens API + Metis 完整开发教程(TypeScript)
一键发币: x402兼容 | Aptos | X Layer | SUI | SOL | BNB | ETH | BASE | ARB | OP | Polygon | Avalanche

大多数交易机器人从"写死的代币 + 价格阈值"开始——它们只决定什么时候交易,不决定交易什么。让机器人自己挑选代币会带来两个更难的问题:

  1. 现在哪些代币值得交易?
  2. 同一个资产(比如比特币)在链上可能有多个竞争性 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);
}

趋势列表本身不够,每个条目还要再调三次(顺序很重要):

  1. resolve({ mint }):确认趋势 mint 确实映射到它声称的规范资产;
  2. getVariants(assetId, { minLiquidityTier }):返回所有链上变体,按流动性排序并过滤到层级下限——第一名成为 chosenMint(真正要交易的 mint);
  3. 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,让你随时知道当前模式。

保护实盘运行的安全模型——全部在代码中强制:

  1. DRY_RUN 默认为 true,实盘必须显式设为 false;
  2. 每次买入前检查持仓数量与仓位上限,并钳制到真实余额;
  3. 每笔交易发送前先模拟,模拟失败即中止;
  4. 报价价格冲击超过 2.5% 一律拒绝,与滑点容忍度无关;
  5. kill-switch 文件可在启动、每次循环与交易之间叫停;
  6. 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 响应永远不会悄悄改变决策。

相关阅读

原文链接

  1. 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
  2. 示例代码仓库 — https://github.com/quiknode-labs/qn-guide-examples

DefiPlot 翻译整理,转载请标明出处。

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。