Files
trumpsignal-backend/app/services/hyperliquid.py
T
k 4ffcb442fe feat: add daily budget, active window, trade snapshots, and price impact monitor
- New migrations for daily_budget, active_window, and bottrade snapshot
- Add trumpstruth scraper and price_impact_monitor service
- Expand bot_engine, hyperliquid, recovery, and tp_sl_monitor logic
- Update API/schemas/models for new features

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:04:49 +08:00

273 lines
11 KiB
Python

"""
Hyperliquid BTC perpetual trading client.
KEY CONCEPT — two wallets:
• account_address : Your MetaMask address. Holds USDC and open positions.
Used for all info/query calls.
• api_private_key : API wallet private key generated at app.hyperliquid.xyz/API.
Used only for signing orders. Cannot withdraw.
Both are read from config (hl_account_address, hl_api_private_key).
"""
import asyncio
import logging
from functools import partial
from typing import Optional
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
logger = logging.getLogger(__name__)
HL_MAINNET_URL = "https://api.hyperliquid.xyz"
HL_TESTNET_URL = "https://api.hyperliquid-testnet.xyz"
# Precision map — Hyperliquid's szDecimals for common coins
# Fetched dynamically via meta() but cached here as fallback
SZ_DECIMALS_FALLBACK = {"BTC": 5, "ETH": 4}
class HyperliquidTrader:
def __init__(
self,
api_private_key: str,
account_address: str,
leverage: int = 3,
mainnet: bool = True,
):
"""
Args:
api_private_key: Private key of the API wallet (from Hyperliquid dashboard).
account_address: Main MetaMask wallet address (holds USDC + positions).
leverage: Isolated leverage to set before each trade.
mainnet: True = mainnet, False = testnet.
"""
self._api_wallet = Account.from_key(api_private_key)
self._account_address = account_address.lower()
self._leverage = leverage
self._base_url = HL_MAINNET_URL if mainnet else HL_TESTNET_URL
# Exchange signs with api_wallet but acts on behalf of account_address
self._exchange = Exchange(
self._api_wallet,
self._base_url,
account_address=self._account_address,
)
self._info = Info(self._base_url, skip_ws=True)
# ── internal helpers ──────────────────────────────────────────────────────
async def _run(self, fn, *args, **kwargs):
"""Run blocking SDK calls in a thread pool without blocking the event loop."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, partial(fn, *args, **kwargs))
async def _get_sz_decimals(self, coin: str) -> int:
try:
meta = await self._run(self._info.meta)
for u in meta.get("universe", []):
if u.get("name") == coin:
return int(u.get("szDecimals", SZ_DECIMALS_FALLBACK.get(coin, 4)))
except Exception:
pass
return SZ_DECIMALS_FALLBACK.get(coin, 4)
async def _mid_price(self, coin: str) -> float:
mids = await self._run(self._info.all_mids)
price = float(mids.get(coin, 0))
if price <= 0:
raise ValueError(f"Cannot get mid price for {coin}")
return price
# ── public interface ─────────────────────────────────────────────────────
async def get_balance(self) -> float:
"""Return withdrawable USDC balance of the main (MetaMask) account."""
try:
state = await self._run(self._info.user_state, self._account_address)
return float(state.get("withdrawable", 0))
except Exception as exc:
logger.error("get_balance error: %s", exc)
return 0.0
async def get_open_positions(self) -> list:
"""Return all open positions of the main account (non-zero size only)."""
try:
state = await self._run(self._info.user_state, self._account_address)
positions = []
for asset_pos in state.get("assetPositions", []):
pos = asset_pos.get("position", {})
szi = float(pos.get("szi", 0))
if szi != 0:
positions.append({
"coin": pos.get("coin"),
"szi": szi, # positive = long, negative = short
"entry_px": float(pos.get("entryPx", 0) or 0),
"unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0),
"leverage": pos.get("leverage", {}),
})
return positions
except Exception as exc:
logger.error("get_open_positions error: %s", exc)
raise
async def set_leverage(self, coin: str) -> None:
"""Set isolated leverage for the given coin."""
try:
await self._run(
self._exchange.update_leverage,
self._leverage,
coin,
False, # is_cross=False → isolated margin
)
logger.info("Set leverage %dx for %s (isolated)", self._leverage, coin)
except Exception as exc:
logger.warning("set_leverage error (non-fatal): %s", exc)
async def open_position(
self,
asset: str,
side: str, # "long" or "short"
size_usd: float,
) -> dict:
"""
Open a market position.
Returns:
{"order_id": str, "fill_price": float, "size_coins": float}
"""
coin = asset.upper()
is_buy = side.lower() == "long"
# 1. Set leverage before opening
await self.set_leverage(coin)
# 2. Compute coin size from USD notional
mid = await self._mid_price(coin)
sz_dec = await self._get_sz_decimals(coin)
size_coins = round(size_usd / mid, sz_dec)
if size_coins <= 0:
raise ValueError(f"Computed size too small: {size_coins} {coin} from ${size_usd}")
# 3. Limit price with 1% slippage (IOC acts as market)
slippage = 0.01
# Hyperliquid px must be ≤5 sig-figs AND divisible by tick size.
# For BTC ~$76k tick=$1 (integer); for ETH ~$3k tick=$0.1. Use 5 sig-figs + coin-based rounding.
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
if raw_px >= 10000:
limit_px = float(round(raw_px)) # integer dollars for BTC
elif raw_px >= 1000:
limit_px = round(raw_px, 1)
elif raw_px >= 100:
limit_px = round(raw_px, 2)
else:
limit_px = round(raw_px, 3)
logger.info(
"Opening %s %s: %.5f coins @ limit %.2f (mid=%.2f, usd=%.2f)",
side, coin, size_coins, limit_px, mid, size_usd,
)
result = await self._run(
self._exchange.order,
coin,
is_buy,
size_coins,
limit_px,
{"limit": {"tif": "Ioc"}}, # Immediate-or-Cancel ≈ market
)
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
status = statuses[0] if statuses else {}
# "error" key present → HL rejected the order entirely
if "error" in status:
raise ValueError(f"HL order rejected: {status['error']}")
filled_info = status.get("filled") or {}
total_sz = float(filled_info.get("totalSz", 0) or 0)
if total_sz <= 0:
# IOC returned with zero fill — no position was opened
raise ValueError(
f"IOC order for {coin} returned 0 fill "
f"(status={status}). No position opened."
)
fill_price = float(filled_info.get("avgPx", mid) or mid)
# oid lives under "filled" for IOC; "resting" is fallback for unexpected GTC
oid_src = filled_info or status.get("resting") or {}
order_id = str(oid_src.get("oid", "")) if oid_src else ""
logger.info("Opened %s %s @ %.2f size=%.5f (order_id=%s)",
side, coin, fill_price, total_sz, order_id)
return {"order_id": order_id, "fill_price": fill_price, "size_coins": total_sz}
async def close_position(self, asset: str) -> dict:
"""
Close all open positions for the given asset using a reduce-only IOC order.
Returns:
{"fill_price": float}
"""
coin = asset.upper()
positions = await self.get_open_positions()
target = next((p for p in positions if p.get("coin") == coin), None)
if target is None:
logger.warning("No open %s position found on HL — already closed externally?", coin)
# Return sentinel so callers can detect "nothing to close" vs "closed @ price"
return {"fill_price": None, "already_closed": True}
szi = float(target["szi"])
is_buy = szi < 0 # closing a short → buy back
size = abs(szi)
mid = await self._mid_price(coin)
slippage = 0.01
# Hyperliquid px must be ≤5 sig-figs AND divisible by tick size.
# For BTC ~$76k tick=$1 (integer); for ETH ~$3k tick=$0.1. Use 5 sig-figs + coin-based rounding.
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
if raw_px >= 10000:
limit_px = float(round(raw_px)) # integer dollars for BTC
elif raw_px >= 1000:
limit_px = round(raw_px, 1)
elif raw_px >= 100:
limit_px = round(raw_px, 2)
else:
limit_px = round(raw_px, 3)
logger.info("Closing %s %s: size=%.5f @ limit %.2f", coin, "short" if is_buy else "long", size, limit_px)
result = await self._run(
self._exchange.order,
coin,
is_buy,
size,
limit_px,
{"limit": {"tif": "Ioc"}},
reduce_only=True,
)
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
status = statuses[0] if statuses else {}
filled_info = status.get("filled") or {}
total_sz = float(filled_info.get("totalSz", 0) or 0)
if total_sz <= 0:
# IOC close got 0 fill — position may have already been closed externally.
# Re-check to be sure before giving up.
positions_after = await self.get_open_positions()
still_open = next((p for p in positions_after if p.get("coin") == coin), None)
if still_open is None:
logger.info("Close IOC got 0 fill but position is gone — treated as closed", )
return {"fill_price": mid, "already_closed": False}
logger.error("Close IOC got 0 fill and position still open for %s", coin)
raise ValueError(f"Failed to close {coin} position: IOC returned 0 fill")
fill_price = float(filled_info.get("avgPx", mid) or mid)
logger.info("Closed %s position @ %.2f (size=%.5f)", coin, fill_price, total_sz)
return {"fill_price": fill_price, "already_closed": False}