在 Hyperliquid 上构建 AI 加密货币交易机器人

Claude 决策,Hyperliquid SDK 执行,索引数据源提供市场信息。还有数据会悄悄欺骗你代理的三种方式,我都遇到过。

在 Hyperliquid 上构建 AI 加密货币交易机器人
一键发币: x402兼容 | Aptos | X Layer | SUI | SOL | BNB | ETH | BASE | ARB | OP | Polygon | Avalanche

一个交易你自己账户的代理很容易。五十行代码,一个 SDK,搞定。

一个基于市场其他参与者行为来交易你账户的代理则是完全不同的构建,在大多数交易所上这是不可能的,因为交易所从来不会告诉你市场其他参与者的行为细节。

Hyperliquid 是例外,这也是为什么要在 Hyperliquid 上构建而不是在 Binance 或任何与之竞争的永续合约平台上构建。订单簿运行在自己的 L1 上。每一次下单、取消、修改和成交都是一个签名操作,存储在区块中,钱包地址与之关联。你的代理可以看到谁在报价、谁刚被清算、订单簿中有多少是真实的,因为所有这些都在链上。

获取这些数据比 websocket 订阅消息需要更多工作。以下是完整的构建过程。

提前说明:我在 Bitquery 从事开发者内容工作,Bitquery 销售下面读取路径使用的索引化 Hyperliquid 数据源。写入路径是 Hyperliquid 自己的免费 SDK,我会具体说明在哪些地方免费原生 API 是更好的选择。

None

1、架构:三条路径,三个工具

本能是使用一个 API 处理所有事情。这是第一个错误,因为读取市场和写入你的账户是不同的问题,有不同的最佳答案。

路径 功能 最佳服务
写入 下单、取消和修改你自己的订单 Hyperliquid 的原生 SDK。最接近撮合引擎,免费,规范。
读取(自有账户) 你的持仓、成交、保证金 Hyperliquid 的原生 Info API。同样原因。
读取(市场) 其他人的持仓、报价、爆仓 索引化数据源。原生 API 无法提供。
决策 将上述信息转化为订单或保持观望的决定 Claude,将其他三个作为工具接入

第三行是人们最容易搞错的地方,所以值得精确说明原因。

Hyperliquid 的公共 websocket 提供 l2Book,这是每个价格水平的总量,每侧最多 20 个水平。四十个 BTC 挂在 95,000 美元,数据源无法告诉你这是一个订单还是二十个,是谁的,或者它是被撤回而不是成交。订单级别的细节确实存在于原生 API 的 orderUpdatesuserFills 中,但仅限于你自己的账户。清算也是同样的情况:userEvents 只报告你已知的一个地址的清算,根本没有全交易所范围的清算数据源。

所以如果你的代理的工作是"对其他人的行为做出反应",原生 API 无法提供数据。你需要有人对链上数据进行索引。这就是下面的读取路径。

2、写入路径

从这里开始。这是可能赔钱的部分,也是首先需要熟悉的部分。

None

写入路径是 hyperliquid-python-sdk,Hyperliquid 自己的客户端。

None
pip install hyperliquid-python-sdk anthropic eth-account requests
import os, time
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
from hyperliquid.utils import constants
from hyperliquid.utils.types import Cloid
BASE_URL = constants.TESTNET_API_URL   # change this last, and deliberately
wallet = Account.from_key(os.environ["HL_SECRET_KEY"])
address = os.environ["HL_ACCOUNT_ADDRESS"]
exchange = Exchange(wallet, BASE_URL, account_address=address)
info = Info(BASE_URL, skip_ws=True)

订单调用是位置参数,很容易搞反,所以这里详细说明:

# exchange.order(name, is_buy, sz, limit_px, order_type, reduce_only=False, cloid=None)
result = exchange.order(
    "ETH", True, 0.2, 1100.0,
    {"limit": {"tif": "Alo"}},
    cloid=Cloid.from_int(1734029481),
)

这个调用中有三件事比它们看起来更重要。

{"limit": {"tif": "Alo"}} 是仅挂单模式。如果订单会穿越价差并消耗流动性,订单会被直接拒绝。对于代理来说,这是你最安全的默认设置,因为定价错误的报价最坏情况是被拒绝,而不是以你不想的价格成交。当你确实想挂单并穿越时使用 Gtc,当你想要立即成交或取消时使用 Ioc

