DEV Community

Julio Molina Soler
Julio Molina Soler

Posted on

When "closed" doesn't mean closed: a Hyperliquid stop-loss post-mortem

When "closed" doesn't mean closed: a Hyperliquid stop-loss post-mortem

A trading bot logged "Stop-loss triggered" every 5 minutes for 36 hours. The position was never closed. Here's what went wrong and how to prevent it.


What happened

The Hyperliquid short bot had a configured stop-loss. ETH moved against the position and breached the threshold. The bot logged:

Closing position. Reason: Stop-loss triggered (-15.2%)
Enter fullscreen mode Exit fullscreen mode

And then logged it again. And again. Every 5 minutes, for 36+ hours.

The position was never actually closed.

By the time the failure was caught manually — via direct log inspection — the unrealized loss had grown from the configured -15% to approximately -25%. The position was closed manually. Total capital lost: roughly 85% of the initial collateral instead of the intended 15%.


Root cause: three bugs stacked

Bug 1 — The close function swallowed exceptions silently

def close_position(exchange, reason):
    try:
        result = exchange.market_close("ETH", None, slippage)
        return result
    except Exception as e:
        log(f"Error closing position: {e}")
        return None  # ← caller never checked this
Enter fullscreen mode Exit fullscreen mode

The function returned None on failure. The caller treated None and a successful close identically — execution continued either way.

Bug 2 — State saved as "closed" regardless of outcome

result = close_position(exchange, reason)
# result is None here — but no check, no branch
save_json(STATE_PATH, {"status": "closed_sl", ...})
sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

The state file was written as "status": "closed_sl" even when the close order failed. On the next run, the bot re-queried the live API, saw the position was still open, triggered the stop-loss again, "closed" it again, saved state as closed again. 499 times.

Bug 3 — Notification system broken

Every bot sends Telegram alerts via:

subprocess.run(["openclaw", "message", "send", ...])
Enter fullscreen mode Exit fullscreen mode

The openclaw binary lives at /home/m900/.npm-global/bin/openclaw — in $PATH for interactive shells, but not for cron jobs. Every alert from every bot had been silently failing for weeks.

Both the trading system and the monitoring system failed at the same time. That's the actual problem. When both fail silently together, there's no recovery signal.


Why the close order was rejected

The Hyperliquid SDK's exchange.market_close() returned:

{
  "status": "ok",
  "response": {
    "type": "order",
    "data": {
      "statuses": [{"error": "Order price cannot be more than 95% away from the reference price"}]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The bot checked result.get("status") == "ok" and interpreted it as success. The actual outcome — order rejected — was one level deeper in the response.

This is a common trap with exchange APIs: HTTP 200 + status: ok means "we received your request". It does not mean the order was filled, or even accepted.


Fixes

1. Fix cron PATH — one line in crontab header:

PATH=/home/m900/.npm-global/bin:/usr/local/bin:/usr/bin:/bin
Enter fullscreen mode Exit fullscreen mode

2. Check the full response, fail loudly:

def close_position(exchange, reason):
    try:
        result = exchange.market_close("ETH", None, slippage)
        if result is None:
            return None
        statuses = result.get("response", {}).get("data", {}).get("statuses", [])
        if any("error" in s for s in statuses):
            log(f"ERROR: order rejected: {statuses}")
            return None
        return result
    except Exception as e:
        log(f"ERROR: exception: {e}")
        return None
Enter fullscreen mode Exit fullscreen mode

3. Caller checks return, state only saved on confirmed close:

result = close_position(exchange, reason)
if result is None:
    notify("🚨 CLOSE FAILED — manual intervention required")
    sys.exit(1)  # don't save closed state
# Only here if close confirmed:
save_json(STATE_PATH, {"status": "closed_sl", ...})
Enter fullscreen mode Exit fullscreen mode

After the fix

The position was closed manually using an IOC buy order with reduce_only=True — bypassing the SDK's market_close abstraction entirely:

result = exchange.order(
    "ETH", True, size,         # buy to close short
    eth_price * 1.05,           # limit 5% above market → fills at market
    {"limit": {"tif": "Ioc"}},  # immediate-or-cancel
    reduce_only=True
)
Enter fullscreen mode Exit fullscreen mode

This filled immediately at market price.

The remaining balance on Hyperliquid (~40% of initial collateral, after losses) was withdrawn to Arbitrum and injected into the ETH/USDC grid bot there, giving it approximately a +47% capital boost.


Takeaways

1. Silent failures in financial systems are worse than crashes.
A crash is obvious. A silent failure that logs success while doing nothing is invisible until you look. Every critical path needs explicit return checks and fail-loud behavior.

2. "Status OK" is not "order filled".
With exchange APIs, always inspect the inner order statuses, not just the top-level API response.

3. Verify the close — don't just place the order.
The correct pattern:

place_order() → wait 30s → query_position() → assert size == 0
Enter fullscreen mode Exit fullscreen mode

Not: place_order() → log "closed" → exit

4. Test your monitoring before you need it.
A simple startup ping on each bot run would have surfaced the broken notification system immediately.

5. Validate the thesis before the trade.
The short was opened based on a funding rate collection strategy. The funding rate was already marginal and the market structure was bullish. The stop-loss failing just amplified a bad entry. When the hedge thesis is wrong, the stop-loss is your last line of defense — make sure it actually works.


Infrastructure: M900 Tiny (Ubuntu 24.04), system cron, Python, Hyperliquid Python SDK v0.22.0
Source: github.com/jmolinasoler/build-log

Top comments (0)