使用 Tokens API 和 Metis Swap API 构建 Solana 交易机器人

QuickNode 发布于 2026-07-23 阅读 17

这篇指南详细介绍了如何构建一个基于 TypeScript 的 Solana 交易机器人,它利用 Tokens API 发现趋势代币,通过纯规则引擎筛选,并使用 Quicknode 的 Metis Jupiter Swap API 执行交易。机器人设计为三层分离:发现层从 Tokens API 获取并解析资产,决策层通过确定性、无网络调用的规则筛选(流动性、风险、成交量、动量),执行层则通过 Solana Kit 自定义传输调用 Metis 进行报价和交换。文章提供了完整的项目结构、配置、代码示例和安全措施(如默认干运行、模拟交易、价格影响上限),强调架构的可测试性和可扩展性。读者可以克隆示例项目,配置 API 密钥和钱包,运行干循环观察决策日志,再决定是否启用实盘交易。

概述

大多数交易机器人从一个硬编码的代币和价格阈值开始,因此它们只决定何时交易,而不决定交易什么。让机器人自主选择代币会引发两个更棘手的问题:选择哪些代币现在值得交易,以及找到每个代币的正确链上变体,因为像比特币这样的单一资产可能作为多个存在流动性差异和风险状况的竞争性铸币存在。

本指南将引导你完成一个无头 TypeScript 机器人的构建过程,该机器人从 Tokens API 拉取 Solana 热门资产,根据配置文件中定义的规则进行筛选,并通过 Quicknode 的 Metis Jupiter Swap API 执行相应的买入和卖出。我们将解释实现每个步骤的代码。

注意: 本指南仅供教育目的。Quicknode 不提供财务建议,也不认可任何交易策略。在做出任何投资决策前,请务必自行研究。

内容概要

  • 构建一个 TypeScript 机器人,使用 Tokens API 发现热门代币,通过纯规则引擎进行筛选,并通过 Quicknode 的 Metis 插件进行交换
  • 将发现、决策和执行作为三个独立的层,决策层是确定性的且无网络依赖,以便可以进行单元测试
  • 默认情况下,机器人以模拟运行模式运行:它会报价并记录预期交易,但直到你明确选择进行实盘交易时才签署任何内容

你将做什么

  • qn-guide-examples 仓库克隆并配置示例项目
  • 了解它如何拉取并去重多个类别中的热门资产宇宙
  • 使用纯函数根据流动性、风险、交易量和动量规则集筛选每个资产
  • 将 Tokens API 的场所名称映射到 Metis 的 dexes 白名单,以便交换定位到预期的池
  • 通过 Solana Kit 自定义传输层访问 Metis,并执行报价/交换流程
  • 在投入实盘之前,先运行一个模拟周期并阅读交易日志

你需要什么

本指南假设你具备 TypeScript、REST API、SPL 代币和去中心化交易所 (DEX) 流动性池的实践知识。

你还需要:

本指南使用以下依赖:

依赖 版本
@solana/kit ^7.0.0
@solana/kit-plugin-signer ^0.13.0
tsx ^4.23.1
typescript ^5.9.3

使用 Quicknode 提升性能

任何按计划报价和交换的机器人都需要可靠、低延迟的基础设施。Quicknode 提供快速的 Solana RPC 端点,而 Metis 插件 将 Jupiter 的链上路由引擎托管为私有、托管的端点,因此你无需自己维护路由服务器。创建一个账户 即可开始。

机器人是如何构建的