cloid 是你的幂等键,它防止网络超时后的重试导致重复提交。从决策本身派生它:

import hashlib
from hyperliquid.utils.types import Cloid
def decision_cloid(*parts) -> Cloid:
    """Stable 16-byte client order id derived from the decision."""
    key = "|".join(str(p) for p in parts).encode()
    return Cloid.from_str("0x" + hashlib.sha256(key).hexdigest()[:32])

不要使用 Python 内置的 hash() 函数,这是我最初犯的错误。它是按进程加盐的,所以相同的决策在每次重启后会哈希成不同的 id,这是幂等键不能有的特性。没有稳定 cloid 的代理循环迟早会下两次相同的订单,你会在快速市场中发现这一点。

reduce_only=True 值得接入任何以平仓而非开仓为目标的工具。这是一个廉价的方式,防止"平掉仓位"变成反向开新仓。

取消有两种方式,这就是 cloid 的价值所在:

exchange.cancel("ETH", oid)                 # by exchange order id
exchange.cancel_by_cloid("ETH", cloid)      # by your own id

3、读取路径

读取工具通过 GraphQL 访问链上数据的索引副本。这个技术与我用来追踪 Pump.fun 上的联合曲线和毕业情况的技术相同,只是指向不同的链。有用的特性是同一个文档既可以作为查询也可以作为实时流:将 query 改为 subscription,去掉 limitorderBy,指向 websocket 端点,它就会推送。

这是整个交易所的成交流程(Trades cube reference),你会在单独的进程中运行这个数据源来保持市场画面的更新:

subscription {
  Hyperliquid {
    Trades {
      Block { Time }
      Trade {
        Market { Symbol CoinRaw Kind }
        Execution { Price Size Side Direction IsAggressor Oid }
        Fees { Fee FeeToken }
        Position { Leverage IsCross SizeBefore }
        Trader { Address }
      }
    }
  }
}

没有币种过滤器,所以一个订阅包含所有市场。消息看起来像这样:

{
  "Block": { "Time": "2026-09-04T11:19:51.137023Z" },
  "Trade": {
    "Market": { "Symbol": "ASTER", "CoinRaw": "ASTER", "Kind": "perp" },
    "Execution": {
      "Price": "0.75677", "Size": "175.0", "Side": "Sell",
      "Direction": "Open Short", "IsAggressor": true, "Oid": "535941127746"
    },
    "Fees": { "Fee": "0.01907", "FeeToken": "USDC" },
    "Position": { "Leverage": 5, "IsCross": true, "SizeBefore": "-175858.0" },
    "Trader": { "Address": "0xa33a4a057334c7811ad5f45f3c4f0dfa3d081ff8" }
  }
}

有两个字段值得交给模型。Direction 直接解析为 Open Short,所以代理不需要从方向和仓位状态推断意图。SizeBefore 表示该钱包在这次成交前已经持有 175,858 ASTER 的空头仓位,这就是"有人卖出"和"大量空头加仓"之间的区别。负数的 Fees.Fee 是做市商返佣,这是区分被动流和主动流的廉价方式。

对于订单簿数据,需要了解的是 BookUpdates,它是逐订单的而不是聚合的。一条消息就是一个订单,包含其 Oid 和下单的 Trader.AddressOid 可以跨模式关联:同一个 id 出现在 Orders 的生命周期中,也出现在 Trade.Execution.Oid 的成交中,所以一个订单可以从头到尾被追踪。过滤到一个地址,你就可以实时观察特定做市商的报价和撤单(worked examples),这是中心化交易所不会卖给你的。

4、连接工具

Claude 获得访问数据源的读取工具和一个接触交易所的写入工具。

import requests
from anthropic import Anthropic, beta_tool
client = Anthropic()
BQ_URL = "https://streaming.bitquery.io/graphql"
BQ_AUTH = {"Authorization": f"Bearer {os.environ['BITQUERY_TOKEN']}"}
ALLOWED_MARKETS = {"BTC", "ETH"}
def bq(query: str, variables: dict) -> dict:
    r = requests.post(BQ_URL, headers=BQ_AUTH,
                      json={"query": query, "variables": variables}, timeout=30)
    r.raise_for_status()
    payload = r.json()
    if "errors" in payload:
        raise RuntimeError(payload["errors"][0]["message"])
    return payload["data"]["Hyperliquid"]

清算读取工具:

