Skip to main content

Our Hyperliquid Bot Refused Every $25 Order. The $25 Cap Was the Bug.

A testnet-only Python starter for the Hyperliquid API, the four problems a live rebuild found (a cap that rounding always broke, tradable-looking delisted coins, a dead-man switch new accounts can't use, a main key left on disk), and the fixed AI prompt

Updated 9 min readMarkdown

Originally published on BotCookery, a free site of build recipes for AI-built trading and prediction-market bots. Written with AI assistance. Every number below comes from real runs against Hyperliquid's public API on Oct 25, 2026. Signed calls are testnet only, and nothing here is financial advice or a trading signal.

We publish a copy-paste prompt that has an AI coding tool build a small Python starter for the Hyperliquid API: reads on mainnet or testnet, and signed orders on testnet only, capped at $25. On Oct 25 we checked whether that prompt actually holds up. We pasted the Oct 22 version into a clean folder, let it build, and ran it live: reads on both networks, a dry-run order for every listed perp, and signed testnet calls with a throwaway key that held no funds.

Most of it worked. Reads, asset lookups, price rounding, the --mainnet flag and the testnet-only guard all did what they should. Every one of the 158 live testnet perps and 178 live mainnet perps got a valid price at $10.

Then we asked for $25, and the bot refused. Every time. Here are the four things that were wrong, and how the prompt changed.

Hyperliquid in one paragraph

Everything is a JSON POST. Reads go to /info with a type (meta, allMids, l2Book, candleSnapshot, clearinghouseState, openOrders) and need no signature. Orders go to /exchange and are signed, which is where the official hyperliquid-python-sdk earns its keep. Testnet lives at api.hyperliquid-testnet.xyz, mainnet at api.hyperliquid.xyz, and they are different markets with different prices. The first lines of each of our cycles were testnet mids, then mainnet mids:

BTC        80973.5 ETH        2352.75 SOL        101.445
BTC        80646.5 ETH        2354.95 SOL        101.925

A few rules the prompt spells out, because an AI tool will otherwise guess:

  • Asset IDs differ between networks. Look up the index and szDecimals from meta() on the same network every time. Never hardcode them.
  • Prices: at most 5 significant figures and at most 6 - szDecimals decimals for perps. Integer prices are always valid.
  • $10 minimum notional per order.
  • Agent wallets have no balance. You approve an agent key to sign for your main account, but balance and open-order lookups must use the main address.

1. A $25 order could never pass

The starter places a post-only (Alo) limit 10% away from the mid, so the order rests instead of filling. Size is usd / limit, rounded up to szDecimals. Rounding up is deliberate: rounding down, or sizing on the mid, leaves a $10 buy under the $10 exchange minimum.

But rounding up always lands the notional a little over what you asked for. And the cap compared that notional to $25. So in the original build, plan BTC buy 25 printed:

refusing: notional 25.51 > $25.0

That wasn't a BTC quirk. It refused all 424 dry-run $25 orders on testnet and all 468 on mainnet. The only orders that could pass were ones small enough that rounding didn't push them over, which made the "$10 to $25" range really "$10 to a bit under $25".

The fix is to check two different things. The requested amount must be between $10 and $25. The order's own notional must be at least $10 and no more than the request plus one size step (10^-szDecimals x limit). From the rebuilt version:

$ python3 cli.py plan BTC buy 25
  "sz": 0.00035,
  "limit_px": 72876.0,
  "notional": 25.5066,
checks: pass

0.00035 x 72,876 = 25.5066. Over $25 by one step's worth of rounding, which is exactly what the cap now allows.

2. Delisted coins looked tradable

allMids still quotes delisted perps. The original build treated "has a mid" as "trades", so:

$ python3 cli.py plan MATIC buy 10
...
checks: pass

MATIC is one of 54 delisted perps on testnet. The meta response marks them with "isDelisted": true, and the asset lookup now raises on that flag:

$ python3 cli.py plan MATIC buy 10
error: MATIC is delisted on testnet

Across three cycles of the rebuilt version, testnet listed 212 perps and mainnet 234. Every live one passed at both $10 and $25 on both sides (316/316 testnet, 356/356 mainnet), and every delisted one was refused:

testnet 212 perps: {'pass_10': 316, 'pass_25': 316, 'refused_delisted': 216} []
mainnet 234 perps: {'pass_10': 356, 'pass_25': 356, 'refused_delisted': 224} []

3. The dead-man switch would have blocked every order on a new account

The prompt has the bot call scheduleCancel before every order: a dead-man switch that cancels all open orders at a set time if the bot dies. The original rule was strict. If scheduleCancel didn't return ok, send no order.

The problem is that Hyperliquid only allows scheduleCancel above a trading-volume threshold. A published reply shows the error as Cannot set scheduled cancel time until enough volume traded. Required: $1000000. A brand-new testnet account will never clear that, so the strict rule meant a reader following the recipe could never place an order at all.

To be clear about what we saw: our throwaway key was never approved and the account was never funded, so the exchange stopped us earlier, before the volume check could come up. The orders.jsonl log from the run shows that refusal on every attempt:

{"kind": "schedule_cancel", "req": {"time": 1792953489708}, "resp": {"status": "err", "response": "User or API Wallet 0x46b7… does not exist."}}

So the volume error itself is sourced, not observed, and the code path for it is tested offline only.

The new behavior keeps the safety without the dead end:

  • On that exact volume error, print dead-man switch unavailable for this account and continue.
  • On any other non-ok reply, still refuse and send no order. That's what happened in our runs: refusing: schedule_cancel failed, no order sent.
  • place now takes --hold SECONDS (10 to 300). If the order rests, the bot waits, then cancels it by oid in a finally block, so Ctrl-C during the hold still cancels. It then checks openOrders that the oid is gone and exits non-zero if it isn't.

Without the exchange's dead-man switch, the bot is its own. That's a weaker guarantee, so the hold is short and the cancel is confirmed.

One more check from the same runs: place and cancel-all don't accept --mainnet at all. Passing it gets cli.py: error: unrecognized arguments: --mainnet before anything is signed.

4. The main key was left on disk

Hyperliquid lets a main account approve an "agent" (API wallet) key that signs on its behalf. The prompt's safety notes say to keep the main key away from the bot and give the bot only the agent key.

The original approve_agent.py loaded the main key from the same env file it then wrote the agent key to. So after a successful approval, the key you're supposed to keep away from the bot sat right next to it.

Now it reads the main key once with getpass.getpass(), never writes it anywhere, and saves only HL_MODE=testnet, HL_AGENT_PRIVATE_KEY and HL_ACCOUNT_ADDRESS (the main address) to ~/.config/hl-bot/.env with chmod 600. It also saves only the agent key the SDK returns from approve_agent, and only if the result is ok. On our unfunded account it saved nothing:

$ python3 approve_agent.py
approve failed, nothing saved: {'status': 'err', 'response': 'Must deposit before performing actions. User: 0xd40d…'}

Already built it? The follow-up prompt

If you built the starter from the earlier prompt, paste this into the same project:

Update my hl-starter:
1. asset_info(): raise on any coin whose meta entry has "isDelisted": true. allMids still returns a mid for delisted perps, so plan/place must not accept them.
2. check_order(): check the requested usd is between $10 and $25, and allow the order's own notional (size x limit) to be at most usd plus one size step (10^-szDecimals x limit). Rounding the size up means comparing the notional itself to $25 refuses every $25 order.
3. place(): Hyperliquid rejects schedule_cancel on accounts without enough traded volume ("Cannot set scheduled cancel time until enough volume traded..."). On that exact error, print "dead-man switch unavailable for this account" and continue; on any other schedule_cancel error, still send no order. Add `--hold SECONDS` (10-300, default 60): after the order rests, wait, then cancel it by oid in a `finally` (so Ctrl-C cancels too) and confirm with openOrders that it's gone; exit non-zero if it isn't.
4. approve_agent.py: read the main testnet key with getpass.getpass() and never write it to any file. Write only HL_MODE=testnet, HL_AGENT_PRIVATE_KEY and HL_ACCOUNT_ADDRESS, keeping other lines in the env file.
Add a test for each, then run pytest and `python3 cli.py plan BTC buy 25`.

Run it yourself

The full prompt is on the recipe page. If you'd rather start from code, the rebuilt starter is on GitHub at lazyclaw137-ship-it/hyperliquid-api-starter-testnet. Reads need nothing:

pip install hyperliquid-python-sdk requests python-dotenv pytest
python3 cli.py mids
python3 cli.py book BTC --mainnet
python3 cli.py plan BTC buy 25

Signed calls need a testnet account and an approved agent key, and stay on testnet. The code checks for that twice.

Verified working on live data (read-only), Oct 25, 2026. Reads ran live on testnet and mainnet, and order sizing was checked against every listed perp on both. Signed testnet calls used an unfunded throwaway key, so the exchange refused them before any order existed. We did not see a resting order, a fill or the dead-man volume error ourselves; the code path for that error is tested offline only.

More recipes, including a Hyperliquid funding-rate monitor, are at botcookery.com.

No comments yet. Be the first to comment.