机器人按间隔运行。每个周期通过三个不同的层移动,保持它们的分离是核心设计原则:

  1. 发现(Tokens API): 拉取热门资产,将每个资产解析为其规范资产,选择最佳链上变体,并读取其风险和市场数据。
  2. 决策(纯规则引擎): 根据你的规则筛选每个候选资产,然后将通过的集合与机器人已持有的资产进行差异比较,生成买入、卖出和持有集合。
  3. 执行(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 + 自定义传输 + 钱包
  engine/
    screen.ts          纯函数:宇宙 -> 通过候选
    diff.ts            纯函数:通过 + 持有 -> 买入/卖出/持有
    dexMap.ts          纯函数:场所标签 -> Metis dexes 标签
    exits.ts           纯函数:止盈 / 止损
  exec/
    executor.ts        选择市场、确定规模、报价、交换、更新状态

克隆项目

完整的机器人代码位于 Quicknode 的 qn-guide-examples 单体仓库中。克隆它,进入示例文件夹,并安装依赖:

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

复制 .env.exampleenv.local 并填写:

cp env.example env.local

添加你的 API 密钥和端点:

env.local

## Solana RPC(你的 Quicknode Solana 主网端点;由 @solana/kit 使用)
SOLANA_RPC_URL=

## Metis(Jupiter Swap)端点。
## 私有:https://jupiter-swap-api.quiknode.pro/YOUR_ENDPOINT/
## 仅用于测试的公共回退:https://public.jupiterapi.com/
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

确保 .gitignore 列表中包含 env.localdev-wallet.json,这样它们永远不会被提交。

项目克隆并配置完成后,本指南的其余部分将介绍代码中最重要的部分:Tokens API 如何驱动发现,纯规则引擎如何决策,以及 Metis 如何执行,而无需从头开始编写所有内容。

定义规则

机器人做出的每个决策都由一个 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 控制机器人每个周期从 Tokens API 拉取的内容:

  • limit:考虑的热门资产数量,上限为 50(/trending 端点的最大值)。
  • categories:Tokens API 类别的范围,用于限定资产宇宙。机器人查询每个配置的类别并合并结果。有效值为 cryptostablecoinlstrwacommodityequityetfindex

screen 是每个候选资产必须通过才能被买入的规则集。所有四个关卡必须全部通过:

  • minLiquidityTier:所选变体的 Tokens API 衍生流动性等级下限(tier1 最深,直到 tier3)。这与任何原始美元金额无关。
  • maxRiskFlags:风险标志的硬性门槛。如果候选资产的风险摘要报告超过此数量的标志,则无论其他一切看起来多好,该资产都会被拒绝。0 表示零容忍。
  • minVolume24hUSD:24 小时交易量的最小值(美元),用于考虑资产是否具有足够流动性以便交易。
  • momentum:价格变动过滤器。window 是回溯期(1h24h),minChangePct 是该时间段内需要买入的最低百分比变化。

portfolio 设置头寸规模以及机器人同时持有的交易数量:

  • quoteMint:机器人买入和卖出所针对的铸币(上面是 USDC)。所有头寸规模都以该铸币的单位计价。
  • maxPositions:最大未平仓头寸数量。一旦达到此数量,机器人将停止买入,直到有头寸退出。
  • targetPositionSizeUSD:机器人每次买入的目标美元金额。
  • maxPositionSizeUSD:任何单个头寸的硬性上限。每次买入前,机器人将交易规模调整为 targetPositionSizeUSD,然后以此值和钱包中的实际报价铸币余额为上限,取两者中的最小值。

exit 决定机器人何时卖出其持有的头寸:

  • sellWhenScreenFails:当为 true 时,在其资产不再通过筛选的那一刻卖出持有的头寸。
  • takeProfitPct:当头寸从其入场成本上涨此百分比时卖出。0 表示禁用。
  • stopLossPct:当头寸从其入场成本下跌此百分比时卖出。0 表示禁用。
  • reentryCooldownMinutes:卖出资产后,在此分钟内阻止重新买入,这样价格波动不会导致同一头寸被频繁买卖。

execution 直接传递给 Metis 的报价和交换调用:

  • slippageBps:最大滑点容忍度,以基点为单位(100 = 1%)。
  • onlyDirectRoutes:当为 true 时,限制路由仅为单跳交换。
  • restrictIntermediateTokens:当为 true 时,将多跳路由限制为一组高流动性的中间代币,这降低了路由通过流动性薄弱池的可能性。
  • maxAccounts:路由可能触及的账户数量上限,这使生成的交易保持在大小限制内。

实际阈值

默认的 /trending 资讯流主要由跨链主流资产主导,大多数非加密资产处于 tier2tier3 级别。tier1 的下限加上 10% 的小时动量阈值会过滤掉几乎所有资产。为了获得一个能实际产生候选资产的示例,tier2 的下限配合 24 小时动量窗口和低个位数百分比阈值是更现实的设置。

发现热门代币

现在来看第一层。Tokens API 是一个第三方只读服务(不是 Quicknode 产品),因此这是唯一使用原生 fetch 的客户端。密钥通过 x-api-key 头传递,绝不通过查询字符串或日志行传递。

资产宇宙来自 /assets/trending 端点。为了将其范围限定为 rules.json 中的类别,机器人每个配置的类别查询一次 /assets/trending 并合并结果。这种范围限定方式还将 stablecoin 类别排除在宇宙之外,这样机器人永远不会尝试交易一种稳定币与另一种。一个资产可能出现在多个铸币下(wBTC 和 cbBTC 都解析为规范资产 "bitcoin"),因此合并通过规范 assetId 去重,保留最高的热门度分数,并上限到限制数量。

src/clients/tokens.ts

// 将来自多个类别查询的热门结果合并为一个宇宙,
// 按规范 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);
}

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);
}