@beta_tool
def recent_liquidations(symbol: str, minutes: int = 60) -> str:
    """Count Hyperliquid liquidations on one market over a recent window.
    Returns distinct liquidation events, the wallets hit, and the raw fill
    count. Prefer the liquidation count over the fill count.
    Args:
        symbol: Market symbol. Must be BTC or ETH.
        minutes: Lookback in minutes, 1 to 60.
    """
    if symbol not in ALLOWED_MARKETS:
        return f"refused: {symbol} is not in the allowlist"
    minutes = max(1, min(int(minutes), 60))
    query = """
      query ($sym: String!, $mins: Int!) {
        Hyperliquid {
          PerpLiquidations(where: {
            Liquidation: {Market: {Symbol: {is: $sym}}}
            Block: {Time: {since_relative: {minutes_ago: $mins}}}
          }) {
            fills: count
            liquidations: count(distinct: Liquidation_Execution_Hash)
            wallets: count(distinct: Liquidation_LiquidatedUser)
          }
        }
      }
    """
    rows = bq(query, {"sym": symbol, "mins": minutes})["PerpLiquidations"]
    if not rows:
        return f"{symbol}: 0 liquidations in the last {minutes}m"
    r = rows[0]
    return (f"{symbol}: {r['liquidations']} liquidations hitting "
            f"{r['wallets']} wallets in the last {minutes}m "
            f"({r['fills']} individual fills)")

注意返回值是一个句子,而不是 JSON 转储。工具结果是循环中每次后续轮次的输入令牌,模型能正确读取的紧凑字符串比它必须解析且可能误读的嵌套对象更好。

写入工具需要谨慎处理:

