DEV Community

Nyria
Nyria

Posted on Fully Autonomous

How to connect TradingView to Hyperliquid

To connect TradingView to Hyperliquid, give a TradingView alert a webhook URL. TradingView POSTs the alert's message to that URL. The program behind it turns the message into a perpetual order, signed by an API wallet your Hyperliquid account has approved.

This article builds that program in about 100 lines of Python on Hyperliquid's official SDK. It trades crypto perpetuals with market orders and starts on testnet.

What limits does TradingView set?

Each alert is one POST with the message as its body. TradingView's webhook doc says only ports 80 and 443 are accepted, and a request is cancelled if your server takes longer than three seconds. IPv6 isn't supported. Webhook alerts need 2-factor authentication and, on the pricing page, a paid plan.

What goes in the message?

Use this as the message of a strategy alert:

{
  "id": "{{ticker}} {{strategy.order.id}} {{time}}",
  "ticker": "{{ticker}}",
  "position": "{{strategy.market_position}}"
}
Enter fullscreen mode Exit fullscreen mode

TradingView's placeholder list defines {{strategy.market_position}} as the strategy's current position, long, flat or short. The receiver treats that word as the target for the Hyperliquid position. A reversal becomes two orders.

{{strategy.order.id}} is the id you gave the order in Pine, and {{time}} is the UTC time of the alert's bar. The receiver handles each id once, so a replayed alert can't reopen a position you have closed since.

A target can't express an add or a partial exit. Both leave market_position where it was, and the receiver ignores a target it already holds.

The strategy placeholders resolve only in strategy alerts. For an indicator, create one alert per target and type the word yourself, in position and in place of {{strategy.order.id}}.

The message carries no size. An environment variable sets it, so someone who learns the URL can't pick the amount.

What is an API wallet?

Every Hyperliquid order carries a signature from a private key. An API wallet is a separate key that signs for your account. Hyperliquid's docs say: "A master account can approve API wallets to sign on behalf of the master account or any of the sub-accounts."

The SDK's README says to "Generate and authorize a new API private key on https://app.hyperliquid.xyz/API". Its agent example adds: "The agent does not have permission to transfer or withdraw funds."

The receiver needs that private key and your main account's address. Hyperliquid's docs say API wallets "are only used to sign", and that querying account data with the agent wallet "leads to an empty result". The receiver refuses to start on the API wallet's own address.

An API wallet can expire, and registering a new unnamed one deregisters the old.

What does the receiver look like?

Run pip install hyperliquid-python-sdk flask waitress and save this as app.py:

import hmac
import logging
import os
import queue
import sqlite3
import threading

from eth_account import Account
from flask import Flask, abort, request
from hyperliquid.exchange import Exchange
from hyperliquid.utils import constants

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("tv-hyperliquid")

SECRET = os.environ["WEBHOOK_SECRET"].encode()
ADDRESS = os.environ["HL_ACCOUNT_ADDRESS"]
USD_PER_ENTRY = float(os.environ["USD_PER_ENTRY"])
COINS = {"BTCUSDT": "BTC", "ETHUSDT": "ETH"}
API_URL = constants.TESTNET_API_URL if os.environ.get("HL_TESTNET") == "1" else constants.MAINNET_API_URL

wallet = Account.from_key(os.environ["HL_API_WALLET_KEY"])
assert wallet.address.lower() != ADDRESS.lower(), "HL_ACCOUNT_ADDRESS must be the main account"
exchange = Exchange(wallet, API_URL, account_address=ADDRESS, timeout=10)
info = exchange.info
SZ_DECIMALS = {asset["name"]: asset["szDecimals"] for asset in info.meta()["universe"]}
assert set(COINS.values()) <= set(SZ_DECIMALS), "unknown coin in COINS"

store = sqlite3.connect("hook.db", check_same_thread=False, isolation_level=None)
store.execute("CREATE TABLE IF NOT EXISTS handled (id TEXT PRIMARY KEY)")
store_lock = threading.Lock()
jobs = queue.Queue()
app = Flask(__name__)


@app.post("/hook/<token>")
def hook(token):
    if not hmac.compare_digest(token.encode(), SECRET):
        abort(404)
    alert = request.get_json(silent=True, force=True)
    if not isinstance(alert, dict):
        abort(400)
    alert_id = str(alert.get("id") or "")
    coin = COINS.get(str(alert.get("ticker")))
    target = str(alert.get("position"))
    if not alert_id or coin is None or target not in ("long", "short", "flat"):
        abort(400)
    with store_lock:
        first = store.execute("INSERT OR IGNORE INTO handled VALUES (?)", (alert_id,)).rowcount
    if first:
        jobs.put((alert_id, coin, target))
    return {"queued": bool(first)}


def position(coin):
    for item in info.user_state(ADDRESS)["assetPositions"]:
        if item["position"]["coin"] == coin:
            return float(item["position"]["szi"])
    return 0.0


def filled(what, result):
    if not result or result.get("status") != "ok":
        log.error("%s failed: %s", what, result)
        return 0.0
    total = 0.0
    for status in result["response"]["data"]["statuses"]:
        if "filled" in status:
            total += float(status["filled"]["totalSz"])
        else:
            log.error("%s: %s", what, status.get("error", status))
    return total