热门数据给你一个资产列表,但并不是筛选所需的一切。对于每个条目,机器人会进行三个额外的调用,并且顺序很重要:

  1. resolve({ mint }) 确认热门铸币实际上映射到它声称的规范资产。
  2. getVariants(assetId, { minLiquidityTier }) 返回每个链上变体,按流动性排序,并过滤到等级下限。顶部条目成为 chosenMint,即机器人会交易的具体铸币。
  3. getRiskSummary(mint) 读取风险标志,针对所选变体的铸币(而非热门铸币)进行评估。

API 不返回风险标志的数值计数。它返回一个包含命名因素的 caps[] 数组,每个因素有一个 tone。机器人将音调为 warningdanger 的因素计为标志,并将 "数据不足" 视为一个额外的标志,这样无法评级的资产就无法通过零标志关卡。

src/clients/tokens.ts

// 筛选的风险关卡计算具有负面音调的命名风险因素。
// 数据不足算作一个标志:无法评级的资产不应通过
// 零标志关卡。
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);
}

动量也有一个类似的注意事项。热门快照仅携带 1 小时和 24 小时窗口的价格变化。当规则要求快照无法提供的窗口时,机器人会回退到比较当前 1 小时成交量速率与 24 小时基线,并记录所使用的依据,这样日志始终解释该数字。

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" };
  }
  // 回退:1 小时成交量速率与 24 小时基线相比,作为高于 (+) 或低于 (-) 该基线的百分比。
  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" };
}

将资产解析为其最佳链上变体并根据执行质量对池进行排名,与 如何使用 Tokens API 研究 Solana 代币交易 中介绍的工作流程相同。

筛选候选资产

这是决策层的核心。给定完全映射的候选资产,它按固定顺序应用四个关卡(流动性等级、风险标志、交易量,然后动量),并仅返回通过的。每个失败的关卡都被收集,因此日志可以一次显示所有原因,而不仅仅是第一个:

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(`流动性等级 ${c.liquidityTier} 低于下限 ${rules.screen.minLiquidityTier}`);
    }
    if (c.riskFlagCount > rules.screen.maxRiskFlags) {
      reasons.push(`${c.riskFlagCount} 个风险标志超过最大值 ${rules.screen.maxRiskFlags}`);
    }
    if (c.volume24hUSD < rules.screen.minVolume24hUSD) {
      reasons.push(`24 小时交易量 $${c.volume24hUSD.toFixed(0)} 低于下限 $${rules.screen.minVolume24hUSD}`);
    }
    if (c.momentumPct < rules.screen.momentum.minChangePct) {
      reasons.push(`动量 ${c.momentumPct.toFixed(2)}% (${c.momentumBasis}) 低于 ${rules.screen.momentum.minChangePct}%`);
    }

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

  return { passing, rejected };
}

一旦知道哪些候选资产通过,diff 函数会将通过集合加上当前持仓转换为具体操作:

  • 买入:通过筛选且尚未持有的候选资产,只要添加它仍保持在 maxPositions 内即可。当容量有限时,动量更高的候选资产获得空缺位置。
  • 卖出:其资产不再通过筛选的持有头寸(当 exit.sellWhenScreenFails 为 true 时)。
  • 持有:已通过筛选且已持有,因此无操作。