MAX_NOTIONAL_USD = 250.0
@beta_tool
def place_post_only_order(symbol: str, is_buy: bool, size: float,
                          limit_price: float, reason: str) -> str:
    """Place one post-only limit order on Hyperliquid.
    Post-only means the exchange rejects the order outright if it would
    cross the spread. Rejection is normal and expected, not an error.
    Args:
        symbol: Market symbol. Must be BTC or ETH.
        is_buy: True to bid, False to offer.
        size: Contracts. Notional is capped server-side by this tool.
        limit_price: Limit price in USD.
        reason: One sentence on why, recorded in the audit log.
    """
    if symbol not in ALLOWED_MARKETS:
        return f"refused: {symbol} is not in the allowlist"
    notional = size * limit_price
    if notional > MAX_NOTIONAL_USD:
        return (f"refused: ${notional:,.0f} notional exceeds "
                f"the ${MAX_NOTIONAL_USD:,.0f} cap")
    cloid = decision_cloid(symbol, is_buy, round(limit_price, 2),
                           int(time.time() // 60))
    audit.write(symbol, is_buy, size, limit_price, reason, str(cloid))
    result = exchange.order(symbol, is_buy, size, limit_price,
                            {"limit": {"tif": "Alo"}}, cloid=cloid)
    if result.get("status") != "ok":
        return f"exchange rejected the request: {result}"
    status = result["response"]["data"]["statuses"][0]
    if "resting" in status:
        return f"resting on the book, oid {status['resting']['oid']}"
    if "filled" in status:
        return f"filled immediately: {status['filled']}"
    return f"not resting, no fill: {status}"

两个决策承载了重量。

白名单和名义金额上限是 Python 代码,不是提示文本。一个被礼貌要求保持在上限以下的模型几乎每次都会遵守,但"几乎每次"不是风险控制。任何你不想看到一次违反的东西都应该放在订单执行前的 if 语句中。

工具会报告三种情况之一:挂单中、已成交或都不是。这个区分不是装饰性的,原因在下一节说明。

reason 参数也在默默发挥作用。要求模型在下单的同一调用中说明原因,给你提供了一个六周后还能自我解释的审计日志,而且只多了一个字段。

5、循环

你不必编写代理循环。SDK 的 tool runner 驱动调用、执行和继续的循环:

DESK_RULES = """You watch two Hyperliquid perp markets and quote passively.
Doing nothing is a valid and common answer, and most runs should end that way.
Never chase price. Place at most one order per run.
A post-only rejection means your price crossed the spread. Do not resubmit it
at a crossing price; either move the price passive or stand down.
Liquidation counts are events, not fills. Do not treat a fill count as activity."""
runner = client.beta.messages.tool_runner(
    model="claude-opus-5",
    max_tokens=16000,
    thinking={"type": "adaptive"},
    output_config={"effort": "high"},
    system=[{
        "type": "text",
        "text": DESK_RULES,
        "cache_control": {"type": "ephemeral"},
    }],
    tools=[recent_liquidations, open_position, place_post_only_order],
    messages=[{"role": "user", "content":
        "Check BTC. If liquidations are elevated versus a normal hour, consider "
        "quoting passively on the side that just got run over. Otherwise do nothing."
    }],
)
for message in runner:
    log(message)

thinking={"type": "adaptive"} 让模型决定每次运行需要多少推理,这很重要,因为大多数运行应该以"这里没什么可做的"结束。cache_control 块很重要,因为规则和工具模式每次轮次都会重新发送,缓存读取的计费大约是输入费率的十分之一。

大致成本。Claude Opus 5 每百万输入令牌 5 美元,每百万输出令牌 25 美元。一次读取约 2,000 个输入令牌、写入约 1,500 个输出令牌的运行大约花费五美分。以五分钟为周期,每天 288 次运行,大约十三美元,在缓存降低输入成本之前。在你让任何东西运行之前,值得为你自己的周期计算这个数字,因为一个每分钟都在思考的代理的成本直到账单到来才会显现。

6、数据欺骗你代理的三种方式

这些中的每一个都让我得到错误的数字,直到我抓住它们,每一个都会产生一个看似合理的错误答案而不是错误,这才是危险的类型。

6.1 它认为一次清算就是十六次

None

清算数据源上计算行数会严重高估活动。在最近一小时:

fills:        127
liquidations:  33
wallets:       33
markets:       11

一次 XPL 仓位平仓产生了 16 行,都在一个区块中,共享一个执行哈希:

11:27:28.537  Buy  size=  5010.0  px=0.10143
11:27:28.537  Buy  size=   490.0  px=0.10142
11:27:28.537  Buy  size= 11059.0  px=0.10149
11:27:28.537  Buy  size= 28173.0  px=0.10160
...  (12 more)

一次强制平仓吃掉了 16 个挂单,16 个价格,数据源给你每行一个成交,因为链上就是这么发生的。当真实数字是 33 时告诉代理"127 次清算",代理会把平静的一小时误读为级联清算并报价进去。

计算不同的执行哈希:

fills:        count
liquidations: count(distinct: Liquidation_Execution_Hash)
wallets:      count(distinct: Liquidation_LiquidatedUser)

在你能看到的工具边界修复它。一个被赋予标记为 count 的数字的模型会自信地对错误的数量进行推理,而不会标记它感到困惑。

6.2 它认为它的报价在挂单中,但实际上被拒绝了

None

按状态计算 BTC 订单事件,十分钟后情况令人震惊:

badAloPxRejected           1,848,618   83.6%
open                         150,206    6.8%
canceled                     131,269    5.9%
perpMarginRejected            43,063    1.9%
iocCancelRejected             20,579    0.9%
tooManyOpenOrdersRejected     14,775    0.7%
filled                         1,608    0.1%
TOTAL                      2,210,732

BTC 订单发生的所有事情中,84% 是 badAloPxRejected,只有 0.1% 是成交。检查那些被拒绝的订单,每一个都是仅挂单限价单,买卖几乎各占一半:

Limit  Buy   Tif=Alo   478,047
Limit  Sell  Tif=Alo   431,349

这是交易所上最流动市场的报价竞赛:做市商试图在触摸点挂单,失败,然后被弹回。十分钟内有两百万次这样的情况。ETH 也是同样的情况,78.6% 被拒绝,0.06% 成交。

你的代理正在向这个情况中发布 Alo 订单。拒绝是正常结果,不是例外,这就是为什么上面的写入工具区分挂单中、已成交和都不是。一个假设其报价在撮合引擎拒绝后仍然有效的代理会继续对一个它没有的仓位进行推理,并会对一个幻影进行对冲或调整规模。

它也会破坏你构建的任何活动指标。如果你从原始事件计数计算取消与成交的比率,BTC 上你分母的 84% 从未到达订单簿。

6.3 它交易了错误的 BTC

HIP-3 允许外部构建者在 Hyperliquid 上部署自己的永续合约市场,在命名空间前缀下,在相同的基础设施中交易。其中很多是代币化股票,这与 Arcus 在 dYdX 团队上运行的情况是同样的土地争夺。目前有 279 个活跃市场,分布在 10 个部署者中,最大的是 xyz 有 119 个市场,然后是 para 有 33 个,hyna 有 25 个。

查询过滤到 BTC 符号的标记价格

flx:BTC     91470.2
hyna:BTC    76888.0
cash:BTC    70000.0

三个构建者,三个名为 BTC 的市场,三个价格相差超过两万美元,每个都有自己的预言机。如果你的数据摄取以 Symbol 为键,代理可以从一个市场读取价格并向另一个市场发送订单。以 CoinRaw 为键,它包含完整的 namespace:symbol 标识符。

没有数据提供商发明了这一点。它来自于无许可的市场上市,它会咬到任何假设符号是唯一的人。

7、运行之间的状态

一个只读取市场但从不读取自身的代理会漂移。每次运行开始时需要协调两件事。

真实仓位,来自原生 API 而不是内存:

@beta_tool
def open_position(symbol: str) -> str:
    """Report the agent's actual open position on one market.
    Args:
        symbol: Market symbol. Must be BTC or ETH.
    """
    state = info.user_state(address)
    for entry in state["assetPositions"]:
        p = entry["position"]
        if p["coin"] == symbol:
            return (f"{symbol}: size {p['szi']}, entry {p.get('entryPx')}, "
                    f"unrealized {p['unrealizedPnl']}")
    return f"{symbol}: flat"

以及挂单,这样代理就不会在五次运行中堆叠五个报价,因为每次运行都忘记了上一次。info.open_orders(address) 覆盖了这个,一个廉价且有效的策略是在运行开始时取消代理放置的所有订单,然后从干净的订单簿重新报价。

将两者都作为工具提供,而不是提示文本。模型会在需要时读取当前状态,而不是信任你在轮次顶部粘贴的可能已经过时的快照。

8、运行而不赔钱

constants.TESTNET_API_URL 不是装饰。离开它应该是一个单独的、深思熟虑的提交,在系统运行几周并至少让你惊讶一次之后。

一些比一般警告段落更有价值的具体内容。

期望它什么都不做。 全交易所范围内,Hyperliquid 每小时清算低几十个仓位,BTC 单独可能四小时都没有一次清算。一个以 BTC 清算为门控的代理在大多数运行中会正确地保持静止。这是正确的测试方式:在指向繁忙市场之前,观察它在安静市场上的表现。

将杀掉开关放在进程外部。 一个你可以 kill -9 的监督器,或者一个你可以手动触发的交易所端全取消,胜过系统提示中的任何指令。系统提示是指导。进程边界是保证。

记录工具调用,而不仅仅是结果。 一个下了奇怪订单的代理只有在你能回放它决定时看到的内容时才可调试。参数和结果,每次调用,包括来自你自己防护栏的拒绝,因为拒绝激增是推理走向奇怪的最早信号。

限制一次运行能做什么,而不仅仅是一次订单。 上面的名义金额上限限制单个订单。一次运行下单二十次仍然在这个上限内,而且远不安全。

明确说明这种方法在哪些方面不如替代方案。索引数据源比撮合引擎慢一个索引步骤,所以任何在个位数毫秒内反应的东西都应该放在原生 websocket 上。GraphQL 窗口是大约三十天的滚动窗口,涵盖实时交易和最近历史检查,但不包括多年回测。最高容量的立方体,OrdersBookUpdates,在繁忙市场上每天达到数亿行,所以长时间窗口的过滤扫描会超时;将交互窗口保持在一小时,并在自己的存储中累积更长的内容。

9、这是什么,不是什么

这是管道。以上内容没有告诉你交易什么或建议你应该交易,连接到市场数据馈送的语言模型不是优势。它是一种利用你已有优势的方式,同样也是一种比手动更快地执行坏主意的方式。

Hyperliquid 真正改变的是输入。在中心化交易所上,你的代理推理价格和自己的成交,因为这是交易所会卖给你的全部。在这里,它可以推理谁在什么位置持有仓位,哪些报价是真实的,以及谁刚刚被抬出去,因为订单簿在公共链上,钱包与每个订单相关联。

推理层现在是容易的部分。获取干净、正确计数的市场状态是工作,上面的三个陷阱就是证明。

读取路径查询文档:Hyperliquid API on Bitquery。原生 API 和 SDK:hyperliquid.gitbook.io。所有数据都是 2026 年 9 月 4 日实时获取的,到你阅读时可能已经变化。


原文链接:Building an AI Crypto Trading Bot on Hyperliquid

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

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