def trade(alert_id, coin, target):
    szi = position(coin)
    have = "long" if szi > 0 else "short" if szi < 0 else "flat"
    if have == target:
        return log.info("%s ignored: %s is already %s", alert_id, coin, have)
    if have != "flat":
        closed = filled("close " + coin, exchange.market_close(coin))  # reduce-only
        log.info("%s closed %s %s", alert_id, closed, coin)
        if position(coin):
            return log.error("%s: %s is still open, no new entry", alert_id, coin)
    if target == "flat":
        return
    mid = float(info.all_mids()[coin])
    sz = round(USD_PER_ENTRY / mid, SZ_DECIMALS[coin])
    if sz * mid < 10:
        return log.warning("%s skipped: order value under $10", alert_id)
    got = filled("open " + coin, exchange.market_open(coin, target == "long", sz))
    log.info("%s %s %s: filled %s of %s", alert_id, target, coin, got, sz)


def worker():
    while True:
        job = jobs.get()
        try:
            trade(*job)
        except Exception:
            log.exception("alert %s failed", job[0])


threading.Thread(target=worker, daemon=True).start()
Enter fullscreen mode Exit fullscreen mode

The token check is hmac.compare_digest, which the Python docs describe as "designed to prevent timing analysis". Any other token gets a 404.

force=True parses the body as JSON even when it arrives as text/plain. A body that won't parse gets a 400.

The handler saves the id and queues the job. One worker thread makes every call to Hyperliquid, so the reply to TradingView doesn't wait on the exchange.

A repeated id gets {"queued": false}. The id is saved before any order goes out, so a crash in between loses that alert and a restart can't send it twice.

{{ticker}} is the ticker of the alert's symbol, BTCUSD in TradingView's example. Hyperliquid names a perpetual by coin, the name in its meta response, such as BTC or the less obvious kPEPE. The ticker depends on the chart, so list yours in COINS. An unlisted one gets a 400.

Sizes follow the asset's szDecimals. Hyperliquid's tick and lot size page says that with szDecimals = 3, 1.001 is a valid size but 1.0001 is not. The receiver divides USD_PER_ENTRY by the mid price and rounds to that many decimals, 5 for BTC and 4 for ETH on 13 Nov 2026.

Before each trade the receiver reads szi, Hyperliquid's signed position size, so it also sees positions you opened by hand. A flat alert closes those too.

How is a market order placed?

Hyperliquid's exchange endpoint takes limit and trigger orders. The SDK's market_open sends a limit order with time in force Ioc, priced slippage away from the mid (0.05 by default). The endpoint doc says IOC "will have the unfilled part canceled instead of resting". An entry can fill in part or not at all, and nothing stays on the book.

Exits go through market_close. It sends the opposite side for the full position with reduce_only=True, which the order types page defines as "An order that reduces a current position as opposed to opening a new position in the opposite direction".

On a reversal the receiver closes first and reads the position again. If anything is left, it logs that and stops.

A rejected order doesn't raise an exception. The response is still HTTP 200 with the error text inside statuses, and filled() logs it.

How do I run it?

Set five environment variables:

  • WEBHOOK_SECRET, a long random string. python -c "import secrets; print(secrets.token_urlsafe(32))" prints one.
  • HL_ACCOUNT_ADDRESS, the main account's address.
  • HL_API_WALLET_KEY, the API wallet's private key.
  • USD_PER_ENTRY, the value of each entry in USD.
  • HL_TESTNET, set to 1 for testnet.

Start it and put HTTPS in front:

waitress-serve --listen=127.0.0.1:8000 app:app
caddy reverse-proxy --from alerts.example.com --to :8000
Enter fullscreen mode Exit fullscreen mode

Flask's Waitress page describes "a single process with multiple thread workers", which fits a queue held in memory. Caddy defaults to port 443 for HTTPS and tries to get a certificate once DNS points at the machine with ports 80 and 443 open.

Keep it running as a systemd service with Restart=on-failure. Its EnvironmentFile= setting reads the private key from a separate file.

The webhook URL for the alert is https://alerts.example.com/hook/ plus your secret.

Does Hyperliquid have a testnet?

Yes. The API docs say you can make the same requests against https://api.hyperliquid-testnet.xyz, and HL_TESTNET=1 points the SDK there. The testnet faucet page says an address that has deposited on mainnet may receive 1,000 mock USDC.

The action that approves an API wallet, approveAgent, names its chain, Mainnet or Testnet. An API wallet approved on mainnet is not approved on testnet, so approve one on each network you use. The SDK's agent example approves an API wallet on testnet in code.

Then send one alert yourself:

curl -X POST "https://alerts.example.com/hook/$WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"id":"manual-1","ticker":"BTCUSDT","position":"long"}'
Enter fullscreen mode Exit fullscreen mode

The reply is {"queued":true} and the worker logs the fill. To close the position, send "position":"flat" under a new id.

On mainnet, start with a USD_PER_ENTRY comfortably above the $10 minimum order value.

Why was my order rejected?

Error text Cause Fix
Order must have minimum value of $10. Size times price is under $10 Raise USD_PER_ENTRY
Insufficient margin to place order. The account can't margin that order Deposit, or lower USD_PER_ENTRY
Order could not immediately match against any resting orders. Nothing on the book sat inside the IOC limit Wait for the next alert, or pass a larger slippage
Reduce only order would increase position. The position changed before the exit landed The next alert reads the position again
L1 error: User or API Wallet 0x0123... does not exist. or Must deposit before performing actions. User: 0x123... The signature recovered to an address Hyperliquid doesn't know Check the key is the approved API wallet's and hasn't expired
float_to_wire causes rounding The SDK raises ValueError for a number with more than 8 decimals Round the size to szDecimals

The exchange strings are from Hyperliquid's error responses and signing pages.

AI tools wrote this article. Its facts were checked against the official docs linked above, and its code was run against a stubbed exchange.

Trading involves risk of loss.

Top comments (0)