每个决策都带有一个由驱动它的数字构建的可读 reason,这成为交易日志中的审计线索。reentryCooldownMinutes 窗口也会在卖出资产后立即阻止重新买入。

映射到 Metis DEX 标签

这两个 API 并不总是对场所名称达成一致。Tokens API 说 "Raydium Clamm" 和 "Orca",而 Metis 将相同的场所称为 "Raydium CLMM" 并将 Orca 池路由为 "Whirlpool"。如果你将 Tokens API 名称直接传递给 Metis 报价,最好的情况是被忽略,最坏的情况是路由到错误的池。

dexMap 模块是翻译器。它规范化两个名称,对照实时 Metis 标签集检查直接匹配,然后回退到显式别名表。未映射的场所会抛出异常,而不是静默发送空的 dexes 列表,因为空的白名单意味着 "路由到任何地方",这违背了将交易定位到 Tokens API 识别的池的初衷。

src/engine/dexMap.ts

const VENUE_ALIASES: Record<string, string[]> = {
  // Orca 池通过 Jupiter 上的 Whirlpool 报价;保留旧标签作为回退。
  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"],
  // ...随着 Tokens API 市场数据中出现新场所而扩展
};

// labelMap 是原始 GET /program-id-to-label 响应:{ 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); // 精确匹配直接获胜
  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 端点,Metis 客户端会获取并缓存该映射。

使用 Solana Kit 调用 Metis API

机器人不是通过原始的 fetch 调用来访问 Metis,而是遵循 Quicknode 的 使用 Solana Kit 模式的插件:在 Kit RPC 客户端上注册自定义的 metis_* 方法,并使用自定义传输层将这些方法路由到你的 Metis 端点,而其他所有方法都流向正常的 Solana JSON-RPC 传输层。其好处是整个应用程序只有一个 RPC 对象:余额读取、交易发送和 Metis 调用都通过同一个客户端进行。

传输层检查每个请求的方法名称并相应地进行分支:

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>;
  };
}

handleMetisRequest 辅助函数将每个 metis_* 方法映射到一条 REST 路由:GET 方法(/quote/program-id-to-label)将其参数转换为查询字符串(数组用逗号连接,这正是 Metis 所需的 dexes 格式),而 POST 方法(/swap)发送 JSON 主体。它将 REST 响应包装在 { result } 中,以便共享的 responseTransformer 可以统一地解包 REST 和 JSON-RPC。

src/clients/rpc.ts

