现象:主币能成交,小币全失败

策略在 testnet 跑了两周,BTC、ETH 的下单一直正常。加入一批低价币之后,所有小币种的单子都被拒:

1
{"status":"err","response":"Order has invalid price."}

不是余额不足,不是杠杆超限,就是价格不合法。而且失败是选择性的——同一个策略、同一套代码,价格在 1 以上的币全部通过,0.0x 区间的币全部被拒。

这个模式本身就指向了答案:问题跟「价格怎么格式化」有关,跟下单逻辑无关。

根因:价格被舍入成了 0

先看当时的格式化代码:

1
px = round(price, 2)

简单粗暴,两位小数。对 BTC(60000 以上)毫无影响,对价格 0.001234 的币:

1
2
>>> round(0.001234, 2)
0.0

价格变成了 0.0。交易所收到一个价格为 0 的订单,返回 Order has invalid price 完全合理——这不是交易所苛刻,是客户端送了个垃圾数据。

但为什么会写死两位小数?因为最初的代码从交易所返回的元数据里取「价格精度」字段:

1
px_decimals = asset_meta["pxDecimals"]   # 想象中应该有这个字段

而 Hyperliquid 的 meta 接口返回的 universe 里**只有 szDecimals,没有 pxDecimals**。取不到就 KeyError,外面套了个 try/except 兜底成默认值 2——错误被吞掉了,问题被藏了两个月。

Hyperliquid 的真实精度规则

价格精度不是一个独立字段,而是由两条约束共同决定(永续合约):

  1. 最多 5 位有效数字
  2. 小数位数最多 6 - szDecimals

两条取更严格的那个。

举几个例子:

价格 szDecimals 允许小数位 结果
62345.7 5 1 62346(5 位有效数字)
3.14159 4 2 3.14
0.0012345 0 6 0.001234(小数位被 cap 到 6)
0.00001234 0 6 0.000012

注意最后一行:价格太小时,6 - szDecimals 这条约束比 5 位有效数字更严格,会把有效数字压到 2 位。精度不够时只能放弃一部分有效数字,否则下单必失败。

修复:按规则推导精度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import math

def price_decimals(price: float, sz_decimals: int) -> int:
"""返回该价格允许的小数位数(Hyperliquid 永续规则)。"""
if price <= 0:
return 0
# 约束一:5 位有效数字所需的小数位
exp = math.floor(math.log10(price))
sig_dec = 4 - exp
# 约束二:小数位上限
cap = 6 - sz_decimals
return max(0, min(cap, sig_dec))


def format_price(price: float, sz_decimals: int) -> str:
"""格式化成交易所接受的字符串。"""
d = price_decimals(price, sz_decimals)
if d <= 0:
# 整数价:按 5 位有效数字四舍五入
digits = 5 - int(math.floor(math.log10(price))) - 1
return str(int(round(price, digits)))
return f"{price:.{d}f}".rstrip("0").rstrip(".")

szDecimals 从哪来?POST /info 带 {"type":"meta"},返回的 universe 数组每一项都有 szDecimals:

1
2
meta = post_info({"type": "meta"})
sz_map = {a["name"]: int(a["szDecimals"]) for a in meta["universe"]}

顺带暴露的第二个问题:精度直接决定最小下单量

修好价格之后,小币还是有一部分失败,报的是另一类错误——Order has invalid size。

原因相同:下单数量也有精度要求,由 szDecimals 直接给出。数量 round(size, 2) 对 szDecimals=0 的币(只能整数)没问题,但对 szDecimals=4 的币会把 0.00005 舍成 0.0,同样变成无效值。

更麻烦的是最小名义金额。Hyperliquid 要求单笔订单名义价值不低于 10 USDC。低价币按固定金额计算数量时:

1
size = 10 / 0.00001234 ≈ 810,000 枚

这时候仓位规模远超预期。所以下单前必须同时校验三个值:

1
2
3
4
5
6
7
8
9
10
11
def validate_order(px: float, sz: float, sz_decimals: int, min_notional=10.0) -> str | None:
"""返回错误原因,None 表示通过。"""
px_str = format_price(px, sz_decimals)
sz_str = f"{sz:.{sz_decimals}f}"
if float(px_str) <= 0:
return f"价格精度不足:{px} 在 szDecimals={sz_decimals} 下会被舍入为 0"
if float(sz_str) <= 0:
return f"数量精度不足:{sz} 在 szDecimals={sz_decimals} 下会被舍入为 0"
if float(px_str) * float(sz_str) < min_notional:
return f"名义价值不足:{float(px_str)*float(sz_str):.2f} < {min_notional}"
return None

在真正发单前跑一遍,错误在本地下单前就暴露,而不是等交易所拒绝。

为什么这个 bug 藏了两个月

复盘一下,有三层原因叠加:

第一层,pxDecimals 字段确实不存在,但代码用 try/except 兜底而不是报错。异常被吞了,程序继续跑,只是精度错了。

第二层,主币占总成交量的大头,日常测试基本都在主币上跑。低价币只在扩展标的池时才被引入,时机很晚。

第三层,交易所的报错信息 Order has invalid price 没有说明为什么无效。它不会告诉你「你的价格是 0」。如果报错里带上收到的价格,定位时间会从两天缩到两分钟。

第三点值得单独记:下单前把格式化后的字符串打日志。这不是可有可无的调试信息,而是唯一能事后定位精度问题的证据。现在每次发单都会记录:

1
2
[order] BTC px=62346 sz=0.001 → px_str=62346 sz_str=0.001
[order] XYZ px=0.001234 sz=12000 → px_str=0.001234 sz_str=12000 notional=14.81

出问题时,一眼就能看出是价格错了还是数量错了。

收口

低价币下单失败,九成是精度问题,排查顺序:

  1. 打印格式化后的价格字符串,看是不是 0 或 0.00
  2. **查 szDecimals**,确认 6 - szDecimals 这条约束
  3. 验证有效数字,5 位是硬上限
  4. 校验名义价值,≥ 10 USDC 是另一道门槛

根本原则是:精度不能靠猜,也不能靠兜底。取不到精度信息就应当让程序失败,而不是悄悄用一个默认值继续跑——默认值在 99% 的场景下是对的,恰好会让人永远发现不了那 1%。


这次修复的完整背景在 记录一次 Hyperliquid Testnet 自动交易亏损原因核对,同期的风控加固(白名单 fail-closed、脏价格守卫)见 记录一次 Hyperliquid 链上-DB 漂移告警与 A2 扫描。