const api = createJsonRpcApi<BotRpcApi>({
  // Kit 7:返回完整的 RpcRequest,而不是裸参数,否则方法名称会丢失。
  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 错误:${envelope.error.message ?? "未知"}`);
    return envelope.result;
  },
});

传输层就位后,Metis 客户端只是这些自定义方法上的类型化包装器。报价包装器从执行规则构建请求,并且仅当白名单非空时才设置 dexes(空列表允许 Metis 路由到任何地方,这对于卖出是你想要的)。它返回完整的解析后的报价对象,未经修改,因为 /swap 需要将整个对象发送回去:

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, // 优先费用的硬性上限:0.001 SOL
          global: false,
        },
      },
    })
    .send();
},

Metis 返回一个 未签名 的 base64 交易,需要签名并执行。

签名并发送

因为 Metis 返回未签名的交易,机器人反序列化它,用开发钱包签名,然后发送。wallet 在启动时通过 Kit 签名插件中的 signerFromFile 加载一次,下面的代码使用其底层的 wallet.keyPair 进行签名。关键的安全步骤是每笔交易在发送前都会进行模拟,失败的模拟会中止交易,而不是在注定失败的交换上浪费手续费。

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);

  // 发送前进行模拟;拒绝发送模拟失败的交易。
  const simulation = await rpc
    .simulateTransaction(wire, { encoding: "base64", replaceRecentBlockhash: true, sigVerify: false })
    .send();
  if (simulation.value.err) {
    throw new Error(`模拟失败,拒绝发送:${JSON.stringify(simulation.value.err)}`);
  }

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

  // 通过轮询签名状态进行确认(最多 60 秒)。
  // ...轮询 getSignatureStatuses 直到已确认或最终化...
  return sig;
}

安全地执行交易

执行器是三个层汇合的地方,也是所有剩余的安全上限在资金移动前立即强制执行的地方。对于买入,它从 Tokens API 获取最深的池,将场所映射到 Metis dexes 白名单,确定交易规模,报价,然后(除非是模拟运行)进行交换。交易规模被限制为目标规模、最大规模和实际钱包余额,取三者中的最小值。

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(); // 假设:无资金情况下报价并记录
    return null;
  }
  // 永远不要超过上限或实际余额。
  return (targetBase < available ? targetBase : available).toString();
}

然后,每个报价都会根据一个硬性的价格影响上限进行检查。即使你的滑点容忍度在技术上允许,一个导致池价格变动超过 2.5% 的报价意味着市场比筛选所认为的更薄,交换会被拒绝:

src/exec/executor.ts

const quote = await metis.quote({ inputMint, outputMint, amountBaseUnits, dexes });
// priceImpactPct 是一个小数分数,尽管名称如此("0.0001" 表示
// 0.01%),根据 Metis 包装的 Jupiter Swap API 参考。在比较之前
// 转换为百分比与百分比上限进行比较。
const priceImpact = Number(quote.priceImpactPct) * 100;
if (!Number.isFinite(priceImpact) || priceImpact > MAX_PRICE_IMPACT_PCT) {
  logger.warn("报价价格影响超过上限,拒绝交换", {
    symbol: candidate.symbol,
    priceImpactPct: quote.priceImpactPct,
    ceilingPct: MAX_PRICE_IMPACT_PCT,
  });
  return;
}

在模拟运行时,执行器记录预期的交易并标记 dryRun: true,并且不触动状态。在实盘成交时,它通过 signAndSend 签名并发送,记录新头寸,并向交易日志追加一行。卖出通过任何 DEX(空的 dexes)路由以获得最佳退出价格,而基于价格的止盈和止损退出(通过实时的卖出报价评估)优先于筛选,这样即使资产仍然通过筛选,止损也会触发。

运行模拟周期

当所有层都清晰后,以下是 index.ts 如何将它们串联起来。主循环拉取代币,解析并筛选它们,与持仓进行差异比较,并执行每个决策,打印一个漏斗分解,以便始终清楚地知道资产在哪一步被淘汰。

使用随附的脚本运行单个周期(建议用于首次运行):

npm run start:once

该脚本设置 RUN_ONCE=true 并为你运行 tsx --env-file=env.local src/index.ts。如果想直接运行命令,请使用 RUN_ONCE=true npx tsx --env-file=env.local src/index.ts

你将看到类似以下的输出(数值会有所不同):

============================================================
模式:模拟运行(不会进行任何交易)
钱包:7Xy...q9F
规则:最多 50 个仓位,$0.1 目标规模,100 个基点滑点
============================================================
周期开始:41 个热门资产(类别:crypto, rwa, commodity, equity, etf)
漏斗:41 拉取 -> 2 获取错误,6 无变体,33 已筛选(3 通过,30 拒绝);持有 0;决策:3
GLD:拒绝 - 动量 1.20% (24h) 低于 5%
SPCX:拒绝 - 1 个风险标志超过最大值 0
WBTC:买入(模拟运行) - 通过筛选:等级=tier1,风险标志=0,24h 量=$88431022,动量=6.14% (24h)
...
RUN_ONCE 已设置,完成一个周期后退出

每个已执行或预期的交易还会向 logs/trades.ndjson 追加一条完整记录,包括场所、发送的 dexes、报价金额、价格影响和原因。该日志是你的审计线索,也是调整规则的原始材料。

停止正在运行的循环

当运行连续循环(npm start)时,创建 kill-switch 文件以干净地停止机器人,而不会中断正在进行的交易:touch STOP。机器人在启动时、每次循环唤醒时以及交易之间检查该文件。

开始实盘交易

当你准备好进行实盘交易时,并且只有在观察了几个模拟周期的行为之后,在 env.local 中设置 DRY_RUN=false,并向开发钱包注入少量 SOL(用于手续费)和报价铸币(默认为 USDC),金额与你的 targetPositionSizeUSD 相匹配。启动横幅将显示 MODE: LIVE TRADING,这样你始终知道当前处于哪种模式。

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

  • DRY_RUN 默认为 true;实盘交易需要显式设置为 false
  • 每次买入前检查头寸数量和规模上限,并限制为实际余额。
  • 每笔交易在发送前都会进行模拟,模拟失败则中止交易。
  • 无论滑点容忍度如何,价格影响超过 2.5% 上限的报价都会被拒绝。
  • kill-switch 文件会在启动时、每次循环唤醒时以及交易之间停止交易。
  • API 密钥和钱包密钥永远不会被记录,密钥也永远不会进入环境(只有密钥对文件路径)。

总结

你已经构建了一个交易机器人,它决定交易什么,而不仅仅是何时交易。它通过 Tokens API 发现热门代币,通过一个确定性的规则引擎(你可以独立进行单元测试)进行筛选,并通过 Quicknode 的 Metis 插件(通过一个单一的 Solana Kit 传输层)路由生成的交换。同样重要的是,你保持了三个关注点的分离:发现和执行获取事实,只有中间的纯引擎做出决策。

你可以收紧规则、添加新的筛选条件,或更换不同的执行场所,而无需触碰决定交易的逻辑,并且你可以在单个 lamport 移动之前通过测试来证明引擎的行为。

常见问题

为什么通过 Solana Kit 自定义传输层路由 Metis,而不是使用 fetch 或 @jup-ag/api?

使用 @solana/kit 自定义传输层为你提供一条链上所有操作的 RPC 路径:余额读取、交易发送和 Metis 报价/交换调用都通过同一个客户端进行。你注册自定义的 metis_* 方法并将它们路由到你的 Metis 端点,而所有其他方法默认使用正常的 Solana JSON-RPC 传输层。这是 Quicknode 插件文档推荐的模式,它避免了代码同时管理两个独立的 HTTP 客户端。

机器人开箱即用是否进行实盘交易?

不会。DRY_RUN 默认为 true,因此机器人会报价并记录它将要进行的交易,但不会签署或发送任何内容。实盘交易需要显式设置 DRY_RUN=false。即便如此,每笔交易在发送前都会进行模拟,头寸规模和数量上限会根据实际钱包余额执行,任何价格影响超过 2.5% 的报价都会被拒绝。

为什么 Tokens API 和 Metis 对同一个 DEX 使用不同的名称?

它们是独立的系统,具有独立的命名。Tokens API 报告人类可读的场所名称,如 'Raydium Clamm' 或 'Orca',而 Metis 使用其程序 ID 到标签映射中的标签,如 'Raydium CLMM',并将 Orca 池路由为 'Whirlpool'。dexMap 模块通过显式别名表桥接两者,并在遇到未映射的场所时抛出异常,而不是发送空的白名单,这会让兑换路由到任何地方。

我可以在不启用 Metis 插件的情况下在我的端点上运行它吗?

用于测试可以。将 METIS_ENDPOINT 指向公共回退 https://public.jupiterapi.com,它不需要密钥。私有的 jupiter-swap-api.quiknode.pro 端点会在每个 Metis 路径上返回 404,直到该端点上启用了 Metis - Jupiter Swap 插件,因此在将机器人指向你的私有 URL 之前,请先启用该插件。

为什么规则引擎保持无网络调用?

确定性和可测试性。因为 screen、diff、dexMap 和退出逻辑是纯函数,相同的输入始终产生相同的决策,因此你可以使用装置和数据对每个关卡(通过、每个单独检查的失败、容量限制买入、退出时筛选失败等)进行单元测试,而无需网络。将发现和执行保持在外意味着一个不稳定的 API 响应永远不会静默地改变交易决策。

资源

我们 ❤️ 反馈!

告诉我们 如果你有任何反馈或对新主题的需求。我们很乐意听取你的意见。

  • 原文链接: quicknode.com/guides/sol...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论